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-lint gate 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 on Stream and HttpResponseMessage are replaced by a chainable HasSseEvent(eventName) matching the string receiver. 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) becomes HasSseEvent("x").AtLeast(2); HasSseEvent("x") (default minimum of 1) is unchanged. The strictContentType and cancellationToken arguments stay on the entry method (HasSseEvent("x", strictContentType: false, cancellationToken: ct)).
  • A null Stream / HttpResponseMessage receiver now fails the assertion ("the receiver was null") instead of throwing ArgumentNullException, matching the long-standing behavior of the string receiver. A null eventName still throws ArgumentNullException, and a negative count still throws ArgumentOutOfRangeException (now from the terminator).

Added

  • Frame narrowers on the Stream and HttpResponseMessage receivers. WithData, WithDataParsedAs<T>, WithId, and WithRetryMillis constrain which frames count within the cancellation-bounded read, producing the same per-narrower diagnostics as the string chain. 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 the SseAssertions core.

Fixed

  • A foreign cancellation during a streaming read is no longer reported as a cancellation-cut. The shared response-read helper caught every OperationCanceledException as a caller-driven partial read; an OperationCanceledException from a different source (for example an HttpClient timeout) 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 distinction EndsCleanlyOnCancellation already made in 0.5.1. This applies to every HttpResponseMessage read path (HasSseEvent, HasFirstSseEvent, HasSseEventsInOrder, HasSseRetryDirective, HasSseRetryDirectiveFirst).
  • Stream acquisition no longer pre-empts the cancellation-cut diagnostic. The HttpResponseMessage read paths passed the cancellation token to ReadAsStreamAsync, 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 given id: directive (matched case-sensitively; a frame without an id: directive never matches). On no match the failure lists every observed Id for events of the requested type, rendering frames without an id: directive as <absent>.
  • WithRetryMillis(Func<int?, bool> predicate) narrows to frames whose RetryMillis satisfies the predicate (null for frames without a retry: 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's Data with the caller-supplied parse delegate and counts frames whose parsed value satisfies predicate. Supply a reflection-free parser (for example a source-generated JsonSerializer.Deserialize with a JsonTypeInfo<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

  • EndsCleanlyOnCancellation no longer treats a foreign-token cancellation as a clean teardown. The read pipeline caught every OperationCanceledException as the cooperative-teardown signal, regardless of which token fired it. A TaskCanceledException raised by HttpClient.Timeout on 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 supplied cancellationToken is the one that was canceled; an OperationCanceledException from 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 the IOException / HttpRequestException transport-failure cases are unchanged.
  • HasSseRetryDirectiveFirst now strips a leading UTF-8 BOM before scanning for the retry: directive. A body that opens with a byte-order mark (U+FEFF) put the mark on the first field line, so retry failed 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 way SseFrameParser does, 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 the string, Stream, and HttpResponseMessage receivers asserts that a retry: directive precedes the first data-bearing event and that the leading directive's value equals millis. Position and value were previously assertable only separately (HasSseRetryDirectiveFirst() for position, HasSseRetryDirective(millis) for value), which a forward-only HttpResponseMessage body 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 leading retry: 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: HasSseRetryDirectiveFirst is documented as matching the first non-empty data: field, and the standard ASP.NET Core control frame shape (event: retry + empty data: + retry: <ms>) is called out as a passing case. The note that a named event: retry with no retry: field fails is unchanged.

Fixed

  • HasSseRetryDirectiveFirst now treats an empty data: line as carrying no payload. Results.ServerSentEvents over an SseItem with a ReconnectionInterval serializes the reconnection control frame as event: retry, then an empty data: line, then retry: <ms> (the runtime fixes the field order to event, data, id, retry). The previous wire-level check read that empty data: line as "a data field appeared before the retry directive" and failed, so the assertion rejected the standard server's own output even though the retry: directive is present and is the first event. The check now ignores empty data: lines (a bare data line, or a data: line with no value after the optional leading space); only a non-empty data: value before the first retry: fails. A standalone retry:-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) on HttpResponseMessage. Reads the response body via ReadAsStreamAsync(cancellationToken) and asserts the read tears down via cooperative cancellation (the read completes, or raises OperationCanceledException) rather than surfacing a transport exception (IOException, HttpRequestException). Mirrors the existing Stream overload's teardown classification and the content-type handling of the other HttpResponseMessage receivers: strictContentType defaults to true (fails with the unexpected-content-type diagnostic when Content-Type's media type is not text/event-stream); a null Content passes. Source-generated via [GenerateAssertion].

Changed

  • README and packed-README clarification: HasSseRetryDirectiveFirst matches the WHATWG retry: directive field, not an event: retry named event. A stream that emits event: retry followed by a data: field carries no retry: 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() (with Stream and string receivers) asserts that the SSE stream sends a retry: directive before any data: field. A retry: 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 raises OperationCanceledException) 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 for EndsCleanlyOnCancellation, 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) on HttpResponseMessage. Synchronous, header-only discriminator (does not read the body). Non-strict mode passes when Content-Type's media type is text/event-stream (case-insensitive) with any trailing parameters such as charset=utf-8. Strict mode requires the bare media type with no parameters. Use as a lightweight smoke-test alternative to the HasSseEvent(strictContentType) form that reads and parses the body.
  • HasFirstSseEvent(string eventName) on string, Stream, and HttpResponseMessage. Asserts the first parsed SSE frame's event: name. Unlabeled frames match HasFirstSseEvent("message") per the WHATWG default-event-name rule. Stream and HttpResponseMessage overloads are async with CancellationToken-bounded reads; the HttpResponseMessage overload validates Content-Type by default (opt-out via strictContentType: false). Failure diagnostics name the observed first event or report "stream contained no events".
  • HasSseEventsInOrder(string[] eventNames) on string (chain) with .WithStrictOrdering() modifier, plus HasSseEventsInOrder(IReadOnlyList<string>, bool strictOrdering, ...) flat form on Stream and HttpResponseMessage. 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 empty eventNames array 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) on string, Stream, and HttpResponseMessage. With millis: null (default), passes when any frame carries a retry: directive (any value); with millis: <n>, passes when at least one frame carries retry: <n> (any-match semantics across multiple retry: directives in the same stream). HttpResponseMessage overload validates Content-Type by default (opt-out via strictContentType: false).
  • SseEventsInOrderAssertion public sealed class consumed by TUnit's source generator to emit the HasSseEventsInOrder chain extension on IAssertionSource<string>. Exposes Evaluate(events, expectedNames, strict) as an internal helper so the flat Stream / HttpResponseMessage overloads share the same ordering logic.

