121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
/**
|
|
* SSE-correctness integration tests (Task 11, Fase 8 B).
|
|
*
|
|
* Drives the real createSSEStream pipeline through a controllable fake upstream.
|
|
* Run with: node --import tsx/esm --test --test-concurrency=1 tests/integration/sse-correctness.test.ts
|
|
*
|
|
* Notes on observed createSSEStream behavior (calibrated invariants):
|
|
* - The TransformStream processes SSE events and emits translated chunks to the client.
|
|
* - [DONE] is consumed by the pipeline: it closes the upstream readable and the
|
|
* TransformStream flushes + terminates, but does NOT re-emit "data: [DONE]" to the output.
|
|
* The output stream closes naturally (reader.read() returns {done:true}).
|
|
* - Upstream errors propagate as TransformStream errors (reader.read() rejects).
|
|
* - Cancel propagates via ReadableStream cancel callback (pipeThrough wires it).
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { fakeUpstreamStream } from "../helpers/fakeUpstreamStream.ts";
|
|
import { createSSEStream } from "../../open-sse/utils/stream.ts";
|
|
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
|
|
|
/** Drain a ReadableStream to a string, with optional timeout guard. */
|
|
async function drain(out: ReadableStream, timeoutMs = 5000): Promise<string> {
|
|
const r = out.getReader();
|
|
const dec = new TextDecoder();
|
|
let s = "";
|
|
const timeout = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error(`drain timeout after ${timeoutMs}ms`)), timeoutMs)
|
|
);
|
|
const read = async () => {
|
|
for (;;) {
|
|
const { done, value } = await r.read();
|
|
if (done) break;
|
|
s += dec.decode(value);
|
|
}
|
|
return s;
|
|
};
|
|
return Promise.race([read(), timeout]);
|
|
}
|
|
|
|
function makeStream(extraOpts: Record<string, unknown> = {}) {
|
|
const up = fakeUpstreamStream();
|
|
const transform = createSSEStream({
|
|
sourceFormat: FORMATS.OPENAI,
|
|
targetFormat: FORMATS.OPENAI,
|
|
model: "m",
|
|
...extraOpts,
|
|
});
|
|
const out = up.stream.pipeThrough(transform as TransformStream);
|
|
return { up, out };
|
|
}
|
|
|
|
test("1. stream closes after [DONE] (no hang)", async () => {
|
|
const { up, out } = makeStream();
|
|
up.push('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n');
|
|
up.push("data: [DONE]\n\n");
|
|
up.close();
|
|
// drain() must return within the timeout — proves the stream closed
|
|
const text = await drain(out);
|
|
assert.ok(text.includes("hi"), `expected 'hi' in output: ${JSON.stringify(text)}`);
|
|
});
|
|
|
|
test("2. client cancel propagates to upstream (abort propagation)", async () => {
|
|
const { up, out } = makeStream();
|
|
let cancelled = false;
|
|
up.onCancel(() => {
|
|
cancelled = true;
|
|
});
|
|
const r = out.getReader();
|
|
await r.cancel("client-abort");
|
|
// Allow microtask queue to flush
|
|
await new Promise((res) => setTimeout(res, 50));
|
|
assert.equal(cancelled, true, "upstream cancel callback must have been called");
|
|
});
|
|
|
|
test("3. no leaked idle timers across N sequential streams", async () => {
|
|
// createSSEStream installs a setInterval idle watchdog per stream.
|
|
// If cleanup (clearInterval) does not run on stream close, timers accumulate.
|
|
// This test creates 10 streams and drains them; it acts as a smoke test that
|
|
// the process does not hang (a leaked setInterval that fires 10s later would
|
|
// prevent the test process from exiting cleanly in --test-force-exit mode).
|
|
for (let i = 0; i < 10; i++) {
|
|
const { up, out } = makeStream();
|
|
up.push("data: [DONE]\n\n");
|
|
up.close();
|
|
await drain(out);
|
|
}
|
|
// If we reach here without a timeout, no blocking resources were leaked.
|
|
assert.ok(true, "all 10 streams completed without hanging");
|
|
});
|
|
|
|
test("4. final snapshot does not duplicate tail text", async () => {
|
|
// Regression guard for the SSE snapshot bug (CLAUDE.md §2, Fase 8 B spec §4.2):
|
|
// the text 'Hello' should appear EXACTLY ONCE in the output, not duplicated.
|
|
const { up, out } = makeStream();
|
|
up.push('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n');
|
|
up.push("data: [DONE]\n\n");
|
|
up.close();
|
|
const text = await drain(out);
|
|
const occurrences = (text.match(/Hello/g) ?? []).length;
|
|
assert.equal(
|
|
occurrences,
|
|
1,
|
|
`'Hello' appeared ${occurrences} times; expected 1. Output: ${JSON.stringify(text)}`
|
|
);
|
|
});
|
|
|
|
test("5. upstream error propagates and closes stream (no hang, Hard Rule #6)", async () => {
|
|
// If upstream errors, the TransformStream must propagate the error so the
|
|
// consumer sees a rejection — never silently swallow and never hang.
|
|
const { up, out } = makeStream();
|
|
up.error(new Error("upstream boom"));
|
|
await assert.rejects(
|
|
async () => {
|
|
await drain(out, 2000);
|
|
},
|
|
undefined, // any error is acceptable — just must not hang
|
|
"upstream error must propagate as a rejection to the consumer"
|
|
);
|
|
});
|