Skip to content

Commit 18977b5

Browse files
bjohansebasclaude
andcommitted
test(e2e): move the reconnection tests to the browser and snapshot their console
The inactivity watchdog is now exercised against a genuinely silent SSE connection (a server heartbeat far beyond the client timeout): the second connect proves it fires on pure silence and the third that it re-arms after a reconnect. Manual recovery gets its own test — disconnect() followed by setOptionsAndConnect() opens a fresh connection that still delivers updates. Both, plus the server-restart catch-up test, snapshot their console sequence; browser network-error noise is filtered out of the snapshots since its volume depends on reconnect timing. The fake-timer equivalents in the jsdom suite are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 16c14b3 commit 18977b5

4 files changed

Lines changed: 113 additions & 78 deletions

File tree

test/client.test.js

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -633,77 +633,6 @@ describe("client", () => {
633633
require("../client-src");
634634
expect(EventSourceStub.instances).toHaveLength(1);
635635
});
636-
637-
it("closes and re-opens the connection on timeout", () => {
638-
// The watchdog interval is created during the client's first load. Fake
639-
// timers must be enabled before that load so jest can drive it.
640-
jest.useFakeTimers({ doNotFake: ["nextTick"] });
641-
// Drop the wrapper opened by the outer beforeEach so we get a fresh
642-
// EventSource scheduled under fake timers.
643-
delete globalThis.__wdmEventSourceWrapper;
644-
EventSourceStub.instances.length = 0;
645-
loadClient();
646-
647-
const [first] = EventSourceStub.instances;
648-
expect(first.closed).toBe(false);
649-
// The watchdog ticks at `timeout/2` and disconnects when
650-
// `Date.now() - lastActivity > timeout`. 30s is enough to cross that
651-
// boundary regardless of which tick reports it first.
652-
jest.advanceTimersByTime(30 * 1000);
653-
expect(first.closed).toBe(true);
654-
// Reconnect is scheduled after `options.timeout` (20s).
655-
jest.advanceTimersByTime(20 * 1000);
656-
expect(EventSourceStub.instances).toHaveLength(2);
657-
});
658-
659-
it("keeps the inactivity watchdog after a reconnect", () => {
660-
jest.useFakeTimers({ doNotFake: ["nextTick"] });
661-
delete globalThis.__wdmEventSourceWrapper;
662-
EventSourceStub.instances.length = 0;
663-
loadClient();
664-
665-
// First silent stall: the watchdog disconnects and a reconnect opens a
666-
// second source 20s later.
667-
jest.advanceTimersByTime(30 * 1000);
668-
jest.advanceTimersByTime(20 * 1000);
669-
expect(EventSourceStub.instances).toHaveLength(2);
670-
671-
// The reconnected source must be watched too: another silent stall has
672-
// to close it and schedule a third connection.
673-
const second = EventSourceStub.lastInstance();
674-
jest.advanceTimersByTime(30 * 1000);
675-
expect(second.closed).toBe(true);
676-
jest.advanceTimersByTime(20 * 1000);
677-
expect(EventSourceStub.instances).toHaveLength(3);
678-
});
679-
680-
it("ignores error events that arrive after disconnect()", () => {
681-
jest.useFakeTimers({ doNotFake: ["nextTick"] });
682-
delete globalThis.__wdmEventSourceWrapper;
683-
EventSourceStub.instances.length = 0;
684-
const freshClient = loadClient();
685-
686-
const [first] = EventSourceStub.instances;
687-
freshClient.disconnect();
688-
689-
// An EventSource fires a queued error when its connection dies — it
690-
// must not resurrect the closed wrapper by scheduling a reconnect.
691-
first.dispatch("error", {});
692-
jest.advanceTimersByTime(60 * 1000);
693-
694-
expect(EventSourceStub.instances).toHaveLength(1);
695-
});
696-
697-
it("disconnect() drops the cached wrapper so a new connect starts fresh", () => {
698-
client.disconnect();
699-
expect(
700-
globalThis.__wdmEventSourceWrapper["/__webpack_hmr"],
701-
).toBeUndefined();
702-
703-
client.setOptionsAndConnect({});
704-
expect(EventSourceStub.instances).toHaveLength(2);
705-
expect(EventSourceStub.lastInstance().closed).toBe(false);
706-
});
707636
});
708637

