Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
History note: All version sections were reformatted on 2026-07-05 for one-time CONVENTIONS §CHANGELOG conformance: forbidden sub-headers folded into the six Keep a Changelog headers, non-user-facing content removed (internal refactors, test counts, coverage numbers, CI and build hygiene, governance churn, roadmap notes), bullets kept in past-tense active voice with code-formatted API leads. The nuget.org Release Notes tab and the GitHub Release for each shipped version are unchanged. A CI
family-lintgate keeps future sections conforming; each is frozen per Rule 7 once shipped.
Unreleased
0.7.0 - 2026-06-16: unify HasSseEvent into one chain across all three receivers
Minor release. The frame narrowers that the string receiver already had (WithData, WithDataParsedAs<T>, WithId, WithRetryMillis) now reach the Stream and HttpResponseMessage receivers, which is the realistic shape for testing a live SSE endpoint: subscribe to an open response, drive activity, and assert per-frame payload under a cancellation-bounded partial read. Achieving this with one coherent HasSseEvent(eventName) surface across all three receivers means the flat HasSseEvent(eventName, minCount, ...) methods are replaced by the chainable form, which is a breaking change.
BREAKING
- The flat
HasSseEvent(eventName, minCount, ...)methods onStreamandHttpResponseMessageare replaced by a chainableHasSseEvent(eventName)matching thestringreceiver. The minimum-count argument moves to the.AtLeast(n)terminator, and the narrowers and other terminators (AtMost,Exactly) are now available on the streaming receivers.- Migration:
HasSseEvent("x", minCount: 2)becomesHasSseEvent("x").AtLeast(2);HasSseEvent("x")(default minimum of 1) is unchanged. ThestrictContentTypeandcancellationTokenarguments stay on the entry method (HasSseEvent("x", strictContentType: false, cancellationToken: ct)).
- Migration:
- A null
Stream/HttpResponseMessagereceiver now fails the assertion ("the receiver was null") instead of throwingArgumentNullException, matching the long-standing behavior of thestringreceiver. A nulleventNamestill throwsArgumentNullException, and a negative count still throwsArgumentOutOfRangeException(now from the terminator).
Added
- Frame narrowers on the
StreamandHttpResponseMessagereceivers.WithData,WithDataParsedAs<T>,WithId, andWithRetryMillisconstrain which frames count within the cancellation-bounded read, producing the same per-narrower diagnostics as thestringchain.WithDataParsedAs<T>takes a caller-supplied (source-generated) parser so the assertion stays AOT-compatible; a throwing parser fails naming the deserializer exception and the offending data. When the read is cancellation-truncated and the expectation is unmet, the cancellation-cut diagnostic still takes precedence over a count or narrower miss. All three receivers share one internal matching engine, so their diagnostics are identical. No public surface was added to theSseAssertionscore.
Fixed
- A foreign cancellation during a streaming read is no longer reported as a cancellation-cut. The shared response-read helper caught every
OperationCanceledExceptionas a caller-driven partial read; anOperationCanceledExceptionfrom a different source (for example anHttpClienttimeout) was therefore masked by the cancellation-cut diagnostic. It is now captured as a partial read only when the supplied token is the one that fired, otherwise it propagates, matching the distinctionEndsCleanlyOnCancellationalready made in 0.5.1. This applies to everyHttpResponseMessageread path (HasSseEvent,HasFirstSseEvent,HasSseEventsInOrder,HasSseRetryDirective,HasSseRetryDirectiveFirst). - Stream acquisition no longer pre-empts the cancellation-cut diagnostic. The
HttpResponseMessageread paths passed the cancellation token toReadAsStreamAsync, so a token that fired during stream acquisition threw out of the assertion instead of surfacing as the cancellation-cut diagnostic. Acquisition is now uncancellable; the token bounds only the body read, where cancellation is captured.
0.6.0 - 2026-06-14: complete the HasSseEvent narrowing chain
Minor release. Adds three narrowers to the HasSseEvent(eventName) chain on the string receiver: WithId, WithRetryMillis, and WithDataParsedAs<T>. The parser already recorded each frame's id: and retry: directives on SseEvent, and the README and SseFrameParser documentation already described these narrowers, but the chain methods did not exist. This release ships them. Purely additive; the 0.5.1 ApiCompat baseline is preserved.
Added
WithId(string id)narrows the chain to frames carrying the givenid:directive (matched case-sensitively; a frame without anid:directive never matches). On no match the failure lists every observedIdfor events of the requested type, rendering frames without anid:directive as<absent>.WithRetryMillis(Func<int?, bool> predicate)narrows to frames whoseRetryMillissatisfies the predicate (nullfor frames without aretry:directive). On no match the failure lists every observed retry value.WithDataParsedAs<T>(Func<string, T> parse, Func<T, bool> predicate)deserializes each candidate frame'sDatawith the caller-suppliedparsedelegate and counts frames whose parsed value satisfiespredicate. Supply a reflection-free parser (for example a source-generatedJsonSerializer.Deserializewith aJsonTypeInfo<T>) to keep the assertion AOT-compatible. If the deserializer throws, the assertion fails naming the exception type, its message, and the offending (truncated) data.
0.5.1 - 2026-06-11: correct cancellation-token attribution and BOM handling
Patch release. Two correctness fixes in the cancellation and retry-first assertions. No public API change; the 0.3.0 ApiCompat baseline is preserved.
Fixed
EndsCleanlyOnCancellationno longer treats a foreign-token cancellation as a clean teardown. The read pipeline caught everyOperationCanceledExceptionas the cooperative-teardown signal, regardless of which token fired it. ATaskCanceledExceptionraised byHttpClient.Timeouton a stalled server (whose token is the client's internal timeout, not the one supplied to the assertion) was therefore classified as a clean pass, so a hung server could satisfy the assertion. Cancellation now counts as clean only when the suppliedcancellationTokenis the one that was canceled; anOperationCanceledExceptionfrom any other source fails with a message naming the foreign cancellation. Behavior change: a test that previously passed because a token other than the supplied one canceled the read will now fail. Token-driven cancellation via the supplied token, normal end-of-stream, and theIOException/HttpRequestExceptiontransport-failure cases are unchanged.HasSseRetryDirectiveFirstnow strips a leading UTF-8 BOM before scanning for theretry:directive. A body that opens with a byte-order mark (U+FEFF) put the mark on the first field line, soretryfailed the field-name match and the assertion reported "no retry directive was found" even when the directive was present and first. The wire-level scan now strips a single leading BOM the same waySseFrameParserdoes, so a BOM-prefixed stream is handled consistently with the parser.
0.5.0 - 2026-06-06: value-pinning retry-first assertion
Minor release. Adds a value-pinning HasSseRetryDirectiveFirst(int millis) overload across all three receivers, so the leading retry: directive's position and value can be asserted in a single read of a forward-only response body. Purely additive; the 0.3.0 ApiCompat baseline is preserved.
Added
HasSseRetryDirectiveFirst(int millis)on thestring,Stream, andHttpResponseMessagereceivers asserts that aretry:directive precedes the first data-bearing event and that the leading directive's value equalsmillis. Position and value were previously assertable only separately (HasSseRetryDirectiveFirst()for position,HasSseRetryDirective(millis)for value), which a forward-onlyHttpResponseMessagebody cannot satisfy across two calls; this overload pins both in a single read. On a value mismatch the failure names the expected and the observed leadingretry:value.
0.4.1 - 2026-06-05: HasSseRetryDirectiveFirst accepts an empty data line before the directive
Patch release. Fixes HasSseRetryDirectiveFirst so it no longer rejects the reconnection control frame emitted by the standard ASP.NET Core SSE writer. No public API change; the 0.3.0 ApiCompat baseline is preserved.
Changed
- README and packed-README correction:
HasSseRetryDirectiveFirstis documented as matching the first non-emptydata:field, and the standard ASP.NET Core control frame shape (event: retry+ emptydata:+retry: <ms>) is called out as a passing case. The note that a namedevent: retrywith noretry:field fails is unchanged.
Fixed
HasSseRetryDirectiveFirstnow treats an emptydata:line as carrying no payload.Results.ServerSentEventsover anSseItemwith aReconnectionIntervalserializes the reconnection control frame asevent: retry, then an emptydata:line, thenretry: <ms>(the runtime fixes the field order to event, data, id, retry). The previous wire-level check read that emptydata:line as "a data field appeared before the retry directive" and failed, so the assertion rejected the standard server's own output even though theretry:directive is present and is the first event. The check now ignores emptydata:lines (a baredataline, or adata:line with no value after the optional leading space); only a non-emptydata:value before the firstretry:fails. A standaloneretry:-only frame and a genuine data-bearing event before the directive both behave as before.
0.4.0 - 2026-06-04: HttpResponseMessage receiver for EndsCleanlyOnCancellation
Minor release. Adds the HttpResponseMessage receiver to EndsCleanlyOnCancellation, so a cancellation-teardown assertion can run directly against an HTTP response without first extracting the body stream. The 0.3.0 retry-first surface already covered string, Stream, and HttpResponseMessage; this release closes the matching gap for the clean-cancellation assertion. Purely additive; the 0.3.0 ApiCompat baseline is preserved.
Added
Assert.That(response).EndsCleanlyOnCancellation(strictContentType, cancellationToken)onHttpResponseMessage. Reads the response body viaReadAsStreamAsync(cancellationToken)and asserts the read tears down via cooperative cancellation (the read completes, or raisesOperationCanceledException) rather than surfacing a transport exception (IOException,HttpRequestException). Mirrors the existingStreamoverload's teardown classification and the content-type handling of the otherHttpResponseMessagereceivers:strictContentTypedefaults totrue(fails with the unexpected-content-type diagnostic whenContent-Type's media type is nottext/event-stream); a nullContentpasses. Source-generated via[GenerateAssertion].
Changed
- README and packed-README clarification:
HasSseRetryDirectiveFirstmatches the WHATWGretry:directive field, not anevent: retrynamed event. A stream that emitsevent: retryfollowed by adata:field carries noretry:field line, so the assertion correctly fails ("no retry directive was found"). The check is spec-strict and reads the wire-level field, not the dispatched event name.
0.3.0 - 2026-06-02: retry-first and clean-cancellation stream assertions
Feature release. Adds two SSE-correctness refinements on the live-stream surface: HasSseRetryDirectiveFirst() asserts the server sent a retry: directive before any data, and EndsCleanlyOnCancellation() asserts a canceled read tears down cooperatively rather than surfacing a transport exception.
Added
Assert.That(response).HasSseRetryDirectiveFirst()(withStreamandstringreceivers) asserts that the SSE stream sends aretry:directive before anydata:field. Aretry:directive is a reconnection-time hint, not a named event, so the order is checked at the wire-field level: a retry-only frame carries no data and is not dispatched as a parsed event, so it would otherwise be invisible to an event-list check. Source-generated via[GenerateAssertion].Assert.That(stream).EndsCleanlyOnCancellation(cancellationToken)asserts that canceling the read mid-stream tears down via cooperative cancellation (the read completes, or raisesOperationCanceledException) rather than surfacing a transport exception (IOException,HttpRequestException). The assertion drains and discards content, checking only teardown behavior.SseFailureMessage.UncleanCancellation(Exception)renders the failure message forEndsCleanlyOnCancellation, naming the transport exception that surfaced. Public, matching the existing failure-message factory surface for consumer-authored typed SSE assertions.
0.2.0 - 2026-05-20: HasSseContentType, HasFirstSseEvent, HasSseEventsInOrder, HasSseRetryDirective
Minor release. Adds four new assertions covering common SSE smoke-test patterns: a header-only Content-Type discriminator (HasSseContentType), a first-event check (HasFirstSseEvent), an ordered-sequence check with optional contiguous mode (HasSseEventsInOrder + .WithStrictOrdering()), and a retry:-directive check (HasSseRetryDirective). Two README accuracy fixes for the v0.1.0 entry-points table; expanded WHATWG default-event-name documentation. No breaking changes; v0.1.0 ApiCompat baseline preserved.
Added
HasSseContentType(bool strict = false)onHttpResponseMessage. Synchronous, header-only discriminator (does not read the body). Non-strict mode passes whenContent-Type's media type istext/event-stream(case-insensitive) with any trailing parameters such ascharset=utf-8. Strict mode requires the bare media type with no parameters. Use as a lightweight smoke-test alternative to theHasSseEvent(strictContentType)form that reads and parses the body.HasFirstSseEvent(string eventName)onstring,Stream, andHttpResponseMessage. Asserts the first parsed SSE frame'sevent:name. Unlabeled frames matchHasFirstSseEvent("message")per the WHATWG default-event-name rule.StreamandHttpResponseMessageoverloads are async withCancellationToken-bounded reads; theHttpResponseMessageoverload validatesContent-Typeby default (opt-out viastrictContentType: false). Failure diagnostics name the observed first event or report"stream contained no events".HasSseEventsInOrder(string[] eventNames)onstring(chain) with.WithStrictOrdering()modifier, plusHasSseEventsInOrder(IReadOnlyList<string>, bool strictOrdering, ...)flat form onStreamandHttpResponseMessage. Default (non-strict) mode requires each named event to appear in the given order with other events permitted between them; strict mode requires the named events to appear contiguously with no other events between them. An emptyeventNamesarray trivially passes. Failure diagnostics name the violation ("X (index N) appeared before Y (index M)","Z was not in the stream", or"W appeared at index N instead of V"for strict contiguous mismatches).HasSseRetryDirective(int? millis = null)onstring,Stream, andHttpResponseMessage. Withmillis: null(default), passes when any frame carries aretry:directive (any value); withmillis: <n>, passes when at least one frame carriesretry: <n>(any-match semantics across multipleretry:directives in the same stream).HttpResponseMessageoverload validatesContent-Typeby default (opt-out viastrictContentType: false).SseEventsInOrderAssertionpublic sealed class consumed by TUnit's source generator to emit theHasSseEventsInOrderchain extension onIAssertionSource<string>. ExposesEvaluate(events, expectedNames, strict)as an internal helper so the flatStream/HttpResponseMessageoverloads share the same ordering logic.
Changed
- README entry-points table: corrected
IsServerSentEventsStream()'s documented return type fromTask<AssertionResult>toAssertionResult(the API is synchronous) and the cancellation parameter name fromcttocancellationTokenforHasSseEventonStreamandHttpResponseMessage(the actual API parameter; named-argument callers usingct:would not compile). The same fixes applied to the package README's entry-points table. - Expanded the README "Default event name" bullet in the Wire-format syntax section with a "Practical consequence for test fixtures" note: unlabeled
data: ...\n\nframes matchHasSseEvent("message"),HasFirstSseEvent("message"), andHasSseEventsInOrder("message")per WHATWG; fixtures that emit unlabeled frames must assert against"message", notnull.
0.1.0 - 2026-05-17: Frame parser, fluent HasSseEvent entry points across three receivers, failure-message extension point
Minor release. Lifts the package from skeleton to functional: the WHATWG / W3C SSE wire-format frame parser ships, with HasSseEvent fluent entry points on string, Stream, and HttpResponseMessage receivers, plus a public SseFailureMessage factory surface for consumer-authored typed assertions.
Added
SseFrameParser.Parse(string)returnsIReadOnlyList<SseEvent>for a WHATWG / W3C SSE wire-format body. Handles all three line terminators (\n,\r\n,\r), strips a single leading UTF-8 BOM at offset 0, ignores comment lines (lines starting with:), accumulates multi-linedata:fields joined with\n, parsesretry:as a non-negative integer (non-numeric values ignored per spec), tolerates unknown fields, and dispatches the trailing frame when the body lacks a final blank line. Per-eventIdandRetryMillissemantics (a small deliberate deviation from the browser stream-wide model) so consumers can assert directly on the wire-format directives observed in each frame.SseFailureMessagepublic extension point with eight factory methods:ParseFailure,EventNotFound,EventCountMismatch,DataPredicateNotMatched,DataDeserializationFailed,RetryMillisPredicateNotMatched,UnexpectedContentType,CancellationCutRead. Truncation rules pinned across the family: per-eventDataat 80 characters, body excerpts at 256 characters, U+2026 ellipsis as suffix.SseCountComparisonpublic enum (AtLeast,AtMost,Exactly) backing the chain's count terminators and the typed comparison label inSseFailureMessage.EventCountMismatch.HasSseEvent(string eventName)chain entry point on thestringreceiver, via[AssertionExtension("HasSseEvent")]. ReturnsSseHasEventAssertion. Chain methods:WithData(Func<string, bool>)(narrow to frames whoseDatasatisfies the predicate),AtLeast(int),AtMost(int),Exactly(int).HasSseEvent(string eventName, int minCount = 1, CancellationToken ct = default)flat entry point on theStreamreceiver via[GenerateAssertion]. ReturnsTask<AssertionResult>. Reads the stream into a buffer viaArrayPool-backedReadAsync; onOperationCanceledException, the partial buffer is parsed as best-effort SSE and asserted against. On chain pass the assertion passes; on chain fail the failure renders theCancellationCutReaddiagnostic.HasSseEvent(string eventName, int minCount = 1, bool strictContentType = true, CancellationToken ct = default)flat entry point on theHttpResponseMessagereceiver via[GenerateAssertion]. ValidatesContent-Type: text/event-streamby default (case-insensitive per RFC 9110 section 8.3.2); opt-out withstrictContentType: falsefor test mocks that serve SSE without the canonical header. Body read usesContent.ReadAsStreamAsyncplus the same partial-buffer cancellation pattern as theStreamreceiver, with encoding resolved from the response'sContent-Typecharset (UTF-8 fallback per WHATWG default).
Changed
- BREAKING:
SseEventrecord reshaped. v0.0.1 declaredSseEvent(string? EventName, string? Id, int? RetryMillis, string Data); v0.1.0 declaresSseEvent(string EventName, string Data, string? Id = null, int? RetryMillis = null).EventNameis now non-nullable and the parser fills in"message"per the WHATWG default when noevent:directive appears in the frame. Constructor parameter order shifts to put non-nullable fields first (EventName,Data) before nullable ones (Id,RetryMillis). The deliberate baseline break is recorded inCompatibilitySuppressions.xml. Migration note for v0.0.1 consumers: if your code matchedevt is SseEvent { EventName: null }(the v0.0.1 idiom for "this frame had noevent:directive"), the branch is dead at v0.1.0 because the parser populates"message"per the spec; switch the pattern toevt is SseEvent { EventName: "message" }. Named-argument constructor calls (new SseEvent(EventName: ..., Data: ...)) continue to compile; positional calls need to swap argument order to match the new shape.
0.0.1 - 2026-05-17: Initial preview, skeleton release establishing repository, package identifiers, and quality bar
First public release of the assertion family's 6th package. Two NuGet packages ship from day one to claim both identifiers on nuget.org: a framework-agnostic SseAssertions core and a TUnit-native SseAssertions.TUnit adapter. .NET 10, AOT-compatible, trimmable, no runtime reflection in the assertion path.
The 0.0.1 scope is intentionally narrow. The release exists to establish the repository, claim the SseAssertions and SseAssertions.TUnit package identifiers on nuget.org, and lock the API style and quality bar before the wider parser + per-frame assertion surface ships at 0.1.0.
Added
SseEventpublic record with the field set defined by the WHATWG / W3C Server-Sent Events specification:EventName(theevent:field, nullable),Id(theid:field, nullable),RetryMillis(theretry:field, nullable),Data(the joineddata:lines, non-null). The stable public data type the assertion family consumes; the 0.1.0 frame parser producesIEnumerable<SseEvent>.SseFormat.LooksLikeServerSentEvents(string)lightweight discriminator returningtruewhen the supplied text contains at least one SSE field marker (event:,data:,id:,retry:) followed by the frame-separator double newline. Intentionally cheap and forgiving; the full per-frame parser arrives in 0.1.0.IsServerSentEventsStream()fluent entry point onstring, generated via TUnit's[GenerateAssertion]. Delegates toSseFormat.LooksLikeServerSentEventsand produces a structured failure message when the text doesn't look like an SSE stream.
Both namespaces ship in their respective shipped assemblies; both NuGet packages are independently versioned but evolve in lockstep at the v0.x stage.