Changed

  • README entry-points table: corrected IsServerSentEventsStream()'s documented return type from Task<AssertionResult> to AssertionResult (the API is synchronous) and the cancellation parameter name from ct to cancellationToken for HasSseEvent on Stream and HttpResponseMessage (the actual API parameter; named-argument callers using ct: 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\n frames match HasSseEvent("message"), HasFirstSseEvent("message"), and HasSseEventsInOrder("message") per WHATWG; fixtures that emit unlabeled frames must assert against "message", not null.

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) returns IReadOnlyList<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-line data: fields joined with \n, parses retry: 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-event Id and RetryMillis semantics (a small deliberate deviation from the browser stream-wide model) so consumers can assert directly on the wire-format directives observed in each frame.
  • SseFailureMessage public extension point with eight factory methods: ParseFailure, EventNotFound, EventCountMismatch, DataPredicateNotMatched, DataDeserializationFailed, RetryMillisPredicateNotMatched, UnexpectedContentType, CancellationCutRead. Truncation rules pinned across the family: per-event Data at 80 characters, body excerpts at 256 characters, U+2026 ellipsis as suffix.
  • SseCountComparison public enum (AtLeast, AtMost, Exactly) backing the chain's count terminators and the typed comparison label in SseFailureMessage.EventCountMismatch.
  • HasSseEvent(string eventName) chain entry point on the string receiver, via [AssertionExtension("HasSseEvent")]. Returns SseHasEventAssertion. Chain methods: WithData(Func<string, bool>) (narrow to frames whose Data satisfies the predicate), AtLeast(int), AtMost(int), Exactly(int).
  • HasSseEvent(string eventName, int minCount = 1, CancellationToken ct = default) flat entry point on the Stream receiver via [GenerateAssertion]. Returns Task<AssertionResult>. Reads the stream into a buffer via ArrayPool-backed ReadAsync; on OperationCanceledException, the partial buffer is parsed as best-effort SSE and asserted against. On chain pass the assertion passes; on chain fail the failure renders the CancellationCutRead diagnostic.
  • HasSseEvent(string eventName, int minCount = 1, bool strictContentType = true, CancellationToken ct = default) flat entry point on the HttpResponseMessage receiver via [GenerateAssertion]. Validates Content-Type: text/event-stream by default (case-insensitive per RFC 9110 section 8.3.2); opt-out with strictContentType: false for test mocks that serve SSE without the canonical header. Body read uses Content.ReadAsStreamAsync plus the same partial-buffer cancellation pattern as the Stream receiver, with encoding resolved from the response's Content-Type charset (UTF-8 fallback per WHATWG default).

Changed

  • BREAKING: SseEvent record reshaped. v0.0.1 declared SseEvent(string? EventName, string? Id, int? RetryMillis, string Data); v0.1.0 declares SseEvent(string EventName, string Data, string? Id = null, int? RetryMillis = null). EventName is now non-nullable and the parser fills in "message" per the WHATWG default when no event: 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 in CompatibilitySuppressions.xml. Migration note for v0.0.1 consumers: if your code matched evt is SseEvent { EventName: null } (the v0.0.1 idiom for "this frame had no event: directive"), the branch is dead at v0.1.0 because the parser populates "message" per the spec; switch the pattern to evt 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

  • SseEvent public record with the field set defined by the WHATWG / W3C Server-Sent Events specification: EventName (the event: field, nullable), Id (the id: field, nullable), RetryMillis (the retry: field, nullable), Data (the joined data: lines, non-null). The stable public data type the assertion family consumes; the 0.1.0 frame parser produces IEnumerable<SseEvent>.
  • SseFormat.LooksLikeServerSentEvents(string) lightweight discriminator returning true when 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 on string, generated via TUnit's [GenerateAssertion]. Delegates to SseFormat.LooksLikeServerSentEvents and 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.