Skip to content

Add Server-Sent Events (SSE) recording support#6

Merged
mattt merged 6 commits intomattt:mainfrom
zshannon:feature/sse-support
Jan 15, 2026
Merged

Add Server-Sent Events (SSE) recording support#6
mattt merged 6 commits intomattt:mainfrom
zshannon:feature/sse-support

Conversation

@zshannon
Copy link
Contributor

SSE streams never complete naturally - they remain open until cancelled. This made recording impossible because the recording logic ran after the streaming loop exited, but the loop never exited.

This change adds a flush mechanism that allows the test trait to trigger recording when the test completes (and cancels the SSE connection).

Key changes:

PlaybackURLProtocol:

  • Add StreamingDelegate class for delegate-based URLSession streaming
  • Use delegate approach instead of session.bytes(for:) to properly handle cancellation and collect streamed data
  • Register a finish handler with PlaybackStore that saves collected data when flush() is called
  • Track streamTask and urlSessionTask for proper cancellation in stopLoading()

PlaybackStore:

  • Add flush() method that triggers all registered streaming finish handlers
  • Add registerStreamingProtocol/unregisterStreamingProtocol for tracking active streaming connections
  • Extract checkRequest() from handleRequest() to separate the "should we use recorded data or hit network?" decision from the actual network call
  • Add recordResponse() for recording after streaming completes
  • Preserve handleRequest() for non-streaming use cases with a note about its streaming limitation

ReplayTrait (Traits.swift):

  • Call flush() before printing the recording success message
  • This ensures SSE data is saved before the test tears down

The flush mechanism works by:

  1. When a streaming request starts, register a finish handler with the store
  2. The handler captures the collected data and response metadata
  3. When flush() is called (at test completion), all handlers execute
  4. Each handler saves its collected data to the HAR file
  5. The "Recorded HTTP traffic" message prints after all recordings complete

zshannon and others added 2 commits January 13, 2026 05:03
SSE streams never complete naturally - they remain open until cancelled.
This made recording impossible because the recording logic ran after the
streaming loop exited, but the loop never exited.

This change adds a flush mechanism that allows the test trait to trigger
recording when the test completes (and cancels the SSE connection).

Key changes:

PlaybackURLProtocol:
- Add StreamingDelegate class for delegate-based URLSession streaming
- Use delegate approach instead of session.bytes(for:) to properly handle
  cancellation and collect streamed data
- Register a finish handler with PlaybackStore that saves collected data
  when flush() is called
- Track streamTask and urlSessionTask for proper cancellation in stopLoading()

PlaybackStore:
- Add flush() method that triggers all registered streaming finish handlers
- Add registerStreamingProtocol/unregisterStreamingProtocol for tracking
  active streaming connections
- Extract checkRequest() from handleRequest() to separate the "should we
  use recorded data or hit network?" decision from the actual network call
- Add recordResponse() for recording after streaming completes
- Preserve handleRequest() for non-streaming use cases with a note about
  its streaming limitation

ReplayTrait (Traits.swift):
- Call flush() before printing the recording success message
- This ensures SSE data is saved before the test tears down

The flush mechanism works by:
1. When a streaming request starts, register a finish handler with the store
2. The handler captures the collected data and response metadata
3. When flush() is called (at test completion), all handlers execute
4. Each handler saves its collected data to the HAR file
5. The "Recorded HTTP traffic" message prints after all recordings complete
startTime: startTime
)
} catch {
// Recording failed silently
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to accumulate errors instead of swallowing them here? (Genuine question; I don't have a sense right now of whether we get an opportunity to emit them any point later...)

@mattt
Copy link
Owner

mattt commented Jan 13, 2026

Hi @zshannon! Thank you for taking a look at this. At first blush, this looks like a sound implementation. I left a handful of comments, but otherwise I think this is ready to go. Lemme give this another once over and test it out on another project using SSEs.

…ering

- Log recording failures instead of silently swallowing errors
- Reorder StreamingDelegate declarations to group dataStream computed
  property with its backing storage
@zshannon
Copy link
Contributor Author

Thanks for the speedy consideration! I wasn't sure on the errors bit either so compromised with printing at least it'll show up in the test console output for the user now.

startTime: startTime
)
} catch {
print("Replay: Failed to record streaming response: \(error)")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Printing is a totally reasonable way to deal with this, though it never feels great to pollute stdout like this. I was holding out hope for some opportunity to communicate errors accumulated by the URL protocol later, but I can't find an obvious place to do that.

So yeah, I think it's fine, since we'd be spamming console while testing, not when a user is running an app.

Looking at the specific log line, I wonder if we can make this more helpful:

  • Which request failed to respond?
  • Is it confusing to call this a streaming response?
  • How much should we try to rhyme with the message printed when recording succeeds

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed 0f7e70b for your consideration

mattt added 2 commits January 14, 2026 04:15
Apparently using a static method was enough to trip up the Swift 6.2.3 compiler on Linux
private var streamTask: Task<Void, Never>?
private var urlSessionTask: URLSessionTask?

private static func description(for request: URLRequest) -> String {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wild that this was enough to trip up the Swift 6.2.3 compiler on Linux

@mattt mattt force-pushed the feature/sse-support branch from 8f93b96 to 8a08d02 Compare January 14, 2026 12:44
Copy link
Owner

@mattt mattt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just pushed one last change to make sure we're invalidating the data task during cleanup.

@zshannon When you have a chance, can you give these latest changes a try and confirm that everything is still working as expected for you?

@zshannon
Copy link
Contributor Author

Tested 8a08d02, works well.

@mattt
Copy link
Owner

mattt commented Jan 15, 2026

Aces. Merging this now. Thanks so much for your contribution, @zshannon!

@mattt mattt merged commit 2101f8f into mattt:main Jan 15, 2026
4 checks passed
@mattt
Copy link
Owner

mattt commented Jan 15, 2026

This is now available in 0.3.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants