Skip to content

Commit 1c10209

Browse files
committed
test(client): cover process-update
Isolate the HMR runtime lookup behind a small util so process-update is loadable under jest, and add coverage for the updated-modules console output.
1 parent cd4b658 commit 1c10209

4 files changed

Lines changed: 231 additions & 3 deletions

File tree

client-src/process-update.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
/* global __webpack_hash__ */
22

3+
import getHot from "./utils/get-hot.js";
34
import { log } from "./utils/log.js";
5+
import reloadPage from "./utils/reload.js";
46

5-
const hot = import.meta.webpackHot;
7+
const maybeHot = getHot();
68

7-
if (!hot) {
9+
if (!maybeHot) {
810
throw new Error("[HMR] Hot Module Replacement is disabled.");
911
}
1012

13+
const hot = maybeHot;
14+
1115
const HMR_DOCS_URL = "https://webpack.js.org/concepts/hot-module-replacement/";
1216

1317
/** @type {string | undefined} */
@@ -60,7 +64,7 @@ export default function applyUpdate(hash, options) {
6064
function performReload() {
6165
if (reload) {
6266
log.warn("Reloading page");
63-
window.location.reload();
67+
reloadPage();
6468
}
6569
}
6670

client-src/utils/get-hot.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Isolated so the HMR runtime can be stubbed in tests — `import.meta` cannot
3+
* be evaluated under jest's CommonJS transform.
4+
* @returns {webpack.Hot | undefined} the webpack HMR runtime API (`import.meta.webpackHot`)
5+
*/
6+
export default function getHot() {
7+
return import.meta.webpackHot;
8+
}

client-src/utils/reload.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Isolated so tests can stub it — `window.location` is not configurable in
3+
* modern jsdom.
4+
*/
5+
export default function reloadPage() {
6+
window.location.reload();
7+
}

test/process-update.test.js

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
/** @typedef {{ status: jest.Mock, check: jest.Mock, apply: jest.Mock }} FakeHot */
6+
/** @typedef {{ error: Error, moduleId: string, type: string }} ErroredEvent */
7+
/** @typedef {{ onErrored: (event: ErroredEvent) => void }} FakeApplyOptions */
8+
9+
jest.mock("../client-src/utils/get-hot", () => jest.fn());
10+
jest.mock("../client-src/utils/reload", () => jest.fn());
11+
12+
/**
13+
* Flush pending promise callbacks.
14+
* @returns {Promise<void>} resolved after one timer tick
15+
*/
16+
function flushPromises() {
17+
return new Promise((resolve) => {
18+
setTimeout(resolve, 0);
19+
});
20+
}
21+
22+
describe("process-update", () => {
23+
/** @type {jest.Mock} */
24+
let getHot;
25+
/** @type {jest.Mock} */
26+
let reloadPage;
27+
/** @type {typeof import("../client-src/process-update").default} */
28+
let applyUpdate;
29+
/** @type {(level: import("../client-src/utils/log").LogLevel) => void} */
30+
let setLogLevel;
31+
32+
/**
33+
* @param {{ status?: string, checkResult?: string[] | null, applyImpl?: (options: FakeApplyOptions) => Promise<string[] | null> }=} behavior fake runtime behavior
34+
* @returns {FakeHot} fake `import.meta.webpackHot`
35+
*/
36+
function makeFakeHot({
37+
status = "idle",
38+
checkResult = ["./a.js"],
39+
applyImpl = () => Promise.resolve(["./a.js"]),
40+
} = {}) {
41+
return {
42+
status: jest.fn(() => status),
43+
check: jest.fn(() => Promise.resolve(checkResult)),
44+
apply: jest.fn(applyImpl),
45+
};
46+
}
47+
48+
/**
49+
* Load a fresh process-update with the given fake runtime. The runtime is
50+
* resolved at module evaluation, so the mock must be configured in the same
51+
* module registry before process-update is required.
52+
* @param {FakeHot} hot fake `import.meta.webpackHot`
53+
* @returns {typeof import("../client-src/process-update").default} applyUpdate
54+
*/
55+
function loadApplyUpdate(hot) {
56+
jest.resetModules();
57+
58+
getHot = require("../client-src/utils/get-hot");
59+
getHot.mockReturnValue(hot);
60+
reloadPage = require("../client-src/utils/reload");
61+
reloadPage.mockReset();
62+
63+
({ setLogLevel } = require("../client-src/utils/log"));
64+
65+
return require("../client-src/process-update").default;
66+
}
67+
68+
beforeEach(() => {
69+
applyUpdate = loadApplyUpdate(makeFakeHot());
70+
71+
// The bundle hash webpack injects; anything different from the payload
72+
// hash makes the client check for updates.
73+
globalThis.__webpack_hash__ = "current-hash";
74+
75+
jest.spyOn(console, "info").mockImplementation(() => {});
76+
jest.spyOn(console, "log").mockImplementation(() => {});
77+
jest.spyOn(console, "warn").mockImplementation(() => {});
78+
jest.spyOn(console, "error").mockImplementation(() => {});
79+
});
80+
81+
afterEach(() => {
82+
setLogLevel("info");
83+
delete globalThis.__webpack_hash__;
84+
jest.restoreAllMocks();
85+
});
86+
87+
it("throws at module evaluation when the HMR runtime is disabled", () => {
88+
jest.isolateModules(() => {
89+
/** @type {jest.Mock} */
90+
const isolatedGetHot = require("../client-src/utils/get-hot");
91+
92+
isolatedGetHot.mockReturnValue(undefined);
93+
94+
expect(() => require("../client-src/process-update")).toThrow(
95+
/Hot Module Replacement is disabled/,
96+
);
97+
});
98+
});
99+
100+
it("checks and applies an update when the hash differs", async () => {
101+
applyUpdate("new-hash", { reload: true });
102+
globalThis.__webpack_hash__ = "new-hash";
103+
await flushPromises();
104+
105+
const hot = getHot();
106+
107+
expect(hot.check).toHaveBeenCalledWith(false);
108+
expect(hot.apply).toHaveBeenCalledTimes(1);
109+
expect(reloadPage).not.toHaveBeenCalled();
110+
});
111+
112+
it("does not check when already up to date", () => {
113+
applyUpdate("current-hash", { reload: true });
114+
115+
expect(getHot().check).not.toHaveBeenCalled();
116+
});
117+
118+
it("reloads when the update cannot be found (server restart)", async () => {
119+
applyUpdate = loadApplyUpdate(makeFakeHot({ checkResult: null }));
120+
121+
applyUpdate("new-hash", { reload: true });
122+
await flushPromises();
123+
124+
expect(reloadPage).toHaveBeenCalledTimes(1);
125+
});
126+
127+
it("ignores an error thrown by an accept handler during apply", async () => {
128+
applyUpdate = loadApplyUpdate(
129+
makeFakeHot({
130+
applyImpl: (applyOptions) => {
131+
applyOptions.onErrored({
132+
error: new Error("accept handler failed"),
133+
moduleId: "./a.js",
134+
type: "accept-errored",
135+
});
136+
137+
return Promise.resolve(["./a.js"]);
138+
},
139+
}),
140+
);
141+
142+
applyUpdate("new-hash", { reload: true });
143+
globalThis.__webpack_hash__ = "new-hash";
144+
await flushPromises();
145+
146+
// The error is logged but does not trigger a reload.
147+
expect(reloadPage).not.toHaveBeenCalled();
148+
});
149+
150+
it("reloads when modules could not be hot updated (unaccepted)", async () => {
151+
applyUpdate = loadApplyUpdate(
152+
makeFakeHot({
153+
checkResult: ["./a.js", "./b.js"],
154+
// Only one of the two updated modules was renewed.
155+
applyImpl: () => Promise.resolve(["./a.js"]),
156+
}),
157+
);
158+
159+
applyUpdate("new-hash", { reload: true });
160+
globalThis.__webpack_hash__ = "new-hash";
161+
await flushPromises();
162+
163+
expect(reloadPage).toHaveBeenCalledTimes(1);
164+
});
165+
166+
it("reloads when the runtime reports a failure status", async () => {
167+
const hot = {
168+
// "idle" when the update starts, "abort" when the failure is handled.
169+
status: jest.fn().mockReturnValueOnce("idle").mockReturnValue("abort"),
170+
check: jest.fn(() => Promise.reject(new Error("check failed"))),
171+
apply: jest.fn(() => Promise.resolve([])),
172+
};
173+
174+
applyUpdate = loadApplyUpdate(hot);
175+
176+
applyUpdate("new-hash", { reload: true });
177+
await flushPromises();
178+
179+
expect(reloadPage).toHaveBeenCalledTimes(1);
180+
});
181+
182+
describe("logging", () => {
183+
/**
184+
* @param {jest.Mock} spy console spy
185+
* @param {string} text expected substring
186+
* @returns {boolean} true when some call contains the text
187+
*/
188+
function logged(spy, text) {
189+
return spy.mock.calls.some((call) => call.join(" ").includes(text));
190+
}
191+
192+
it("lists the updated modules at the default info level", async () => {
193+
applyUpdate = loadApplyUpdate(
194+
makeFakeHot({
195+
checkResult: ["./a.js", "./b.js"],
196+
applyImpl: () => Promise.resolve(["./a.js", "./b.js"]),
197+
}),
198+
);
199+
200+
applyUpdate("new-hash", { reload: true });
201+
globalThis.__webpack_hash__ = "new-hash";
202+
await flushPromises();
203+
204+
expect(logged(console.info, "Updated modules:")).toBe(true);
205+
expect(logged(console.info, "./a.js")).toBe(true);
206+
expect(logged(console.info, "./b.js")).toBe(true);
207+
});
208+
});
209+
});

0 commit comments

Comments
 (0)