709638
describe("with no EventSource", () => {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`hot client (browser) reconnects after a server restart and syncs up on missed builds 1`] = `
4+
[
5+
"[webpack-dev-middleware] connected",
6+
"[webpack-dev-middleware] connected",
7+
"[webpack-dev-middleware] Checking for updates on the server...",
8+
"[webpack-dev-middleware] Hot updated 1 modules.",
9+
"[webpack-dev-middleware] App is up to date.",
10+
]
11+
`;
12+
13+
exports[`hot client (browser) reconnects manually with setOptionsAndConnect() after disconnect() 1`] = `
14+
[
15+
"[webpack-dev-middleware] connected",
16+
"[webpack-dev-middleware] connected",
17+
"[webpack-dev-middleware] bundle rebuilding (<fixture>/app.js changed)",
18+
"[webpack-dev-middleware] bundle rebuilt in Xms",
19+
"[webpack-dev-middleware] Checking for updates on the server...",
20+
"[webpack-dev-middleware] Hot updated 1 modules.",
21+
"[webpack-dev-middleware] App is up to date.",
22+
]
23+
`;
24+
25+
exports[`hot client (browser) watchdog-reconnects a silent connection and stays armed afterwards 1`] = `
26+
[
27+
"[webpack-dev-middleware] connected",
28+
"[webpack-dev-middleware] connected",
29+
"[webpack-dev-middleware] connected",
30+
]
31+
`;

test/e2e/client.test.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import collectConsole from "../helpers/console-collector";
1+
import collectConsole, { normalizeConsole } from "../helpers/console-collector";
22
import createHotApp from "../helpers/hot-app";
33
import runBrowser from "../helpers/run-browser";
44

@@ -165,6 +165,77 @@ describe("hot client (browser)", () => {
165165

166166
await waitForAppText(page, "v2");
167167
expect(await readReloadMarker(page)).toBe(true);
168+
169+
// The whole story in one place: connect, silent gap while the server was
170+
// down, reconnect, and the catch-up sync applying the missed build.
171+
await console_.waitFor("App is up to date");
172+
expect(normalizeConsole(console_.messages)).toMatchSnapshot();
173+
});
174+
175+
it("watchdog-reconnects a silent connection and stays armed afterwards", async () => {
176+
app = await createHotApp({
177+
query: "?timeout=1000",
178+
code: acceptedApp("v1"),
179+
// A heartbeat far beyond the client timeout leaves the connection open
180+
// but silent, so only the inactivity watchdog can trigger reconnects.
181+
hot: { heartbeat: 3600000 },
182+
});
183+
({ page, browser } = await runBrowser());
184+
const console_ = collectConsole(page);
185+
186+
await page.goto(app.url);
187+
await waitForAppText(page, "v1");
188+
189+
// Three connects = the initial one plus two watchdog cycles: the second
190+
// proves the watchdog fires on pure silence (no error event involved),
191+
// the third that it re-arms after a reconnect instead of dying with the
192+
// first clearInterval.
193+
await console_.waitForCount("connected", 3);
194+
195+
// Nothing but connects: the silent cycles produce no other output. The
196+
// snapshot is taken before the edit — the watchdog keeps cycling, so any
197+
// later cut would race with the next reconnect.
198+
expect(normalizeConsole(console_.messages)).toMatchSnapshot();
199+
200+
// The reconnected connection still delivers updates.
201+
app.edit(acceptedApp("v2"));
202+
await waitForAppText(page, "v2");
203+
204+
expect(
205+
await page.evaluate(() => document.getElementById("app").textContent),
206+
).toBe("v2");
207+
});
208+
209+
it("reconnects manually with setOptionsAndConnect() after disconnect()", async () => {
210+
app = await createHotApp({
211+
code: `
212+
globalThis.hotClient = require(${JSON.stringify(CLIENT_ENTRY)});
213+
document.getElementById("app").textContent = "v1";
214+
if (module.hot) {
215+
module.hot.accept();
216+
}
217+
`,
218+
});
219+
({ page, browser } = await runBrowser());
220+
const console_ = collectConsole(page);
221+
222+
await page.goto(app.url);
223+
await waitForAppText(page, "v1");
224+
await console_.waitFor("connected");
225+
226+
// disconnect() drops the cached wrapper, so a manual connect starts a
227+
// fresh connection on the same path.
228+
await page.evaluate(() => {
229+
globalThis.hotClient.disconnect();
230+
globalThis.hotClient.setOptionsAndConnect({});
231+
});
232+
await console_.waitForCount("connected", 2);
233+
234+
app.edit(acceptedApp("v2"));
235+
await waitForAppText(page, "v2");
236+
await console_.waitFor("App is up to date");
237+
238+
expect(normalizeConsole(console_.messages)).toMatchSnapshot();
168239
});
169240

170241
it("routes server publish() payloads to subscribe handlers", async () => {

test/helpers/console-collector.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,20 @@ function collectConsole(page) {
4545

4646
/**
4747
* Strip the run-specific parts (timings, temp fixture paths) so browser
48-
* console output can be snapshotted.
48+
* console output can be snapshotted. Network-error noise emitted by the
49+
* browser itself ("Failed to load resource: …") is dropped — how many of
50+
* those a severed connection produces depends on reconnect timing.
4951
* @param {string[]} messages raw console messages
5052
* @returns {string[]} normalized messages
5153
*/
5254
function normalizeConsole(messages) {
53-
return messages.map((text) =>
54-
text
55-
.replaceAll(/\d+\s?ms/g, "Xms")
56-
.replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, "<fixture>"),
57-
);
55+
return messages
56+
.filter((text) => !text.startsWith("Failed to load resource"))
57+
.map((text) =>
58+
text
59+
.replaceAll(/\d+\s?ms/g, "Xms")
60+
.replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, "<fixture>"),
61+
);
5862
}
5963

6064
module.exports = collectConsole;

0 commit comments

Comments
 (0)