mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
HapiClient/SSE per docs/api/client-contract/sse.md and the web reference (web/src/hooks/useSSE.ts): - SSELineParser: incremental frame parser (LF/CRLF, multi-line data, comments, retry ignored); per-block ids only — sticky-cursor semantics live in the caller so id-less heartbeats can never blank the cursor. - ReconnectPolicy + SSETimings: 0s first retry, 1s x2 capped at 30s, 300s slow ceiling after 8 attempts, 0-500ms injectable jitter; connect timeout 10s, staleness 90s, foreground-resume 45s, watchdog tick 10s. - SSETransport protocol + URLSessionSSETransport (URLSession.bytes with dedicated config: 300s idle timeout, 7d resource, no waitsForConnectivity). - actor SSEClient: idle/connecting/connected/backoff/suspended machine; handshake-gated connected state surfacing resume ok|gap; per-subscription cursor sent as ?lastEventId, advanced only after consumer yield (at-least-once); one free token-refresh retry per connect cycle on 401; suspend defers retries, resume applies the 45s staleness check; acceptEncodingIdentity escape hatch (gzip streaming verification TODO). - NetworkPathObserving + NWPathObserver: path change while connected is treated as a transport error (immediate reconnect). - swift-testing suite on a fake transport + manual clock: parser edge cases, exact backoff schedule + seeded jitter bounds, handshake gating, ok/gap verdicts, cursor replay + isolation, heartbeat vs watchdog, 90s staleness, connect timeout, suspend/resume, event ordering, unknown-type passthrough, 401 bypass cap, path-change reconnect.
115 lines
4.4 KiB
Swift
115 lines
4.4 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import HapiClient
|
|
|
|
@Suite("SSE line parser")
|
|
struct SSELineParserTests {
|
|
private func frames(_ text: String) -> [SSEFrame] {
|
|
var parser = SSELineParser()
|
|
return parser.consume(Data(text.utf8))
|
|
}
|
|
|
|
@Test func joinsMultiLineDataWithNewline() {
|
|
let result = frames("data: line1\ndata: line2\ndata: line3\n\n")
|
|
#expect(result == [SSEFrame(data: "line1\nline2\nline3")])
|
|
}
|
|
|
|
@Test func emitsIdOnlyForTheBlockThatCarriedIt() {
|
|
// A dispatched id is consumed; the next block starts with no id.
|
|
// Cursor stickiness across id-less frames is the caller's job.
|
|
let result = frames("id: 018f:1:aa\ndata: first\n\ndata: second\n\n")
|
|
#expect(result.count == 2)
|
|
#expect(result[0] == SSEFrame(id: "018f:1:aa", data: "first"))
|
|
#expect(result[1].id == nil)
|
|
#expect(result[1].data == "second")
|
|
}
|
|
|
|
@Test func heartbeatShapedFrameHasNilId() {
|
|
let result = frames("id: 42\ndata: {\"a\":1}\n\ndata: {\"type\":\"heartbeat\"}\n\n")
|
|
#expect(result.map(\.id) == ["42", nil])
|
|
}
|
|
|
|
@Test func idFromNonDispatchedBlockTagsNextDispatch() {
|
|
// Mirrors the EventSource last-event-id buffer: an id-only block sets
|
|
// the id even though nothing dispatches, and the next data block
|
|
// carries it.
|
|
let result = frames("id: X\n\ndata: y\n\n")
|
|
#expect(result == [SSEFrame(id: "X", data: "y")])
|
|
}
|
|
|
|
@Test func handlesCRLFLineEndings() {
|
|
let result = frames("id: 5\r\nevent: named\r\ndata: hello\r\ndata: world\r\n\r\n")
|
|
#expect(result == [SSEFrame(id: "5", event: "named", data: "hello\nworld")])
|
|
}
|
|
|
|
@Test func ignoresInterleavedComments() {
|
|
let result = frames(": keepalive\ndata: a\n: mid-frame comment\ndata: b\n\n: trailing\n")
|
|
#expect(result == [SSEFrame(data: "a\nb")])
|
|
}
|
|
|
|
@Test func reassemblesFramesSplitAcrossChunks() {
|
|
let text = "id: 018f:9:bb\r\ndata: {\"type\":\"x\"}\r\n\r\ndata: tail\n\n"
|
|
let whole = frames(text)
|
|
|
|
// Byte-at-a-time feeding must produce the identical frames,
|
|
// including a CRLF split between the CR and the LF.
|
|
var parser = SSELineParser()
|
|
var collected: [SSEFrame] = []
|
|
for byte in Data(text.utf8) {
|
|
collected += parser.consume(Data([byte]))
|
|
}
|
|
#expect(collected == whole)
|
|
#expect(whole.count == 2)
|
|
#expect(whole[0] == SSEFrame(id: "018f:9:bb", data: "{\"type\":\"x\"}"))
|
|
|
|
// And an arbitrary mid-line split.
|
|
var parser2 = SSELineParser()
|
|
let bytes = Data(text.utf8)
|
|
let cut = 7
|
|
var collected2 = parser2.consume(bytes.prefix(cut))
|
|
collected2 += parser2.consume(bytes.dropFirst(cut))
|
|
#expect(collected2 == whole)
|
|
}
|
|
|
|
@Test func ignoresRetryAndUnknownFields() {
|
|
let result = frames("retry: 5000\nfancy-field: nope\ndata: a\n\n")
|
|
#expect(result == [SSEFrame(data: "a")])
|
|
}
|
|
|
|
@Test func lineWithoutColonIsFieldWithEmptyValue() {
|
|
// Per spec, "data" alone is the data field with an empty value.
|
|
let result = frames("data\n\n")
|
|
#expect(result == [SSEFrame(data: "")])
|
|
}
|
|
|
|
@Test func stripsExactlyOneLeadingSpace() {
|
|
#expect(frames("data:no-space\n\n") == [SSEFrame(data: "no-space")])
|
|
#expect(frames("data: two-spaces\n\n") == [SSEFrame(data: " two-spaces")])
|
|
}
|
|
|
|
@Test func emptyDataFieldDispatchesEmptyString() {
|
|
#expect(frames("data:\n\n") == [SSEFrame(data: "")])
|
|
}
|
|
|
|
@Test func blankLineWithoutDataDispatchesNothingAndResetsEventType() {
|
|
let result = frames("event: named\n\ndata: x\n\n")
|
|
// The first block had no data: no frame, and its event type must not
|
|
// leak into the following block.
|
|
#expect(result == [SSEFrame(data: "x")])
|
|
}
|
|
|
|
@Test func idContainingNulIsIgnored() {
|
|
let result = frames("id: a\u{0000}b\ndata: x\n\n")
|
|
#expect(result == [SSEFrame(id: nil, data: "x")])
|
|
}
|
|
|
|
@Test func incompleteTrailingBlockIsNotEmitted() {
|
|
var parser = SSELineParser()
|
|
let first = parser.consume(Data("data: complete\n\ndata: partial".utf8))
|
|
#expect(first == [SSEFrame(data: "complete")])
|
|
// The unterminated tail only dispatches once its blank line arrives.
|
|
let second = parser.consume(Data("\n\n".utf8))
|
|
#expect(second == [SSEFrame(data: "partial")])
|
|
}
|
|
}
|