Skip to content

Commit 25d5dc5

Browse files
bjohansebasclaude
andcommitted
test(e2e): read the SSE protocol raw off a real server
A plain http reader against the running middleware covers what the fake-response units simulated: the keep-alive handshake headers, real heartbeat frames on real timers, and the catch-up sync reaching only the newly connecting client while already-attached ones stay quiet. The three fake-timer/fake-response equivalents leave hot.test.js; what remains there has no end-to-end path (pure parsing, HTTP/2 headers, forced pairing edges, progress throttling). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2e0fb14 commit 25d5dc5

2 files changed

Lines changed: 110 additions & 47 deletions

File tree

test/e2e/protocol.test.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import http from "node:http";
2+
3+
import createHotApp from "../helpers/hot-app";
4+
5+
jest.setTimeout(120000);
6+
7+
/**
8+
* Open a raw SSE connection and collect frames as they arrive.
9+
* @param {string} url SSE endpoint url
10+
* @returns {Promise<{ headers: EXPECTED_ANY, frames: string[], close: () => void }>} reader
11+
*/
12+
function openSseReader(url) {
13+
return new Promise((resolve, reject) => {
14+
const request = http.get(url, (res) => {
15+
/** @type {string[]} */
16+
const frames = [];
17+
res.setEncoding("utf8");
18+
res.on("data", (chunk) => {
19+
for (const line of chunk.split("\n")) {
20+
if (line.startsWith("data: ")) {
21+
frames.push(line.slice("data: ".length));
22+
}
23+
}
24+
});
25+
resolve({
26+
headers: res.headers,
27+
frames,
28+
close: () => request.destroy(),
29+
});
30+
});
31+
request.on("error", reject);
32+
});
33+
}
34+
35+
/**
36+
* @param {string[]} frames collected frames
37+
* @param {(frame: string) => boolean} predicate match
38+
* @param {number=} timeout give-up timeout
39+
* @returns {Promise<void>} resolved when a frame matches
40+
*/
41+
async function waitForFrame(frames, predicate, timeout = 30000) {
42+
const start = Date.now();
43+
44+
while (Date.now() - start < timeout) {
45+
if (frames.some((frame) => predicate(frame))) {
46+
return;
47+
}
48+
49+
await new Promise((resolve) => {
50+
setTimeout(resolve, 50);
51+
});
52+
}
53+
54+
throw new Error(`No frame matched.\nSeen:\n${frames.join("\n")}`);
55+
}
56+
57+
// eslint-disable-next-line jsdoc/reject-any-type
58+
/** @typedef {any} EXPECTED_ANY */
59+
60+
describe("SSE protocol (raw)", () => {
61+
let app;
62+
/** @type {{ close: () => void }[]} */
63+
let readers = [];
64+
65+
afterEach(async () => {
66+
for (const reader of readers) {
67+
reader.close();
68+
}
69+
readers = [];
70+
if (app) {
71+
const closing = app;
72+
app = undefined;
73+
await closing.close();
74+
}
75+
});
76+
77+
it("keeps the HTTP/1 connection alive and heartbeats it", async () => {
78+
app = await createHotApp({
79+
code: "document.title = 'x';",
80+
hot: { heartbeat: 100 },
81+
});
82+
83+
const reader = await openSseReader(`${app.url}__webpack_hmr`);
84+
readers.push(reader);
85+
86+
expect(reader.headers.connection).toBe("keep-alive");
87+
expect(reader.headers["content-type"]).toBe(
88+
"text/event-stream;charset=utf-8",
89+
);
90+
91+
// Real timers, real frames.
92+
await waitForFrame(reader.frames, (frame) => frame === "💓");
93+
});
94+
95+
it("catch-up syncs only the newly connecting client", async () => {
96+
app = await createHotApp({ code: "document.title = 'x';" });
97+
98+
const early = await openSseReader(`${app.url}__webpack_hmr`);
99+
readers.push(early);
100+
await waitForFrame(early.frames, (frame) => frame.includes('"sync"'));
101+
early.frames.length = 0;
102+
103+
// A later client gets the catch-up sync; the connected one must not.
104+
const late = await openSseReader(`${app.url}__webpack_hmr`);
105+
readers.push(late);
106+
await waitForFrame(late.frames, (frame) => frame.includes('"sync"'));
107+
108+
expect(early.frames.some((frame) => frame.includes('"sync"'))).toBe(false);
109+
});
110+
});

test/hot.test.js

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -165,37 +165,6 @@ describe("hot middleware (unit)", () => {
165165
jest.useRealTimers();
166166
});
167167

168-
it("emits a heartbeat at the configured interval", () => {
169-
const stream = createEventStream(1000, noopLogger);
170-
const writes = [];
171-
const fakeRes = {
172-
writableEnded: false,
173-
write: (chunk) => writes.push(chunk),
174-
writeHead: () => {},
175-
end: () => {},
176-
};
177-
const fakeReq = {
178-
httpVersion: "1.1",
179-
socket: { setKeepAlive: () => {} },
180-
on: () => {},
181-
};
182-
183-
stream.handler(fakeReq, fakeRes);
184-
writes.length = 0;
185-
jest.advanceTimersByTime(1000);
186-
187-
expect(writes.some((w) => w.includes("💓"))).toBe(true);
188-
189-
stream.close();
190-
});
191-
192-
it("sets Connection: keep-alive for HTTP/1 clients", () => {
193-
const stream = createEventStream(5000, noopLogger);
194-
const { headers } = attachClient(stream, { httpVersion: "1.1" });
195-
expect(headers.Connection).toBe("keep-alive");
196-
stream.close();
197-
});
198-
199168
it("does not set Connection: keep-alive for HTTP/2 clients", () => {
200169
const stream = createEventStream(5000, noopLogger);
201170
const { headers } = attachClient(stream, { httpVersion: "2.0" });
@@ -366,22 +335,6 @@ describe("createHot", () => {
366335
hot.close();
367336
});
368337

369-
it("does not re-send the catch-up sync to already connected clients", () => {
370-
const compiler = makeFakeCompiler();
371-
const hot = createHot(compiler, {});
372-
const { writes: firstWrites } = attachClient({ handler: hot.handle });
373-
374-
compiler.emitDone(makeFakeStats());
375-
firstWrites.length = 0;
376-
377-
const { writes: secondWrites } = attachClient({ handler: hot.handle });
378-
379-
expect(secondWrites.some((w) => w.includes('"action":"sync"'))).toBe(true);
380-
expect(firstWrites.some((w) => w.includes('"action":"sync"'))).toBe(false);
381-
382-
hot.close();
383-
});
384-
385338
it("pairs bundles by name when the compilation order changes", () => {
386339
const compiler = makeFakeCompiler();
387340
const hot = createHot(compiler, {});

0 commit comments

Comments
 (0)