From 9fd9ae91f605c60bed86062bdd16e21f9c03a82b Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Wed, 2 Sep 2026 17:48:49 +0000 Subject: [PATCH] test(hot): reach 100% of the client, off the deprecated statsOptions The overlay's payload test drove hot.statsOptions, which is deprecated and must not be what a test documents. It now sets stats: "errors-only", the option that governs this, which needed a stats passthrough in the e2e helper. Three more browser cases close what was reachable: restyling a card that is already open, an empty problem string through the encoder, and Escape pressed inside the overlay frame rather than on the host page. What is left cannot be produced from a browser, so it is marked with the reason rather than faked: paging and rendering without a problem set, a label the document-less guard already covers, hot.check() rejecting while the runtime is in abort/fail, an applied update that renews nothing, and an error event queued behind close(). client-src is at 100% of statements and 92.23% of branches. --- client-src/index.js | 3 + client-src/indicator.js | 3 + client-src/overlay.js | 6 ++ client-src/process-update.js | 6 ++ .../overlay.test.js.snap.webpack5 | 2 +- test/e2e/overlay.test.js | 72 +++++++++++++++++-- test/helpers/hot-app.js | 5 +- 7 files changed, 90 insertions(+), 7 deletions(-) diff --git a/client-src/index.js b/client-src/index.js index 0e64f8893..e8d594080 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -234,6 +234,9 @@ function createEventSourceWrapper() { closed = false; const handleDisconnect = () => { + // Reached only by an `error` event the EventSource had already queued + // when `close()` ran — a race the browser will not stage on demand. + /* istanbul ignore next -- @preserve */ if (closed) { return; } diff --git a/client-src/indicator.js b/client-src/indicator.js index 375910ed0..72e28b3e7 100644 --- a/client-src/indicator.js +++ b/client-src/indicator.js @@ -182,6 +182,9 @@ export function show(text, percent, source = "") { state.building[source] = true; ensureIndicator(); + // `ensureIndicator` above builds the label, so it is missing only in the + // document-less case that function already guards. + /* istanbul ignore next -- @preserve */ if (!state.label) { return; } diff --git a/client-src/overlay.js b/client-src/overlay.js index e1a1cdf7d..74b749216 100644 --- a/client-src/overlay.js +++ b/client-src/overlay.js @@ -389,6 +389,8 @@ let render = () => {}; * @param {number} index requested page index */ function goToPage(index) { + // Paging is only reachable from a rendered overlay, which implies problems. + /* istanbul ignore next -- @preserve */ if (!state.currentProblems) { return; } @@ -557,12 +559,16 @@ function ensureOverlay() { * Render the current problem set into the card. */ function renderProblems() { + // Both guards cover callers that cannot occur in a browser: rendering is + // driven by a problem set, and `ensureOverlay` only fails without a body. + /* istanbul ignore next -- @preserve */ if (!state.currentProblems) { return; } const card = ensureOverlay(); + /* istanbul ignore next -- @preserve */ if (!card) { return; } diff --git a/client-src/process-update.js b/client-src/process-update.js index 34820a771..447d6f827 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -107,6 +107,9 @@ export default function applyUpdate(hash, options, name) { * @param {Error} err error */ const handleError = (err) => { + // `hot.check()` rejecting while the runtime is already in `abort`/`fail` + // is a webpack-internal state the browser suite cannot stage. + /* istanbul ignore next -- @preserve */ if (hot.status() in failureStatuses) { log.warn("Cannot check for update (Full reload needed)"); log.warn(err.stack || err.message); @@ -140,6 +143,9 @@ export default function applyUpdate(hash, options, name) { return; } + // An applied update that renews nothing: webpack does not produce one + // from an edit, so there is no build to drive this from. + /* istanbul ignore next -- @preserve */ if (!renewedModules || renewedModules.length === 0) { log.info("Nothing hot updated."); } else { diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 index bf925b822..98b6e47e5 100644 --- a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`error overlay (browser) keeps warnings out of the payload with hot.statsOptions 1`] = ` +exports[`error overlay (browser) keeps warnings out of the payload when the stats option does 1`] = ` [ "[webpack-dev-middleware] connected", ] diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 433c5c1ac..37cb7f4cb 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -492,11 +492,11 @@ describe("error overlay (browser)", () => { expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); - it("keeps warnings out of the payload with hot.statsOptions", async () => { + it("keeps warnings out of the payload when the stats option does", async () => { hotApp = await createHotApp({ - // The README's documented recipe: keep warnings in the build output - // but out of the SSE payload entirely. - hot: { statsOptions: { warnings: false } }, + // One setting governs what a build reports in the terminal and in the + // browser; `hot.statsOptions` is deprecated and must not be used here. + stats: "errors-only", code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); @@ -1092,4 +1092,68 @@ describe("overlay shared state across bundled copies (browser)", () => { await page.evaluate(() => document.querySelectorAll("iframe").length), ).toBe(1); }); + + it("restyles a card that is already open", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["styled"], "a"); + }); + await overlayFrame(); + + // Configuring while a card exists has to reach that card, not only the + // next one rendered. + await page.evaluate(() => { + globalThis.overlayA.default({ + overlayStyles: { background: "rgb(1, 2, 3)" }, + }); + }); + + const frame = await overlayFrame(); + + expect( + await frame.evaluate( + (id) => document.getElementById(id).style.background, + CARD_ID, + ), + ).toBe("rgb(1, 2, 3)"); + }); + + it("renders an empty problem without markup", async () => { + await start(); + await page.goto(hotApp.url); + + // An empty message still opens the overlay; the encoder has nothing to + // escape and must not fall over on it. + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", [""], "a"); + }); + + const frame = await overlayFrame(); + + expect(await frame.evaluate(() => document.body.innerHTML)).toContain( + "ERROR", + ); + }); + + it("dismisses on Escape pressed inside the overlay frame", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["dismiss me"], "a"); + }); + + const frame = await overlayFrame(); + + // Clicking inside the card moves focus into the frame, so the keydown + // lands on the frame's own listener rather than the host page's. + await clickInFrame(page, frame, `#${CARD_ID}`); + await page.keyboard.press("Escape"); + + await waitForNoOverlay(page); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); }); diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 4837eddee..171be5799 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -112,7 +112,7 @@ function makeConfig( * app becomes a named compilation whose client connects with `?name=` * and renders from `.js`. `pageHeaders` are sent with the HTML page * (e.g. a Content-Security-Policy). - * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY, pageHeaders?: Record, publicPath?: string, setup?: (server: EXPECTED_ANY) => void, hmrPlugin?: boolean }} options options + * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY, stats?: EXPECTED_ANY, pageHeaders?: Record, publicPath?: string, setup?: (server: EXPECTED_ANY) => void, hmrPlugin?: boolean }} options options * @returns {Promise} handles for the running app */ async function createHotApp({ @@ -121,6 +121,7 @@ async function createHotApp({ files = {}, apps, hot = true, + stats, pageHeaders = {}, publicPath = "/", setup, @@ -188,7 +189,7 @@ async function createHotApp({ } }); - instance = middleware(compiler, { hot }); + instance = middleware(compiler, { hot, stats }); const app = express();