From fbb2400274e7954815d49742ffd02da53d7a83c9 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 19:59:21 -0500 Subject: [PATCH 01/48] test: adopt a hybrid unit + browser e2e model, shard CI, and snapshot the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hot client, overlay, and building indicator are now exercised end to end in headless Chrome (puppeteer 22, the last CJS release jest can require) against a real webpack watcher: updates applied without reload, the full-reload fallback, reconnect with catch-up sync after a server restart, disconnect() semantics, overlay lifecycle (Escape on the host page, HTML-in-error-message escaping, warnings, pagination), the building badge, multi-compiler ?name= filtering with per-bundle overlay slots, and the custom publish/subscribe API. Console behavior is snapshotted from the real browser console: the full info-level update cycle, logging=none/warn/error gates, and the per-bundle dedup where a sibling's clean rebuild does not re-log another bundle's unchanged warning while its own rebuild does. Each multi-compiler compilation gets its own context dir so editing one entry cannot invalidate the sibling's watcher and shuffle event order. The jsdom suites stay, slimmed to what a browser cannot exercise deterministically: process-update internals, shared state between bundled copies, option parsing, and protocol edges. The review of the new tests also surfaced a real client bug, now fixed: an EventSource error event queued behind disconnect() re-armed the reconnect timer on the orphaned wrapper — close() is now final. e2e runs serially through the new test:e2e script (four concurrent Chrome instances plus watchers starved the rest of the suite), and CI shards every test job 4x with jest --shard, uploading per-shard coverage that a final job aggregates for codecov, mirroring webpack-dev-server's setup. Co-Authored-By: Claude Fable 5 --- .github/workflows/nodejs.yml | 31 +- client-src/index.js | 23 +- package-lock.json | 761 ++++++++++++++++++ package.json | 6 +- .../client.test.js.snap.webpack5 | 53 -- test/client.test.js | 436 +--------- .../logging.test.js.snap.webpack5 | 32 + .../multi-compiler.test.js.snap.webpack5 | 24 + test/e2e/client.test.js | 274 +++++++ test/e2e/indicator.test.js | 145 ++++ test/e2e/logging.test.js | 135 ++++ test/e2e/multi-compiler.test.js | 194 +++++ test/e2e/overlay.test.js | 229 ++++++ test/fixtures/js3/bundle.js | 117 +++ test/fixtures/js3/index.html | 1 + test/fixtures/js3/svg.svg | 33 + test/helpers/console-collector.js | 61 ++ test/helpers/hot-app.js | 269 +++++++ test/helpers/puppeteer-constants.js | 21 + test/helpers/run-browser.js | 60 ++ test/logging.test.js | 4 + test/overlay.test.js | 19 - test/process-update.test.js | 28 - 23 files changed, 2416 insertions(+), 540 deletions(-) create mode 100644 test/e2e/__snapshots__/logging.test.js.snap.webpack5 create mode 100644 test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 create mode 100644 test/e2e/client.test.js create mode 100644 test/e2e/indicator.test.js create mode 100644 test/e2e/logging.test.js create mode 100644 test/e2e/multi-compiler.test.js create mode 100644 test/e2e/overlay.test.js create mode 100644 test/fixtures/js3/bundle.js create mode 100644 test/fixtures/js3/index.html create mode 100644 test/fixtures/js3/svg.svg create mode 100644 test/helpers/console-collector.js create mode 100644 test/helpers/hot-app.js create mode 100644 test/helpers/puppeteer-constants.js create mode 100644 test/helpers/run-browser.js diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 913694014..281deee63 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -56,7 +56,7 @@ jobs: run: if [ -n "$(git status types --porcelain)" ]; then echo "Missing types. Update types by running 'npm run build:types'"; exit 1; else echo "All types are valid"; fi test: - name: Test - ${{ matrix.os }} - Node v${{ matrix.node-version }}, Webpack ${{ matrix.webpack-version }} + name: Test - ${{ matrix.os }} - Node v${{ matrix.node-version }}, Webpack ${{ matrix.webpack-version }} (${{ matrix.shard }}) strategy: fail-fast: false @@ -64,11 +64,12 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] node-version: [20.x, 22.x, 24.x, 25.x] webpack-version: [latest] + shard: ["1/4", "2/4", "3/4", "4/4"] runs-on: ${{ matrix.os }} concurrency: - group: test-${{ matrix.os }}-v${{ matrix.node-version }}-${{ matrix.webpack-version }}-${{ github.ref }} + group: test-${{ matrix.os }}-v${{ matrix.node-version }}-${{ matrix.webpack-version }}-${{ matrix.shard }}-${{ github.ref }} cancel-in-progress: true steps: @@ -84,9 +85,33 @@ jobs: run: npm ci - name: Run tests for webpack version ${{ matrix.webpack-version }} - run: npm run test:coverage -- --ci + run: npm run test:coverage -- --ci --shard=${{ matrix.shard }} + + - name: Run browser e2e tests + run: npm run test:e2e -- --ci --shard=${{ matrix.shard }} + + - name: Upload coverage artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-${{ matrix.os }}-${{ matrix.node-version }}-${{ strategy.job-index }} + path: coverage/lcov.info + + upload-coverage: + name: Upload coverage to codecov + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download coverage artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: coverage-* + path: coverage - name: Submit coverage data to codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} + directory: coverage diff --git a/client-src/index.js b/client-src/index.js index 41e2eb2ae..0579461ea 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -145,6 +145,7 @@ function createEventSourceWrapper() { let timer; /** @type {ReturnType} */ let reconnectTimer; + let closed = false; const handleOnline = () => { log.info("connected"); @@ -162,18 +163,30 @@ function createEventSourceWrapper() { }; /** - * Close the connection and stop the activity timer without scheduling a - * reconnection. A reconnection that is already pending is cancelled too, so - * closing during the reconnect window really is final. + * Tear the current connection down without deciding whether it is final. */ - const close = () => { + const stop = () => { clearInterval(timer); clearTimeout(reconnectTimer); source.close(); }; + /** + * Close for good: no reconnection is scheduled, a pending one is cancelled, + * and error events already queued behind the close (the EventSource fires + * one when its connection dies) can no longer resurrect the wrapper. + */ + const close = () => { + closed = true; + stop(); + }; + const handleDisconnect = () => { - close(); + if (closed) { + return; + } + + stop(); reconnectTimer = setTimeout(init, /** @type {number} */ (options.timeout)); }; diff --git a/package-lock.json b/package-lock.json index f5d835bde..bbedf694d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.6.0", + "puppeteer": "^22.15.0", "router": "^2.2.0", "supertest": "^7.0.0", "typescript": "^6.0.2", @@ -5125,6 +5126,42 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", + "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.5", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5226,6 +5263,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -5528,6 +5572,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.4", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", @@ -6704,6 +6759,19 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6768,6 +6836,21 @@ "fastq": "^1.17.1" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -6926,6 +7009,112 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -6939,6 +7128,16 @@ "node": ">=6.0.0" } }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/better-path-resolve": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", @@ -7069,6 +7268,41 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7318,6 +7552,31 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", + "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -7728,6 +7987,43 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8003,6 +8299,16 @@ "node": ">=18" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -8229,6 +8535,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/del": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/del/-/del-8.0.1.tgz", @@ -8425,6 +8746,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1312386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", + "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -8538,6 +8866,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", @@ -8833,6 +9171,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -9662,6 +10022,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -10103,6 +10473,43 @@ "dev": true, "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -10133,6 +10540,13 @@ "node": ">=6.0.0" } }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -10344,6 +10758,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -10866,6 +11290,21 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/git-hooks-list": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.2.1.tgz", @@ -11365,6 +11804,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -11513,6 +11973,16 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -15209,6 +15679,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -15266,6 +15743,16 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -15887,6 +16374,40 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -16054,6 +16575,13 @@ "node": ">=8" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16294,6 +16822,16 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -16337,6 +16875,54 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -16347,6 +16933,44 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", + "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", + "deprecated": "< 24.15.0 is no longer supported", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1312386", + "puppeteer-core": "22.15.0" + }, + "bin": { + "puppeteer": "lib/esm/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "22.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", + "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.3.0", + "chromium-bidi": "0.6.3", + "debug": "^4.3.6", + "devtools-protocol": "0.0.1312386", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -17381,6 +18005,17 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -17394,6 +18029,36 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -17615,6 +18280,18 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -18010,6 +18687,44 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -18075,6 +18790,16 @@ "node": ">=8" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thingies": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", @@ -18104,6 +18829,13 @@ "node": ">=20" } }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -18526,6 +19258,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -18760,6 +19503,13 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -19366,6 +20116,17 @@ "node": ">=8" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 00cd9f096..d4a3ef3a6 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,12 @@ "build:code": "babel src -d dist --copy-files", "build:client": "babel client-src -d client --copy-files", "build": "npm-run-all -p \"build:**\"", - "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest", + "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns \"/test/e2e/\"", + "test:e2e": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest test/e2e --runInBand", "test:watch": "npm run test:only -- --watch", "test:coverage": "npm run test:only -- --collectCoverageFrom=\"src/**/*.js\" --coverage", "pretest": "npm run lint", - "test": "npm run test:coverage", + "test": "npm-run-all -s test:coverage test:e2e", "prepare": "husky && npm run build", "version": "changeset version", "release": "npm run build && changeset publish" @@ -100,6 +101,7 @@ "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", "prettier": "^3.6.0", + "puppeteer": "^22.15.0", "router": "^2.2.0", "supertest": "^7.0.0", "typescript": "^6.0.2", diff --git a/test/__snapshots__/client.test.js.snap.webpack5 b/test/__snapshots__/client.test.js.snap.webpack5 index dfb887fca..9705a8ed5 100644 --- a/test/__snapshots__/client.test.js.snap.webpack5 +++ b/test/__snapshots__/client.test.js.snap.webpack5 @@ -1,58 +1,5 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`client with default options shows overlay on errored builds 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle has 2 errors", - ], - [ - "[webpack-dev-middleware] Something broke -Actually, 2 things broke", - ], -] -`; - -exports[`client with default options shows overlay on warning builds by default (dev-server parity) 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle has 1 warnings", - ], - [ - "[webpack-dev-middleware] This isn't great, but it's not terrible", - ], -] -`; - -exports[`client with logging option emits info-level logs (including the [webpack-dev-middleware] prefix) by default 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle rebuilt in 100ms", - ], -] -`; - -exports[`client with logging option logging=error silences info and warn but keeps error 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle has 1 errors", - ], - [ - "[webpack-dev-middleware] boom", - ], -] -`; - -exports[`client with logging option logging=warn silences info but keeps warn 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle has 1 warnings", - ], - [ - "[webpack-dev-middleware] something", - ], -] -`; - exports[`client with overlay warnings enabled via the dev-server-shaped option shows overlay on errored builds 1`] = ` [ [ diff --git a/test/client.test.js b/test/client.test.js index 057ddbd34..b5c9aec03 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -115,57 +115,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("connects to /__webpack_hmr", () => { - expect(EventSourceStub.instances).toHaveLength(1); - expect(EventSourceStub.instances[0].url).toBe("/__webpack_hmr"); - }); - - it("triggers webpack on successful builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).toHaveBeenCalledTimes(1); - }); - - it("passes reload:true to the updater by default", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).toHaveBeenCalledWith( - "1234567890abcdef", - expect.objectContaining({ reload: true }), - undefined, - ); - }); - - it("triggers webpack on successful syncs", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "sync", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).toHaveBeenCalledTimes(1); - }); - it("logs the changed file on building messages", () => { EventSourceStub.lastInstance().onmessage( makeMessage({ action: "building", file: "/src/index.js" }), @@ -231,52 +180,6 @@ describe("client", () => { expect(processUpdate).not.toHaveBeenCalled(); }); - it("shows overlay on errored builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke", "Actually, 2 things broke"], - warnings: [], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.showProblems).toHaveBeenCalledWith("errors", [ - "Something broke", - "Actually, 2 things broke", - ]); - expect(console.error.mock.calls).toMatchSnapshot(); - }); - - it("hides overlay after errored build is fixed", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke", "Actually, 2 things broke"], - warnings: [], - modules: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef2", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.clear).toHaveBeenCalledWith(""); - expect(clientOverlay.clear).toHaveBeenCalledWith("runtime"); - }); - it("updates overlay when an errored build becomes a warning", () => { const es = EventSourceStub.lastInstance(); es.onmessage( @@ -321,24 +224,6 @@ describe("client", () => { expect(processUpdate).toHaveBeenCalledTimes(1); }); - it("shows overlay on warning builds by default (dev-server parity)", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ - "This isn't great, but it's not terrible", - ]); - // Warnings also surface through the logger. - expect(console.warn.mock.calls).toMatchSnapshot(); - }); - it("shows overlay after warning build becomes an error", () => { const es = EventSourceStub.lastInstance(); es.onmessage( @@ -386,127 +271,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("keeps one bundle's errors when another bundle succeeds", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - name: "app", - time: 100, - hash: "app-hash", - errors: ["app broke"], - warnings: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ - "app broke", - ]); - - // A clean build from another bundle must not wipe app's errors. - es.onmessage( - makeMessage({ - action: "built", - name: "admin", - time: 100, - hash: "admin-hash", - errors: [], - warnings: [], - }), - ); - expect(clientOverlay.clear).not.toHaveBeenCalled(); - expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ - "app broke", - ]); - - // Fixing the broken bundle finally clears the overlay. - es.onmessage( - makeMessage({ - action: "built", - name: "app", - time: 100, - hash: "app-hash-2", - errors: [], - warnings: [], - }), - ); - expect(clientOverlay.clear).toHaveBeenCalledWith(""); - expect(clientOverlay.clear).toHaveBeenCalledWith("runtime"); - }); - - it("re-logs the same error text after the bundle's own successful build", () => { - const es = EventSourceStub.lastInstance(); - const brokenPayload = { - action: "built", - name: "app", - time: 100, - hash: "app-hash", - errors: ["app broke"], - warnings: [], - }; - es.onmessage(makeMessage(brokenPayload)); - - const appLogs = () => - console.error.mock.calls.filter((call) => - call.join(" ").includes("app broke"), - ).length; - const before = appLogs(); - - expect(before).toBeGreaterThan(0); - - // The bundle's own success drops its console cache… - es.onmessage( - makeMessage({ - action: "built", - name: "app", - time: 100, - hash: "app-hash-2", - errors: [], - warnings: [], - }), - ); - // …so breaking again with the exact same text logs again. - es.onmessage(makeMessage({ ...brokenPayload, hash: "app-hash-3" })); - - expect(appLogs()).toBe(before * 2); - }); - - it("does not re-log a bundle's unchanged errors when a sibling succeeds", () => { - const es = EventSourceStub.lastInstance(); - const appPayload = { - action: "sync", - name: "app", - time: 100, - hash: "app-hash", - errors: ["app broke"], - warnings: [], - }; - es.onmessage(makeMessage(appPayload)); - - const appLogs = () => - console.error.mock.calls.filter((call) => - call.join(" ").includes("app broke"), - ).length; - const before = appLogs(); - - expect(before).toBeGreaterThan(0); - - // A clean sibling build clears only its own console cache, so - // re-publishing app's identical errors stays de-duplicated. - es.onmessage( - makeMessage({ - action: "built", - name: "admin", - time: 100, - hash: "admin-hash", - errors: [], - warnings: [], - }), - ); - es.onmessage(makeMessage(appPayload)); - - expect(appLogs()).toBe(before); - }); - it("shows the union of problems from every broken bundle", () => { const es = EventSourceStub.lastInstance(); es.onmessage( @@ -732,41 +496,6 @@ describe("client", () => { }); }); - describe("with reload disabled", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - loadClient("?reload=false"); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("passes reload:false to the updater", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).toHaveBeenCalledWith( - "1234567890abcdef", - expect.objectContaining({ reload: false }), - undefined, - ); - }); - }); - describe("with overlay runtime/trusted-types options", () => { it("forwards them to the overlay factory", () => { globalThis.EventSource = makeEventSourceStub(); @@ -840,63 +569,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("shows the badge while building and hides it when built", () => { - loadClient(); - const es = EventSourceStub.lastInstance(); - - es.onmessage(makeMessage({ action: "building", file: "/src/a.js" })); - - const host = document.getElementById(INDICATOR_ID); - - expect(host).not.toBeNull(); - expect(host.shadowRoot.textContent).toContain("Rebuilding"); - expect(host.shadowRoot.textContent).toContain("/src/a.js"); - - es.onmessage( - makeMessage({ - action: "built", - time: 1, - hash: "h", - errors: [], - warnings: [], - }), - ); - - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - - it("updates the badge with progress events", () => { - loadClient(); - const es = EventSourceStub.lastInstance(); - - es.onmessage(makeMessage({ action: "building" })); - es.onmessage( - makeMessage({ action: "progress", percent: 42, message: "building" }), - ); - - const host = document.getElementById(INDICATOR_ID); - - expect(host.shadowRoot.textContent).toContain("42%"); - - // The progress ring replaces the pulsing dot and reflects the percent. - const [, ringValue] = host.shadowRoot.querySelectorAll("circle"); - const length = 2 * Math.PI * 6; - - expect(Number(ringValue.getAttribute("stroke-dashoffset"))).toBeCloseTo( - length * (1 - 42 / 100), - 3, - ); - }); - - it("does not show the badge when progress is disabled", () => { - loadClient("?progress=false"); - EventSourceStub.lastInstance().onmessage( - makeMessage({ action: "building" }), - ); - - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - it("keeps the badge until every compilation finished", () => { loadClient(); const es = EventSourceStub.lastInstance(); @@ -995,21 +667,6 @@ describe("client", () => { expect(EventSourceStub.instances).toHaveLength(2); }); - it("disconnect() closes the connection and does not reconnect", () => { - jest.useFakeTimers({ doNotFake: ["nextTick"] }); - delete globalThis.__wdmEventSourceWrapper; - EventSourceStub.instances.length = 0; - client = loadClient(); - - const [first] = EventSourceStub.instances; - client.disconnect(); - - expect(first.closed).toBe(true); - // Neither the watchdog nor a reconnect timer may re-open it. - jest.advanceTimersByTime(60 * 1000); - expect(EventSourceStub.instances).toHaveLength(1); - }); - it("keeps the inactivity watchdog after a reconnect", () => { jest.useFakeTimers({ doNotFake: ["nextTick"] }); delete globalThis.__wdmEventSourceWrapper; @@ -1031,20 +688,20 @@ describe("client", () => { expect(EventSourceStub.instances).toHaveLength(3); }); - it("disconnect() during the reconnect window cancels the pending reconnect", () => { + it("ignores error events that arrive after disconnect()", () => { jest.useFakeTimers({ doNotFake: ["nextTick"] }); delete globalThis.__wdmEventSourceWrapper; EventSourceStub.instances.length = 0; const freshClient = loadClient(); - // A connection error schedules a reconnect `timeout` (20s) out. const [first] = EventSourceStub.instances; - first.dispatch("error", {}); - expect(first.closed).toBe(true); - - // Disconnecting inside that window must cancel it — nothing may reopen. freshClient.disconnect(); + + // An EventSource fires a queued error when its connection dies — it + // must not resurrect the closed wrapper by scheduling a reconnect. + first.dispatch("error", {}); jest.advanceTimersByTime(60 * 1000); + expect(EventSourceStub.instances).toHaveLength(1); }); @@ -1060,87 +717,6 @@ describe("client", () => { }); }); - describe("with logging option", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("emits info-level logs (including the [webpack-dev-middleware] prefix) by default", () => { - loadClient(); - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(console.info.mock.calls).toMatchSnapshot(); - }); - - it("logging=none silences every level", () => { - loadClient("?logging=none"); - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["boom"], - warnings: [], - modules: [], - }), - ); - expect(console.info).not.toHaveBeenCalled(); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - it("logging=warn silences info but keeps warn", () => { - loadClient("?logging=warn"); - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["something"], - modules: [], - }), - ); - expect(console.info).not.toHaveBeenCalled(); - expect(console.warn.mock.calls).toMatchSnapshot(); - }); - - it("logging=error silences info and warn but keeps error", () => { - loadClient("?logging=error"); - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["boom"], - warnings: [], - modules: [], - }), - ); - expect(console.info).not.toHaveBeenCalled(); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error.mock.calls).toMatchSnapshot(); - }); - }); - describe("with no EventSource", () => { beforeEach(() => { delete globalThis.EventSource; diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 new file mode 100644 index 000000000..78eaea769 --- /dev/null +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`client logging (browser) logging=error keeps errors only 1`] = ` +[ + "[webpack-dev-middleware] bundle has 1 errors", + "[webpack-dev-middleware] ./app.js 1:7 +Module parse failed: Unexpected token (1:7) +You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders +> broken {{{", +] +`; + +exports[`client logging (browser) logging=none silences the whole cycle 1`] = `[]`; + +exports[`client logging (browser) logging=warn keeps warnings only 1`] = ` +[ + "[webpack-dev-middleware] bundle has 1 warnings", + "[webpack-dev-middleware] ./app.js 5:10-22 +Critical dependency: the request of a dependency is an expression", +] +`; + +exports[`client logging (browser) logs a full update cycle at the default info level, with the prefix 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; diff --git a/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 new file mode 100644 index 000000000..6b2821dee --- /dev/null +++ b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`multi-compiler (browser) logs per-bundle lifecycles and deduplicates warning re-logs on sibling builds 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle 'widget' has 1 warnings", + "[webpack-dev-middleware] ./entry.js 11:8-20 +Critical dependency: the request of a dependency is an expression", + "[webpack-dev-middleware] bundle 'app' rebuilding (/app/entry.js changed)", + "[webpack-dev-middleware] bundle 'app' rebuilding (/app/entry.js changed)", + "[webpack-dev-middleware] bundle 'app' rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] bundle 'app' rebuilt in Xms", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", + "[webpack-dev-middleware] bundle 'widget' rebuilding (/widget/entry.js changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilding (/widget/entry.js changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", + "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js new file mode 100644 index 000000000..2150a5783 --- /dev/null +++ b/test/e2e/client.test.js @@ -0,0 +1,274 @@ +import collectConsole from "../helpers/console-collector"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(120000); + +const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + +/** + * A self-accepting module: HMR updates re-run it in place. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function acceptedApp(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * A module that never accepts updates — applying one needs a full reload. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function unacceptedApp(text) { + return `document.getElementById("app").textContent = ${JSON.stringify(text)};`; +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} text expected #app text + * @returns {Promise} resolved when rendered + */ +function waitForAppText(page, text) { + return page + .waitForFunction( + (expected) => document.getElementById("app")?.textContent === expected, + { timeout: 30000 }, + text, + ) + .then(() => {}); +} + +/** + * Plant a marker that survives HMR but not a navigation. + * @param {import("puppeteer").Page} page page + * @returns {Promise} resolved when set + */ +async function plantReloadMarker(page) { + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} marker value (undefined after a reload) + */ +function readReloadMarker(page) { + return page.evaluate(() => globalThis.__notReloaded); +} + +describe("hot client (browser)", () => { + let app; + let browser; + let page; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (app) { + const closing = app; + app = undefined; + await closing.close(); + } + } + }); + + it("connects and applies an update without reloading the page", async () => { + app = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + await plantReloadMarker(page); + + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(await readReloadMarker(page)).toBe(true); + }); + + it("falls back to a full reload when the update is not accepted", async () => { + app = await createHotApp({ code: unacceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await plantReloadMarker(page); + + app.edit(unacceptedApp("v2")); + + // The reload wipes the marker and the new code renders. + await waitForAppText(page, "v2"); + expect(await readReloadMarker(page)).toBeUndefined(); + }); + + it("warns instead of reloading when reload=false", async () => { + app = await createHotApp({ + query: "?reload=false", + code: unacceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await plantReloadMarker(page); + + app.edit(unacceptedApp("v2")); + await console_.waitFor("couldn't be hot updated"); + + // Old code keeps running and the page did not reload. + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + expect(await readReloadMarker(page)).toBe(true); + }); + + it("reconnects after a server restart and syncs up on missed builds", async () => { + app = await createHotApp({ + query: "?timeout=1000", + code: acceptedApp("v1"), + // Heartbeats faster than the shortened timeout, so the inactivity + // watchdog does not churn disconnect/reconnect cycles mid-test. + hot: { heartbeat: 300 }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await plantReloadMarker(page); + + // Rebuild while the server is down — the client must pick the update up + // through the catch-up sync after it reconnects. + await app.stopHttp(); + const rebuilt = app.nextBuild(); + app.edit(acceptedApp("v2")); + await rebuilt; + await app.startHttp(); + + await waitForAppText(page, "v2"); + expect(await readReloadMarker(page)).toBe(true); + }); + + it("routes server publish() payloads to subscribe handlers", async () => { + app = await createHotApp({ + code: ` + const hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + hotClient.subscribe((payload) => { + globalThis.__custom = payload; + }); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + // The documented custom-events API: server-side publish, client-side + // subscribe for payloads whose action the client does not recognise. + app.instance.context.hot.publish({ action: "my-event", value: 42 }); + + await page.waitForFunction( + () => globalThis.__custom && globalThis.__custom.value === 42, + { timeout: 30000 }, + ); + expect(await page.evaluate(() => globalThis.__custom.action)).toBe( + "my-event", + ); + }); + + it("disconnect() during the reconnect window cancels the pending reconnect", async () => { + app = await createHotApp({ + query: "?timeout=1000", + hot: { heartbeat: 300 }, + code: ` + globalThis.hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await console_.waitFor("connected"); + + // Sever the connection (a reconnect gets scheduled ~1s out), disconnect + // inside that window, then bring the server back. + await app.stopHttp(); + await page.evaluate(() => globalThis.hotClient.disconnect()); + await app.startHttp(); + + const rebuilt = app.nextBuild(); + app.edit(acceptedApp("v2")); + await rebuilt; + + // Enough time for the cancelled reconnect to have fired if it survived. + await new Promise((resolve) => { + setTimeout(resolve, 3000); + }); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + }); + + it("disconnect() closes the connection and stops receiving updates", async () => { + app = await createHotApp({ + code: ` + globalThis.hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + await page.evaluate(() => globalThis.hotClient.disconnect()); + + const rebuilt = app.nextBuild(); + app.edit(acceptedApp("v2")); + await rebuilt; + + // Grace period: were the connection still alive, the update would land + // well within it. + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + }); +}); diff --git a/test/e2e/indicator.test.js b/test/e2e/indicator.test.js new file mode 100644 index 000000000..dd27daae0 --- /dev/null +++ b/test/e2e/indicator.test.js @@ -0,0 +1,145 @@ +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(120000); + +const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; + +/** + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function app(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * Watch for the badge from inside the page — it only exists for the duration + * of a rebuild. Presence is recorded race-free by a MutationObserver (an + * interval could miss a sub-tick rebuild entirely); the badge text, which + * lives in a shadow root the body observer cannot see into, is additionally + * sampled on a fast interval. + * @param {import("puppeteer").Page} page page + * @returns {Promise} resolved once the watcher is installed + */ +async function installBadgeSampler(page) { + await page.evaluate((id) => { + globalThis.__badgeSeen = false; + globalThis.__badgeTexts = []; + + const record = () => { + const host = document.getElementById(id); + if (host) { + globalThis.__badgeSeen = true; + globalThis.__badgeTexts.push( + host.shadowRoot ? host.shadowRoot.textContent : "", + ); + } + }; + + new MutationObserver(record).observe(document.body, { + childList: true, + subtree: true, + }); + setInterval(record, 10); + }, INDICATOR_ID); +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} text expected #app text + * @returns {Promise} resolved when rendered + */ +function waitForAppText(page, text) { + return page + .waitForFunction( + (expected) => document.getElementById("app")?.textContent === expected, + { timeout: 30000 }, + text, + ) + .then(() => {}); +} + +describe("building indicator (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (hotApp) { + const closing = hotApp; + hotApp = undefined; + await closing.close(); + } + } + }); + + it("shows the badge during a rebuild and removes it afterwards", async () => { + hotApp = await createHotApp({ code: app("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + + expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(true); + // The build finished, so the badge must be gone again. + await page.waitForFunction( + (id) => document.getElementById(id) === null, + { timeout: 30000 }, + INDICATOR_ID, + ); + }); + + it("shows the compilation percentage when hot.progress is enabled", async () => { + hotApp = await createHotApp({ + code: app("v1"), + hot: { progress: true }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + + const texts = await page.evaluate(() => globalThis.__badgeTexts); + + expect(texts.some((text) => text.includes("%"))).toBe(true); + }); + + it("never appears when progress=false", async () => { + hotApp = await createHotApp({ + query: "?progress=false", + code: app("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + + expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(false); + }); +}); diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js new file mode 100644 index 000000000..6fadba152 --- /dev/null +++ b/test/e2e/logging.test.js @@ -0,0 +1,135 @@ +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(120000); + +/** + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function app(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} text expected #app text + * @returns {Promise} resolved when rendered + */ +function waitForAppText(page, text) { + return page + .waitForFunction( + (expected) => document.getElementById("app")?.textContent === expected, + { timeout: 30000 }, + text, + ) + .then(() => {}); +} + +describe("client logging (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (hotApp) { + const closing = hotApp; + hotApp = undefined; + await closing.close(); + } + } + }); + + it("logs a full update cycle at the default info level, with the prefix", async () => { + hotApp = await createHotApp({ code: app("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + await console_.waitFor("App is up to date"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logging=none silences the whole cycle", async () => { + hotApp = await createHotApp({ query: "?logging=none", code: app("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + + // The update applied (asserted through the DOM above) — give any stray + // logging a beat to surface before pinning the silence. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logging=warn keeps warnings only", async () => { + hotApp = await createHotApp({ + query: "?logging=warn", + // `require()` produces webpack's "Critical dependency" + // warning without failing the build. + code: ` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + // The warning arrives with the connect-time sync. + await console_.waitFor("Critical dependency"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logging=error keeps errors only", async () => { + hotApp = await createHotApp({ query: "?logging=error", code: app("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit("broken {{{"); + await console_.waitFor("Module parse failed"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); +}); diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js new file mode 100644 index 000000000..136be3aba --- /dev/null +++ b/test/e2e/multi-compiler.test.js @@ -0,0 +1,194 @@ +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(120000); + +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; + +/** + * Each bundle renders into its own div so the page shows both compilations + * side by side. + * @param {string} name bundle name + * @param {string} text rendered text + * @returns {string} app source + */ +function bundleApp(name, text) { + return ` + let el = document.getElementById("out-${name}"); + if (!el) { + el = document.createElement("div"); + el.id = "out-${name}"; + document.body.append(el); + } + el.textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} id element id + * @param {string} text expected text + * @returns {Promise} resolved when rendered + */ +function waitForText(page, id, text) { + return page + .waitForFunction( + (elementId, expected) => + document.getElementById(elementId)?.textContent === expected, + { timeout: 30000 }, + id, + text, + ) + .then(() => {}); +} + +describe("multi-compiler (browser)", () => { + let app; + let browser; + let page; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (app) { + const closing = app; + app = undefined; + await closing.close(); + } + } + }); + + it("updates only the bundle that changed, without reloading the page", async () => { + app = await createHotApp({ + apps: [ + { name: "app", code: bundleApp("app", "app-v1") }, + { name: "widget", code: bundleApp("widget", "widget-v1") }, + ], + }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForText(page, "out-app", "app-v1"); + await waitForText(page, "out-widget", "widget-v1"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + app.edit("widget", bundleApp("widget", "widget-v2")); + await waitForText(page, "out-widget", "widget-v2"); + + // The sibling bundle was left alone and nothing reloaded — the unchanged + // bundle's event is a `sync` its own client applies as a no-op, and the + // `?name=` filter keeps the widget's `built` away from the app's client. + expect( + await page.evaluate(() => document.getElementById("out-app").textContent), + ).toBe("app-v1"); + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + }); + + it("logs per-bundle lifecycles and deduplicates warning re-logs on sibling builds", async () => { + // Warnings (unlike build errors) do not block applying updates or force + // reloads, which keeps the console sequence deterministic enough to + // snapshot. The `require()` produces webpack's "Critical + // dependency" warning; the template keeps it on a fixed line so the + // warning text is identical across edits. + const widgetWithWarning = (text) => ` + let el = document.getElementById("out-widget"); + if (!el) { + el = document.createElement("div"); + el.id = "out-widget"; + document.body.append(el); + } + el.textContent = ${JSON.stringify(text)}; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `; + + app = await createHotApp({ + apps: [ + { name: "app", code: bundleApp("app", "app-v1") }, + { name: "widget", code: widgetWithWarning("widget-v1") }, + ], + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForText(page, "out-app", "app-v1"); + await waitForText(page, "out-widget", "widget-v1"); + // The widget's warning arrives with the connect-time sync, logged once. + await console_.waitFor("Critical dependency"); + + // A sibling's clean rebuild must NOT re-log the widget's unchanged + // warning (the console cache is per bundle). + app.edit("app", bundleApp("app", "app-v2")); + await waitForText(page, "out-app", "app-v2"); + await console_.waitFor("App is up to date"); + + // The widget's own rebuild drops its cache, so the identical warning + // text is logged again. + app.edit("widget", widgetWithWarning("widget-v2")); + await waitForText(page, "out-widget", "widget-v2"); + await console_.waitForCount("App is up to date", 2); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("keeps one bundle's overlay errors while a sibling rebuilds successfully", async () => { + app = await createHotApp({ + apps: [ + { name: "app", code: bundleApp("app", "app-v1") }, + { name: "widget", code: bundleApp("widget", "widget-v1") }, + ], + }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForText(page, "out-app", "app-v1"); + + app.edit("widget", "broken widget {{{"); + const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { + timeout: 30000, + }); + const frame = await handle.contentFrame(); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "Module parse failed", + ); + + // A sibling's successful rebuild applies its update but must not wipe the + // widget's problems from the shared overlay. + app.edit("app", bundleApp("app", "app-v2")); + await waitForText(page, "out-app", "app-v2"); + + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "Module parse failed", + ); + + app.edit("widget", bundleApp("widget", "widget-v2")); + await page.waitForFunction( + (id) => document.getElementById(id) === null, + { timeout: 30000 }, + OVERLAY_ID, + ); + await waitForText(page, "out-widget", "widget-v2"); + }); +}); diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js new file mode 100644 index 000000000..1e7fee8ab --- /dev/null +++ b/test/e2e/overlay.test.js @@ -0,0 +1,229 @@ +import collectConsole from "../helpers/console-collector"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(120000); + +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; + +/** + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function app(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} the overlay iframe's frame + */ +async function waitForOverlay(page) { + const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { + timeout: 30000, + }); + + return handle.contentFrame(); +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} resolved once the overlay is gone + */ +function waitForNoOverlay(page) { + return page + .waitForFunction( + (id) => document.getElementById(id) === null, + { timeout: 30000 }, + OVERLAY_ID, + ) + .then(() => {}); +} + +describe("error overlay (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (hotApp) { + const closing = hotApp; + hotApp = undefined; + await closing.close(); + } + } + }); + + it("shows build errors and clears when the build recovers", async () => { + hotApp = await createHotApp({ code: app("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("this is not valid javascript {{{"); + const frame = await waitForOverlay(page); + const text = await frame.evaluate(() => document.body.textContent); + + expect(text).toContain("Module parse failed"); + + hotApp.edit(app("fixed")); + await waitForNoOverlay(page); + + // The recovery build also applies (directly or via the full-reload + // fallback, depending on how webpack chained the broken build's hashes). + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "fixed", + { timeout: 30000 }, + ); + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("dismisses the overlay when pressing Escape on the host page", async () => { + hotApp = await createHotApp({ code: app("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("also broken {{{"); + await waitForOverlay(page); + + await page.keyboard.press("Escape"); + await waitForNoOverlay(page); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("catches runtime errors and renders their message as text, not markup", async () => { + hotApp = await createHotApp({ + // The error must be thrown from the page's own script — errors raised + // inside evaluate() surface to window.onerror as opaque "Script error". + code: ` + document.getElementById("app").textContent = "v1"; + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "v1", + ); + + // The message doubles as an XSS probe: it must render as text. + await page.evaluate(() => { + globalThis.boom('runtime boom '); + }); + + const frame = await waitForOverlay(page); + const text = await frame.evaluate(() => document.body.textContent); + + expect(text).toContain("runtime boom"); + expect( + await frame.evaluate(() => document.querySelector("img") !== null), + ).toBe(false); + // The onerror payload would run in the iframe's realm — check it there. + expect(await frame.evaluate(() => globalThis.__xss)).toBeUndefined(); + }); + + it("shows build warnings (dev-server parity)", async () => { + hotApp = await createHotApp({ + // `require()` produces webpack's "Critical dependency" + // warning without failing the build. + code: ` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected — the request cannot be resolved at runtime + } + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + // The warning arrives with the connect-time sync — no rebuild needed. + const frame = await waitForOverlay(page); + const text = await frame.evaluate(() => document.body.textContent); + + expect(text).toContain("WARNING"); + expect(text).toContain("Critical dependency"); + }); + + it("paginates multiple problems with a counter", async () => { + hotApp = await createHotApp({ + // try/catch: the emitted modules re-throw their parse error at require + // time, and an uncaught throw would add a third, runtime-error problem. + code: ` + try { + require("./a"); + } catch (err) { + // expected + } + try { + require("./b"); + } catch (err) { + // expected + } + `, + files: { + "a.js": "broken a {{{", + "b.js": "broken b {{{", + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + const frame = await waitForOverlay(page); + + // One problem at a time, with a counter. + await frame.waitForFunction(() => + document.body.textContent.includes("1 / 2"), + ); + + await frame.click('[aria-label="Next problem"]'); + await frame.waitForFunction(() => + document.body.textContent.includes("2 / 2"), + ); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "2 / 2", + ); + }); + + it("does not appear when overlay=false", async () => { + hotApp = await createHotApp({ query: "?overlay=false", code: app("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + + hotApp.edit("broken again {{{"); + // The problem still reaches the console — just not the DOM. + await console_.waitFor("Module parse failed"); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); +}); diff --git a/test/fixtures/js3/bundle.js b/test/fixtures/js3/bundle.js new file mode 100644 index 000000000..9ed28d00d --- /dev/null +++ b/test/fixtures/js3/bundle.js @@ -0,0 +1,117 @@ +/* + * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development"). + * This devtool is neither made for production nor for readable output files. + * It uses "eval()" calls to create a separate source file in the browser devtools. + * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/) + * or disable the default devtool with "devtool: false". + * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/). + */ +/******/ (() => { // webpackBootstrap +/******/ "use strict"; +/******/ var __webpack_modules__ = ({ + +/***/ "./index.html" +/*!********************!*\ + !*** ./index.html ***! + \********************/ +(__unused_webpack_module, __webpack_exports__, __webpack_require__) { + +eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + \"index.html\");\n\n//# sourceURL=webpack:///./index.html?\n}"); + +/***/ }, + +/***/ "./svg.svg" +/*!*****************!*\ + !*** ./svg.svg ***! + \*****************/ +(__unused_webpack_module, __webpack_exports__, __webpack_require__) { + +eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + \"svg.svg\");\n\n//# sourceURL=webpack:///./svg.svg?\n}"); + +/***/ }, + +/***/ "./foo.js" +/*!****************!*\ + !*** ./foo.js ***! + \****************/ +(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) { + +eval("{\n\n__webpack_require__(/*! ./svg.svg */ \"./svg.svg\");\n__webpack_require__(/*! ./index.html */ \"./index.html\");\n\nconsole.log('Hey.'); // eslint-disable-line no-console\n\n\n//# sourceURL=webpack:///./foo.js?\n}"); + +/***/ } + +/******/ }); +/************************************************************************/ +/******/ // The module cache +/******/ var __webpack_module_cache__ = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ // Check if module is in cache +/******/ var cachedModule = __webpack_module_cache__[moduleId]; +/******/ if (cachedModule !== undefined) { +/******/ return cachedModule.exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = __webpack_module_cache__[moduleId] = { +/******/ // no module.id needed +/******/ // no module.loaded needed +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ if (!(moduleId in __webpack_modules__)) { +/******/ delete __webpack_module_cache__[moduleId]; +/******/ var e = new Error("Cannot find module '" + moduleId + "'"); +/******/ e.code = 'MODULE_NOT_FOUND'; +/******/ throw e; +/******/ } +/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/************************************************************************/ +/******/ /* webpack/runtime/define property getters */ +/******/ (() => { +/******/ // define getter functions for harmony exports +/******/ __webpack_require__.d = (exports, definition) => { +/******/ for(var key in definition) { +/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { +/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); +/******/ } +/******/ } +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/hasOwnProperty shorthand */ +/******/ (() => { +/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) +/******/ })(); +/******/ +/******/ /* webpack/runtime/make namespace object */ +/******/ (() => { +/******/ // define __esModule on exports +/******/ __webpack_require__.r = (exports) => { +/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { +/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); +/******/ } +/******/ Object.defineProperty(exports, '__esModule', { value: true }); +/******/ }; +/******/ })(); +/******/ +/******/ /* webpack/runtime/publicPath */ +/******/ (() => { +/******/ __webpack_require__.p = "/static-three/"; +/******/ })(); +/******/ +/************************************************************************/ +/******/ +/******/ // startup +/******/ // Load entry module and return exports +/******/ // This entry module can't be inlined because the eval devtool is used. +/******/ var __webpack_exports__ = __webpack_require__("./foo.js"); +/******/ +/******/ })() +; \ No newline at end of file diff --git a/test/fixtures/js3/index.html b/test/fixtures/js3/index.html new file mode 100644 index 000000000..999238f7d --- /dev/null +++ b/test/fixtures/js3/index.html @@ -0,0 +1 @@ +My Index. diff --git a/test/fixtures/js3/svg.svg b/test/fixtures/js3/svg.svg new file mode 100644 index 000000000..33350a7f8 --- /dev/null +++ b/test/fixtures/js3/svg.svg @@ -0,0 +1,33 @@ + + SVG Logo + + + + + + + + + + + + + + + + + + + + + + + + SVG + + + + + + + diff --git a/test/helpers/console-collector.js b/test/helpers/console-collector.js new file mode 100644 index 000000000..a08f00399 --- /dev/null +++ b/test/helpers/console-collector.js @@ -0,0 +1,61 @@ +/** + * Collect the page's console output and wait for expected messages, so e2e + * assertions never rely on fixed sleeps. + * @param {import("puppeteer").Page} page page + * @returns {{ messages: string[], waitFor: (substring: string, timeout?: number) => Promise, waitForCount: (substring: string, count: number, timeout?: number) => Promise }} collector + */ +function collectConsole(page) { + /** @type {string[]} */ + const messages = []; + + page.on("console", (message) => { + messages.push(message.text()); + }); + + /** + * @param {string} substring substring to look for + * @param {number} count how many matching messages to wait for + * @param {number} timeout give-up timeout in milliseconds + * @returns {Promise} resolved when enough messages arrived + */ + async function waitForCount(substring, count, timeout = 30000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (messages.filter((text) => text.includes(substring)).length >= count) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + + throw new Error( + `Timed out waiting for ${count} console message(s) containing "${substring}".\nSeen:\n${messages.join("\n")}`, + ); + } + + return { + messages, + waitForCount, + waitFor: (substring, timeout) => waitForCount(substring, 1, timeout), + }; +} + +/** + * Strip the run-specific parts (timings, temp fixture paths) so browser + * console output can be snapshotted. + * @param {string[]} messages raw console messages + * @returns {string[]} normalized messages + */ +function normalizeConsole(messages) { + return messages.map((text) => + text + .replaceAll(/\d+\s?ms/g, "Xms") + .replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, ""), + ); +} + +module.exports = collectConsole; +module.exports.normalizeConsole = normalizeConsole; diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js new file mode 100644 index 000000000..0cab4169f --- /dev/null +++ b/test/helpers/hot-app.js @@ -0,0 +1,269 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const express = require("express"); +const webpack = require("webpack"); + +const middleware = require("../../src"); + +const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + +/** + * @param {string[]} scripts script sources + * @returns {string} page html + */ +function pageHtml(scripts) { + const tags = scripts.map((src) => ``).join(""); + + return `wdm e2e
${tags}`; +} + +/** + * @param {string} name compilation name ("" for the single-compiler mode) + * @param {string} dir fixture directory + * @param {string} appFile entry file + * @param {string} query extra client query ("?..." or "") + * @returns {EXPECTED_ANY} webpack configuration + */ +function makeConfig(name, dir, appFile, query) { + const clientQuery = name + ? `?name=${name}${query ? `&${query.replace(/^\?/, "")}` : ""}` + : query; + + return { + ...(name ? { name } : {}), + mode: "development", + context: dir, + entry: [`${CLIENT_ENTRY}${clientQuery}`, appFile], + output: { + path: path.join(dir, "dist"), + filename: name ? `${name}.js` : "main.js", + publicPath: "/", + // Both compilations share a context dir; without distinct uniqueNames + // their runtimes would fight over the same global hot-update callback + // and updates would fail with ChunkLoadError. + ...(name ? { uniqueName: name } : {}), + }, + plugins: [new webpack.HotModuleReplacementPlugin()], + infrastructureLogging: { level: "none" }, + stats: "none", + devtool: false, + // Polling keeps rebuild detection deterministic across filesystems. + watchOptions: { aggregateTimeout: 50, poll: 100 }, + }; +} + +/** + * Spin up a real HMR app for the browser e2e tests: a webpack compiler in + * watch mode over a temporary source directory, served by the middleware with + * the `hot` option through express. Rebuilds are triggered by editing the + * fixtures with `edit()`. + * + * Single-compiler mode: pass `code` (entry app.js) and optionally `query` + * (appended to the client entry) and `files` (extra fixture files by relative + * name). Multi-compiler mode: pass `apps: [{ name, code }]` instead — each + * app becomes a named compilation whose client connects with `?name=` + * and renders from `.js`. + * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY }} options options + * @returns {Promise} handles for the running app + */ +async function createHotApp({ + query = "", + code, + files = {}, + apps, + hot = true, +}) { + const dir = fs.mkdtempSync( + path.join(fs.realpathSync.native(os.tmpdir()), "wdm-e2e-"), + ); + + /** @type {EXPECTED_ANY} */ + let instance; + /** @type {EXPECTED_ANY} */ + let server; + + const removeDir = () => { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + }; + + try { + for (const [relative, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, relative), content); + } + + /** @type {Record} entry file per app name */ + const entryFiles = {}; + /** @type {EXPECTED_ANY} */ + let config; + /** @type {string[]} */ + let scripts; + + if (apps) { + config = apps.map((app) => { + // One context dir per compilation: editing one app's entry must not + // invalidate the sibling compiler through the shared directory, or + // the order of the two `building` events becomes nondeterministic. + const appDir = path.join(dir, app.name); + fs.mkdirSync(appDir, { recursive: true }); + entryFiles[app.name] = path.join(appDir, "entry.js"); + fs.writeFileSync(entryFiles[app.name], app.code); + return makeConfig(app.name, appDir, entryFiles[app.name], query); + }); + scripts = apps.map((app) => `/${app.name}.js`); + } else { + entryFiles[""] = path.join(dir, "app.js"); + fs.writeFileSync(entryFiles[""], /** @type {string} */ (code)); + config = makeConfig("", dir, entryFiles[""], query); + scripts = ["/main.js"]; + } + + const compiler = webpack(config); + + /** @type {((stats: EXPECTED_ANY) => void)[]} */ + const buildWaiters = []; + + compiler.hooks.done.tap("wdm-e2e", (stats) => { + for (const waiter of buildWaiters.splice(0)) { + waiter(stats); + } + }); + + instance = middleware(compiler, { hot }); + + const app = express(); + + app.get("/", (_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end(pageHtml(scripts)); + }); + app.use(instance); + + /** + * @param {number} port port to bind (0 for ephemeral) + * @param {number} attempts retries left for EADDRINUSE races + * @returns {Promise} listening server + */ + const listen = (port, attempts = 10) => + new Promise((resolve, reject) => { + const created = app.listen(port); + created.once("listening", () => resolve(created)); + created.once("error", (error) => { + if (error.code === "EADDRINUSE" && attempts > 0) { + setTimeout(() => { + listen(port, attempts - 1).then(resolve, reject); + }, 100); + } else { + reject(error); + } + }); + }); + + server = await listen(0); + const { port } = server.address(); + + await new Promise((resolve) => { + instance.waitUntilValid(resolve); + }); + + return { + url: `http://127.0.0.1:${port}/`, + port, + instance, + dir, + + /** + * Rewrite an app source; the watcher picks it up and rebuilds. In + * multi-compiler mode the first argument is the app name. + * @param {string} nameOrSource app name (multi) or source (single) + * @param {string=} maybeSource source when a name was given + */ + edit(nameOrSource, maybeSource) { + if (maybeSource === undefined) { + fs.writeFileSync(entryFiles[""], nameOrSource); + } else { + fs.writeFileSync(entryFiles[nameOrSource], maybeSource); + } + }, + + /** + * Rewrite an extra fixture file (from `files`). + * @param {string} relative file name relative to the fixture dir + * @param {string} content new content + */ + editFile(relative, content) { + fs.writeFileSync(path.join(dir, relative), content); + }, + + /** + * @returns {Promise} resolves with the stats of the next completed build + */ + nextBuild() { + return new Promise((resolve) => { + buildWaiters.push(resolve); + }); + }, + + /** + * Stop only the HTTP server (the compiler keeps watching), severing + * every open connection so clients see a disconnect. + * @returns {Promise} resolved when closed + */ + stopHttp() { + return new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); + }, + + /** + * Bring the HTTP server back on the same port, retrying while the OS + * releases it. + * @returns {Promise} resolved when listening + */ + async startHttp() { + server = await listen(port); + }, + + /** + * @returns {Promise} resolved when everything is torn down + */ + async close() { + await new Promise((resolve) => { + instance.close(resolve); + }); + await new Promise((resolve) => { + if (!server.listening) { + resolve(); + return; + } + server.closeAllConnections(); + server.close(() => resolve()); + }); + removeDir(); + }, + }; + } catch (error) { + // Partial-failure teardown: without it a broken startup leaks the + // watcher, the server, and the temp dir for the rest of the run. + if (instance) { + await new Promise((resolve) => { + instance.close(resolve); + }); + } + if (server) { + await new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); + } + removeDir(); + throw error; + } +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +module.exports = createHotApp; diff --git a/test/helpers/puppeteer-constants.js b/test/helpers/puppeteer-constants.js new file mode 100644 index 000000000..55a6f67b3 --- /dev/null +++ b/test/helpers/puppeteer-constants.js @@ -0,0 +1,21 @@ +// Chromium flags for the e2e browser, same spirit as webpack-dev-server's +// puppeteer-constants: deterministic rendering, no sandbox (CI containers), +// and no background throttling that could delay SSE/HMR timers. +const puppeteerArgs = [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + "--disable-extensions", + "--disable-default-apps", + "--disable-translate", + "--disable-sync", + "--metrics-recording-only", + "--mute-audio", + "--no-first-run", + "--force-color-profile=srgb", +]; + +module.exports = { puppeteerArgs }; diff --git a/test/helpers/run-browser.js b/test/helpers/run-browser.js new file mode 100644 index 000000000..5cf26d4d5 --- /dev/null +++ b/test/helpers/run-browser.js @@ -0,0 +1,60 @@ +// Pinned to v22, the last CJS build — jest cannot require the ESM-only +// versions that follow. +const puppeteer = require("puppeteer"); + +const { puppeteerArgs } = require("./puppeteer-constants"); + +/** + * @typedef {object} RunBrowserResult + * @property {import("puppeteer").Page} page page + * @property {import("puppeteer").Browser} browser browser + */ + +/** + * Create a page in the given browser, answering favicon requests so they do + * not produce noise (same pattern as webpack-dev-server's run-browser helper). + * @param {import("puppeteer").Browser} browser browser + * @returns {Promise} configured page + */ +async function runPage(browser) { + const page = await browser.newPage(); + + await page.setRequestInterception(true); + + page.on("request", (interceptedRequest) => { + if (interceptedRequest.isInterceptResolutionHandled()) { + return; + } + + if (interceptedRequest.url().includes("favicon.ico")) { + interceptedRequest.respond({ + status: 200, + contentType: "image/png", + body: "Empty", + }); + } else { + interceptedRequest.continue(); + } + }); + + await page.setViewport({ width: 800, height: 600 }); + + return page; +} + +/** + * @returns {Promise} browser and a ready page + */ +async function runBrowser() { + const browser = await puppeteer.launch({ + headless: true, + args: puppeteerArgs, + }); + + const page = await runPage(browser); + + return { page, browser }; +} + +module.exports = runBrowser; +module.exports.runPage = runPage; diff --git a/test/logging.test.js b/test/logging.test.js index 7fb288e81..02e83fbc9 100644 --- a/test/logging.test.js +++ b/test/logging.test.js @@ -3,6 +3,10 @@ import os from "node:os"; import path from "node:path"; import { stripVTControlCharacters } from "node:util"; +// Every test boots webpack from scratch in a child process; the first, cold +// one can exceed the global 20s limit on a loaded machine. +jest.setTimeout(60000); + function extractErrorEntry(string) { const matches = string.match(/error:\s\D[^:||\n||\r]+/gim); diff --git a/test/overlay.test.js b/test/overlay.test.js index 05be7497a..04fc24297 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -45,12 +45,6 @@ describe("overlay", () => { }); describe("showProblems", () => { - it("mounts the overlay on the document body", () => { - expect(getOverlay()).toBeNull(); - showProblems("errors", ["./a.js 1:1\nboom"]); - expect(getOverlay()).not.toBeNull(); - }); - it("renders an ERROR badge in the error color for errors", () => { showProblems("errors", ["boom"]); const badge = getCard().querySelector("span"); @@ -133,25 +127,12 @@ describe("overlay", () => { }); describe("clear", () => { - it("removes the overlay from the DOM", () => { - showProblems("errors", ["boom"]); - expect(getOverlay()).not.toBeNull(); - clear(); - expect(getOverlay()).toBeNull(); - }); - it("is a no-op when nothing is shown", () => { expect(() => clear()).not.toThrow(); }); }); describe("dismiss", () => { - it("closes when pressing Escape", () => { - showProblems("errors", ["boom"]); - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - expect(getOverlay()).toBeNull(); - }); - it("closes when clicking the backdrop", () => { showProblems("errors", ["boom"]); getOverlay().contentDocument.body.click(); diff --git a/test/process-update.test.js b/test/process-update.test.js index 36010bf33..0eb107e0c 100644 --- a/test/process-update.test.js +++ b/test/process-update.test.js @@ -146,18 +146,6 @@ describe("process-update", () => { }); }); - it("checks and applies an update when the hash differs", async () => { - applyUpdate("new-hash", { reload: true }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - const hot = getHot(); - - expect(hot.check).toHaveBeenCalledWith(false); - expect(hot.apply).toHaveBeenCalledTimes(1); - expect(reloadPage).not.toHaveBeenCalled(); - }); - it("does not check when already up to date", () => { applyUpdate("current-hash", { reload: true }); @@ -217,22 +205,6 @@ describe("process-update", () => { expect(reloadPage).not.toHaveBeenCalled(); }); - it("reloads when modules could not be hot updated (unaccepted)", async () => { - applyUpdate = loadApplyUpdate( - makeFakeHot({ - checkResult: ["./a.js", "./b.js"], - // Only one of the two updated modules was renewed. - applyImpl: () => Promise.resolve(["./a.js"]), - }), - ); - - applyUpdate("new-hash", { reload: true }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(reloadPage).toHaveBeenCalledTimes(1); - }); - it("reloads when the runtime reports a failure status", async () => { const hot = { // "idle" when the update starts, "abort" when the failure is handled. From 711f0ef6203c46291f8d4f3ca5c0f7632a8410b0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 20:03:29 -0500 Subject: [PATCH 02/48] test(e2e): snapshot the logging=log module detail and drop the covered unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A logging=log e2e run now snapshots the collapsed "Updated modules:" group and its per-module entries straight from the browser console (puppeteer surfaces group frames as console events). The jsdom equivalents — the process-update logging describe and the building-message log test, whose output already appears verbatim in the info-level e2e snapshot — are removed. Co-Authored-By: Claude Fable 5 --- test/client.test.js | 11 ---- .../logging.test.js.snap.webpack5 | 14 ++++ test/e2e/logging.test.js | 18 +++++ test/process-update.test.js | 65 ------------------- 4 files changed, 32 insertions(+), 76 deletions(-) diff --git a/test/client.test.js b/test/client.test.js index b5c9aec03..3359a0916 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -115,17 +115,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("logs the changed file on building messages", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ action: "building", file: "/src/index.js" }), - ); - expect( - console.info.mock.calls.some(([msg]) => - msg.includes("rebuilding (/src/index.js changed)"), - ), - ).toBe(true); - }); - it("calls subscribeAll handler on default messages", () => { const spy = jest.fn(); client.subscribeAll(spy); diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 index 78eaea769..c607060d4 100644 --- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -10,6 +10,20 @@ You may need an appropriate loader to handle this file type, currently no loader ] `; +exports[`client logging (browser) logging=log adds the collapsed per-module detail 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] Updated modules:", + "[webpack-dev-middleware] - ./app.js", + "console.groupEnd", + "[webpack-dev-middleware] App is up to date.", +] +`; + exports[`client logging (browser) logging=none silences the whole cycle 1`] = `[]`; exports[`client logging (browser) logging=warn keeps warnings only 1`] = ` diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index 6fadba152..7fd5921de 100644 --- a/test/e2e/logging.test.js +++ b/test/e2e/logging.test.js @@ -70,6 +70,24 @@ describe("client logging (browser)", () => { expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); + it("logging=log adds the collapsed per-module detail", async () => { + hotApp = await createHotApp({ query: "?logging=log", code: app("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(app("v2")); + await waitForAppText(page, "v2"); + await console_.waitFor("App is up to date"); + + // Includes the "Updated modules:" collapsed group and its " - ./app.js" + // entry, which the info level gates off. + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + it("logging=none silences the whole cycle", async () => { hotApp = await createHotApp({ query: "?logging=none", code: app("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/process-update.test.js b/test/process-update.test.js index 0eb107e0c..340230b49 100644 --- a/test/process-update.test.js +++ b/test/process-update.test.js @@ -220,69 +220,4 @@ describe("process-update", () => { expect(reloadPage).toHaveBeenCalledTimes(1); }); - - describe("logging", () => { - /** - * @param {jest.Mock} spy console spy - * @param {string} text expected substring - * @returns {boolean} true when some call contains the text - */ - function logged(spy, text) { - return spy.mock.calls.some((call) => call.join(" ").includes(text)); - } - - beforeEach(() => { - if (typeof console.groupCollapsed !== "function") { - console.groupCollapsed = () => {}; - } - if (typeof console.groupEnd !== "function") { - console.groupEnd = () => {}; - } - jest.spyOn(console, "groupCollapsed").mockImplementation(() => {}); - jest.spyOn(console, "groupEnd").mockImplementation(() => {}); - }); - - it("logs a one-line summary at the default info level", async () => { - applyUpdate = loadApplyUpdate( - makeFakeHot({ - checkResult: ["./a.js", "./b.js"], - applyImpl: () => Promise.resolve(["./a.js", "./b.js"]), - }), - ); - - applyUpdate("new-hash", { reload: true }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(logged(console.info, "Hot updated 2 modules.")).toBe(true); - // Groups are gated below the "log" level. - expect(console.groupCollapsed).not.toHaveBeenCalled(); - expect(logged(console.log, "./a.js")).toBe(false); - }); - - it("adds a collapsed group with the module detail at the log level", async () => { - applyUpdate = loadApplyUpdate( - makeFakeHot({ - checkResult: ["./a.js", "./b.js"], - applyImpl: () => Promise.resolve(["./a.js", "./b.js"]), - }), - ); - setLogLevel("log"); - - applyUpdate("new-hash", { reload: true }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(logged(console.info, "Hot updated 2 modules.")).toBe(true); - expect( - logged( - /** @type {jest.Mock} */ (console.groupCollapsed), - "Updated modules:", - ), - ).toBe(true); - expect(logged(console.log, "./a.js")).toBe(true); - expect(logged(console.log, "./b.js")).toBe(true); - expect(console.groupEnd).toHaveBeenCalled(); - }); - }); }); From 969ec3f6c47ce1773873bb090fb912783be678c9 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 20:27:45 -0500 Subject: [PATCH 03/48] test(e2e): move the reconnection tests to the browser and snapshot their console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test/client.test.js | 71 ------------------ .../client.test.js.snap.webpack5 | 31 ++++++++ test/e2e/client.test.js | 73 ++++++++++++++++++- test/helpers/console-collector.js | 16 ++-- 4 files changed, 113 insertions(+), 78 deletions(-) create mode 100644 test/e2e/__snapshots__/client.test.js.snap.webpack5 diff --git a/test/client.test.js b/test/client.test.js index 3359a0916..f784a5374 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -633,77 +633,6 @@ describe("client", () => { require("../client-src"); expect(EventSourceStub.instances).toHaveLength(1); }); - - it("closes and re-opens the connection on timeout", () => { - // The watchdog interval is created during the client's first load. Fake - // timers must be enabled before that load so jest can drive it. - jest.useFakeTimers({ doNotFake: ["nextTick"] }); - // Drop the wrapper opened by the outer beforeEach so we get a fresh - // EventSource scheduled under fake timers. - delete globalThis.__wdmEventSourceWrapper; - EventSourceStub.instances.length = 0; - loadClient(); - - const [first] = EventSourceStub.instances; - expect(first.closed).toBe(false); - // The watchdog ticks at `timeout/2` and disconnects when - // `Date.now() - lastActivity > timeout`. 30s is enough to cross that - // boundary regardless of which tick reports it first. - jest.advanceTimersByTime(30 * 1000); - expect(first.closed).toBe(true); - // Reconnect is scheduled after `options.timeout` (20s). - jest.advanceTimersByTime(20 * 1000); - expect(EventSourceStub.instances).toHaveLength(2); - }); - - it("keeps the inactivity watchdog after a reconnect", () => { - jest.useFakeTimers({ doNotFake: ["nextTick"] }); - delete globalThis.__wdmEventSourceWrapper; - EventSourceStub.instances.length = 0; - loadClient(); - - // First silent stall: the watchdog disconnects and a reconnect opens a - // second source 20s later. - jest.advanceTimersByTime(30 * 1000); - jest.advanceTimersByTime(20 * 1000); - expect(EventSourceStub.instances).toHaveLength(2); - - // The reconnected source must be watched too: another silent stall has - // to close it and schedule a third connection. - const second = EventSourceStub.lastInstance(); - jest.advanceTimersByTime(30 * 1000); - expect(second.closed).toBe(true); - jest.advanceTimersByTime(20 * 1000); - expect(EventSourceStub.instances).toHaveLength(3); - }); - - it("ignores error events that arrive after disconnect()", () => { - jest.useFakeTimers({ doNotFake: ["nextTick"] }); - delete globalThis.__wdmEventSourceWrapper; - EventSourceStub.instances.length = 0; - const freshClient = loadClient(); - - const [first] = EventSourceStub.instances; - freshClient.disconnect(); - - // An EventSource fires a queued error when its connection dies — it - // must not resurrect the closed wrapper by scheduling a reconnect. - first.dispatch("error", {}); - jest.advanceTimersByTime(60 * 1000); - - expect(EventSourceStub.instances).toHaveLength(1); - }); - - it("disconnect() drops the cached wrapper so a new connect starts fresh", () => { - client.disconnect(); - expect( - globalThis.__wdmEventSourceWrapper["/__webpack_hmr"], - ).toBeUndefined(); - - client.setOptionsAndConnect({}); - expect(EventSourceStub.instances).toHaveLength(2); - expect(EventSourceStub.lastInstance().closed).toBe(false); - }); }); describe("with no EventSource", () => { diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 new file mode 100644 index 000000000..09c6e8f5d --- /dev/null +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`hot client (browser) reconnects after a server restart and syncs up on missed builds 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; + +exports[`hot client (browser) reconnects manually with setOptionsAndConnect() after disconnect() 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; + +exports[`hot client (browser) watchdog-reconnects a silent connection and stays armed afterwards 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", +] +`; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 2150a5783..53cf08896 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -1,4 +1,4 @@ -import collectConsole from "../helpers/console-collector"; +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; @@ -165,6 +165,77 @@ describe("hot client (browser)", () => { await waitForAppText(page, "v2"); expect(await readReloadMarker(page)).toBe(true); + + // The whole story in one place: connect, silent gap while the server was + // down, reconnect, and the catch-up sync applying the missed build. + await console_.waitFor("App is up to date"); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("watchdog-reconnects a silent connection and stays armed afterwards", async () => { + app = await createHotApp({ + query: "?timeout=1000", + code: acceptedApp("v1"), + // A heartbeat far beyond the client timeout leaves the connection open + // but silent, so only the inactivity watchdog can trigger reconnects. + hot: { heartbeat: 3600000 }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + + // Three connects = the initial one plus two watchdog cycles: the second + // proves the watchdog fires on pure silence (no error event involved), + // the third that it re-arms after a reconnect instead of dying with the + // first clearInterval. + await console_.waitForCount("connected", 3); + + // Nothing but connects: the silent cycles produce no other output. The + // snapshot is taken before the edit — the watchdog keeps cycling, so any + // later cut would race with the next reconnect. + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + + // The reconnected connection still delivers updates. + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v2"); + }); + + it("reconnects manually with setOptionsAndConnect() after disconnect()", async () => { + app = await createHotApp({ + code: ` + globalThis.hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + // disconnect() drops the cached wrapper, so a manual connect starts a + // fresh connection on the same path. + await page.evaluate(() => { + globalThis.hotClient.disconnect(); + globalThis.hotClient.setOptionsAndConnect({}); + }); + await console_.waitForCount("connected", 2); + + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + await console_.waitFor("App is up to date"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); it("routes server publish() payloads to subscribe handlers", async () => { diff --git a/test/helpers/console-collector.js b/test/helpers/console-collector.js index a08f00399..9d48c6a44 100644 --- a/test/helpers/console-collector.js +++ b/test/helpers/console-collector.js @@ -45,16 +45,20 @@ function collectConsole(page) { /** * Strip the run-specific parts (timings, temp fixture paths) so browser - * console output can be snapshotted. + * console output can be snapshotted. Network-error noise emitted by the + * browser itself ("Failed to load resource: …") is dropped — how many of + * those a severed connection produces depends on reconnect timing. * @param {string[]} messages raw console messages * @returns {string[]} normalized messages */ function normalizeConsole(messages) { - return messages.map((text) => - text - .replaceAll(/\d+\s?ms/g, "Xms") - .replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, ""), - ); + return messages + .filter((text) => !text.startsWith("Failed to load resource")) + .map((text) => + text + .replaceAll(/\d+\s?ms/g, "Xms") + .replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, ""), + ); } module.exports = collectConsole; From b133e33c790dfc26c679eeeacf13be85e519ed27 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 20:46:12 -0500 Subject: [PATCH 04/48] test(e2e): exercise the indicator's shared state with two real bundled copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-compiler helper already produces what the jsdom suite could only simulate: two genuinely separate bundled copies of the indicator module on one page. Each bundle now exposes its copy as a global and the tests drive both from the browser — a second copy adopts the same badge instead of stacking one, per-source counting spans copies, hiding an unknown source is ignored, and a leaner state shape left by an older package version gets its missing fields filled (planted via evaluateOnNewDocument before the bundles load). The jsdom indicator suite is removed. Co-Authored-By: Claude Fable 5 --- test/e2e/indicator.test.js | 139 +++++++++++++++++++++++++++++++++++++ test/indicator.test.js | 79 --------------------- 2 files changed, 139 insertions(+), 79 deletions(-) delete mode 100644 test/indicator.test.js diff --git a/test/e2e/indicator.test.js b/test/e2e/indicator.test.js index dd27daae0..82454997a 100644 --- a/test/e2e/indicator.test.js +++ b/test/e2e/indicator.test.js @@ -4,6 +4,8 @@ import runBrowser from "../helpers/run-browser"; jest.setTimeout(120000); const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; +const INDICATOR_ENTRY = require.resolve("../../client-src/indicator.js"); +const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; /** * @param {string} text text rendered into #app @@ -143,3 +145,140 @@ describe("building indicator (browser)", () => { expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(false); }); }); + +describe("indicator shared state across bundled copies (browser)", () => { + let hotApp; + let browser; + let page; + + /** + * Two real bundled copies of the indicator module — one per compilation — + * exposed as globals so the tests can drive both from the page. + * @param {string} globalName global to expose the copy under + * @returns {string} app source + */ + const exposeIndicator = (globalName) => + `globalThis.${globalName} = require(${JSON.stringify(INDICATOR_ENTRY)});`; + + const start = async () => { + hotApp = await createHotApp({ + apps: [ + { name: "a", code: exposeIndicator("indicatorA") }, + { name: "b", code: exposeIndicator("indicatorB") }, + ], + }); + ({ page, browser } = await runBrowser()); + }; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (hotApp) { + const closing = hotApp; + hotApp = undefined; + await closing.close(); + } + } + }); + + it("drives a single badge from a second bundled copy", async () => { + await start(); + await page.goto(hotApp.url); + + const state = await page.evaluate((id) => { + globalThis.indicatorA.show("Rebuilding…"); + globalThis.indicatorB.show("Rebuilding… 42%", 42); + + const hosts = document.querySelectorAll(`#${id}`); + + return { + count: hosts.length, + text: hosts[0] ? hosts[0].shadowRoot.textContent : "", + }; + }, INDICATOR_ID); + + // One badge, adopted (not stacked) by the second copy, showing its text. + expect(state.count).toBe(1); + expect(state.text).toContain("42%"); + + // The state is shared, so the first copy's hide() removes the badge the + // second copy updated. + await page.evaluate(() => globalThis.indicatorA.hide()); + expect( + await page.evaluate((id) => document.getElementById(id), INDICATOR_ID), + ).toBeNull(); + }); + + it("keeps the badge while another copy's source is still building", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.indicatorA.show("Rebuilding a…", undefined, "a"); + globalThis.indicatorB.show("Rebuilding b…", undefined, "b"); + globalThis.indicatorB.hide("b"); + }); + expect( + await page.evaluate( + (id) => document.getElementById(id) !== null, + INDICATOR_ID, + ), + ).toBe(true); + + await page.evaluate(() => globalThis.indicatorA.hide("a")); + expect( + await page.evaluate( + (id) => document.getElementById(id) !== null, + INDICATOR_ID, + ), + ).toBe(false); + }); + + it("ignores hiding an unknown source and still removes unconditionally", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.indicatorA.show("Rebuilding…", undefined, "a"); + globalThis.indicatorA.hide("unknown"); + }); + expect( + await page.evaluate( + (id) => document.getElementById(id) !== null, + INDICATOR_ID, + ), + ).toBe(true); + + // Without a source the badge is removed even with builds pending. + await page.evaluate(() => globalThis.indicatorA.hide()); + expect( + await page.evaluate( + (id) => document.getElementById(id) !== null, + INDICATOR_ID, + ), + ).toBe(false); + }); + + it("fills state fields missing from an older copy's shape", async () => { + await start(); + // An older package version created a leaner shared state before the + // bundles load. + await page.evaluateOnNewDocument((key) => { + globalThis[key] = { host: null }; + }, INDICATOR_STATE_KEY); + await page.goto(hotApp.url); + + const count = await page.evaluate((id) => { + globalThis.indicatorA.show("Rebuilding…"); + return document.querySelectorAll(`#${id}`).length; + }, INDICATOR_ID); + + expect(count).toBe(1); + }); +}); diff --git a/test/indicator.test.js b/test/indicator.test.js deleted file mode 100644 index 475a2b9cf..000000000 --- a/test/indicator.test.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { hide, show } from "../client-src/indicator"; - -const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; -const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; - -describe("indicator shared state across module copies", () => { - afterEach(() => { - hide(); - for (const element of document.querySelectorAll(`#${INDICATOR_ID}`)) { - element.remove(); - } - delete window[INDICATOR_STATE_KEY]; - }); - - it("drives a single badge from a second bundled copy", () => { - show("Rebuilding…"); - - expect(document.querySelectorAll(`#${INDICATOR_ID}`)).toHaveLength(1); - - // A second copy of the module (e.g. the webpack-dev-server client - // bundling its own) must adopt the same badge, not stack another. - jest.resetModules(); - - const secondCopy = require("../client-src/indicator"); - - secondCopy.show("Rebuilding… 42%", 42); - - expect(document.querySelectorAll(`#${INDICATOR_ID}`)).toHaveLength(1); - - const host = document.getElementById(INDICATOR_ID); - - expect(host.shadowRoot.textContent).toContain("42%"); - - // The state is shared, so the first copy's hide() removes the badge the - // second copy updated. - hide(); - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - - it("keeps the badge while another source is still building", () => { - show("Rebuilding app…", undefined, "app"); - show("Rebuilding admin…", undefined, "admin"); - - hide("admin"); - expect(document.getElementById(INDICATOR_ID)).not.toBeNull(); - - hide("app"); - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - - it("ignores hiding a source that never reported and still removes unconditionally", () => { - show("Rebuilding…", undefined, "app"); - - hide("unknown"); - expect(document.getElementById(INDICATOR_ID)).not.toBeNull(); - - // Without a source the badge is removed even with builds pending. - hide(); - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - - it("fills state fields missing from an older copy's shape", () => { - // Simulate an older package version having created a leaner state. - window[INDICATOR_STATE_KEY] = { host: null }; - - jest.resetModules(); - - const newerCopy = require("../client-src/indicator"); - - expect(() => newerCopy.show("Rebuilding…")).not.toThrow(); - expect(document.querySelectorAll(`#${INDICATOR_ID}`)).toHaveLength(1); - - newerCopy.hide(); - }); -}); From c22ccdaa66056d51364eb0c5fbc459676b4c6c41 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 21:34:18 -0500 Subject: [PATCH 05/48] test(e2e): cover the client option surface in the browser and slim the jsdom suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscribe/subscribeAll contract, the ?name= filter, per-source badge counting, the shared SSE connection between bundled clients (asserted explicitly in the multi-compiler console snapshot: one "connected" for two clients), the dev-server-shaped overlay option, and the warnings filter function — parsed from the entry query and applied against two real warning-producing modules — now all run in Chrome. The warnings overlay test also covers clearing on a recovered build, and the pagination test snapshots the two-error console output that the deleted jsdom snapshot used to document. client.test.js keeps only what the browser cannot pin deterministically: problem-type transitions, the multi-bundle problem union, option forwarding to the overlay factory, dynamicPublicPath URL building, and protocol edges. Co-Authored-By: Claude Fable 5 --- .../client.test.js.snap.webpack5 | 13 - test/client.test.js | 245 +----------------- .../overlay.test.js.snap.webpack5 | 16 ++ test/e2e/client.test.js | 11 + test/e2e/overlay.test.js | 78 +++++- 5 files changed, 105 insertions(+), 258 deletions(-) delete mode 100644 test/__snapshots__/client.test.js.snap.webpack5 create mode 100644 test/e2e/__snapshots__/overlay.test.js.snap.webpack5 diff --git a/test/__snapshots__/client.test.js.snap.webpack5 b/test/__snapshots__/client.test.js.snap.webpack5 deleted file mode 100644 index 9705a8ed5..000000000 --- a/test/__snapshots__/client.test.js.snap.webpack5 +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`client with overlay warnings enabled via the dev-server-shaped option shows overlay on errored builds 1`] = ` -[ - [ - "[webpack-dev-middleware] bundle has 2 errors", - ], - [ - "[webpack-dev-middleware] Something broke -Actually, 2 things broke", - ], -] -`; diff --git a/test/client.test.js b/test/client.test.js index f784a5374..4b3a99ed3 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -99,7 +99,6 @@ describe("client", () => { describe("with default options", () => { let EventSourceStub; - let client; beforeEach(() => { EventSourceStub = makeEventSourceStub(); @@ -108,53 +107,13 @@ describe("client", () => { jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); jest.spyOn(console, "error").mockImplementation(() => {}); - client = loadClient(); + loadClient(); }); afterEach(() => { jest.restoreAllMocks(); }); - it("calls subscribeAll handler on default messages", () => { - const spy = jest.fn(); - client.subscribeAll(spy); - const message = { - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }; - EventSourceStub.lastInstance().onmessage(makeMessage(message)); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(message); - }); - - it("calls subscribeAll handler on custom messages", () => { - const spy = jest.fn(); - client.subscribeAll(spy); - EventSourceStub.lastInstance().onmessage( - makeMessage({ action: "thingy" }), - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ action: "thingy" }); - }); - - it("calls only the custom handler for custom messages", () => { - const spy = jest.fn(); - client.subscribe(spy); - EventSourceStub.lastInstance().onmessage( - makeMessage({ custom: "thingy" }), - ); - EventSourceStub.lastInstance().onmessage( - makeMessage({ action: "built" }), - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ custom: "thingy" }); - expect(processUpdate).not.toHaveBeenCalled(); - }); - it("does not trigger webpack on errored builds", () => { EventSourceStub.lastInstance().onmessage( makeMessage({ @@ -289,41 +248,6 @@ describe("client", () => { }); }); - describe("with an overlay warnings filter function", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - loadClient( - '?overlay={"warnings":"function(message){return message.includes(`keep`)}"}', - ); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("only shows the warnings the filter keeps (dev-server parity)", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["drop this warning", "keep this warning"], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ - "keep this warning", - ]); - }); - }); - describe("with overlay warnings enabled via the dev-server-shaped option", () => { let EventSourceStub; @@ -341,69 +265,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("shows overlay on errored builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke", "Actually, 2 things broke"], - warnings: [], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.showProblems).toHaveBeenCalledWith("errors", [ - "Something broke", - "Actually, 2 things broke", - ]); - expect(console.error.mock.calls).toMatchSnapshot(); - }); - - it("shows overlay on warning builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.showProblems).toHaveBeenCalledWith("warnings", [ - "This isn't great, but it's not terrible", - ]); - }); - - it("hides overlay after warning build is fixed", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef2", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(1); - expect(clientOverlay.clear).toHaveBeenCalledWith(""); - expect(clientOverlay.clear).toHaveBeenCalledWith("runtime"); - }); - it("updates overlay after errored build becomes a warning", () => { const es = EventSourceStub.lastInstance(); es.onmessage( @@ -438,53 +299,6 @@ describe("client", () => { }); }); - describe("with name option", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - loadClient("?name=test"); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("does not trigger webpack when event name differs", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - name: "foo", - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).not.toHaveBeenCalled(); - }); - - it("does not trigger webpack on sync when event name differs", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - name: "bar", - action: "sync", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).not.toHaveBeenCalled(); - }); - }); - describe("with overlay runtime/trusted-types options", () => { it("forwards them to the overlay factory", () => { globalThis.EventSource = makeEventSourceStub(); @@ -543,55 +357,6 @@ describe("client", () => { }); }); - describe("with progress option", () => { - const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("keeps the badge until every compilation finished", () => { - loadClient(); - const es = EventSourceStub.lastInstance(); - - es.onmessage(makeMessage({ action: "building", name: "app" })); - es.onmessage(makeMessage({ action: "building", name: "admin" })); - - es.onmessage( - makeMessage({ - action: "built", - name: "app", - time: 1, - hash: "h1", - errors: [], - warnings: [], - }), - ); - // "admin" is still building — its `built` has not arrived yet. - expect(document.getElementById(INDICATOR_ID)).not.toBeNull(); - - es.onmessage( - makeMessage({ - action: "built", - name: "admin", - time: 1, - hash: "h2", - errors: [], - warnings: [], - }), - ); - expect(document.getElementById(INDICATOR_ID)).toBeNull(); - }); - }); - describe("connection lifecycle", () => { let EventSourceStub; let client; @@ -625,14 +390,6 @@ describe("client", () => { ), ).toBe(true); }); - - it("reuses the EventSource wrapper across reloads on the same path", () => { - // Re-loading the entry on the same page should reuse the cached SSE - // connection rather than opening a new one. - jest.resetModules(); - require("../client-src"); - expect(EventSourceStub.instances).toHaveLength(1); - }); }); describe("with no EventSource", () => { diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 new file mode 100644 index 000000000..1b924fb5f --- /dev/null +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`error overlay (browser) paginates multiple problems with a counter 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle has 2 errors", + "[webpack-dev-middleware] ./a.js 1:7 +Module parse failed: Unexpected token (1:7) +You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders +> broken a {{{ +./b.js 1:7 +Module parse failed: Unexpected token (1:7) +You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders +> broken b {{{", +] +`; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 53cf08896..d1ce7cd87 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -242,6 +242,10 @@ describe("hot client (browser)", () => { app = await createHotApp({ code: ` const hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + globalThis.__all = []; + hotClient.subscribeAll((payload) => { + globalThis.__all.push(payload.action); + }); hotClient.subscribe((payload) => { globalThis.__custom = payload; }); @@ -269,6 +273,13 @@ describe("hot client (browser)", () => { expect(await page.evaluate(() => globalThis.__custom.action)).toBe( "my-event", ); + + // subscribeAll saw both the protocol traffic (the connect-time sync) and + // the custom payload; subscribe saw only the custom one. + const all = await page.evaluate(() => globalThis.__all); + + expect(all).toContain("sync"); + expect(all).toContain("my-event"); }); it("disconnect() during the reconnect window cancels the pending reconnect", async () => { diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 1e7fee8ab..0db825638 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -1,4 +1,4 @@ -import collectConsole from "../helpers/console-collector"; +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; @@ -169,6 +169,76 @@ describe("error overlay (browser)", () => { expect(text).toContain("WARNING"); expect(text).toContain("Critical dependency"); + + // A build without the warning clears the overlay again. + hotApp.edit(app("fixed")); + await waitForNoOverlay(page); + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it('overlay={"warnings":false} suppresses warnings (dev-server shape)', async () => { + hotApp = await createHotApp({ + query: '?overlay={"warnings":false}', + code: ` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + // The warning still reaches the console — just not the DOM. + await console_.waitFor("Critical dependency"); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("applies a warnings filter function from the query (dev-server parity)", async () => { + hotApp = await createHotApp({ + query: + '?overlay={"warnings":"function(message){return message.includes(`a.js`)}"}', + // Two warning-producing modules; the filter keeps only a.js's warning. + code: ` + document.getElementById("app").textContent = "v1"; + try { + require("./a"); + } catch (err) { + // expected + } + try { + require("./b"); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `, + files: { + "a.js": + 'const depA = "./nothing"; try { require(depA); } catch (err) {}', + "b.js": + 'const depB = "./nothing"; try { require(depB); } catch (err) {}', + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + const frame = await waitForOverlay(page); + const text = await frame.evaluate(() => document.body.textContent); + + expect(text).toContain("./a.js"); + expect(text).not.toContain("./b.js"); }); it("paginates multiple problems with a counter", async () => { @@ -193,11 +263,17 @@ describe("error overlay (browser)", () => { }, }); ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); await page.goto(hotApp.url); const frame = await waitForOverlay(page); + // Both errors also reach the console, joined into a single error call — + // snapshotted once the second one's code frame is in. + await console_.waitFor("> broken b"); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + // One problem at a time, with a counter. await frame.waitForFunction(() => document.body.textContent.includes("1 / 2"), From a7a52bc3f310c82fd680c82ce1f5c0f2e8a03675 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 21:51:22 -0500 Subject: [PATCH 06/48] test(e2e): assert the multi-bundle problem union in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking both bundles shows the union of their problems in the shared overlay — paginated one per page, in whichever order the compilers finished. The two jsdom equivalents are removed: the union test, and no-apply-on-errored-builds, which the multi-compiler console snapshot already documents (no "Checking for updates" follows a bundle's error report). Co-Authored-By: Claude Fable 5 --- test/client.test.js | 60 --------------------------------- test/e2e/multi-compiler.test.js | 43 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 60 deletions(-) diff --git a/test/client.test.js b/test/client.test.js index 4b3a99ed3..7eea9185c 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -114,20 +114,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("does not trigger webpack on errored builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke"], - warnings: [], - modules: [], - }), - ); - expect(processUpdate).not.toHaveBeenCalled(); - }); - it("updates overlay when an errored build becomes a warning", () => { const es = EventSourceStub.lastInstance(); es.onmessage( @@ -202,52 +188,6 @@ describe("client", () => { }); }); - describe("with multi-compiler payloads", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - loadClient(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("shows the union of problems from every broken bundle", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - name: "app", - time: 100, - hash: "app-hash", - errors: ["app broke"], - warnings: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - name: "admin", - time: 100, - hash: "admin-hash", - errors: ["admin broke too"], - warnings: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ - "app broke", - "admin broke too", - ]); - }); - }); - describe("with overlay warnings enabled via the dev-server-shaped option", () => { let EventSourceStub; diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js index 136be3aba..0e861e561 100644 --- a/test/e2e/multi-compiler.test.js +++ b/test/e2e/multi-compiler.test.js @@ -151,6 +151,49 @@ describe("multi-compiler (browser)", () => { expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); + it("shows the union of problems from every broken bundle", async () => { + app = await createHotApp({ + apps: [ + { name: "app", code: bundleApp("app", "app-v1") }, + { name: "widget", code: bundleApp("widget", "widget-v1") }, + ], + }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForText(page, "out-app", "app-v1"); + await waitForText(page, "out-widget", "widget-v1"); + + app.edit("app", "broken app {{{"); + app.edit("widget", "broken widget {{{"); + + const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { + timeout: 30000, + }); + const frame = await handle.contentFrame(); + + // Both bundles' problems share the overlay: the pager counts the union + // (one problem per page; which bundle finishes breaking first varies). + await frame.waitForFunction( + () => document.body.textContent.includes("1 / 2"), + { timeout: 30000 }, + ); + + const firstPage = await frame.evaluate(() => document.body.textContent); + + await frame.click('[aria-label="Next problem"]'); + await frame.waitForFunction( + () => document.body.textContent.includes("2 / 2"), + { timeout: 30000 }, + ); + + const secondPage = await frame.evaluate(() => document.body.textContent); + const union = firstPage + secondPage; + + expect(union).toContain("> broken app"); + expect(union).toContain("> broken widget"); + }); + it("keeps one bundle's overlay errors while a sibling rebuilds successfully", async () => { app = await createHotApp({ apps: [ From b01db524d43ad790045343d1ade64cd64f825e3e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Fri, 24 Jul 2026 22:01:00 -0500 Subject: [PATCH 07/48] test(e2e): collapse the rebuilding path in console snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webpack's invalid hook sometimes reports the watched directory instead of the changed file under the polling watcher, so the "bundle rebuilding (…)" line alternated between the file and the fixture dir and flaked the snapshots. The normalizer now collapses both forms to "( changed)". Co-Authored-By: Claude Fable 5 --- test/e2e/__snapshots__/client.test.js.snap.webpack5 | 2 +- test/e2e/__snapshots__/logging.test.js.snap.webpack5 | 4 ++-- .../__snapshots__/multi-compiler.test.js.snap.webpack5 | 8 ++++---- test/helpers/console-collector.js | 8 +++++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 index 09c6e8f5d..2cea83a8b 100644 --- a/test/e2e/__snapshots__/client.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -14,7 +14,7 @@ exports[`hot client (browser) reconnects manually with setOptionsAndConnect() af [ "[webpack-dev-middleware] connected", "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilding ( changed)", "[webpack-dev-middleware] bundle rebuilt in Xms", "[webpack-dev-middleware] Checking for updates on the server...", "[webpack-dev-middleware] Hot updated 1 modules.", diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 index c607060d4..d6cfe385b 100644 --- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -13,7 +13,7 @@ You may need an appropriate loader to handle this file type, currently no loader exports[`client logging (browser) logging=log adds the collapsed per-module detail 1`] = ` [ "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilding ( changed)", "[webpack-dev-middleware] bundle rebuilt in Xms", "[webpack-dev-middleware] Checking for updates on the server...", "[webpack-dev-middleware] Hot updated 1 modules.", @@ -37,7 +37,7 @@ Critical dependency: the request of a dependency is an expression", exports[`client logging (browser) logs a full update cycle at the default info level, with the prefix 1`] = ` [ "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] bundle rebuilding (/app.js changed)", + "[webpack-dev-middleware] bundle rebuilding ( changed)", "[webpack-dev-middleware] bundle rebuilt in Xms", "[webpack-dev-middleware] Checking for updates on the server...", "[webpack-dev-middleware] Hot updated 1 modules.", diff --git a/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 index 6b2821dee..b2d833710 100644 --- a/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 @@ -6,15 +6,15 @@ exports[`multi-compiler (browser) logs per-bundle lifecycles and deduplicates wa "[webpack-dev-middleware] bundle 'widget' has 1 warnings", "[webpack-dev-middleware] ./entry.js 11:8-20 Critical dependency: the request of a dependency is an expression", - "[webpack-dev-middleware] bundle 'app' rebuilding (/app/entry.js changed)", - "[webpack-dev-middleware] bundle 'app' rebuilding (/app/entry.js changed)", + "[webpack-dev-middleware] bundle 'app' rebuilding ( changed)", + "[webpack-dev-middleware] bundle 'app' rebuilding ( changed)", "[webpack-dev-middleware] bundle 'app' rebuilt in Xms", "[webpack-dev-middleware] Checking for updates on the server...", "[webpack-dev-middleware] bundle 'app' rebuilt in Xms", "[webpack-dev-middleware] Hot updated 1 modules.", "[webpack-dev-middleware] App is up to date.", - "[webpack-dev-middleware] bundle 'widget' rebuilding (/widget/entry.js changed)", - "[webpack-dev-middleware] bundle 'widget' rebuilding (/widget/entry.js changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilding ( changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilding ( changed)", "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", "[webpack-dev-middleware] Checking for updates on the server...", diff --git a/test/helpers/console-collector.js b/test/helpers/console-collector.js index 9d48c6a44..d3e1e5a79 100644 --- a/test/helpers/console-collector.js +++ b/test/helpers/console-collector.js @@ -57,7 +57,13 @@ function normalizeConsole(messages) { .map((text) => text .replaceAll(/\d+\s?ms/g, "Xms") - .replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, ""), + .replaceAll(/[^\s(]*wdm-e2e-[^/\s]*/g, "") + // The invalid hook sometimes reports the watched directory instead of + // the file (polling watcher) — collapse both forms. + .replaceAll( + /rebuilding \(\S* changed\)/g, + "rebuilding ( changed)", + ), ); } From 0a5c6e52f06db6a780f129118ca6754e19361a5c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 17:50:01 -0500 Subject: [PATCH 08/48] test(e2e): isolate the indicator shared-state tests from the hot client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundles' real hot clients call indicator.hide("a")/hide("b") when their connect-time syncs arrive — the same source names these tests register — so a late sync could wipe a source mid-test. progress=false keeps the client away from the indicator entirely. --- test/e2e/indicator.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/indicator.test.js b/test/e2e/indicator.test.js index 82454997a..79ee6ff51 100644 --- a/test/e2e/indicator.test.js +++ b/test/e2e/indicator.test.js @@ -162,6 +162,7 @@ describe("indicator shared state across bundled copies (browser)", () => { const start = async () => { hotApp = await createHotApp({ + query: "?progress=false", apps: [ { name: "a", code: exposeIndicator("indicatorA") }, { name: "b", code: exposeIndicator("indicatorB") }, From cb7592dcdc8ed42a27e5e150b988deb8d4d752a6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 17:55:22 -0500 Subject: [PATCH 09/48] test: drop a stray build artifact committed into fixtures test/fixtures/js3 was compiled output (a misdirected copy of the dev-server-false test's dist, whose real destination under test/outputs is gitignored) swept up by a bulk git add. Co-Authored-By: Claude Fable 5 --- test/fixtures/js3/bundle.js | 117 ----------------------------------- test/fixtures/js3/index.html | 1 - test/fixtures/js3/svg.svg | 33 ---------- 3 files changed, 151 deletions(-) delete mode 100644 test/fixtures/js3/bundle.js delete mode 100644 test/fixtures/js3/index.html delete mode 100644 test/fixtures/js3/svg.svg diff --git a/test/fixtures/js3/bundle.js b/test/fixtures/js3/bundle.js deleted file mode 100644 index 9ed28d00d..000000000 --- a/test/fixtures/js3/bundle.js +++ /dev/null @@ -1,117 +0,0 @@ -/* - * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development"). - * This devtool is neither made for production nor for readable output files. - * It uses "eval()" calls to create a separate source file in the browser devtools. - * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/) - * or disable the default devtool with "devtool: false". - * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/). - */ -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -/******/ var __webpack_modules__ = ({ - -/***/ "./index.html" -/*!********************!*\ - !*** ./index.html ***! - \********************/ -(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + \"index.html\");\n\n//# sourceURL=webpack:///./index.html?\n}"); - -/***/ }, - -/***/ "./svg.svg" -/*!*****************!*\ - !*** ./svg.svg ***! - \*****************/ -(__unused_webpack_module, __webpack_exports__, __webpack_require__) { - -eval("{__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (__webpack_require__.p + \"svg.svg\");\n\n//# sourceURL=webpack:///./svg.svg?\n}"); - -/***/ }, - -/***/ "./foo.js" -/*!****************!*\ - !*** ./foo.js ***! - \****************/ -(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) { - -eval("{\n\n__webpack_require__(/*! ./svg.svg */ \"./svg.svg\");\n__webpack_require__(/*! ./index.html */ \"./index.html\");\n\nconsole.log('Hey.'); // eslint-disable-line no-console\n\n\n//# sourceURL=webpack:///./foo.js?\n}"); - -/***/ } - -/******/ }); -/************************************************************************/ -/******/ // The module cache -/******/ var __webpack_module_cache__ = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ // Check if module is in cache -/******/ var cachedModule = __webpack_module_cache__[moduleId]; -/******/ if (cachedModule !== undefined) { -/******/ return cachedModule.exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = __webpack_module_cache__[moduleId] = { -/******/ // no module.id needed -/******/ // no module.loaded needed -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ if (!(moduleId in __webpack_modules__)) { -/******/ delete __webpack_module_cache__[moduleId]; -/******/ var e = new Error("Cannot find module '" + moduleId + "'"); -/******/ e.code = 'MODULE_NOT_FOUND'; -/******/ throw e; -/******/ } -/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__); -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/************************************************************************/ -/******/ /* webpack/runtime/define property getters */ -/******/ (() => { -/******/ // define getter functions for harmony exports -/******/ __webpack_require__.d = (exports, definition) => { -/******/ for(var key in definition) { -/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { -/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); -/******/ } -/******/ } -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/hasOwnProperty shorthand */ -/******/ (() => { -/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) -/******/ })(); -/******/ -/******/ /* webpack/runtime/make namespace object */ -/******/ (() => { -/******/ // define __esModule on exports -/******/ __webpack_require__.r = (exports) => { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ })(); -/******/ -/******/ /* webpack/runtime/publicPath */ -/******/ (() => { -/******/ __webpack_require__.p = "/static-three/"; -/******/ })(); -/******/ -/************************************************************************/ -/******/ -/******/ // startup -/******/ // Load entry module and return exports -/******/ // This entry module can't be inlined because the eval devtool is used. -/******/ var __webpack_exports__ = __webpack_require__("./foo.js"); -/******/ -/******/ })() -; \ No newline at end of file diff --git a/test/fixtures/js3/index.html b/test/fixtures/js3/index.html deleted file mode 100644 index 999238f7d..000000000 --- a/test/fixtures/js3/index.html +++ /dev/null @@ -1 +0,0 @@ -My Index. diff --git a/test/fixtures/js3/svg.svg b/test/fixtures/js3/svg.svg deleted file mode 100644 index 33350a7f8..000000000 --- a/test/fixtures/js3/svg.svg +++ /dev/null @@ -1,33 +0,0 @@ - - SVG Logo - - - - - - - - - - - - - - - - - - - - - - - - SVG - - - - - - - From 413541b178191d657a98a67e568b9a55252cb3cc Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 18:00:12 -0500 Subject: [PATCH 10/48] test: let test:only take file arguments without losing the e2e exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --testPathIgnorePatterns is variadic, so a positional file after `npm run test:only --` was consumed as another ignore pattern — silently excluding the very file being targeted (the = form appends all the same). The exclusion now lives in jest.config.js and lifts itself when a test/e2e path is requested explicitly. Co-Authored-By: Claude Fable 5 --- jest.config.js | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 066708912..8715cec72 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,16 @@ +// The browser e2e suites run serially through `npm run test:e2e` — excluded +// here so the regular run stays parallel. The ignore lifts itself when a +// test/e2e path is requested explicitly (a CLI flag would swallow positional +// file arguments, --testPathIgnorePatterns being variadic). +const runningE2E = process.argv.some((arg) => arg.includes("test/e2e")); + module.exports = { testEnvironment: "node", collectCoverage: false, coveragePathIgnorePatterns: ["test", "/node_modules"], moduleFileExtensions: ["js", "json"], testMatch: ["**/test/**/*.test.js"], + testPathIgnorePatterns: runningE2E ? [] : ["/node_modules/", "/test/e2e/"], setupFilesAfterEnv: ["/setupTest.js"], globalSetup: "./test/helpers/globalSetup.js", snapshotResolver: "./test/helpers/snapshotResolver.js", diff --git a/package.json b/package.json index d4a3ef3a6..f3d821f24 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "build:code": "babel src -d dist --copy-files", "build:client": "babel client-src -d client --copy-files", "build": "npm-run-all -p \"build:**\"", - "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest --testPathIgnorePatterns \"/test/e2e/\"", + "test:only": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest", "test:e2e": "node --experimental-vm-modules ./node_modules/jest-cli/bin/jest test/e2e --runInBand", "test:watch": "npm run test:only -- --watch", "test:coverage": "npm run test:only -- --collectCoverageFrom=\"src/**/*.js\" --coverage", From a525f0545b051fdcc2ca32a43b4e7af3dcb5cbd8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 18:14:08 -0500 Subject: [PATCH 11/48] test(e2e): exercise the overlay's shared state with two real bundled copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the indicator: each compilation exposes its own bundled copy of the overlay module and the tests drive both from the browser — a second copy adopts the shared iframe instead of stacking one, both sources paginate together in the union, either copy can dismiss what the other rendered, errors from one copy outrank another's warnings until the erroring source recovers, clearing an unreported source does not rebuild the card another copy is showing, and a leaner state shape left by an older package version gets its missing fields filled. The jsdom equivalents are removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 187 +++++++++++++++++++++++++++++++++++++++ test/overlay.test.js | 97 -------------------- 2 files changed, 187 insertions(+), 97 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 0db825638..a3777fd29 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -303,3 +303,190 @@ describe("error overlay (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); }); + +describe("overlay shared state across bundled copies (browser)", () => { + const OVERLAY_ENTRY = require.resolve("../../client-src/overlay.js"); + const OVERLAY_STATE_KEY = "__webpack_dev_middleware_hot_overlay_state__"; + const CARD_ID = `${OVERLAY_ID}-card`; + + let hotApp; + let browser; + let page; + + /** + * Two real bundled copies of the overlay module — one per compilation — + * exposed as globals so the tests can drive both from the page. + * @param {string} globalName global to expose the copy under + * @returns {string} app source + */ + const exposeOverlay = (globalName) => + `globalThis.${globalName} = require(${JSON.stringify(OVERLAY_ENTRY)});`; + + const start = async () => { + hotApp = await createHotApp({ + // overlay=false keeps the real hot clients from reporting into the + // overlay these tests drive themselves. + query: "?overlay=false", + apps: [ + { name: "a", code: exposeOverlay("overlayA") }, + { name: "b", code: exposeOverlay("overlayB") }, + ], + }); + ({ page, browser } = await runBrowser()); + }; + + /** + * @returns {Promise} the overlay iframe's frame + */ + const overlayFrame = async () => { + const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { + timeout: 30000, + }); + + return handle.contentFrame(); + }; + + afterEach(async () => { + // try/finally: a rejected browser.close() must not leak the watcher, + // the server, and the temp dir behind it. + try { + if (browser) { + await browser.close(); + } + } finally { + browser = undefined; + if (hotApp) { + const closing = hotApp; + hotApp = undefined; + await closing.close(); + } + } + }); + + it("shows the problems of two copies in the same overlay", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["boom from copy A"]); + globalThis.overlayB.showProblems("errors", ["boom from copy B"], "b"); + }); + + // One iframe: the second copy adopted it instead of stacking another, + // and both sources are paginated together in the union. + expect( + await page.evaluate(() => document.querySelectorAll("iframe").length), + ).toBe(1); + + const frame = await overlayFrame(); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "boom from copy A", + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "1 / 2", + ); + + await frame.click('[aria-label="Next problem"]'); + await frame.waitForFunction(() => + document.body.textContent.includes("boom from copy B"), + ); + + // The state is shared both ways: dismissing from the first copy removes + // the overlay the second copy rendered into. + await page.evaluate(() => globalThis.overlayA.clear()); + expect( + await page.evaluate(() => document.querySelectorAll("iframe").length), + ).toBe(0); + }); + + it("prefers one copy's errors over another copy's warnings", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["boom from copy A"]); + globalThis.overlayB.showProblems( + "warnings", + ["careful from copy B"], + "b", + ); + }); + + const frame = await overlayFrame(); + const errorRed = "rgb(255, 51, 72)"; + const warningYellow = "rgb(255, 211, 14)"; + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "boom from copy A", + ); + expect(await frame.evaluate(() => document.body.textContent)).not.toContain( + "careful from copy B", + ); + expect( + await frame.evaluate( + (id) => document.getElementById(id).style.borderTopColor, + CARD_ID, + ), + ).toBe(errorRed); + + // Once the erroring source recovers, the warnings surface. + await page.evaluate(() => globalThis.overlayA.clear("")); + await frame.waitForFunction(() => + document.body.textContent.includes("careful from copy B"), + ); + expect( + await frame.evaluate( + (id) => document.getElementById(id).style.borderTopColor, + CARD_ID, + ), + ).toBe(warningYellow); + }); + + it("does not re-render when a copy clears a source that reported nothing", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["a", "b"], "x"); + }); + + const frame = await overlayFrame(); + + await frame.evaluate((id) => { + globalThis.__cardChild = document.getElementById(id).firstElementChild; + }, CARD_ID); + + // What the reporter does on every clean build of its own bundle. + await page.evaluate(() => globalThis.overlayB.clear("never-reported")); + + // Same DOM nodes — the card the other copy is showing was not rebuilt. + expect( + await frame.evaluate( + (id) => + globalThis.__cardChild === + document.getElementById(id).firstElementChild, + CARD_ID, + ), + ).toBe(true); + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + }); + + it("fills state fields missing from an older copy's shape", async () => { + await start(); + // An older package version created a leaner shared state before the + // bundles load. + await page.evaluateOnNewDocument((key) => { + globalThis[key] = { frame: null, card: null }; + }, OVERLAY_STATE_KEY); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["boom"], "newer"); + }); + + expect( + await page.evaluate(() => document.querySelectorAll("iframe").length), + ).toBe(1); + }); +}); diff --git a/test/overlay.test.js b/test/overlay.test.js index 04fc24297..1cbc3ff01 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -438,101 +438,4 @@ describe("overlay", () => { configureOverlay({ ansiColors: { red: "ff3348" } }); }); }); - - describe("shared state across module copies", () => { - it("shows the errors of two clients in the same overlay", () => { - // First client (e.g. the webpack-dev-middleware SSE client) reports. - showProblems("errors", ["boom from the wdm client"]); - - expect(document.querySelectorAll("iframe")).toHaveLength(1); - expect(getCard().textContent).toContain("boom from the wdm client"); - - // A second client bundling its own copy of the module (e.g. the - // webpack-dev-server client) reports into the SAME overlay: it adopts - // the shared iframe instead of stacking a duplicate, and its own - // source slot joins the union next to the first client's problems. - jest.resetModules(); - - const secondClient = require("../client-src/overlay"); - - secondClient.showProblems("errors", ["boom from the wds client"], "wds"); - - expect(document.querySelectorAll("iframe")).toHaveLength(1); - // Both clients' errors are in the union, paginated together. - expect(getCard().textContent).toContain("boom from the wdm client"); - expect(getCard().textContent).toContain("1 / 2"); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight" }), - ); - expect(getCard().textContent).toContain("boom from the wds client"); - - // The state is shared both ways: dismissing from the first client - // removes the overlay the second client rendered. - clear(); - expect(getOverlay()).toBeNull(); - expect(document.querySelectorAll("iframe")).toHaveLength(0); - }); - - it("prefers one client's errors over another client's warnings", () => { - showProblems("errors", ["boom from the wdm client"]); - - jest.resetModules(); - - const secondClient = require("../client-src/overlay"); - - // Errors from any source suppress warnings in the union, so another - // client's warnings do not hide the broken build. - secondClient.showProblems( - "warnings", - ["careful from the wds client"], - "wds", - ); - - expect(document.querySelectorAll("iframe")).toHaveLength(1); - expect(getCard().textContent).toContain("boom from the wdm client"); - expect(getCard().textContent).not.toContain( - "careful from the wds client", - ); - expect(getCard().style.borderTopColor).toBe("rgb(255, 51, 72)"); - - // Once the erroring source recovers, the warnings surface. - secondClient.clear(""); - - expect(getCard().textContent).toContain("careful from the wds client"); - expect(getCard().style.borderTopColor).toBe("rgb(255, 211, 14)"); - }); - - it("does not re-render when clearing a source that reported nothing", () => { - showProblems("errors", ["a", "b"], "x"); - - const cardChild = getCard().firstElementChild; - - // What the reporter does on every clean build of its own bundle. - clear("never-reported"); - - // Same DOM nodes — the card another client is showing was not rebuilt. - expect(getCard().firstElementChild).toBe(cardChild); - expect(getOverlay()).not.toBeNull(); - }); - - it("fills state fields missing from an older copy's shape", () => { - const OVERLAY_STATE_KEY = "__webpack_dev_middleware_hot_overlay_state__"; - - // Simulate an older package version having created a leaner state. - window[OVERLAY_STATE_KEY] = { frame: null, card: null }; - - jest.resetModules(); - - const newerCopy = require("../client-src/overlay"); - - expect(() => - newerCopy.showProblems("errors", ["boom"], "newer"), - ).not.toThrow(); - expect(document.querySelectorAll("iframe")).toHaveLength(1); - - newerCopy.clear(); - delete window[OVERLAY_STATE_KEY]; - }); - }); }); From 0fef0a2227811db74da3c4070b7531252de0b312 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 18:22:32 -0500 Subject: [PATCH 12/48] test(e2e): dismiss the overlay with real clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backdrop click, close (×) button, and click-inside-stays-open now run against real mouse events in the overlay iframe — the backdrop case clicks the frame's top-left corner away from the centered card, and the close-button pass rides the catch-up sync that re-shows the overlay after a reload. The jsdom dismiss describe is removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 29 +++++++++++++++++++++++++++++ test/overlay.test.js | 22 ---------------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index a3777fd29..913879c1c 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -106,6 +106,35 @@ describe("error overlay (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); + it("dismisses on backdrop and close-button clicks, but not inside the card", async () => { + hotApp = await createHotApp({ code: app("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken for clicks {{{"); + let frame = await waitForOverlay(page); + + // Clicking inside the card keeps the overlay open… + await frame.click(`#${OVERLAY_ID}-card`); + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + + // …clicking the backdrop (top-left corner, away from the centered card) + // dismisses it. + const body = await frame.$("body"); + await body.click({ offset: { x: 5, y: 5 } }); + await waitForNoOverlay(page); + + // A reload brings the overlay back through the catch-up sync — dismiss + // it again through the close (×) button. + await page.reload(); + frame = await waitForOverlay(page); + await frame.click('[aria-label="Close"]'); + await waitForNoOverlay(page); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + it("catches runtime errors and renders their message as text, not markup", async () => { hotApp = await createHotApp({ // The error must be thrown from the page's own script — errors raised diff --git a/test/overlay.test.js b/test/overlay.test.js index 1cbc3ff01..5c2dd41cd 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -132,28 +132,6 @@ describe("overlay", () => { }); }); - describe("dismiss", () => { - it("closes when clicking the backdrop", () => { - showProblems("errors", ["boom"]); - getOverlay().contentDocument.body.click(); - expect(getOverlay()).toBeNull(); - }); - - it("stays open when clicking inside the card", () => { - showProblems("errors", ["boom"]); - getCard().click(); - expect(getOverlay()).not.toBeNull(); - }); - - it("closes when clicking the close button", () => { - showProblems("errors", ["boom"]); - const closeButton = getCard().querySelector("button"); - expect(closeButton).not.toBeNull(); - closeButton.click(); - expect(getOverlay()).toBeNull(); - }); - }); - describe("runtime errors", () => { it("shows uncaught errors in the overlay and accumulates them", () => { configureOverlay({ catchRuntimeError: true }); From 847721e62566f5cf4d7a4b313055c93447092a0d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 18:55:08 -0500 Subject: [PATCH 13/48] chore: gitignore the js3 fixture output The one-error-one-warning-one-* fixture configs build into test/fixtures/js3 by design, so every logging run recreates it. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6b5d2aaf1..3d7f92f20 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ Thumbs.db *.sublime-project *.sublime-workspace yarn.lock +/test/fixtures/js3 From b8d7747b6c3928dbbacb0b30e5b84f4e288af236 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 18:55:32 -0500 Subject: [PATCH 14/48] test(e2e): accumulate runtime errors in the browser and settle on webpack 5.109 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-error accumulation (two errors paging "1 / 2" ↔ "2 / 2") and unhandled promise rejections now run against real page errors — the rejection fixture rejects from the page's own script so the event carries the real reason. Their jsdom equivalents are removed. The branch also settles on webpack 5.109: the lockfile catches up with the already-bumped range, and the assertions and console snapshots adopt 5.109's parse-error format (numbered code-frame gutter, caret line, and the "File was parsed as module type" note) instead of normalizing it away. Co-Authored-By: Claude Fable 5 --- package-lock.json | 4 +- .../logging.test.js.snap.webpack5 | 4 +- .../overlay.test.js.snap.webpack5 | 8 +- test/e2e/multi-compiler.test.js | 4 +- test/e2e/overlay.test.js | 73 ++++++++++++++++++- test/overlay.test.js | 44 ----------- 6 files changed, 85 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbedf694d..05d5bac0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webpack-dev-middleware", - "version": "8.0.4", + "version": "8.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webpack-dev-middleware", - "version": "8.0.4", + "version": "8.1.0", "license": "MIT", "dependencies": { "ansi-html-community": "^0.0.8", diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 index d6cfe385b..09a8e5df1 100644 --- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -5,8 +5,10 @@ exports[`client logging (browser) logging=error keeps errors only 1`] = ` "[webpack-dev-middleware] bundle has 1 errors", "[webpack-dev-middleware] ./app.js 1:7 Module parse failed: Unexpected token (1:7) +File was parsed as module type 'javascript/auto'. You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders -> broken {{{", +> 1 | broken {{{ + | ^", ] `; diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 index 1b924fb5f..df504bc98 100644 --- a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -6,11 +6,15 @@ exports[`error overlay (browser) paginates multiple problems with a counter 1`] "[webpack-dev-middleware] bundle has 2 errors", "[webpack-dev-middleware] ./a.js 1:7 Module parse failed: Unexpected token (1:7) +File was parsed as module type 'javascript/auto'. You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders -> broken a {{{ +> 1 | broken a {{{ + | ^ ./b.js 1:7 Module parse failed: Unexpected token (1:7) +File was parsed as module type 'javascript/auto'. You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders -> broken b {{{", +> 1 | broken b {{{ + | ^", ] `; diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js index 0e861e561..e5a2c5988 100644 --- a/test/e2e/multi-compiler.test.js +++ b/test/e2e/multi-compiler.test.js @@ -190,8 +190,8 @@ describe("multi-compiler (browser)", () => { const secondPage = await frame.evaluate(() => document.body.textContent); const union = firstPage + secondPage; - expect(union).toContain("> broken app"); - expect(union).toContain("> broken widget"); + expect(union).toContain("broken app {{{"); + expect(union).toContain("broken widget {{{"); }); it("keeps one bundle's overlay errors while a sibling rebuilds successfully", async () => { diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 913879c1c..a79b811f6 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -300,7 +300,7 @@ describe("error overlay (browser)", () => { // Both errors also reach the console, joined into a single error call — // snapshotted once the second one's code frame is in. - await console_.waitFor("> broken b"); + await console_.waitFor("broken b {{{"); expect(normalizeConsole(console_.messages)).toMatchSnapshot(); // One problem at a time, with a counter. @@ -318,6 +318,77 @@ describe("error overlay (browser)", () => { ); }); + it("accumulates runtime errors and pages between them", async () => { + hotApp = await createHotApp({ + code: ` + document.getElementById("app").textContent = "v1"; + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "v1", + ); + + await page.evaluate(() => globalThis.boom("boom-one")); + const frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("boom-one"), + ); + + // A second error joins the pager; the newest one is shown. + await page.evaluate(() => globalThis.boom("boom-two")); + await frame.waitForFunction( + () => + document.body.textContent.includes("boom-two") && + document.body.textContent.includes("2 / 2"), + ); + + // The previous error stays reachable. + await frame.click('[aria-label="Previous problem"]'); + await frame.waitForFunction(() => + document.body.textContent.includes("boom-one"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "1 / 2", + ); + }); + + it("shows unhandled promise rejections", async () => { + hotApp = await createHotApp({ + // The rejection must originate from the page's own script so the + // unhandledrejection event carries the real reason. + code: ` + document.getElementById("app").textContent = "v1"; + globalThis.rejectSoon = (message) => { + setTimeout(() => { + Promise.reject(new Error(message)); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "v1", + ); + + await page.evaluate(() => globalThis.rejectSoon("rejected-boom")); + + const frame = await waitForOverlay(page); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "rejected-boom", + ); + }); + it("does not appear when overlay=false", async () => { hotApp = await createHotApp({ query: "?overlay=false", code: app("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/overlay.test.js b/test/overlay.test.js index 5c2dd41cd..f0fd18a2f 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -133,50 +133,6 @@ describe("overlay", () => { }); describe("runtime errors", () => { - it("shows uncaught errors in the overlay and accumulates them", () => { - configureOverlay({ catchRuntimeError: true }); - - globalThis.dispatchEvent( - new ErrorEvent("error", { - error: new Error("boom-runtime"), - message: "boom-runtime", - }), - ); - - expect(getOverlay()).not.toBeNull(); - expect(getCard().textContent).toContain( - "Uncaught runtime error: boom-runtime", - ); - - globalThis.dispatchEvent( - new ErrorEvent("error", { - error: new Error("boom-2"), - message: "boom-2", - }), - ); - - // With pagination (default) the newest runtime error is shown, and the - // previous one stays reachable. - expect(getCard().textContent).toContain("boom-2"); - expect(getCard().textContent).toContain("2 / 2"); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowLeft" }), - ); - expect(getCard().textContent).toContain("boom-runtime"); - }); - - it("shows unhandled promise rejections", () => { - configureOverlay({ catchRuntimeError: true }); - - const event = new Event("unhandledrejection"); - /** @type {EXPECTED_ANY} */ (event).reason = new Error("rejected-boom"); - globalThis.dispatchEvent(event); - - expect(getOverlay()).not.toBeNull(); - expect(getCard().textContent).toContain("rejected-boom"); - }); - it("ignores errors already caught by a React error boundary", () => { configureOverlay({ catchRuntimeError: true }); From 04968c233525b6c26f6830719ca5f68239a4939a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 19:16:37 -0500 Subject: [PATCH 15/48] fix(client): replace webpack's logging runtime with a self-contained logger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webpack/lib/logging/runtime.js pulls tapable into the client bundle, and tapable compiles its hooks with new Function — an EvalError under a require-trusted-types-for 'script' CSP that killed the client's logging and surfaced spurious runtime errors in the overlay. The replacement is a level-gated console logger with byte-identical output (same prefix merging, same level gates, groups from the "log" level up), verified against the existing console snapshots. It also drops tapable and webpack's logging machinery from the bundle. Co-Authored-By: Claude Fable 5 --- client-src/utils/log.js | 82 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/client-src/utils/log.js b/client-src/utils/log.js index 56f1d82d4..59c6378e6 100644 --- a/client-src/utils/log.js +++ b/client-src/utils/log.js @@ -1,18 +1,84 @@ -// @ts-expect-error -- no published types for this entry point -import logger from "webpack/lib/logging/runtime.js"; +/* eslint-disable no-console -- this module is the console logger */ +// A self-contained level-gated console logger. webpack's logging runtime is +// deliberately not used: it pulls tapable into the bundle, whose hook +// compiler calls `new Function` — which throws under a +// `require-trusted-types-for 'script'` Content Security Policy. -const LOGGER_NAME = "webpack-dev-middleware"; -const DEFAULT_LEVEL = "info"; +const LOG_PREFIX = "[webpack-dev-middleware]"; /** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ +const LEVELS = ["none", "error", "warn", "info", "log", "verbose"]; + +const DEFAULT_LEVEL = "info"; + +let currentLevel = DEFAULT_LEVEL; + /** - * @param {LogLevel} level log level (or `false` for off, `true` for default) + * @param {LogLevel} level log level (or `false` for off, `true` for "log") */ export function setLogLevel(level) { - logger.configureDefaultLogger({ level }); + if (level === false) { + currentLevel = "none"; + } else if (level === true) { + currentLevel = "log"; + } else if (LEVELS.includes(level)) { + currentLevel = level; + } +} + +/** + * @param {string} level level of the message + * @returns {boolean} true when the current level shows the message + */ +function enabled(level) { + return LEVELS.indexOf(currentLevel) >= LEVELS.indexOf(level); } -setLogLevel(DEFAULT_LEVEL); +/** + * Prefix like webpack's logger: merged into the first argument when it is a + * string, passed separately otherwise (e.g. Error objects). + * @param {unknown[]} args arguments + * @returns {unknown[]} prefixed arguments + */ +function prefixed(args) { + return typeof args[0] === "string" + ? [`${LOG_PREFIX} ${args[0]}`, ...args.slice(1)] + : [LOG_PREFIX, ...args]; +} -export const log = logger.getLogger(LOGGER_NAME); +export const log = { + /** + * @param {...unknown} args arguments + */ + error(...args) { + if (enabled("error")) console.error(...prefixed(args)); + }, + /** + * @param {...unknown} args arguments + */ + warn(...args) { + if (enabled("warn")) console.warn(...prefixed(args)); + }, + /** + * @param {...unknown} args arguments + */ + info(...args) { + if (enabled("info")) console.info(...prefixed(args)); + }, + /** + * @param {...unknown} args arguments + */ + log(...args) { + if (enabled("log")) console.log(...prefixed(args)); + }, + /** + * @param {...unknown} args arguments + */ + groupCollapsed(...args) { + if (enabled("log")) console.groupCollapsed(...prefixed(args)); + }, + groupEnd() { + if (enabled("log")) console.groupEnd(); + }, +}; From bb135d35ada3b1a44f4fa327cf55344abcf43989 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 19:17:30 -0500 Subject: [PATCH 16/48] test(e2e): enforce the Trusted Types CSP for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page is served with require-trusted-types-for 'script' and a trusted-types allowlist holding only the configured policy name — under real Chrome enforcement, which jsdom cannot do, the overlay only renders if every HTML write went through that policy (including inside the about:blank iframe, which inherits the page's CSP). The jsdom trusted-types test is removed; the helper gained a pageHeaders option. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 33 +++++++++++++++++++++++++++++++++ test/helpers/hot-app.js | 9 +++++++-- test/overlay.test.js | 23 ----------------------- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index a79b811f6..98a21d856 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -389,6 +389,39 @@ describe("error overlay (browser)", () => { ); }); + it("renders under an enforced Trusted Types CSP with the configured policy", async () => { + hotApp = await createHotApp({ + query: '?overlay={"trustedTypesPolicyName":"wdm-test"}', + code: app("v1"), + // Real enforcement, which jsdom cannot do: every HTML sink must go + // through the "wdm-test" policy or Chrome throws. The about:blank + // overlay iframe inherits this policy from the page. + pageHeaders: { + "Content-Security-Policy": + "require-trusted-types-for 'script'; trusted-types wdm-test", + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "v1", + ); + + hotApp.edit("broken by csp {{{"); + + // The overlay renders — proof that every innerHTML write went through + // the configured policy (a raw write, or a policy under any other name, + // would have thrown under this CSP). + const frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("Module parse failed"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "broken by csp", + ); + }); + it("does not appear when overlay=false", async () => { hotApp = await createHotApp({ query: "?overlay=false", code: app("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 0cab4169f..6f9ff554a 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -64,8 +64,9 @@ function makeConfig(name, dir, appFile, query) { * (appended to the client entry) and `files` (extra fixture files by relative * name). Multi-compiler mode: pass `apps: [{ name, code }]` instead — each * app becomes a named compilation whose client connects with `?name=` - * and renders from `.js`. - * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY }} options options + * 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 }} options options * @returns {Promise} handles for the running app */ async function createHotApp({ @@ -74,6 +75,7 @@ async function createHotApp({ files = {}, apps, hot = true, + pageHeaders = {}, }) { const dir = fs.mkdtempSync( path.join(fs.realpathSync.native(os.tmpdir()), "wdm-e2e-"), @@ -136,6 +138,9 @@ async function createHotApp({ app.get("/", (_req, res) => { res.setHeader("Content-Type", "text/html"); + for (const [name, value] of Object.entries(pageHeaders)) { + res.setHeader(name, value); + } res.end(pageHtml(scripts)); }); app.use(instance); diff --git a/test/overlay.test.js b/test/overlay.test.js index f0fd18a2f..d047cd59b 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -324,29 +324,6 @@ describe("overlay", () => { }); }); - describe("trusted types", () => { - afterEach(() => { - delete globalThis.trustedTypes; - }); - - it("creates a policy with the configured name and renders through it", () => { - globalThis.trustedTypes = { - createPolicy: jest.fn((name, rules) => ({ - createHTML: rules.createHTML, - })), - }; - - configureOverlay({ trustedTypesPolicyName: "custom#policy" }); - showProblems("errors", ["boom"]); - - expect(globalThis.trustedTypes.createPolicy).toHaveBeenCalledWith( - "custom#policy", - expect.objectContaining({ createHTML: expect.any(Function) }), - ); - expect(getCard().textContent).toContain("boom"); - }); - }); - describe("configureOverlay", () => { it("returns the overlay API", () => { const api = configureOverlay({}); From 3e86c543098751146c4cae9476f4ddc56e2db76d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 19:23:15 -0500 Subject: [PATCH 17/48] fix(client): keep HMR alive when Trusted Types blocks the logging runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under a require-trusted-types-for 'script' CSP, tapable — bundled through webpack's logging runtime — cannot compile its hooks: the first log call throws an EvalError from new Function, killing whatever listener happened to log (and with it the update flow). The logger methods are now guarded, so webpack's logging runtime stays the engine and enforcement just turns logging off. Co-Authored-By: Claude Fable 5 --- client-src/utils/log.js | 98 +++++++++++++---------------------------- 1 file changed, 30 insertions(+), 68 deletions(-) diff --git a/client-src/utils/log.js b/client-src/utils/log.js index 59c6378e6..4e8b99d3e 100644 --- a/client-src/utils/log.js +++ b/client-src/utils/log.js @@ -1,84 +1,46 @@ -/* eslint-disable no-console -- this module is the console logger */ -// A self-contained level-gated console logger. webpack's logging runtime is -// deliberately not used: it pulls tapable into the bundle, whose hook -// compiler calls `new Function` — which throws under a -// `require-trusted-types-for 'script'` Content Security Policy. - -const LOG_PREFIX = "[webpack-dev-middleware]"; - -/** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ - -const LEVELS = ["none", "error", "warn", "info", "log", "verbose"]; +// @ts-expect-error -- no published types for this entry point +import logger from "webpack/lib/logging/runtime.js"; +const LOGGER_NAME = "webpack-dev-middleware"; const DEFAULT_LEVEL = "info"; -let currentLevel = DEFAULT_LEVEL; +/** @typedef {false | true | "none" | "error" | "warn" | "info" | "log" | "verbose"} LogLevel */ /** - * @param {LogLevel} level log level (or `false` for off, `true` for "log") + * @param {LogLevel} level log level (or `false` for off, `true` for default) */ export function setLogLevel(level) { - if (level === false) { - currentLevel = "none"; - } else if (level === true) { - currentLevel = "log"; - } else if (LEVELS.includes(level)) { - currentLevel = level; - } + logger.configureDefaultLogger({ level }); } -/** - * @param {string} level level of the message - * @returns {boolean} true when the current level shows the message - */ -function enabled(level) { - return LEVELS.indexOf(currentLevel) >= LEVELS.indexOf(level); -} +setLogLevel(DEFAULT_LEVEL); + +const rawLog = logger.getLogger(LOGGER_NAME); /** - * Prefix like webpack's logger: merged into the first argument when it is a - * string, passed separately otherwise (e.g. Error objects). - * @param {unknown[]} args arguments - * @returns {unknown[]} prefixed arguments + * Guard a logger method: under a `require-trusted-types-for 'script'` + * Content Security Policy, tapable (bundled through webpack's logging + * runtime) cannot compile its hooks — `new Function` throws an EvalError on + * the first log call. Swallowing it keeps HMR fully functional with logging + * off instead of breaking whatever listener happened to log. + * @param {string} method logger method name + * @returns {(...args: unknown[]) => void} guarded method */ -function prefixed(args) { - return typeof args[0] === "string" - ? [`${LOG_PREFIX} ${args[0]}`, ...args.slice(1)] - : [LOG_PREFIX, ...args]; +function guarded(method) { + return (...args) => { + try { + rawLog[method](...args); + } catch { + // Logging is unavailable (e.g. Trusted Types enforcement). + } + }; } export const log = { - /** - * @param {...unknown} args arguments - */ - error(...args) { - if (enabled("error")) console.error(...prefixed(args)); - }, - /** - * @param {...unknown} args arguments - */ - warn(...args) { - if (enabled("warn")) console.warn(...prefixed(args)); - }, - /** - * @param {...unknown} args arguments - */ - info(...args) { - if (enabled("info")) console.info(...prefixed(args)); - }, - /** - * @param {...unknown} args arguments - */ - log(...args) { - if (enabled("log")) console.log(...prefixed(args)); - }, - /** - * @param {...unknown} args arguments - */ - groupCollapsed(...args) { - if (enabled("log")) console.groupCollapsed(...prefixed(args)); - }, - groupEnd() { - if (enabled("log")) console.groupEnd(); - }, + error: guarded("error"), + warn: guarded("warn"), + info: guarded("info"), + log: guarded("log"), + groupCollapsed: guarded("groupCollapsed"), + groupEnd: guarded("groupEnd"), }; From 1f591f5e796a0f9322646b57008c699dae46771f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 19:51:04 -0500 Subject: [PATCH 18/48] test: prove SSE broadcast with two real pages and a mid-connection publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two browser pages connected to the same server both apply a single edit — broadcast proven end to end. The unit variant also stops passing vacuously: it now publishes while both SSE clients are attached instead of only observing the per-client connect-time sync. Co-Authored-By: Claude Fable 5 --- test/e2e/client.test.js | 22 +++++++++++++++++++++- test/middleware.test.js | 23 ++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index d1ce7cd87..85e2a3e16 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -1,6 +1,6 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; -import runBrowser from "../helpers/run-browser"; +import runBrowser, { runPage } from "../helpers/run-browser"; jest.setTimeout(120000); @@ -102,6 +102,26 @@ describe("hot client (browser)", () => { expect(await readReloadMarker(page)).toBe(true); }); + it("broadcasts one build to every connected page", async () => { + app = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const pageTwo = await runPage(browser); + + await page.goto(app.url); + await pageTwo.goto(app.url); + await waitForAppText(page, "v1"); + await waitForAppText(pageTwo, "v1"); + + // One edit, one publish ed build — every connected page applies it. + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + await waitForAppText(pageTwo, "v2"); + + expect( + await pageTwo.evaluate(() => document.getElementById("app").textContent), + ).toBe("v2"); + }); + it("falls back to a full reload when the update is not accepted", async () => { app = await createHotApp({ code: unacceptedApp("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/middleware.test.js b/test/middleware.test.js index 4da141838..7ec458e3d 100644 --- a/test/middleware.test.js +++ b/test/middleware.test.js @@ -7299,13 +7299,26 @@ describe.each([ await waitUntilValid(instance); - const [first, second] = await Promise.all([ - readSseEvents(server, "/__webpack_hmr"), - readSseEvents(server, "/__webpack_hmr"), + const events = Promise.all([ + readSseEvents(server, "/__webpack_hmr", 1500), + readSseEvents(server, "/__webpack_hmr", 1500), ]); - expect(first.some((event) => event.action === "sync")).toBe(true); - expect(second.some((event) => event.action === "sync")).toBe(true); + // Broadcast while both clients are attached — the connect-time sync + // alone is written per client and would make this pass vacuously. + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + instance.context.hot.publish({ action: "broadcast-check" }); + + const [first, second] = await events; + + expect(first.some((event) => event.action === "broadcast-check")).toBe( + true, + ); + expect(second.some((event) => event.action === "broadcast-check")).toBe( + true, + ); }); }); }); From 742c60e1935778c6edd2f63599ccbecec6d5cb6a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 20:11:00 -0500 Subject: [PATCH 19/48] test(e2e): poll app text on an interval so hidden pages resolve waitForFunction's default requestAnimationFrame polling never fires on a hidden document, so the backgrounded first tab of the broadcast test hung on a wait for text it had already rendered. Also settle the connections before editing so both pages receive the broadcast rather than a catch-up sync. Co-Authored-By: Claude Fable 5 --- test/e2e/client.test.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 85e2a3e16..9fb4ec4f1 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -38,7 +38,9 @@ function waitForAppText(page, text) { return page .waitForFunction( (expected) => document.getElementById("app")?.textContent === expected, - { timeout: 30000 }, + // Interval polling: the default requestAnimationFrame polling freezes + // on hidden pages (e.g. the backgrounded tab in the two-page test). + { timeout: 30000, polling: 100 }, text, ) .then(() => {}); @@ -106,13 +108,19 @@ describe("hot client (browser)", () => { app = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); const pageTwo = await runPage(browser); + const consoleOne = collectConsole(page); + const consoleTwo = collectConsole(pageTwo); await page.goto(app.url); await pageTwo.goto(app.url); await waitForAppText(page, "v1"); await waitForAppText(pageTwo, "v1"); + // Both pages must be attached before the build, so what they receive is + // the broadcast — not their own catch-up sync. + await consoleOne.waitFor("connected"); + await consoleTwo.waitFor("connected"); - // One edit, one publish ed build — every connected page applies it. + // One edit, one published build — every connected page applies it. app.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); await waitForAppText(pageTwo, "v2"); From e1b9ecaa373f50414a9e59355fc4addde71d9396 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 20:23:37 -0500 Subject: [PATCH 20/48] test(e2e): drive instance.invalidate() and instance.close() against a browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invalidate() with unchanged sources rebuilds and reaches the page as a building event followed by a no-op sync — the client neither re-renders nor reloads (a built here would 404 on the never-emitted manifest). close() ends the SSE stream under a connected page: the client falls into its reconnect loop against an endpoint that no longer speaks SSE and the page keeps running untouched. Co-Authored-By: Claude Fable 5 --- test/e2e/client.test.js | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 9fb4ec4f1..6a1ce3490 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -266,6 +266,65 @@ describe("hot client (browser)", () => { expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); + it("rebuilds on instance.invalidate() and syncs connected pages as a no-op", async () => { + app = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await plantReloadMarker(page); + + // A server-side invalidate with unchanged sources: the rebuild reaches + // the browser, and its unchanged hash arrives as a sync the client + // applies as a no-op (a `built` here would 404 on the missing manifest). + const rebuilt = app.nextBuild(); + app.instance.invalidate(); + await rebuilt; + await console_.waitFor("bundle rebuilding"); + + // Give the sync a beat to land before pinning that nothing changed. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + expect(await readReloadMarker(page)).toBe(true); + }); + + it("keeps the page alive after instance.close()", async () => { + app = await createHotApp({ + query: "?timeout=1000", + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await plantReloadMarker(page); + + // Closing the middleware ends the SSE stream; the client falls into its + // reconnect loop against an endpoint that no longer speaks SSE. + await new Promise((resolve) => { + app.instance.close(resolve); + }); + + // A few reconnect windows later the page is still running untouched. + await new Promise((resolve) => { + setTimeout(resolve, 2500); + }); + + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + expect(await readReloadMarker(page)).toBe(true); + }); + it("routes server publish() payloads to subscribe handlers", async () => { app = await createHotApp({ code: ` From 17a475c784fbbe9a6e3a68e569c8ae140d92e4c5 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 20:43:01 -0500 Subject: [PATCH 21/48] test(e2e): keyboard pagination, paginate=false, and the cross-copy runtime filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys page through problems with real keyboard events (the frame holds focus after the pager click), paginate=false shows the full problem list with no counter, and a runtime filter configured by a later bundled copy is honored by the window listeners the first copy attached — proven non-vacuously by first catching an error through copy A, then watching copy B's rejecting filter keep the overlay away. The three jsdom equivalents are removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 80 +++++++++++++++++++++++++++++++++++++++- test/overlay.test.js | 47 ----------------------- 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 98a21d856..9da4f2bc7 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -313,11 +313,58 @@ describe("error overlay (browser)", () => { document.body.textContent.includes("2 / 2"), ); + // Real keyboard navigation — the frame has focus after the click. + await page.keyboard.press("ArrowLeft"); + await frame.waitForFunction(() => + document.body.textContent.includes("1 / 2"), + ); + await page.keyboard.press("ArrowRight"); + await frame.waitForFunction(() => + document.body.textContent.includes("2 / 2"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( "2 / 2", ); }); + it("shows the full problem list when paginate=false", async () => { + hotApp = await createHotApp({ + query: '?overlay={"paginate":false}', + code: ` + try { + require("./a"); + } catch (err) { + // expected + } + try { + require("./b"); + } catch (err) { + // expected + } + `, + files: { + "a.js": "broken a {{{", + "b.js": "broken b {{{", + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + const frame = await waitForOverlay(page); + await frame.waitForFunction( + () => + document.body.textContent.includes("broken a {{{") && + document.body.textContent.includes("broken b {{{"), + ); + + // Both problems at once, no pager. + expect(await frame.evaluate(() => document.body.textContent)).not.toContain( + "1 / 2", + ); + }); + it("accumulates runtime errors and pages between them", async () => { hotApp = await createHotApp({ code: ` @@ -453,7 +500,12 @@ describe("overlay shared state across bundled copies (browser)", () => { * @returns {string} app source */ const exposeOverlay = (globalName) => - `globalThis.${globalName} = require(${JSON.stringify(OVERLAY_ENTRY)});`; + `globalThis.${globalName} = require(${JSON.stringify(OVERLAY_ENTRY)}); + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + };`; const start = async () => { hotApp = await createHotApp({ @@ -605,6 +657,32 @@ describe("overlay shared state across bundled copies (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); }); + it("honors the runtime filter configured by a later copy", async () => { + await start(); + await page.goto(hotApp.url); + + // The first copy attaches the window listeners; a runtime error lands in + // the overlay, proving they are live. + await page.evaluate(() => { + globalThis.overlayA.default({ catchRuntimeError: true }); + globalThis.boom("caught-by-A"); + }); + await page.waitForSelector(`#${OVERLAY_ID}`, { timeout: 30000 }); + await page.evaluate(() => globalThis.overlayA.clear()); + + // A later copy swaps in a rejecting filter — the listeners the first + // copy attached must honor it. + await page.evaluate(() => { + globalThis.overlayB.default({ catchRuntimeError: () => false }); + globalThis.boom("filtered-out"); + }); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + it("fills state fields missing from an older copy's shape", async () => { await start(); // An older package version created a leaner shared state before the diff --git a/test/overlay.test.js b/test/overlay.test.js index d047cd59b..39d618441 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -170,30 +170,6 @@ describe("overlay", () => { expect(getCard().textContent).not.toContain("boom-before"); expect(getCard().textContent).not.toContain("1 / 2"); }); - - it("honors the runtime filter configured by a later copy", () => { - // The window listeners were attached by this copy… - configureOverlay({ catchRuntimeError: true }); - - // …but the filter comes from a second bundled copy of the module. - jest.resetModules(); - - const laterCopy = require("../client-src/overlay"); - - laterCopy.default({ catchRuntimeError: () => false }); - - globalThis.dispatchEvent( - new ErrorEvent("error", { - error: new Error("filtered-out"), - message: "filtered-out", - }), - ); - - expect(getOverlay()).toBeNull(); - - // Restore the plain-boolean configuration for the remaining tests. - configureOverlay({ catchRuntimeError: true }); - }); }); describe("open in editor", () => { @@ -274,20 +250,6 @@ describe("overlay", () => { expect(getCard().textContent).toContain("first boom"); }); - it("navigates with the arrow keys", () => { - showProblems("errors", ["first boom", "second boom"]); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight" }), - ); - expect(getCard().textContent).toContain("second boom"); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowLeft" }), - ); - expect(getCard().textContent).toContain("first boom"); - }); - it("resets to the first page on a new problem set", () => { showProblems("errors", ["first boom", "second boom"]); @@ -313,15 +275,6 @@ describe("overlay", () => { expect(getCard().textContent).toContain("second boom"); expect(getCard().textContent).toContain("2 / 2"); }); - - it("shows the full list when disabled", () => { - configureOverlay({ paginate: false }); - showProblems("errors", ["first boom", "second boom"]); - - expect(getCard().textContent).toContain("first boom"); - expect(getCard().textContent).toContain("second boom"); - expect(getCard().textContent).not.toContain("1 / 2"); - }); }); describe("configureOverlay", () => { From 69b00b1d0efe83695a52fa32987695694e3d3bed Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 20:49:24 -0500 Subject: [PATCH 22/48] test: drop two overlay unit tests the e2e suite already covers The accent-bar colors are asserted (both hues, including the error-to-warning flip) by the shared-state e2e, and the default pagination counter by the pagination e2e. Co-Authored-By: Claude Fable 5 --- test/overlay.test.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/test/overlay.test.js b/test/overlay.test.js index 39d618441..046282ec9 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -59,13 +59,6 @@ describe("overlay", () => { expect(badge.style.backgroundColor).toBe("rgb(255, 211, 14)"); }); - it("colors the top accent bar red for errors and yellow for warnings", () => { - showProblems("errors", ["boom"]); - expect(getCard().style.borderTopColor).toBe("rgb(255, 51, 72)"); - showProblems("warnings", ["careful"]); - expect(getCard().style.borderTopColor).toBe("rgb(255, 211, 14)"); - }); - it("highlights the file path and leaves the location uncolored", () => { showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); const pathSpan = [...getCard().querySelectorAll("span")].find( @@ -215,14 +208,6 @@ describe("overlay", () => { configureOverlay({ paginate: true }); }); - it("shows one problem at a time with a counter by default", () => { - showProblems("errors", ["first boom", "second boom", "third boom"]); - - expect(getCard().textContent).toContain("first boom"); - expect(getCard().textContent).not.toContain("second boom"); - expect(getCard().textContent).toContain("1 / 3"); - }); - it("navigates with the prev/next buttons and clamps at the ends", () => { showProblems("errors", ["first boom", "second boom"]); From 76e9b0fdb8ddd937a5e645bde74b39372fa04556 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 20:56:00 -0500 Subject: [PATCH 23/48] test(e2e): cover pagination clamping and page retention, drop the jsdom describe The pagination e2e now clamps at the last page under real clicks and key presses, and a shared-state test drives a bundled copy through the page-index semantics: a re-publish of the same problems keeps the page the user navigated to, a different set starts back at page one. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 44 +++++++++++++++++++++++++++++ test/overlay.test.js | 60 ---------------------------------------- 2 files changed, 44 insertions(+), 60 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 9da4f2bc7..b61b863b8 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -323,6 +323,9 @@ describe("error overlay (browser)", () => { document.body.textContent.includes("2 / 2"), ); + // Clamped at the last page. + await frame.click('[aria-label="Next problem"]'); + await page.keyboard.press("ArrowRight"); expect(await frame.evaluate(() => document.body.textContent)).toContain( "2 / 2", ); @@ -683,6 +686,47 @@ describe("overlay shared state across bundled copies (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); + it("resets the page for a new problem set and keeps it for a re-publish", async () => { + await start(); + await page.goto(hotApp.url); + + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["first boom", "second boom"]); + }); + const frame = await overlayFrame(); + await frame.click('[aria-label="Next problem"]'); + await frame.waitForFunction(() => + document.body.textContent.includes("2 / 2"), + ); + + // Re-publishing the same problems (every clean rebuild does) keeps the + // page the user navigated to… + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", ["first boom", "second boom"]); + }); + await frame.waitForFunction(() => + document.body.textContent.includes("2 / 2"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "second boom", + ); + + // …while a different set starts back at the first page. + await page.evaluate(() => { + globalThis.overlayA.showProblems("errors", [ + "new first", + "new second", + "new third", + ]); + }); + await frame.waitForFunction(() => + document.body.textContent.includes("1 / 3"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "new first", + ); + }); + it("fills state fields missing from an older copy's shape", async () => { await start(); // An older package version created a leaner shared state before the diff --git a/test/overlay.test.js b/test/overlay.test.js index 046282ec9..2e3e43e52 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -202,66 +202,6 @@ describe("overlay", () => { }); }); - describe("pagination", () => { - afterEach(() => { - // Restore the default. - configureOverlay({ paginate: true }); - }); - - it("navigates with the prev/next buttons and clamps at the ends", () => { - showProblems("errors", ["first boom", "second boom"]); - - const next = [...getCard().querySelectorAll("button")].find( - (button) => button.getAttribute("aria-label") === "Next problem", - ); - - next.click(); - expect(getCard().textContent).toContain("second boom"); - expect(getCard().textContent).toContain("2 / 2"); - - // Clamped at the last page. - const nextAgain = [...getCard().querySelectorAll("button")].find( - (button) => button.getAttribute("aria-label") === "Next problem", - ); - - nextAgain.click(); - expect(getCard().textContent).toContain("2 / 2"); - - const prev = [...getCard().querySelectorAll("button")].find( - (button) => button.getAttribute("aria-label") === "Previous problem", - ); - - prev.click(); - expect(getCard().textContent).toContain("first boom"); - }); - - it("resets to the first page on a new problem set", () => { - showProblems("errors", ["first boom", "second boom"]); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight" }), - ); - showProblems("errors", ["new first", "new second", "new third"]); - - expect(getCard().textContent).toContain("new first"); - expect(getCard().textContent).toContain("1 / 3"); - }); - - it("keeps the current page when the same problems are re-published", () => { - showProblems("errors", ["first boom", "second boom"]); - - getOverlay().contentDocument.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowRight" }), - ); - expect(getCard().textContent).toContain("2 / 2"); - - showProblems("errors", ["first boom", "second boom"]); - - expect(getCard().textContent).toContain("second boom"); - expect(getCard().textContent).toContain("2 / 2"); - }); - }); - describe("configureOverlay", () => { it("returns the overlay API", () => { const api = configureOverlay({}); From eaff5d275e6b9515a4316ca0d538e9d5665c68ff Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:00:18 -0500 Subject: [PATCH 24/48] test: drop two more overlay unit tests the e2e suite covers Problem replacement is asserted by the page-retention e2e (a new set swaps the rendered content), and the overlay API shape is exercised by every shared-state test driving real bundled copies. Co-Authored-By: Claude Fable 5 --- test/overlay.test.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/overlay.test.js b/test/overlay.test.js index 2e3e43e52..a13894d07 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -100,13 +100,6 @@ describe("overlay", () => { ); }); - it("replaces previous problems on each call", () => { - showProblems("errors", ["first"]); - showProblems("errors", ["second"]); - expect(getCard().textContent).toContain("second"); - expect(getCard().textContent).not.toContain("first"); - }); - it("re-mounts the overlay when the iframe was removed without clear()", () => { showProblems("errors", ["boom"]); // A framework wiping `document.body` removes the iframe behind our back. @@ -203,12 +196,6 @@ describe("overlay", () => { }); describe("configureOverlay", () => { - it("returns the overlay API", () => { - const api = configureOverlay({}); - expect(typeof api.showProblems).toBe("function"); - expect(typeof api.clear).toBe("function"); - }); - it("applies custom overlay styles to the card", () => { configureOverlay({ overlayStyles: { maxWidth: "500px" } }); showProblems("errors", ["boom"]); From 3ff5d95accd90f1c145140a566da66c105fd43d4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:02:25 -0500 Subject: [PATCH 25/48] test: assert the dismiss hint where it is acted on The Escape e2e now checks the card advertises the dismissal it is about to perform; the jsdom hint test is removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 7 ++++++- test/overlay.test.js | 7 ------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index b61b863b8..f96d3dac7 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -98,7 +98,12 @@ describe("error overlay (browser)", () => { await page.goto(hotApp.url); hotApp.edit("also broken {{{"); - await waitForOverlay(page); + const frame = await waitForOverlay(page); + + // The card advertises exactly what this test is about to do. + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "Click outside, press Esc, or fix the code to dismiss.", + ); await page.keyboard.press("Escape"); await waitForNoOverlay(page); diff --git a/test/overlay.test.js b/test/overlay.test.js index a13894d07..3ca60eeae 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -93,13 +93,6 @@ describe("overlay", () => { expect(getCard().textContent).toContain("https://example.com/a."); }); - it("shows a dismiss hint", () => { - showProblems("errors", ["boom"]); - expect(getCard().textContent).toContain( - "Click outside, press Esc, or fix the code to dismiss.", - ); - }); - it("re-mounts the overlay when the iframe was removed without clear()", () => { showProblems("errors", ["boom"]); // A framework wiping `document.body` removes the iframe behind our back. From e29bef1eaa23cee4caedccb9f44c2194505f06f2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:06:29 -0500 Subject: [PATCH 26/48] test(e2e): assert linkification on real error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-error e2e verifies webpack's own "See https://…" becomes a safe new-tab link (href, target, rel), and the runtime-error e2e keeps the sentence-ending dot out of the href. The two jsdom linkify tests are removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 29 ++++++++++++++++++++++++++++- test/overlay.test.js | 18 ------------------ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index f96d3dac7..4ea44c85c 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -79,6 +79,24 @@ describe("error overlay (browser)", () => { expect(text).toContain("Module parse failed"); + // webpack's own "See https://…" is linkified into a safe new-tab link. + expect( + await frame.evaluate(() => { + const link = document.querySelector("a"); + return ( + link && { + href: link.getAttribute("href"), + target: link.getAttribute("target"), + rel: link.getAttribute("rel"), + } + ); + }), + ).toEqual({ + href: "https://webpack.js.org/concepts#loaders", + target: "_blank", + rel: "noopener noreferrer", + }); + hotApp.edit(app("fixed")); await waitForNoOverlay(page); @@ -162,13 +180,22 @@ describe("error overlay (browser)", () => { // The message doubles as an XSS probe: it must render as text. await page.evaluate(() => { - globalThis.boom('runtime boom '); + globalThis.boom( + 'runtime boom See https://example.com/a.', + ); }); const frame = await waitForOverlay(page); const text = await frame.evaluate(() => document.body.textContent); expect(text).toContain("runtime boom"); + // Linkified with the sentence-ending dot kept out of the href. + expect( + await frame.evaluate(() => + document.querySelector("a")?.getAttribute("href"), + ), + ).toBe("https://example.com/a"); + expect(text).toContain("https://example.com/a."); expect( await frame.evaluate(() => document.querySelector("img") !== null), ).toBe(false); diff --git a/test/overlay.test.js b/test/overlay.test.js index 3ca60eeae..75e07cc17 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -75,24 +75,6 @@ describe("overlay", () => { expect(findSpanByColor("rgb(255, 107, 107)")).toBeDefined(); }); - it("turns URLs into links that open in a new tab", () => { - showProblems("errors", ["See https://webpack.js.org/concepts#loaders"]); - const link = getCard().querySelector("a"); - expect(link).not.toBeNull(); - expect(link.getAttribute("href")).toBe( - "https://webpack.js.org/concepts#loaders", - ); - expect(link.getAttribute("target")).toBe("_blank"); - expect(link.getAttribute("rel")).toBe("noopener noreferrer"); - }); - - it("keeps trailing punctuation out of the link href", () => { - showProblems("errors", ["Docs: https://example.com/a."]); - const link = getCard().querySelector("a"); - expect(link.getAttribute("href")).toBe("https://example.com/a"); - expect(getCard().textContent).toContain("https://example.com/a."); - }); - it("re-mounts the overlay when the iframe was removed without clear()", () => { showProblems("errors", ["boom"]); // A framework wiping `document.body` removes the iframe behind our back. From 6a7670a9582353c193c5a5710815a77ff3585018 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:19:42 -0500 Subject: [PATCH 27/48] test: drop the hot unit tests the browser suites already prove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The building payload's file and compiler name are pinned by the e2e console snapshots, sync-vs-built by the invalidate and multi-compiler runs, custom publishes by the subscribe round-trip, and the event stream's delivery, broadcast, and close behavior by the two-page and instance.close() tests. The invalidate e2e now snapshots its console — a file-less building line and a sync silent enough not to log. What stays in hot.test.js is the transport level: headers, heartbeat frames, the headersSent guard, duplicate-name pairing, and progress throttling. Co-Authored-By: Claude Fable 5 --- .../client.test.js.snap.webpack5 | 7 + test/e2e/client.test.js | 4 + test/hot.test.js | 190 ------------------ 3 files changed, 11 insertions(+), 190 deletions(-) diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 index 2cea83a8b..f96014766 100644 --- a/test/e2e/__snapshots__/client.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`hot client (browser) rebuilds on instance.invalidate() and syncs connected pages as a no-op 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle rebuilding", +] +`; + exports[`hot client (browser) reconnects after a server restart and syncs up on missed builds 1`] = ` [ "[webpack-dev-middleware] connected", diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 6a1ce3490..ec7ede457 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -289,6 +289,10 @@ describe("hot client (browser)", () => { setTimeout(resolve, 500); }); + // Two lines only: no file on the building event (nothing triggered it) + // and a sync so uneventful it does not even log. + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + expect( await page.evaluate(() => document.getElementById("app").textContent), ).toBe("v1"); diff --git a/test/hot.test.js b/test/hot.test.js index f7a4c45d8..097b7f535 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -189,53 +189,6 @@ describe("hot middleware (unit)", () => { stream.close(); }); - it("publishes JSON payloads to attached clients", () => { - const stream = createEventStream(5000, noopLogger); - const writes = []; - const fakeRes = { - writableEnded: false, - write: (chunk) => writes.push(chunk), - writeHead: () => {}, - end: () => {}, - }; - const fakeReq = { - httpVersion: "1.1", - socket: { setKeepAlive: () => {} }, - on: () => {}, - }; - - stream.handler(fakeReq, fakeRes); - stream.publish({ action: "built", hash: "abc" }); - - expect(writes.some((w) => w.includes('"action":"built"'))).toBe(true); - expect(writes.some((w) => w.includes('"hash":"abc"'))).toBe(true); - - stream.close(); - }); - - it("close ends connected clients", () => { - const stream = createEventStream(5000, noopLogger); - let ended = false; - const fakeRes = { - writableEnded: false, - write: () => {}, - writeHead: () => {}, - end: () => { - ended = true; - }, - }; - const fakeReq = { - httpVersion: "1.1", - socket: { setKeepAlive: () => {} }, - on: () => {}, - }; - - stream.handler(fakeReq, fakeRes); - stream.close(); - - expect(ended).toBe(true); - }); - it("sets Connection: keep-alive for HTTP/1 clients", () => { const stream = createEventStream(5000, noopLogger); const { headers } = attachClient(stream, { httpVersion: "1.1" }); @@ -250,25 +203,6 @@ describe("hot middleware (unit)", () => { stream.close(); }); - it("broadcasts events to every attached client", () => { - const stream = createEventStream(5000, noopLogger); - const clients = [ - attachClient(stream), - attachClient(stream), - attachClient(stream), - ]; - - for (const c of clients) c.writes.length = 0; - - stream.publish({ action: "built", hash: "xyz" }); - - for (const c of clients) { - expect(c.writes.some((w) => w.includes('"hash":"xyz"'))).toBe(true); - } - - stream.close(); - }); - it("logs client connect and disconnect with the active count", () => { const messages = []; const stream = createEventStream(5000, { @@ -317,55 +251,6 @@ describe("createHot", () => { hot.close(); }); - it("exposes publish() so callers can broadcast custom payloads", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - hot.publish({ action: "custom-thing", payload: 42 }); - - expect( - writes.some( - (w) => - w.includes('"action":"custom-thing"') && w.includes('"payload":42'), - ), - ).toBe(true); - - hot.close(); - }); - - it("includes the changed file in the building payload", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - compiler.emitInvalid("/src/index.js"); - - expect( - writes.some( - (w) => - w.includes('"action":"building"') && - w.includes('"file":"/src/index.js"'), - ), - ).toBe(true); - - hot.close(); - }); - - it("omits the file field when the invalid hook reports none", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - compiler.emitInvalid(); - - const building = writes.find((w) => w.includes('"action":"building"')); - expect(building).toBeDefined(); - expect(building).not.toContain('"file"'); - - hot.close(); - }); - it("ends a response whose headers were already sent instead of registering it", async () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); @@ -399,61 +284,6 @@ describe("createHot", () => { }); }); - it("includes the compilation name in the building payload", () => { - const compiler = makeFakeCompiler(); - compiler.name = "main"; - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - compiler.emitInvalid(); - - // The client pairs `building` with the `built`/`sync` that follows by - // name — without it the building indicator can never be hidden. - const building = writes.find((w) => w.includes('"action":"building"')); - expect(building).toContain('"name":"main"'); - - hot.close(); - }); - - it("names the building payload after the compiler that invalidated in a multi-compiler", () => { - const app = makeFakeCompiler(); - app.name = "app"; - const widget = makeFakeCompiler(); - widget.name = "widget"; - const multi = makeFakeCompiler(); - multi.compilers = [app, widget]; - const hot = createHot(multi, {}); - const { writes } = attachClient({ handler: hot.handle }); - - widget.emitInvalid("/src/widget.js"); - - const building = writes.filter((w) => w.includes('"action":"building"')); - expect(building).toHaveLength(1); - expect(building[0]).toContain('"name":"widget"'); - expect(building[0]).toContain('"file":"/src/widget.js"'); - - hot.close(); - }); - - it("publishes sync instead of built when a bundle's hash did not change", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - compiler.emitDone(makeFakeStats({ hash: "same" })); - writes.length = 0; - - // A rebuild that produced the same hash (e.g. another bundle of a - // multi-compiler changed) must not be announced as `built`. - compiler.emitInvalid(); - compiler.emitDone(makeFakeStats({ hash: "same" })); - - expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(true); - expect(writes.some((w) => w.includes('"action":"built"'))).toBe(false); - - hot.close(); - }); - it("pairs bundles sharing a name by occurrence so unchanged ones publish sync", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); @@ -481,26 +311,6 @@ describe("createHot", () => { hot.close(); }); - it("publishes built when the bundle's hash changed", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes } = attachClient({ handler: hot.handle }); - - compiler.emitDone(makeFakeStats({ hash: "one" })); - writes.length = 0; - - compiler.emitInvalid(); - compiler.emitDone(makeFakeStats({ hash: "two" })); - - expect( - writes.some( - (w) => w.includes('"action":"built"') && w.includes('"hash":"two"'), - ), - ).toBe(true); - - hot.close(); - }); - it("publishes built only for the changed bundles of a multi-compiler", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); From 6d89ed45d6809da43b2dabc762ec69f2dc2e7d0f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:36:02 -0500 Subject: [PATCH 28/48] test(e2e): snapshot the console wherever it is asserted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hot.statsOptions warnings-filtering runs in the browser — its snapshot is a lone "connected", the warning never entering the payload, while the overlay={"warnings":false} snapshot shows the warning reaching the console and stopping there. Three hot unit tests fall away: the late-connect sync and post-close handling are covered by the e2e and framework suites, and statsOptions forwarding by the new run. Co-Authored-By: Claude Fable 5 --- .../overlay.test.js.snap.webpack5 | 15 +++++ test/e2e/overlay.test.js | 35 ++++++++++ test/hot.test.js | 67 ------------------- 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 index df504bc98..299bbcbad 100644 --- a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -1,5 +1,20 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`error overlay (browser) keeps warnings out of the payload with hot.statsOptions 1`] = ` +[ + "[webpack-dev-middleware] connected", +] +`; + +exports[`error overlay (browser) overlay={"warnings":false} suppresses warnings (dev-server shape) 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle has 1 warnings", + "[webpack-dev-middleware] ./app.js 5:10-22 +Critical dependency: the request of a dependency is an expression", +] +`; + exports[`error overlay (browser) paginates multiple problems with a counter 1`] = ` [ "[webpack-dev-middleware] connected", diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 4ea44c85c..2c524ba0d 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -261,6 +261,41 @@ describe("error overlay (browser)", () => { await console_.waitFor("Critical dependency"); expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("keeps warnings out of the payload with hot.statsOptions", 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 } }, + code: ` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await console_.waitFor("connected"); + + // The sync arrived (connected implies it); give rendering a beat. + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + // Just the connect — the warning never entered the payload. + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); it("applies a warnings filter function from the query (dev-server parity)", async () => { diff --git a/test/hot.test.js b/test/hot.test.js index 097b7f535..42005dde5 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -366,44 +366,6 @@ describe("createHot", () => { hot.close(); }); - it("sends a sync payload to a client that connects after a build", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - - // A build finishes BEFORE anyone connects. - compiler.emitDone(makeFakeStats()); - - const { writes } = attachClient({ handler: hot.handle }); - - expect(writes.some((w) => w.includes('"action":"sync"'))).toBe(true); - - hot.close(); - }); - - it("responds 404 instead of hanging when a client connects after close()", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - - hot.close(); - - /** @type {number | undefined} */ - let statusCode; - let ended = false; - const res = { - writeHead: (code) => { - statusCode = code; - }, - end: () => { - ended = true; - }, - }; - - hot.handle({}, res); - - expect(statusCode).toBe(404); - expect(ended).toBe(true); - }); - it("does not re-send the catch-up sync to already connected clients", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); @@ -479,35 +441,6 @@ describe("createHot", () => { hot.close(); }); - it("forwards custom statsOptions to stats.toJson", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, { - statsOptions: { modules: true, ids: true }, - }); - attachClient({ handler: hot.handle }); - - /** @type {EXPECTED_OBJECT} */ - let receivedOptions; - - compiler.emitDone({ - toJson(statsOptions) { - receivedOptions = statsOptions; - return { - time: 1, - hash: "h", - warnings: [], - errors: [], - modules: [], - }; - }, - compilation: undefined, - }); - - expect(receivedOptions).toMatchObject({ modules: true, ids: true }); - - hot.close(); - }); - it("publishes throttled progress events when progress is enabled", () => { const compiler = makeFakeCompiler(); /** @type {EXPECTED_OBJECT} */ From 2e0fb147659c69bc6a169df572b7843d74911923 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 21:49:48 -0500 Subject: [PATCH 29/48] test(e2e): problem-type transitions, runtimeErrors=false, warning updates, and dynamicPublicPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A warning overlay escalates to an error overlay when the build breaks, an error overlay becomes a warning overlay on partial recovery (read through the page realm so the reload fallback cannot detach it), overlay={"runtimeErrors":false} leaves runtime errors uncaught, updates carrying warnings apply without a reload, and the client connects and updates through a dynamic public path served under /assets/ (the helper gained a publicPath option). Six jsdom equivalents are removed — client.test.js keeps only the slash-edge parsing and the protocol guards nothing can reach end to end. Co-Authored-By: Claude Fable 5 --- test/client.test.js | 176 --------------------------------------- test/e2e/client.test.js | 50 +++++++++++ test/e2e/overlay.test.js | 105 +++++++++++++++++++++++ test/helpers/hot-app.js | 12 +-- 4 files changed, 162 insertions(+), 181 deletions(-) diff --git a/test/client.test.js b/test/client.test.js index 7eea9185c..5d239d823 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -22,14 +22,6 @@ jest.mock("../client-src/overlay", () => { return factory; }); -/** - * @param {EXPECTED_ANY} obj message payload - * @returns {{ data: string }} fake SSE event - */ -function makeMessage(obj) { - return { data: typeof obj === "string" ? obj : JSON.stringify(obj) }; -} - /** * Stub `EventSource` so each test can drive `message`/`error`/`open` events. * @returns {EXPECTED_ANY} fake constructor + last instance accessor @@ -97,168 +89,6 @@ describe("client", () => { jest.useRealTimers(); }); - describe("with default options", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - loadClient(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("updates overlay when an errored build becomes a warning", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke", "Actually, 2 things broke"], - warnings: [], - modules: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef2", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); - expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("warnings", [ - "This isn't great, but it's not terrible", - ]); - // The overlay content is replaced, not dismissed. - expect(clientOverlay.clear).not.toHaveBeenCalled(); - }); - - it("triggers webpack on warning builds", () => { - EventSourceStub.lastInstance().onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - expect(processUpdate).toHaveBeenCalledTimes(1); - }); - - it("shows overlay after warning build becomes an error", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef2", - errors: ["Something broke", "Actually, 2 things broke"], - warnings: [], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); - expect(clientOverlay.showProblems).toHaveBeenLastCalledWith("errors", [ - "Something broke", - "Actually, 2 things broke", - ]); - }); - }); - - describe("with overlay warnings enabled via the dev-server-shaped option", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - loadClient('?overlay={"warnings":true}'); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("updates overlay after errored build becomes a warning", () => { - const es = EventSourceStub.lastInstance(); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke"], - warnings: [], - modules: [], - }), - ); - es.onmessage( - makeMessage({ - action: "built", - time: 100, - hash: "1234567890abcdef2", - errors: [], - warnings: ["This isn't great, but it's not terrible"], - modules: [], - }), - ); - expect(clientOverlay.showProblems).toHaveBeenCalledTimes(2); - expect(clientOverlay.showProblems).toHaveBeenNthCalledWith(1, "errors", [ - "Something broke", - ]); - expect(clientOverlay.showProblems).toHaveBeenNthCalledWith( - 2, - "warnings", - ["This isn't great, but it's not terrible"], - ); - }); - }); - - describe("with overlay runtime/trusted-types options", () => { - it("forwards them to the overlay factory", () => { - globalThis.EventSource = makeEventSourceStub(); - - loadClient( - '?overlay={"runtimeErrors":false,"trustedTypesPolicyName":"webpack#overlay","openEditorEndpoint":"/__open-editor"}', - ); - - const overlayFactory = require("../client-src/overlay"); - - expect(overlayFactory).toHaveBeenCalledWith( - expect.objectContaining({ - catchRuntimeError: false, - trustedTypesPolicyName: "webpack#overlay", - openEditorEndpoint: "/__open-editor", - }), - ); - }); - }); - describe("with dynamicPublicPath", () => { let EventSourceStub; @@ -274,12 +104,6 @@ describe("client", () => { jest.restoreAllMocks(); }); - it("appends the SSE path to the public path like webpack appends filenames", () => { - globalThis.__webpack_public_path__ = "/assets/"; - loadClient("?dynamicPublicPath=true"); - expect(EventSourceStub.lastInstance().url).toBe("/assets/__webpack_hmr"); - }); - it("preserves intentional double slashes inside the public path", () => { globalThis.__webpack_public_path__ = "https://host//rewritten/"; loadClient("?dynamicPublicPath=true"); diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index ec7ede457..c88a23217 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -329,6 +329,56 @@ describe("hot client (browser)", () => { expect(await readReloadMarker(page)).toBe(true); }); + it("applies updates that carry warnings", async () => { + const warningApp = (text) => ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `; + + app = await createHotApp({ code: warningApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await plantReloadMarker(page); + + // Warnings do not block HMR: the update lands without a reload. + app.edit(warningApp("v2")); + await waitForAppText(page, "v2"); + + expect(await readReloadMarker(page)).toBe(true); + }); + + it("connects through a dynamic public path", async () => { + app = await createHotApp({ + publicPath: "/assets/", + hot: { path: "/assets/__webpack_hmr" }, + query: "?dynamicPublicPath=true&path=/__webpack_hmr", + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + // __webpack_public_path__ ("/assets/") + the path option's basename. + await console_.waitFor("connected"); + await plantReloadMarker(page); + + app.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + expect(await readReloadMarker(page)).toBe(true); + }); + it("routes server publish() payloads to subscribe handlers", async () => { app = await createHotApp({ code: ` diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 2c524ba0d..200641e7f 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -237,6 +237,111 @@ describe("error overlay (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); + it("escalates a warning overlay to an error overlay", async () => { + hotApp = await createHotApp({ + code: ` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + const frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("Critical dependency"), + ); + + // The build breaks: the same overlay now shows the error instead. + hotApp.edit("broken after warning {{{"); + await frame.waitForFunction( + () => + document.body.textContent.includes("Module parse failed") && + !document.body.textContent.includes("Critical dependency"), + ); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "ERROR", + ); + }); + + it("turns an error overlay into a warning overlay on partial recovery", async () => { + hotApp = await createHotApp({ code: "broken from the start {{{" }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForOverlay(page); + + // Recovering into a warning-carrying build replaces the error content + // (possibly via the reload fallback, which re-syncs the warning). The + // overlay is read through the page realm so a reload cannot detach it. + hotApp.edit(` + document.getElementById("app").textContent = "v1"; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `); + await page.waitForFunction( + (id) => { + const body = document.getElementById(id)?.contentDocument?.body; + return ( + body && + body.textContent.includes("Critical dependency") && + !body.textContent.includes("Module parse failed") + ); + }, + { timeout: 30000, polling: 100 }, + OVERLAY_ID, + ); + expect( + await page.evaluate( + (id) => document.getElementById(id).contentDocument.body.textContent, + OVERLAY_ID, + ), + ).toContain("WARNING"); + }); + + it('overlay={"runtimeErrors":false} leaves runtime errors uncaught', async () => { + hotApp = await createHotApp({ + query: '?overlay={"runtimeErrors":false}', + code: ` + document.getElementById("app").textContent = "v1"; + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await page.waitForFunction( + () => document.getElementById("app")?.textContent === "v1", + ); + + await page.evaluate(() => globalThis.boom("uncaught boom")); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + it('overlay={"warnings":false} suppresses warnings (dev-server shape)', async () => { hotApp = await createHotApp({ query: '?overlay={"warnings":false}', diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 6f9ff554a..e5e24c5d0 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -24,9 +24,10 @@ function pageHtml(scripts) { * @param {string} dir fixture directory * @param {string} appFile entry file * @param {string} query extra client query ("?..." or "") + * @param {string=} publicPath output public path * @returns {EXPECTED_ANY} webpack configuration */ -function makeConfig(name, dir, appFile, query) { +function makeConfig(name, dir, appFile, query, publicPath = "/") { const clientQuery = name ? `?name=${name}${query ? `&${query.replace(/^\?/, "")}` : ""}` : query; @@ -39,7 +40,7 @@ function makeConfig(name, dir, appFile, query) { output: { path: path.join(dir, "dist"), filename: name ? `${name}.js` : "main.js", - publicPath: "/", + publicPath, // Both compilations share a context dir; without distinct uniqueNames // their runtimes would fight over the same global hot-update callback // and updates would fail with ChunkLoadError. @@ -66,7 +67,7 @@ function makeConfig(name, dir, appFile, query) { * 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 }} options options + * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY, pageHeaders?: Record, publicPath?: string }} options options * @returns {Promise} handles for the running app */ async function createHotApp({ @@ -76,6 +77,7 @@ async function createHotApp({ apps, hot = true, pageHeaders = {}, + publicPath = "/", }) { const dir = fs.mkdtempSync( path.join(fs.realpathSync.native(os.tmpdir()), "wdm-e2e-"), @@ -117,8 +119,8 @@ async function createHotApp({ } else { entryFiles[""] = path.join(dir, "app.js"); fs.writeFileSync(entryFiles[""], /** @type {string} */ (code)); - config = makeConfig("", dir, entryFiles[""], query); - scripts = ["/main.js"]; + config = makeConfig("", dir, entryFiles[""], query, publicPath); + scripts = [`${publicPath}main.js`]; } const compiler = webpack(config); From 25d5dc515818728d307e32840c5461a149cadbf8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:00:03 -0500 Subject: [PATCH 30/48] 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 --- test/e2e/protocol.test.js | 110 ++++++++++++++++++++++++++++++++++++++ test/hot.test.js | 47 ---------------- 2 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 test/e2e/protocol.test.js diff --git a/test/e2e/protocol.test.js b/test/e2e/protocol.test.js new file mode 100644 index 000000000..a79f0ba09 --- /dev/null +++ b/test/e2e/protocol.test.js @@ -0,0 +1,110 @@ +import http from "node:http"; + +import createHotApp from "../helpers/hot-app"; + +jest.setTimeout(120000); + +/** + * Open a raw SSE connection and collect frames as they arrive. + * @param {string} url SSE endpoint url + * @returns {Promise<{ headers: EXPECTED_ANY, frames: string[], close: () => void }>} reader + */ +function openSseReader(url) { + return new Promise((resolve, reject) => { + const request = http.get(url, (res) => { + /** @type {string[]} */ + const frames = []; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + for (const line of chunk.split("\n")) { + if (line.startsWith("data: ")) { + frames.push(line.slice("data: ".length)); + } + } + }); + resolve({ + headers: res.headers, + frames, + close: () => request.destroy(), + }); + }); + request.on("error", reject); + }); +} + +/** + * @param {string[]} frames collected frames + * @param {(frame: string) => boolean} predicate match + * @param {number=} timeout give-up timeout + * @returns {Promise} resolved when a frame matches + */ +async function waitForFrame(frames, predicate, timeout = 30000) { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (frames.some((frame) => predicate(frame))) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + + throw new Error(`No frame matched.\nSeen:\n${frames.join("\n")}`); +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +describe("SSE protocol (raw)", () => { + let app; + /** @type {{ close: () => void }[]} */ + let readers = []; + + afterEach(async () => { + for (const reader of readers) { + reader.close(); + } + readers = []; + if (app) { + const closing = app; + app = undefined; + await closing.close(); + } + }); + + it("keeps the HTTP/1 connection alive and heartbeats it", async () => { + app = await createHotApp({ + code: "document.title = 'x';", + hot: { heartbeat: 100 }, + }); + + const reader = await openSseReader(`${app.url}__webpack_hmr`); + readers.push(reader); + + expect(reader.headers.connection).toBe("keep-alive"); + expect(reader.headers["content-type"]).toBe( + "text/event-stream;charset=utf-8", + ); + + // Real timers, real frames. + await waitForFrame(reader.frames, (frame) => frame === "💓"); + }); + + it("catch-up syncs only the newly connecting client", async () => { + app = await createHotApp({ code: "document.title = 'x';" }); + + const early = await openSseReader(`${app.url}__webpack_hmr`); + readers.push(early); + await waitForFrame(early.frames, (frame) => frame.includes('"sync"')); + early.frames.length = 0; + + // A later client gets the catch-up sync; the connected one must not. + const late = await openSseReader(`${app.url}__webpack_hmr`); + readers.push(late); + await waitForFrame(late.frames, (frame) => frame.includes('"sync"')); + + expect(early.frames.some((frame) => frame.includes('"sync"'))).toBe(false); + }); +}); diff --git a/test/hot.test.js b/test/hot.test.js index 42005dde5..62e4b5dc0 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -165,37 +165,6 @@ describe("hot middleware (unit)", () => { jest.useRealTimers(); }); - it("emits a heartbeat at the configured interval", () => { - const stream = createEventStream(1000, noopLogger); - const writes = []; - const fakeRes = { - writableEnded: false, - write: (chunk) => writes.push(chunk), - writeHead: () => {}, - end: () => {}, - }; - const fakeReq = { - httpVersion: "1.1", - socket: { setKeepAlive: () => {} }, - on: () => {}, - }; - - stream.handler(fakeReq, fakeRes); - writes.length = 0; - jest.advanceTimersByTime(1000); - - expect(writes.some((w) => w.includes("💓"))).toBe(true); - - stream.close(); - }); - - it("sets Connection: keep-alive for HTTP/1 clients", () => { - const stream = createEventStream(5000, noopLogger); - const { headers } = attachClient(stream, { httpVersion: "1.1" }); - expect(headers.Connection).toBe("keep-alive"); - stream.close(); - }); - it("does not set Connection: keep-alive for HTTP/2 clients", () => { const stream = createEventStream(5000, noopLogger); const { headers } = attachClient(stream, { httpVersion: "2.0" }); @@ -366,22 +335,6 @@ describe("createHot", () => { hot.close(); }); - it("does not re-send the catch-up sync to already connected clients", () => { - const compiler = makeFakeCompiler(); - const hot = createHot(compiler, {}); - const { writes: firstWrites } = attachClient({ handler: hot.handle }); - - compiler.emitDone(makeFakeStats()); - firstWrites.length = 0; - - const { writes: secondWrites } = attachClient({ handler: hot.handle }); - - expect(secondWrites.some((w) => w.includes('"action":"sync"'))).toBe(true); - expect(firstWrites.some((w) => w.includes('"action":"sync"'))).toBe(false); - - hot.close(); - }); - it("pairs bundles by name when the compilation order changes", () => { const compiler = makeFakeCompiler(); const hot = createHot(compiler, {}); From 60432e2e755992860b11c29a838d2a556f222678 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:14:33 -0500 Subject: [PATCH 31/48] test(e2e): make the warning-dedup sequence independent of the catch-up race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared SSE connection writes its catch-up sync once — a sibling bundle still evaluating when it arrives misses it, so a warning carried by that sync reached the console on some runs and not others. The widget now starts clean (an empty catch-up cannot vary the sequence) and picks its warning up through a build the page observes. Co-Authored-By: Claude Fable 5 --- .../multi-compiler.test.js.snap.webpack5 | 7 +++++ test/e2e/multi-compiler.test.js | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 index b2d833710..6e1eb0777 100644 --- a/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 @@ -3,9 +3,16 @@ exports[`multi-compiler (browser) logs per-bundle lifecycles and deduplicates warning re-logs on sibling builds 1`] = ` [ "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle 'widget' rebuilding ( changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilding ( changed)", + "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", + "[webpack-dev-middleware] bundle 'widget' rebuilt in Xms", "[webpack-dev-middleware] bundle 'widget' has 1 warnings", "[webpack-dev-middleware] ./entry.js 11:8-20 Critical dependency: the request of a dependency is an expression", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] Hot updated 2 modules.", + "[webpack-dev-middleware] App is up to date.", "[webpack-dev-middleware] bundle 'app' rebuilding ( changed)", "[webpack-dev-middleware] bundle 'app' rebuilding ( changed)", "[webpack-dev-middleware] bundle 'app' rebuilt in Xms", diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js index e5a2c5988..4a1f61004 100644 --- a/test/e2e/multi-compiler.test.js +++ b/test/e2e/multi-compiler.test.js @@ -97,11 +97,8 @@ describe("multi-compiler (browser)", () => { }); it("logs per-bundle lifecycles and deduplicates warning re-logs on sibling builds", async () => { - // Warnings (unlike build errors) do not block applying updates or force - // reloads, which keeps the console sequence deterministic enough to - // snapshot. The `require()` produces webpack's "Critical - // dependency" warning; the template keeps it on a fixed line so the - // warning text is identical across edits. + // Warnings do not block applies or force reloads; the fixed-position + // `require()` keeps the warning text identical across edits. const widgetWithWarning = (text) => ` let el = document.getElementById("out-widget"); if (!el) { @@ -121,10 +118,12 @@ describe("multi-compiler (browser)", () => { } `; + // The widget starts clean: an empty catch-up sync cannot vary the + // sequence (a still-evaluating bundle may miss it entirely). app = await createHotApp({ apps: [ { name: "app", code: bundleApp("app", "app-v1") }, - { name: "widget", code: widgetWithWarning("widget-v1") }, + { name: "widget", code: bundleApp("widget", "widget-v1") }, ], }); ({ page, browser } = await runBrowser()); @@ -133,20 +132,22 @@ describe("multi-compiler (browser)", () => { await page.goto(app.url); await waitForText(page, "out-app", "app-v1"); await waitForText(page, "out-widget", "widget-v1"); - // The widget's warning arrives with the connect-time sync, logged once. + await console_.waitFor("connected"); + + app.edit("widget", widgetWithWarning("widget-v1b")); + await waitForText(page, "out-widget", "widget-v1b"); await console_.waitFor("Critical dependency"); + await console_.waitFor("App is up to date"); - // A sibling's clean rebuild must NOT re-log the widget's unchanged - // warning (the console cache is per bundle). + // A sibling's clean rebuild must not re-log the widget's warning. app.edit("app", bundleApp("app", "app-v2")); await waitForText(page, "out-app", "app-v2"); - await console_.waitFor("App is up to date"); + await console_.waitForCount("App is up to date", 2); - // The widget's own rebuild drops its cache, so the identical warning - // text is logged again. + // The widget's own rebuild drops its cache: same text logs again. app.edit("widget", widgetWithWarning("widget-v2")); await waitForText(page, "out-widget", "widget-v2"); - await console_.waitForCount("App is up to date", 2); + await console_.waitForCount("App is up to date", 3); expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); From d9614bb7c3e1597683625c522a4288896964f7c6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:25:14 -0500 Subject: [PATCH 32/48] test(e2e): adopt webpack-dev-server's 400s test cap for cold CI runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-step browser test spent its whole 120s budget on a cold Windows runner without any single wait hanging — first build, rebuild, and reload each run several times slower there. dev-server runs all its tests under a flat --test-timeout=400000; the e2e suites now use the same cap, while the per-wait 30s timeouts keep catching real hangs. Co-Authored-By: Claude Fable 5 --- test/e2e/client.test.js | 2 +- test/e2e/indicator.test.js | 2 +- test/e2e/logging.test.js | 2 +- test/e2e/multi-compiler.test.js | 2 +- test/e2e/overlay.test.js | 2 +- test/e2e/protocol.test.js | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index c88a23217..f27b853ee 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -2,7 +2,7 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser, { runPage } from "../helpers/run-browser"; -jest.setTimeout(120000); +jest.setTimeout(400000); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); diff --git a/test/e2e/indicator.test.js b/test/e2e/indicator.test.js index 79ee6ff51..d7c2506fd 100644 --- a/test/e2e/indicator.test.js +++ b/test/e2e/indicator.test.js @@ -1,7 +1,7 @@ import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; -jest.setTimeout(120000); +jest.setTimeout(400000); const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; const INDICATOR_ENTRY = require.resolve("../../client-src/indicator.js"); diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index 7fd5921de..b5314bb24 100644 --- a/test/e2e/logging.test.js +++ b/test/e2e/logging.test.js @@ -2,7 +2,7 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; -jest.setTimeout(120000); +jest.setTimeout(400000); /** * @param {string} text text rendered into #app diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js index 4a1f61004..ee105abb0 100644 --- a/test/e2e/multi-compiler.test.js +++ b/test/e2e/multi-compiler.test.js @@ -2,7 +2,7 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; -jest.setTimeout(120000); +jest.setTimeout(400000); const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 200641e7f..9a932d31a 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -2,7 +2,7 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; -jest.setTimeout(120000); +jest.setTimeout(400000); const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; diff --git a/test/e2e/protocol.test.js b/test/e2e/protocol.test.js index a79f0ba09..3ed1cd266 100644 --- a/test/e2e/protocol.test.js +++ b/test/e2e/protocol.test.js @@ -2,7 +2,7 @@ import http from "node:http"; import createHotApp from "../helpers/hot-app"; -jest.setTimeout(120000); +jest.setTimeout(400000); /** * Open a raw SSE connection and collect frames as they arrive. From 68d8e29d29d8e79924fb1092374b4fc4d1d1bdb6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:39:57 -0500 Subject: [PATCH 33/48] test(e2e): share the waits, fixtures, and teardown across the browser suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One helper now owns the text/overlay waits (all on interval polling — only one of the five copies had received the hidden-page fix), the app fixtures (accepted, unaccepted, warning-carrying, boom), the overlay and indicator element ids, and the browser/app teardown that every describe had cloned. Two snapshots move a warning column, following the shared fixture's indentation. Co-Authored-By: Claude Fable 5 --- .../logging.test.js.snap.webpack5 | 2 +- .../overlay.test.js.snap.webpack5 | 2 +- test/e2e/client.test.js | 75 +------ test/e2e/indicator.test.js | 77 ++----- test/e2e/logging.test.js | 85 +++----- test/e2e/multi-compiler.test.js | 36 +--- test/e2e/overlay.test.js | 199 +++--------------- test/helpers/e2e.js | 160 ++++++++++++++ 8 files changed, 245 insertions(+), 391 deletions(-) create mode 100644 test/helpers/e2e.js diff --git a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 index 09a8e5df1..236816084 100644 --- a/test/e2e/__snapshots__/logging.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -31,7 +31,7 @@ exports[`client logging (browser) logging=none silences the whole cycle 1`] = `[ exports[`client logging (browser) logging=warn keeps warnings only 1`] = ` [ "[webpack-dev-middleware] bundle has 1 warnings", - "[webpack-dev-middleware] ./app.js 5:10-22 + "[webpack-dev-middleware] ./app.js 5:6-18 Critical dependency: the request of a dependency is an expression", ] `; diff --git a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 index 299bbcbad..bf925b822 100644 --- a/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -10,7 +10,7 @@ exports[`error overlay (browser) overlay={"warnings":false} suppresses warnings [ "[webpack-dev-middleware] connected", "[webpack-dev-middleware] bundle has 1 warnings", - "[webpack-dev-middleware] ./app.js 5:10-22 + "[webpack-dev-middleware] ./app.js 5:6-18 Critical dependency: the request of a dependency is an expression", ] `; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index f27b853ee..a569361bf 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -1,4 +1,11 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { + acceptedApp, + closeE2e, + unacceptedApp, + waitForAppText, + warningApp, +} from "../helpers/e2e"; import createHotApp from "../helpers/hot-app"; import runBrowser, { runPage } from "../helpers/run-browser"; @@ -6,46 +13,6 @@ jest.setTimeout(400000); const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); -/** - * A self-accepting module: HMR updates re-run it in place. - * @param {string} text text rendered into #app - * @returns {string} app source - */ -function acceptedApp(text) { - return ` - document.getElementById("app").textContent = ${JSON.stringify(text)}; - if (module.hot) { - module.hot.accept(); - } - `; -} - -/** - * A module that never accepts updates — applying one needs a full reload. - * @param {string} text text rendered into #app - * @returns {string} app source - */ -function unacceptedApp(text) { - return `document.getElementById("app").textContent = ${JSON.stringify(text)};`; -} - -/** - * @param {import("puppeteer").Page} page page - * @param {string} text expected #app text - * @returns {Promise} resolved when rendered - */ -function waitForAppText(page, text) { - return page - .waitForFunction( - (expected) => document.getElementById("app")?.textContent === expected, - // Interval polling: the default requestAnimationFrame polling freezes - // on hidden pages (e.g. the backgrounded tab in the two-page test). - { timeout: 30000, polling: 100 }, - text, - ) - .then(() => {}); -} - /** * Plant a marker that survives HMR but not a navigation. * @param {import("puppeteer").Page} page page @@ -71,20 +38,7 @@ describe("hot client (browser)", () => { let page; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (app) { - const closing = app; - app = undefined; - await closing.close(); - } - } + ({ browser, app } = await closeE2e(browser, app)); }); it("connects and applies an update without reloading the page", async () => { @@ -330,19 +284,6 @@ describe("hot client (browser)", () => { }); it("applies updates that carry warnings", async () => { - const warningApp = (text) => ` - document.getElementById("app").textContent = ${JSON.stringify(text)}; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected - } - if (module.hot) { - module.hot.accept(); - } - `; - app = await createHotApp({ code: warningApp("v1") }); ({ page, browser } = await runBrowser()); diff --git a/test/e2e/indicator.test.js b/test/e2e/indicator.test.js index d7c2506fd..a0289ec9e 100644 --- a/test/e2e/indicator.test.js +++ b/test/e2e/indicator.test.js @@ -1,25 +1,17 @@ +import { + INDICATOR_ID, + acceptedApp, + closeE2e, + waitForAppText, +} from "../helpers/e2e"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; jest.setTimeout(400000); -const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; const INDICATOR_ENTRY = require.resolve("../../client-src/indicator.js"); const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; -/** - * @param {string} text text rendered into #app - * @returns {string} app source - */ -function app(text) { - return ` - document.getElementById("app").textContent = ${JSON.stringify(text)}; - if (module.hot) { - module.hot.accept(); - } - `; -} - /** * Watch for the badge from inside the page — it only exists for the duration * of a rebuild. Presence is recorded race-free by a MutationObserver (an @@ -52,52 +44,24 @@ async function installBadgeSampler(page) { }, INDICATOR_ID); } -/** - * @param {import("puppeteer").Page} page page - * @param {string} text expected #app text - * @returns {Promise} resolved when rendered - */ -function waitForAppText(page, text) { - return page - .waitForFunction( - (expected) => document.getElementById("app")?.textContent === expected, - { timeout: 30000 }, - text, - ) - .then(() => {}); -} - describe("building indicator (browser)", () => { let hotApp; let browser; let page; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (hotApp) { - const closing = hotApp; - hotApp = undefined; - await closing.close(); - } - } + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); }); it("shows the badge during a rebuild and removes it afterwards", async () => { - hotApp = await createHotApp({ code: app("v1") }); + hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); await waitForAppText(page, "v1"); await installBadgeSampler(page); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(true); @@ -111,7 +75,7 @@ describe("building indicator (browser)", () => { it("shows the compilation percentage when hot.progress is enabled", async () => { hotApp = await createHotApp({ - code: app("v1"), + code: acceptedApp("v1"), hot: { progress: true }, }); ({ page, browser } = await runBrowser()); @@ -120,7 +84,7 @@ describe("building indicator (browser)", () => { await waitForAppText(page, "v1"); await installBadgeSampler(page); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); const texts = await page.evaluate(() => globalThis.__badgeTexts); @@ -131,7 +95,7 @@ describe("building indicator (browser)", () => { it("never appears when progress=false", async () => { hotApp = await createHotApp({ query: "?progress=false", - code: app("v1"), + code: acceptedApp("v1"), }); ({ page, browser } = await runBrowser()); @@ -139,7 +103,7 @@ describe("building indicator (browser)", () => { await waitForAppText(page, "v1"); await installBadgeSampler(page); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(false); @@ -172,20 +136,7 @@ describe("indicator shared state across bundled copies (browser)", () => { }; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (hotApp) { - const closing = hotApp; - hotApp = undefined; - await closing.close(); - } - } + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); }); it("drives a single badge from a second bundled copy", async () => { diff --git a/test/e2e/logging.test.js b/test/e2e/logging.test.js index b5314bb24..5c83b2ee4 100644 --- a/test/e2e/logging.test.js +++ b/test/e2e/logging.test.js @@ -1,61 +1,26 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { + acceptedApp, + closeE2e, + waitForAppText, + warningApp, +} from "../helpers/e2e"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; jest.setTimeout(400000); -/** - * @param {string} text text rendered into #app - * @returns {string} app source - */ -function app(text) { - return ` - document.getElementById("app").textContent = ${JSON.stringify(text)}; - if (module.hot) { - module.hot.accept(); - } - `; -} - -/** - * @param {import("puppeteer").Page} page page - * @param {string} text expected #app text - * @returns {Promise} resolved when rendered - */ -function waitForAppText(page, text) { - return page - .waitForFunction( - (expected) => document.getElementById("app")?.textContent === expected, - { timeout: 30000 }, - text, - ) - .then(() => {}); -} - describe("client logging (browser)", () => { let hotApp; let browser; let page; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (hotApp) { - const closing = hotApp; - hotApp = undefined; - await closing.close(); - } - } + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); }); it("logs a full update cycle at the default info level, with the prefix", async () => { - hotApp = await createHotApp({ code: app("v1") }); + hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -63,7 +28,7 @@ describe("client logging (browser)", () => { await waitForAppText(page, "v1"); await console_.waitFor("connected"); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); await console_.waitFor("App is up to date"); @@ -71,7 +36,10 @@ describe("client logging (browser)", () => { }); it("logging=log adds the collapsed per-module detail", async () => { - hotApp = await createHotApp({ query: "?logging=log", code: app("v1") }); + hotApp = await createHotApp({ + query: "?logging=log", + code: acceptedApp("v1"), + }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -79,7 +47,7 @@ describe("client logging (browser)", () => { await waitForAppText(page, "v1"); await console_.waitFor("connected"); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); await console_.waitFor("App is up to date"); @@ -89,14 +57,17 @@ describe("client logging (browser)", () => { }); it("logging=none silences the whole cycle", async () => { - hotApp = await createHotApp({ query: "?logging=none", code: app("v1") }); + hotApp = await createHotApp({ + query: "?logging=none", + code: acceptedApp("v1"), + }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); await page.goto(hotApp.url); await waitForAppText(page, "v1"); - hotApp.edit(app("v2")); + hotApp.edit(acceptedApp("v2")); await waitForAppText(page, "v2"); // The update applied (asserted through the DOM above) — give any stray @@ -113,18 +84,7 @@ describe("client logging (browser)", () => { query: "?logging=warn", // `require()` produces webpack's "Critical dependency" // warning without failing the build. - code: ` - document.getElementById("app").textContent = "v1"; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected - } - if (module.hot) { - module.hot.accept(); - } - `, + code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -138,7 +98,10 @@ describe("client logging (browser)", () => { }); it("logging=error keeps errors only", async () => { - hotApp = await createHotApp({ query: "?logging=error", code: app("v1") }); + hotApp = await createHotApp({ + query: "?logging=error", + code: acceptedApp("v1"), + }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); diff --git a/test/e2e/multi-compiler.test.js b/test/e2e/multi-compiler.test.js index ee105abb0..d28b957a0 100644 --- a/test/e2e/multi-compiler.test.js +++ b/test/e2e/multi-compiler.test.js @@ -1,11 +1,10 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { OVERLAY_ID, closeE2e, waitForText } from "../helpers/e2e"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; jest.setTimeout(400000); -const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; - /** * Each bundle renders into its own div so the page shows both compilations * side by side. @@ -28,44 +27,13 @@ function bundleApp(name, text) { `; } -/** - * @param {import("puppeteer").Page} page page - * @param {string} id element id - * @param {string} text expected text - * @returns {Promise} resolved when rendered - */ -function waitForText(page, id, text) { - return page - .waitForFunction( - (elementId, expected) => - document.getElementById(elementId)?.textContent === expected, - { timeout: 30000 }, - id, - text, - ) - .then(() => {}); -} - describe("multi-compiler (browser)", () => { let app; let browser; let page; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (app) { - const closing = app; - app = undefined; - await closing.close(); - } - } + ({ browser, app } = await closeE2e(browser, app)); }); it("updates only the bundle that changed, without reloading the page", async () => { diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 9a932d31a..6051b1255 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -1,74 +1,31 @@ import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { + CARD_ID, + OVERLAY_ID, + acceptedApp, + boomApp, + closeE2e, + waitForAppText, + waitForNoOverlay, + waitForOverlay, + warningApp, +} from "../helpers/e2e"; import createHotApp from "../helpers/hot-app"; import runBrowser from "../helpers/run-browser"; jest.setTimeout(400000); -const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; - -/** - * @param {string} text text rendered into #app - * @returns {string} app source - */ -function app(text) { - return ` - document.getElementById("app").textContent = ${JSON.stringify(text)}; - if (module.hot) { - module.hot.accept(); - } - `; -} - -/** - * @param {import("puppeteer").Page} page page - * @returns {Promise} the overlay iframe's frame - */ -async function waitForOverlay(page) { - const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { - timeout: 30000, - }); - - return handle.contentFrame(); -} - -/** - * @param {import("puppeteer").Page} page page - * @returns {Promise} resolved once the overlay is gone - */ -function waitForNoOverlay(page) { - return page - .waitForFunction( - (id) => document.getElementById(id) === null, - { timeout: 30000 }, - OVERLAY_ID, - ) - .then(() => {}); -} - describe("error overlay (browser)", () => { let hotApp; let browser; let page; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (hotApp) { - const closing = hotApp; - hotApp = undefined; - await closing.close(); - } - } + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); }); it("shows build errors and clears when the build recovers", async () => { - hotApp = await createHotApp({ code: app("v1") }); + hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); @@ -97,7 +54,7 @@ describe("error overlay (browser)", () => { rel: "noopener noreferrer", }); - hotApp.edit(app("fixed")); + hotApp.edit(acceptedApp("fixed")); await waitForNoOverlay(page); // The recovery build also applies (directly or via the full-reload @@ -110,7 +67,7 @@ describe("error overlay (browser)", () => { }); it("dismisses the overlay when pressing Escape on the host page", async () => { - hotApp = await createHotApp({ code: app("v1") }); + hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); @@ -130,7 +87,7 @@ describe("error overlay (browser)", () => { }); it("dismisses on backdrop and close-button clicks, but not inside the card", async () => { - hotApp = await createHotApp({ code: app("v1") }); + hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); @@ -162,21 +119,12 @@ describe("error overlay (browser)", () => { hotApp = await createHotApp({ // The error must be thrown from the page's own script — errors raised // inside evaluate() surface to window.onerror as opaque "Script error". - code: ` - document.getElementById("app").textContent = "v1"; - globalThis.boom = (message) => { - setTimeout(() => { - throw new Error(message); - }, 0); - }; - `, + code: boomApp("v1"), }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); - await page.waitForFunction( - () => document.getElementById("app")?.textContent === "v1", - ); + await waitForAppText(page, "v1"); // The message doubles as an XSS probe: it must render as text. await page.evaluate(() => { @@ -207,18 +155,7 @@ describe("error overlay (browser)", () => { hotApp = await createHotApp({ // `require()` produces webpack's "Critical dependency" // warning without failing the build. - code: ` - document.getElementById("app").textContent = "v1"; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected — the request cannot be resolved at runtime - } - if (module.hot) { - module.hot.accept(); - } - `, + code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); @@ -232,25 +169,14 @@ describe("error overlay (browser)", () => { expect(text).toContain("Critical dependency"); // A build without the warning clears the overlay again. - hotApp.edit(app("fixed")); + hotApp.edit(acceptedApp("fixed")); await waitForNoOverlay(page); expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); it("escalates a warning overlay to an error overlay", async () => { hotApp = await createHotApp({ - code: ` - document.getElementById("app").textContent = "v1"; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected - } - if (module.hot) { - module.hot.accept(); - } - `, + code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); @@ -318,21 +244,12 @@ describe("error overlay (browser)", () => { it('overlay={"runtimeErrors":false} leaves runtime errors uncaught', async () => { hotApp = await createHotApp({ query: '?overlay={"runtimeErrors":false}', - code: ` - document.getElementById("app").textContent = "v1"; - globalThis.boom = (message) => { - setTimeout(() => { - throw new Error(message); - }, 0); - }; - `, + code: boomApp("v1"), }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); - await page.waitForFunction( - () => document.getElementById("app")?.textContent === "v1", - ); + await waitForAppText(page, "v1"); await page.evaluate(() => globalThis.boom("uncaught boom")); await new Promise((resolve) => { @@ -345,18 +262,7 @@ describe("error overlay (browser)", () => { it('overlay={"warnings":false} suppresses warnings (dev-server shape)', async () => { hotApp = await createHotApp({ query: '?overlay={"warnings":false}', - code: ` - document.getElementById("app").textContent = "v1"; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected - } - if (module.hot) { - module.hot.accept(); - } - `, + code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -374,18 +280,7 @@ describe("error overlay (browser)", () => { // The README's documented recipe: keep warnings in the build output // but out of the SSE payload entirely. hot: { statsOptions: { warnings: false } }, - code: ` - document.getElementById("app").textContent = "v1"; - const dep = "./nothing"; - try { - require(dep); - } catch (err) { - // expected - } - if (module.hot) { - module.hot.accept(); - } - `, + code: warningApp("v1"), }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -542,21 +437,12 @@ describe("error overlay (browser)", () => { it("accumulates runtime errors and pages between them", async () => { hotApp = await createHotApp({ - code: ` - document.getElementById("app").textContent = "v1"; - globalThis.boom = (message) => { - setTimeout(() => { - throw new Error(message); - }, 0); - }; - `, + code: boomApp("v1"), }); ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); - await page.waitForFunction( - () => document.getElementById("app")?.textContent === "v1", - ); + await waitForAppText(page, "v1"); await page.evaluate(() => globalThis.boom("boom-one")); const frame = await waitForOverlay(page); @@ -598,9 +484,7 @@ describe("error overlay (browser)", () => { ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); - await page.waitForFunction( - () => document.getElementById("app")?.textContent === "v1", - ); + await waitForAppText(page, "v1"); await page.evaluate(() => globalThis.rejectSoon("rejected-boom")); @@ -614,7 +498,7 @@ describe("error overlay (browser)", () => { it("renders under an enforced Trusted Types CSP with the configured policy", async () => { hotApp = await createHotApp({ query: '?overlay={"trustedTypesPolicyName":"wdm-test"}', - code: app("v1"), + code: acceptedApp("v1"), // Real enforcement, which jsdom cannot do: every HTML sink must go // through the "wdm-test" policy or Chrome throws. The about:blank // overlay iframe inherits this policy from the page. @@ -626,9 +510,7 @@ describe("error overlay (browser)", () => { ({ page, browser } = await runBrowser()); await page.goto(hotApp.url); - await page.waitForFunction( - () => document.getElementById("app")?.textContent === "v1", - ); + await waitForAppText(page, "v1"); hotApp.edit("broken by csp {{{"); @@ -645,7 +527,10 @@ describe("error overlay (browser)", () => { }); it("does not appear when overlay=false", async () => { - hotApp = await createHotApp({ query: "?overlay=false", code: app("v1") }); + hotApp = await createHotApp({ + query: "?overlay=false", + code: acceptedApp("v1"), + }); ({ page, browser } = await runBrowser()); const console_ = collectConsole(page); @@ -662,7 +547,6 @@ describe("error overlay (browser)", () => { describe("overlay shared state across bundled copies (browser)", () => { const OVERLAY_ENTRY = require.resolve("../../client-src/overlay.js"); const OVERLAY_STATE_KEY = "__webpack_dev_middleware_hot_overlay_state__"; - const CARD_ID = `${OVERLAY_ID}-card`; let hotApp; let browser; @@ -707,20 +591,7 @@ describe("overlay shared state across bundled copies (browser)", () => { }; afterEach(async () => { - // try/finally: a rejected browser.close() must not leak the watcher, - // the server, and the temp dir behind it. - try { - if (browser) { - await browser.close(); - } - } finally { - browser = undefined; - if (hotApp) { - const closing = hotApp; - hotApp = undefined; - await closing.close(); - } - } + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); }); it("shows the problems of two copies in the same overlay", async () => { diff --git a/test/helpers/e2e.js b/test/helpers/e2e.js new file mode 100644 index 000000000..764e2d2cb --- /dev/null +++ b/test/helpers/e2e.js @@ -0,0 +1,160 @@ +/* global document -- evaluated inside the browser via waitForFunction */ +const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; +const CARD_ID = `${OVERLAY_ID}-card`; +const INDICATOR_ID = "webpack-dev-middleware-building-indicator"; + +/** + * @param {import("puppeteer").Page} page page + * @param {string} id element id + * @param {string} text expected text + * @returns {Promise} resolved when rendered + */ +function waitForText(page, id, text) { + return page + .waitForFunction( + (elementId, expected) => + document.getElementById(elementId)?.textContent === expected, + // Interval polling: the default requestAnimationFrame polling freezes + // on hidden pages (e.g. a backgrounded tab). + { timeout: 30000, polling: 100 }, + id, + text, + ) + .then(() => {}); +} + +/** + * @param {import("puppeteer").Page} page page + * @param {string} text expected #app text + * @returns {Promise} resolved when rendered + */ +function waitForAppText(page, text) { + return waitForText(page, "app", text); +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} the overlay iframe's frame + */ +async function waitForOverlay(page) { + const handle = await page.waitForSelector(`#${OVERLAY_ID}`, { + timeout: 30000, + }); + + return handle.contentFrame(); +} + +/** + * @param {import("puppeteer").Page} page page + * @returns {Promise} resolved once the overlay is gone + */ +function waitForNoOverlay(page) { + return page + .waitForFunction( + (id) => document.getElementById(id) === null, + { timeout: 30000, polling: 100 }, + OVERLAY_ID, + ) + .then(() => {}); +} + +/** + * A self-accepting module: HMR updates re-run it in place. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function acceptedApp(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * A module that never accepts updates — applying one needs a full reload. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function unacceptedApp(text) { + return `document.getElementById("app").textContent = ${JSON.stringify(text)};`; +} + +/** + * Self-accepting and carrying webpack's "Critical dependency" warning — the + * fixed-position `require()` keeps the warning text identical + * across edits. + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function warningApp(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + const dep = "./nothing"; + try { + require(dep); + } catch (err) { + // expected + } + if (module.hot) { + module.hot.accept(); + } + `; +} + +/** + * Exposes a boom(message) helper that throws from the page's own script — + * errors raised inside evaluate() reach window.onerror as "Script error". + * @param {string} text text rendered into #app + * @returns {string} app source + */ +function boomApp(text) { + return ` + document.getElementById("app").textContent = ${JSON.stringify(text)}; + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + }; + `; +} + +/** + * Tear a test's browser and hot-app down; a rejected browser.close() must + * not leak the watcher, the server, and the temp dir behind it. + * @param {import("puppeteer").Browser=} browser browser + * @param {EXPECTED_ANY=} app hot app + * @returns {Promise<{ browser: undefined, app: undefined }>} cleared slots + */ +async function closeE2e(browser, app) { + try { + if (browser) { + await browser.close(); + } + } finally { + if (app) { + await app.close(); + } + } + + return { browser: undefined, app: undefined }; +} + +// eslint-disable-next-line jsdoc/reject-any-type +/** @typedef {any} EXPECTED_ANY */ + +module.exports = { + CARD_ID, + INDICATOR_ID, + OVERLAY_ID, + acceptedApp, + boomApp, + closeE2e, + unacceptedApp, + waitForAppText, + waitForNoOverlay, + waitForOverlay, + waitForText, + warningApp, +}; From 29041979674c14fe6424ddbd444144d6d8cd0db6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:44:52 -0500 Subject: [PATCH 34/48] test(e2e): click the backdrop by raw mouse coordinates ElementHandle.click's scrollIntoViewIfNeeded pre-check stalled CDP on a slow Windows runner (Runtime.callFunctionOn timing out). The overlay iframe is fixed at the viewport origin, so a raw mouse click at (5,5) lands on the backdrop without any evaluate round-trip. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 6051b1255..6a3b136fb 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -101,8 +101,7 @@ describe("error overlay (browser)", () => { // …clicking the backdrop (top-left corner, away from the centered card) // dismisses it. - const body = await frame.$("body"); - await body.click({ offset: { x: 5, y: 5 } }); + await page.mouse.click(5, 5); await waitForNoOverlay(page); // A reload brings the overlay back through the catch-up sync — dismiss From 309d18715c6d7bca3c63c6fa97a25301265eeac2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 22:52:40 -0500 Subject: [PATCH 35/48] test(e2e): open the clicked file reference through a real endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay's file chip carries webpack's real file:line:column reference, and clicking it lands a GET on an express route the test mounts through the helper's new setup hook — the full documented open-in-editor round trip, not just a fetch spy. Without an endpoint configured no chip is rendered. The jsdom describe is removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 53 ++++++++++++++++++++++++++++++++++++++++ test/helpers/hot-app.js | 8 +++++- test/overlay.test.js | 37 ---------------------------- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 6a3b136fb..dbcdb6f11 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -494,6 +494,59 @@ describe("error overlay (browser)", () => { ); }); + it("opens the clicked file reference through the configured endpoint", async () => { + /** @type {string[]} */ + const opened = []; + + hotApp = await createHotApp({ + query: '?overlay={"openEditorEndpoint":"/__open-editor"}', + code: acceptedApp("v1"), + setup: (server) => { + server.get("/__open-editor", (req, res) => { + opened.push(String(req.query.fileName)); + res.end(); + }); + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken for the editor {{{"); + const frame = await waitForOverlay(page); + const chip = await frame.waitForSelector("[data-open-file]", { + timeout: 30000, + }); + + // The chip carries webpack's real file:line:column reference, and + // clicking it reaches the server-side route. + expect(await chip.evaluate((el) => el.getAttribute("data-open-file"))).toBe( + "./app.js:1:7", + ); + + await chip.click(); + const start = Date.now(); + while (opened.length === 0 && Date.now() - start < 30000) { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + } + + expect(opened).toEqual(["./app.js:1:7"]); + }); + + it("does not mark file chips when no endpoint is configured", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken without editor {{{"); + const frame = await waitForOverlay(page); + + expect(await frame.$("[data-open-file]")).toBeNull(); + }); + it("renders under an enforced Trusted Types CSP with the configured policy", async () => { hotApp = await createHotApp({ query: '?overlay={"trustedTypesPolicyName":"wdm-test"}', diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index e5e24c5d0..0cbfdbe5a 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -67,7 +67,7 @@ function makeConfig(name, dir, appFile, query, publicPath = "/") { * 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 }} options options + * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY, pageHeaders?: Record, publicPath?: string, setup?: (server: EXPECTED_ANY) => void }} options options * @returns {Promise} handles for the running app */ async function createHotApp({ @@ -78,6 +78,7 @@ async function createHotApp({ hot = true, pageHeaders = {}, publicPath = "/", + setup, }) { const dir = fs.mkdtempSync( path.join(fs.realpathSync.native(os.tmpdir()), "wdm-e2e-"), @@ -145,6 +146,11 @@ async function createHotApp({ } res.end(pageHtml(scripts)); }); + if (setup) { + // Extra routes (e.g. an open-editor endpoint) mount before the + // middleware. + setup(app); + } app.use(instance); /** diff --git a/test/overlay.test.js b/test/overlay.test.js index 75e07cc17..4a009733d 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -133,43 +133,6 @@ describe("overlay", () => { }); }); - describe("open in editor", () => { - afterEach(() => { - configureOverlay({ openEditorEndpoint: "" }); - delete globalThis.fetch; - }); - - it("makes file chips clickable and calls the configured endpoint", () => { - // eslint-disable-next-line jest/prefer-spy-on -- jsdom does not define fetch - globalThis.fetch = jest.fn(() => Promise.resolve()); - configureOverlay({ openEditorEndpoint: "/__open-editor" }); - showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); - - const chip = /** @type {Document} */ ( - getOverlay().contentDocument - ).querySelector("[data-open-file]"); - - expect(chip).not.toBeNull(); - expect(chip.getAttribute("data-open-file")).toBe("./src/render.js:7:2"); - - chip.click(); - - expect(globalThis.fetch).toHaveBeenCalledWith( - `/__open-editor?fileName=${encodeURIComponent("./src/render.js:7:2")}`, - ); - }); - - it("does not mark file chips when no endpoint is configured", () => { - showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); - - expect( - /** @type {Document} */ (getOverlay().contentDocument).querySelector( - "[data-open-file]", - ), - ).toBeNull(); - }); - }); - describe("configureOverlay", () => { it("applies custom overlay styles to the card", () => { configureOverlay({ overlayStyles: { maxWidth: "500px" } }); From 13c77227175f61190ea63cc4cda3106ab5c7dc50 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:07:25 -0500 Subject: [PATCH 36/48] test(e2e): feed the client real heartbeats and a malformed frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a 100ms server heartbeat, several real 💓 frames cross the wire and reach neither subscribers (only the catch-up sync does) nor the console — snapshotted as a lone "connected". A rogue SSE endpoint mounted through the helper's setup hook feeds the real EventSource a non-JSON frame: the client warns (snapshotted, V8's wording is pinned by the puppeteer-locked Chrome) and the page keeps running. The two jsdom equivalents leave; client.test.js is down to the slash-edge parsing and the no-EventSource guard. Co-Authored-By: Claude Fable 5 --- test/client.test.js | 35 ----------- .../client.test.js.snap.webpack5 | 18 +++++- test/e2e/client.test.js | 59 +++++++++++++++++++ 3 files changed, 75 insertions(+), 37 deletions(-) diff --git a/test/client.test.js b/test/client.test.js index 5d239d823..7ea7aa827 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -121,41 +121,6 @@ describe("client", () => { }); }); - describe("connection lifecycle", () => { - let EventSourceStub; - let client; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - client = loadClient(); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("ignores heartbeat messages", () => { - const handler = jest.fn(); - client.subscribeAll(handler); - EventSourceStub.lastInstance().dispatch("message", { data: "💓" }); - expect(handler).not.toHaveBeenCalled(); - expect(processUpdate).not.toHaveBeenCalled(); - }); - - it("warns on invalid JSON", () => { - EventSourceStub.lastInstance().dispatch("message", { data: "not-json{" }); - expect( - console.warn.mock.calls.some(([msg]) => - /Invalid HMR message/.test(msg), - ), - ).toBe(true); - }); - }); - describe("with no EventSource", () => { beforeEach(() => { delete globalThis.EventSource; diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 index f96014766..a9852682a 100644 --- a/test/e2e/__snapshots__/client.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`hot client (browser) keeps heartbeats away from subscribers and the console 1`] = ` +[ + "[webpack-dev-middleware] connected", +] +`; + exports[`hot client (browser) rebuilds on instance.invalidate() and syncs connected pages as a no-op 1`] = ` [ "[webpack-dev-middleware] connected", @@ -29,10 +35,18 @@ exports[`hot client (browser) reconnects manually with setOptionsAndConnect() af ] `; -exports[`hot client (browser) watchdog-reconnects a silent connection and stays armed afterwards 1`] = ` +exports[`hot client (browser) warns on malformed frames without breaking the page 1`] = ` [ "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] Invalid HMR message: not-json{ +SyntaxError: Unexpected token 'o', "not-json{" is not valid JSON", +] +`; + +exports[`hot client (browser) watchdog-reconnects a silent connection and stays armed afterwards 1`] = ` +[ "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", ] `; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index a569361bf..69bba9d7f 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -320,6 +320,65 @@ describe("hot client (browser)", () => { expect(await readReloadMarker(page)).toBe(true); }); + it("keeps heartbeats away from subscribers and the console", async () => { + app = await createHotApp({ + hot: { heartbeat: 100 }, + code: ` + const hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + globalThis.__all = []; + hotClient.subscribeAll((payload) => { + globalThis.__all.push(payload.action); + }); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + // Several heartbeat periods pass... + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + // ...and none of them reached the subscribers (only the catch-up sync + // did) or the console. + expect(await page.evaluate(() => globalThis.__all)).toEqual(["sync"]); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("warns on malformed frames without breaking the page", async () => { + app = await createHotApp({ + query: "?path=/__fake_hmr", + code: acceptedApp("v1"), + // A rogue SSE endpoint feeding the real EventSource a non-JSON frame. + setup: (server) => { + server.get("/__fake_hmr", (_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("\n"); + res.write("data: not-json{\n\n"); + }); + }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("Invalid HMR message"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + expect( + await page.evaluate(() => document.getElementById("app").textContent), + ).toBe("v1"); + }); + it("routes server publish() payloads to subscribe handlers", async () => { app = await createHotApp({ code: ` From ca99266a51c09da6511279ff99686abf185dc6b0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:15:12 -0500 Subject: [PATCH 37/48] =?UTF-8?q?test(e2e):=20finish=20the=20client=20migr?= =?UTF-8?q?ation=20=E2=80=94=20runtime=20public=20path=20and=20no-EventSou?= =?UTF-8?q?rce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamic public path is now asserted on the URLs the browser actually requests: a bundled setter swaps __webpack_public_path__ at runtime (the free variable only exists inside the bundle) and manual reconnects show intentional double slashes surviving and trailing slashes not doubling. Deleting EventSource before the bundle loads (evaluateOnNewDocument) snapshots the polyfill warning with the page still rendering. The jsdom client suite is gone — nothing was left that a real browser could not cover. Co-Authored-By: Claude Fable 5 --- test/client.test.js | 140 ------------------ .../client.test.js.snap.webpack5 | 6 + test/e2e/client.test.js | 71 +++++++++ 3 files changed, 77 insertions(+), 140 deletions(-) delete mode 100644 test/client.test.js diff --git a/test/client.test.js b/test/client.test.js deleted file mode 100644 index 7ea7aa827..000000000 --- a/test/client.test.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @jest-environment jsdom - */ - -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ - -/** @type {EXPECTED_ANY} */ -let processUpdate; -/** @type {{ showProblems: jest.Mock, clear: jest.Mock }} */ -let clientOverlay; - -jest.mock("../client-src/process-update", () => { - const fn = jest.fn(); - return fn; -}); - -jest.mock("../client-src/overlay", () => { - const overlay = { showProblems: jest.fn(), clear: jest.fn() }; - const factory = jest.fn(() => overlay); - factory.__getOverlay = () => overlay; - return factory; -}); - -/** - * Stub `EventSource` so each test can drive `message`/`error`/`open` events. - * @returns {EXPECTED_ANY} fake constructor + last instance accessor - */ -function makeEventSourceStub() { - /** @type {EXPECTED_ANY[]} */ - const instances = []; - function EventSourceStub(url) { - this.url = url; - this.listeners = { open: [], error: [], message: [] }; - this.closed = false; - this.addEventListener = (type, fn) => { - if (this.listeners[type]) this.listeners[type].push(fn); - }; - this.dispatch = (type, event) => { - for (const fn of this.listeners[type] || []) fn(event); - }; - this.onmessage = (event) => this.dispatch("message", event); - // eslint-disable-next-line jest/prefer-spy-on - this.close = jest.fn(() => { - this.closed = true; - }); - instances.push(this); - } - EventSourceStub.instances = instances; - EventSourceStub.lastInstance = () => instances[instances.length - 1]; - return EventSourceStub; -} - -/** - * Reset module state so each test loads a fresh client. The per-page - * singletons on `window` are NOT cleared here — the outer `afterEach` handles - * that, so tests that re-require the client on the same "page" can observe - * the wrapper being reused. - * @param {string=} resourceQuery `__resourceQuery` value injected by webpack - * @returns {EXPECTED_ANY} client module - */ -function loadClient(resourceQuery = "") { - jest.resetModules(); - globalThis.__resourceQuery = resourceQuery; - processUpdate = require("../client-src/process-update"); - processUpdate.mockReset(); - - const overlayFactory = require("../client-src/overlay"); - - clientOverlay = overlayFactory.__getOverlay(); - clientOverlay.showProblems.mockReset(); - clientOverlay.clear.mockReset(); - - return require("../client-src"); -} - -describe("client", () => { - afterEach(() => { - for (const el of document.querySelectorAll( - "#webpack-dev-middleware-building-indicator", - )) { - el.remove(); - } - delete globalThis.__resourceQuery; - delete globalThis.EventSource; - delete globalThis.__wdmEventSourceWrapper; - delete globalThis.__webpack_dev_middleware_hot_reporter__; - delete globalThis.__webpack_dev_middleware_hot_indicator_state__; - jest.useRealTimers(); - }); - - describe("with dynamicPublicPath", () => { - let EventSourceStub; - - beforeEach(() => { - EventSourceStub = makeEventSourceStub(); - globalThis.EventSource = EventSourceStub; - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - delete globalThis.__webpack_public_path__; - jest.restoreAllMocks(); - }); - - it("preserves intentional double slashes inside the public path", () => { - globalThis.__webpack_public_path__ = "https://host//rewritten/"; - loadClient("?dynamicPublicPath=true"); - expect(EventSourceStub.lastInstance().url).toBe( - "https://host//rewritten/__webpack_hmr", - ); - }); - - it("does not produce a double slash when the public path has a trailing slash", () => { - globalThis.__webpack_public_path__ = "https://localhost:3000/assets/"; - loadClient("?dynamicPublicPath=true"); - expect(EventSourceStub.lastInstance().url).toBe( - "https://localhost:3000/assets/__webpack_hmr", - ); - }); - }); - - describe("with no EventSource", () => { - beforeEach(() => { - delete globalThis.EventSource; - jest.spyOn(console, "warn").mockImplementation(() => {}); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it("emits a warning and does not connect", () => { - loadClient(); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.warn.mock.calls[0][0]).toMatch(/EventSource/); - }); - }); -}); diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 index a9852682a..30e410eaf 100644 --- a/test/e2e/__snapshots__/client.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -35,6 +35,12 @@ exports[`hot client (browser) reconnects manually with setOptionsAndConnect() af ] `; +exports[`hot client (browser) warns and stays connectionless without EventSource support 1`] = ` +[ + "[webpack-dev-middleware] webpack-dev-middleware's hot client requires EventSource to work. Include a polyfill if you want to support this browser: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events#Tools", +] +`; + exports[`hot client (browser) warns on malformed frames without breaking the page 1`] = ` [ "[webpack-dev-middleware] connected", diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index 69bba9d7f..d9467d440 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -298,6 +298,77 @@ describe("hot client (browser)", () => { expect(await readReloadMarker(page)).toBe(true); }); + it("builds the SSE url from the runtime public path, slashes intact", async () => { + app = await createHotApp({ + query: "?autoConnect=false", + code: ` + globalThis.hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + globalThis.setPublicPath = (value) => { + __webpack_public_path__ = value; + }; + document.getElementById("app").textContent = "v1"; + `, + }); + ({ page, browser } = await runBrowser()); + + /** @type {string[]} */ + const requested = []; + page.on("request", (request) => { + requested.push(request.url()); + }); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + + // The runtime public path is picked up at connect time — intentional + // double slashes survive, a trailing slash does not double up. + await page.evaluate(() => { + globalThis.setPublicPath("https://host//rewritten/"); + globalThis.hotClient.setOptionsAndConnect({ + dynamicPublicPath: "true", + path: "/__webpack_hmr", + }); + }); + await page.evaluate(() => { + globalThis.setPublicPath("https://localhost:3000/assets/"); + globalThis.hotClient.setOptionsAndConnect({ + dynamicPublicPath: "true", + path: "/__webpack_hmr", + }); + }); + + const start = Date.now(); + while ( + Date.now() - start < 30000 && + !( + requested.includes("https://host//rewritten/__webpack_hmr") && + requested.includes("https://localhost:3000/assets/__webpack_hmr") + ) + ) { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + } + + expect(requested).toContain("https://host//rewritten/__webpack_hmr"); + expect(requested).toContain("https://localhost:3000/assets/__webpack_hmr"); + }); + + it("warns and stays connectionless without EventSource support", async () => { + app = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.evaluateOnNewDocument(() => { + delete globalThis.EventSource; + }); + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("requires EventSource"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + it("connects through a dynamic public path", async () => { app = await createHotApp({ publicPath: "/assets/", From 443decbeb48deb3edcddd9783ae07acd8b75fdf2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:18:36 -0500 Subject: [PATCH 38/48] test(e2e): pin only the first three connects of the watchdog cycle The silent watchdog keeps reconnecting by design, so a fourth "connected" can land in the same poll window as the awaited third and flake the full-list snapshot. Co-Authored-By: Claude Fable 5 --- test/e2e/__snapshots__/client.test.js.snap.webpack5 | 4 ++-- test/e2e/client.test.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/e2e/__snapshots__/client.test.js.snap.webpack5 b/test/e2e/__snapshots__/client.test.js.snap.webpack5 index 30e410eaf..449ed3f8a 100644 --- a/test/e2e/__snapshots__/client.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -52,7 +52,7 @@ SyntaxError: Unexpected token 'o', "not-json{" is not valid JSON", exports[`hot client (browser) watchdog-reconnects a silent connection and stays armed afterwards 1`] = ` [ "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] connected", - "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] connected", ] `; diff --git a/test/e2e/client.test.js b/test/e2e/client.test.js index d9467d440..8ee4fb8b0 100644 --- a/test/e2e/client.test.js +++ b/test/e2e/client.test.js @@ -175,9 +175,9 @@ describe("hot client (browser)", () => { await console_.waitForCount("connected", 3); // Nothing but connects: the silent cycles produce no other output. The - // snapshot is taken before the edit — the watchdog keeps cycling, so any - // later cut would race with the next reconnect. - expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + // watchdog keeps cycling, so only the first three are pinned — a fourth + // may already have landed. + expect(normalizeConsole(console_.messages).slice(0, 3)).toMatchSnapshot(); // The reconnected connection still delivers updates. app.edit(acceptedApp("v2")); From c62468818c3a190e7dcf5232f4a2a8618c98055f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:44:32 -0500 Subject: [PATCH 39/48] test(e2e): process the update paths against the real HMR runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish() turns out to be the injection point the fake runtimes simulated: a built announcing a hash the server never produced walks the real check → manifest 404 → reload fallback; a sync carrying the bundle's own hash (read through a bundled getter — __webpack_hash__ only exists inside the bundle) locks the compilation name so a sibling's impossible hash is ignored, snapshotted as pure silence; and without HotModuleReplacementPlugin the disabled-runtime error logs exactly once across two builds. The accept-handler error path runs on a genuinely throwing accept callback: reload by default, a warning and the broken state kept with reload=false. Six fake-runtime units leave; what stays needs forced runtime states no real flow reaches. Co-Authored-By: Claude Fable 5 --- .../process-update.test.js.snap.webpack5 | 30 +++ test/e2e/process-update.test.js | 199 ++++++++++++++++++ test/helpers/hot-app.js | 24 ++- test/process-update.test.js | 97 --------- 4 files changed, 249 insertions(+), 101 deletions(-) create mode 100644 test/e2e/__snapshots__/process-update.test.js.snap.webpack5 create mode 100644 test/e2e/process-update.test.js diff --git a/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 b/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 new file mode 100644 index 000000000..fb89e9b1e --- /dev/null +++ b/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`update processing (browser) ignores sibling bundles once the own compilation is identified 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle 'admin' rebuilt in Xms", +] +`; + +exports[`update processing (browser) logs the disabled HMR runtime once and never reloads 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] [HMR] Hot Module Replacement is disabled. Add HotModuleReplacementPlugin to the webpack configuration.", + "[webpack-dev-middleware] bundle rebuilt in Xms", + "[webpack-dev-middleware] bundle rebuilt in Xms", +] +`; + +exports[`update processing (browser) stays on the broken state when reload=false and an accept handler throws 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] bundle rebuilding ( changed)", + "[webpack-dev-middleware] bundle rebuilt in Xms", + "[webpack-dev-middleware] Checking for updates on the server...", + "[webpack-dev-middleware] JSHandle@error", + "[webpack-dev-middleware] Ignored an error while updating module ./app.js (accept-errored)", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; diff --git a/test/e2e/process-update.test.js b/test/e2e/process-update.test.js new file mode 100644 index 000000000..46243a231 --- /dev/null +++ b/test/e2e/process-update.test.js @@ -0,0 +1,199 @@ +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { acceptedApp, closeE2e, waitForAppText } from "../helpers/e2e"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +/** + * The app accepts ./dep with a handler that throws — the error path HMR + * cannot recover from in place. + * @returns {string} app source + */ +function throwingAcceptApp() { + return ` + require("./dep"); + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept("./dep.js", () => { + throw new Error("accept boom"); + }); + } + `; +} + +describe("update processing (browser)", () => { + let app; + let browser; + let page; + + afterEach(async () => { + ({ browser, app } = await closeE2e(browser, app)); + }); + + it("reloads when an accept handler throws during apply", async () => { + app = await createHotApp({ + code: throwingAcceptApp(), + files: { "dep.js": "module.exports = 1;" }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + app.editFile("dep.js", "module.exports = 2;"); + + // The failed apply falls back to a full reload: the marker is wiped. + await page.waitForFunction(() => globalThis.__notReloaded === undefined, { + timeout: 30000, + polling: 100, + }); + expect(await page.evaluate(() => globalThis.__notReloaded)).toBeUndefined(); + }); + + it("stays on the broken state when reload=false and an accept handler throws", async () => { + app = await createHotApp({ + query: "?reload=false", + code: throwingAcceptApp(), + files: { "dep.js": "module.exports = 1;" }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + app.editFile("dep.js", "module.exports = 2;"); + await console_.waitFor("Ignored an error while updating"); + + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("reloads when the announced update cannot be found", async () => { + app = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + // A build the server never produced: the real runtime fetches the + // hot-update manifest, gets a 404, and falls back to a reload. + app.instance.context.hot.publish({ + action: "built", + name: "", + time: 1, + hash: "0000000000000000", + errors: [], + warnings: [], + }); + + await page.waitForFunction(() => globalThis.__notReloaded === undefined, { + timeout: 30000, + polling: 100, + }); + expect(await page.evaluate(() => globalThis.__notReloaded)).toBeUndefined(); + }); + + it("ignores sibling bundles once the own compilation is identified", async () => { + app = await createHotApp({ + // __webpack_hash__ only exists inside the bundle — expose a getter. + code: ` + globalThis.getHash = () => __webpack_hash__; + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + const currentHash = await page.evaluate(() => globalThis.getHash()); + + // The own sync locks the name… + app.instance.context.hot.publish({ + action: "sync", + name: "app", + time: 1, + hash: currentHash, + errors: [], + warnings: [], + }); + // …so a sibling's impossible hash is ignored instead of checked. + app.instance.context.hot.publish({ + action: "built", + name: "admin", + time: 1, + hash: "0000000000000000", + errors: [], + warnings: [], + }); + + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logs the disabled HMR runtime once and never reloads", async () => { + app = await createHotApp({ + code: 'document.getElementById("app").textContent = "v1";', + hmrPlugin: false, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + app.instance.context.hot.publish({ + action: "built", + name: "", + time: 1, + hash: "aaaa", + errors: [], + warnings: [], + }); + await console_.waitFor("Hot Module Replacement is disabled"); + app.instance.context.hot.publish({ + action: "built", + name: "", + time: 1, + hash: "bbbb", + errors: [], + warnings: [], + }); + + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); +}); diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index 0cbfdbe5a..fa8427baf 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -25,9 +25,17 @@ function pageHtml(scripts) { * @param {string} appFile entry file * @param {string} query extra client query ("?..." or "") * @param {string=} publicPath output public path + * @param {boolean=} hmrPlugin include HotModuleReplacementPlugin * @returns {EXPECTED_ANY} webpack configuration */ -function makeConfig(name, dir, appFile, query, publicPath = "/") { +function makeConfig( + name, + dir, + appFile, + query, + publicPath = "/", + hmrPlugin = true, +) { const clientQuery = name ? `?name=${name}${query ? `&${query.replace(/^\?/, "")}` : ""}` : query; @@ -46,7 +54,7 @@ function makeConfig(name, dir, appFile, query, publicPath = "/") { // and updates would fail with ChunkLoadError. ...(name ? { uniqueName: name } : {}), }, - plugins: [new webpack.HotModuleReplacementPlugin()], + plugins: hmrPlugin ? [new webpack.HotModuleReplacementPlugin()] : [], infrastructureLogging: { level: "none" }, stats: "none", devtool: false, @@ -67,7 +75,7 @@ function makeConfig(name, dir, appFile, query, publicPath = "/") { * 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 }} options options + * @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 * @returns {Promise} handles for the running app */ async function createHotApp({ @@ -79,6 +87,7 @@ async function createHotApp({ pageHeaders = {}, publicPath = "/", setup, + hmrPlugin = true, }) { const dir = fs.mkdtempSync( path.join(fs.realpathSync.native(os.tmpdir()), "wdm-e2e-"), @@ -120,7 +129,14 @@ async function createHotApp({ } else { entryFiles[""] = path.join(dir, "app.js"); fs.writeFileSync(entryFiles[""], /** @type {string} */ (code)); - config = makeConfig("", dir, entryFiles[""], query, publicPath); + config = makeConfig( + "", + dir, + entryFiles[""], + query, + publicPath, + hmrPlugin, + ); scripts = [`${publicPath}main.js`]; } diff --git a/test/process-update.test.js b/test/process-update.test.js index 340230b49..3cba7cecf 100644 --- a/test/process-update.test.js +++ b/test/process-update.test.js @@ -83,45 +83,7 @@ describe("process-update", () => { jest.restoreAllMocks(); }); - it("logs an error once when the HMR runtime is disabled", () => { - applyUpdate = loadApplyUpdate(undefined); - - expect(() => applyUpdate("h1", { reload: true })).not.toThrow(); - applyUpdate("h2", { reload: true }); - - const runtimeErrors = console.error.mock.calls.filter((call) => - call.join(" ").includes("Hot Module Replacement is disabled"), - ); - - expect(runtimeErrors).toHaveLength(1); - expect(reloadPage).not.toHaveBeenCalled(); - }); - describe("multi-compiler bundles", () => { - it("ignores events from sibling bundles once the own compilation is identified", async () => { - // The `sync` sent on connect carries the bundle's own hash and locks the name. - applyUpdate("current-hash", { reload: true }, "app"); - - const hot = getHot(); - - expect(hot.check).not.toHaveBeenCalled(); - - // A sibling bundle rebuilds with a hash this runtime can never match. - applyUpdate("admin-hash", { reload: true }, "admin"); - await flushPromises(); - - expect(hot.check).not.toHaveBeenCalled(); - expect(reloadPage).not.toHaveBeenCalled(); - - // The own bundle rebuilding still applies the update. - applyUpdate("app-hash-2", { reload: true }, "app"); - globalThis.__webpack_hash__ = "app-hash-2"; - await flushPromises(); - - expect(hot.check).toHaveBeenCalledWith(false); - expect(hot.apply).toHaveBeenCalledTimes(1); - }); - it("does not reload when a pre-lock sibling check resolves without an update", async () => { applyUpdate = loadApplyUpdate(makeFakeHot({ checkResult: null })); globalThis.__webpack_hash__ = "app-hash"; @@ -146,65 +108,6 @@ describe("process-update", () => { }); }); - it("does not check when already up to date", () => { - applyUpdate("current-hash", { reload: true }); - - expect(getHot().check).not.toHaveBeenCalled(); - }); - - it("reloads when the update cannot be found (server restart)", async () => { - applyUpdate = loadApplyUpdate(makeFakeHot({ checkResult: null })); - - applyUpdate("new-hash", { reload: true }); - await flushPromises(); - - expect(reloadPage).toHaveBeenCalledTimes(1); - }); - - it("reloads when an accept handler errors during apply (onErrored)", async () => { - applyUpdate = loadApplyUpdate( - makeFakeHot({ - applyImpl: (applyOptions) => { - applyOptions.onErrored({ - error: new Error("accept handler failed"), - moduleId: "./a.js", - type: "accept-errored", - }); - - return Promise.resolve(["./a.js"]); - }, - }), - ); - - applyUpdate("new-hash", { reload: true }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(reloadPage).toHaveBeenCalledTimes(1); - }); - - it("does not reload on errored apply when reload is disabled", async () => { - applyUpdate = loadApplyUpdate( - makeFakeHot({ - applyImpl: (applyOptions) => { - applyOptions.onErrored({ - error: new Error("accept handler failed"), - moduleId: "./a.js", - type: "accept-errored", - }); - - return Promise.resolve(["./a.js"]); - }, - }), - ); - - applyUpdate("new-hash", { reload: false }); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(reloadPage).not.toHaveBeenCalled(); - }); - it("reloads when the runtime reports a failure status", async () => { const hot = { // "idle" when the update starts, "abort" when the failure is handled. From 1d41b297fe5b8eecb826947c4177e1bea79e0c70 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:44:40 -0500 Subject: [PATCH 40/48] test(e2e): click frames through DOM-domain coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close-button click stalled on a Windows runner with the same Runtime.callFunctionOn timeout as the backdrop before it — main-world evaluates go unresponsive after navigations there. clickInFrame resolves the element through the isolated-world selector wait, takes its clickable point from the DOM domain, and clicks with raw mouse input; the in-card and close-button clicks use it. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 5 +++-- test/helpers/e2e.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index dbcdb6f11..5217d86b9 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -4,6 +4,7 @@ import { OVERLAY_ID, acceptedApp, boomApp, + clickInFrame, closeE2e, waitForAppText, waitForNoOverlay, @@ -96,7 +97,7 @@ describe("error overlay (browser)", () => { let frame = await waitForOverlay(page); // Clicking inside the card keeps the overlay open… - await frame.click(`#${OVERLAY_ID}-card`); + await clickInFrame(page, frame, `#${CARD_ID}`); expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); // …clicking the backdrop (top-left corner, away from the centered card) @@ -108,7 +109,7 @@ describe("error overlay (browser)", () => { // it again through the close (×) button. await page.reload(); frame = await waitForOverlay(page); - await frame.click('[aria-label="Close"]'); + await clickInFrame(page, frame, '[aria-label="Close"]'); await waitForNoOverlay(page); expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); diff --git a/test/helpers/e2e.js b/test/helpers/e2e.js index 764e2d2cb..0663ee7d4 100644 --- a/test/helpers/e2e.js +++ b/test/helpers/e2e.js @@ -120,6 +120,22 @@ function boomApp(text) { `; } +/** + * Click inside a frame through raw mouse input: the element's position comes + * from the DOM domain (no main-world evaluate, which can stall CDP on slow + * Windows runners after navigations). + * @param {import("puppeteer").Page} page page owning the frame + * @param {import("puppeteer").Frame} frame frame + * @param {string} selector element selector + * @returns {Promise} resolved after the click + */ +async function clickInFrame(page, frame, selector) { + const handle = await frame.waitForSelector(selector, { timeout: 30000 }); + const point = await handle.clickablePoint(); + + await page.mouse.click(point.x, point.y); +} + /** * Tear a test's browser and hot-app down; a rejected browser.close() must * not leak the watcher, the server, and the temp dir behind it. @@ -150,6 +166,7 @@ module.exports = { OVERLAY_ID, acceptedApp, boomApp, + clickInFrame, closeE2e, unacceptedApp, waitForAppText, From 13218bd894b4dfd3c5ffe8ff3d77e8a3f60e23bf Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 25 Jul 2026 23:58:22 -0500 Subject: [PATCH 41/48] test(e2e): the pre-lock catch-up race and the missing-update-chunk path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling-sync race runs deterministically against the real runtime: a slowed-down manifest route holds the sibling's check open while the own sync locks the compilation name, and the failed check is discarded in silence — snapshotted as a connect and a single "Checking". Trying to force the runtime's failure status through a missing update chunk surfaced the real behavior instead: check rejects with a ChunkLoadError, no failure status is reached, and the client logs "Update check failed" and stays put — now pinned as its own test. The abort-status unit stays: no real flow reaches that state on demand. Co-Authored-By: Claude Fable 5 --- .../process-update.test.js.snap.webpack5 | 7 ++ test/e2e/process-update.test.js | 99 +++++++++++++++++++ test/process-update.test.js | 25 ----- 3 files changed, 106 insertions(+), 25 deletions(-) diff --git a/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 b/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 index fb89e9b1e..bcc740da0 100644 --- a/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 +++ b/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 @@ -1,5 +1,12 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`update processing (browser) ignores a sibling's failed catch-up once the own sync locks the name 1`] = ` +[ + "[webpack-dev-middleware] connected", + "[webpack-dev-middleware] Checking for updates on the server...", +] +`; + exports[`update processing (browser) ignores sibling bundles once the own compilation is identified 1`] = ` [ "[webpack-dev-middleware] connected", diff --git a/test/e2e/process-update.test.js b/test/e2e/process-update.test.js index 46243a231..f8214e605 100644 --- a/test/e2e/process-update.test.js +++ b/test/e2e/process-update.test.js @@ -156,6 +156,105 @@ describe("update processing (browser)", () => { expect(normalizeConsole(console_.messages)).toMatchSnapshot(); }); + it("ignores a sibling's failed catch-up once the own sync locks the name", async () => { + app = await createHotApp({ + code: ` + globalThis.getHash = () => __webpack_hash__; + document.getElementById("app").textContent = "v1"; + if (module.hot) { + module.hot.accept(); + } + `, + // The sibling's manifest lookup is held open long enough for the own + // sync to lock the compilation name first. + setup: (server) => { + server.get(/\.hot-update\.json$/, (_req, res) => { + setTimeout(() => { + res.status(404).end(); + }, 300); + }); + }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + const currentHash = await page.evaluate(() => globalThis.getHash()); + + // Connect-time catch-up: the sibling arrives first and starts a check + // against a manifest that was never emitted… + app.instance.context.hot.publish({ + action: "sync", + name: "admin", + time: 1, + hash: "0000000000000000", + errors: [], + warnings: [], + }); + // …and the own sync locks the name before that check resolves. + app.instance.context.hot.publish({ + action: "sync", + name: "app", + time: 1, + hash: currentHash, + errors: [], + warnings: [], + }); + + await new Promise((resolve) => { + setTimeout(resolve, 1500); + }); + + // The failed sibling check is discarded instead of reloading. + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logs a failed check without reloading when the update chunk is missing", async () => { + app = await createHotApp({ + code: acceptedApp("v1"), + // A manifest pointing at an update chunk nobody can serve: the real + // runtime rejects with a ChunkLoadError but never reaches a failure + // status, so the client logs and stays put. + setup: (server) => { + server.get(/\.hot-update\.json$/, (_req, res) => { + res.json({ c: ["main"], r: [], m: [] }); + }); + server.get(/\.hot-update\.js$/, (_req, res) => { + res.status(404).end(); + }); + }, + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(app.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + await page.evaluate(() => { + globalThis.__notReloaded = true; + }); + + app.instance.context.hot.publish({ + action: "built", + name: "", + time: 1, + hash: "0000000000000000", + errors: [], + warnings: [], + }); + + await console_.waitFor("Update check failed"); + + expect(await page.evaluate(() => globalThis.__notReloaded)).toBe(true); + }); + it("logs the disabled HMR runtime once and never reloads", async () => { app = await createHotApp({ code: 'document.getElementById("app").textContent = "v1";', diff --git a/test/process-update.test.js b/test/process-update.test.js index 3cba7cecf..d27e79f97 100644 --- a/test/process-update.test.js +++ b/test/process-update.test.js @@ -83,31 +83,6 @@ describe("process-update", () => { jest.restoreAllMocks(); }); - describe("multi-compiler bundles", () => { - it("does not reload when a pre-lock sibling check resolves without an update", async () => { - applyUpdate = loadApplyUpdate(makeFakeHot({ checkResult: null })); - globalThis.__webpack_hash__ = "app-hash"; - - // Connect-time catch-up: the sibling's sync arrives first and starts a - // check against a manifest that was never emitted… - applyUpdate("admin-hash", { reload: true }, "admin"); - // …and the own bundle's sync locks the name before that check resolves. - applyUpdate("app-hash", { reload: true }, "app"); - await flushPromises(); - - expect(getHot().check).toHaveBeenCalledTimes(1); - expect(reloadPage).not.toHaveBeenCalled(); - }); - - it("still checks named events while the own compilation is unknown", async () => { - applyUpdate("new-hash", { reload: true }, "admin"); - globalThis.__webpack_hash__ = "new-hash"; - await flushPromises(); - - expect(getHot().check).toHaveBeenCalledWith(false); - }); - }); - it("reloads when the runtime reports a failure status", async () => { const hot = { // "idle" when the update starts, "abort" when the failure is handled. From 7917f708190eeb066c6d1b6a916d7bc8925c8541 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:08:05 -0500 Subject: [PATCH 42/48] test: drop the last process-update unit The failure-status branch it exercised is unreachable through the real runtime: the client's ignoreUnaccepted/ignoreDeclined/ignoreErrored options close every path webpack 5.109 has to the abort and fail statuses. The branch itself stays as a cheap guard for upstream changes. Co-Authored-By: Claude Fable 5 --- test/process-update.test.js | 101 ------------------------------------ 1 file changed, 101 deletions(-) delete mode 100644 test/process-update.test.js diff --git a/test/process-update.test.js b/test/process-update.test.js deleted file mode 100644 index d27e79f97..000000000 --- a/test/process-update.test.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @jest-environment jsdom - */ - -/** @typedef {{ status: jest.Mock, check: jest.Mock, apply: jest.Mock }} FakeHot */ -/** @typedef {{ error: Error, moduleId: string, type: string }} ErroredEvent */ -/** @typedef {{ onErrored: (event: ErroredEvent) => void }} FakeApplyOptions */ - -jest.mock("../client-src/utils/get-hot", () => jest.fn()); -jest.mock("../client-src/utils/reload", () => jest.fn()); - -/** - * Flush pending promise callbacks. - * @returns {Promise} resolved after one timer tick - */ -function flushPromises() { - return new Promise((resolve) => { - setTimeout(resolve, 0); - }); -} - -describe("process-update", () => { - /** @type {jest.Mock} */ - let getHot; - /** @type {jest.Mock} */ - let reloadPage; - /** @type {typeof import("../client-src/process-update").default} */ - let applyUpdate; - /** @type {(level: import("../client-src/utils/log").LogLevel) => void} */ - let setLogLevel; - - /** - * @param {{ status?: string, checkResult?: string[] | null, applyImpl?: (options: FakeApplyOptions) => Promise }=} behavior fake runtime behavior - * @returns {FakeHot} fake `import.meta.webpackHot` - */ - function makeFakeHot({ - status = "idle", - checkResult = ["./a.js"], - applyImpl = () => Promise.resolve(["./a.js"]), - } = {}) { - return { - status: jest.fn(() => status), - check: jest.fn(() => Promise.resolve(checkResult)), - apply: jest.fn(applyImpl), - }; - } - - /** - * Load a fresh process-update with the given fake runtime configured in the - * same module registry. - * @param {FakeHot | undefined} hot fake `import.meta.webpackHot` - * @returns {typeof import("../client-src/process-update").default} applyUpdate - */ - function loadApplyUpdate(hot) { - jest.resetModules(); - - getHot = require("../client-src/utils/get-hot"); - getHot.mockReturnValue(hot); - reloadPage = require("../client-src/utils/reload"); - reloadPage.mockReset(); - - ({ setLogLevel } = require("../client-src/utils/log")); - - return require("../client-src/process-update").default; - } - - beforeEach(() => { - applyUpdate = loadApplyUpdate(makeFakeHot()); - - // The bundle hash webpack injects; anything different from the payload - // hash makes the client check for updates. - globalThis.__webpack_hash__ = "current-hash"; - - jest.spyOn(console, "info").mockImplementation(() => {}); - jest.spyOn(console, "log").mockImplementation(() => {}); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - setLogLevel("info"); - delete globalThis.__webpack_hash__; - jest.restoreAllMocks(); - }); - - it("reloads when the runtime reports a failure status", async () => { - const hot = { - // "idle" when the update starts, "abort" when the failure is handled. - status: jest.fn().mockReturnValueOnce("idle").mockReturnValue("abort"), - check: jest.fn(() => Promise.reject(new Error("check failed"))), - apply: jest.fn(() => Promise.resolve([])), - }; - - applyUpdate = loadApplyUpdate(hot); - - applyUpdate("new-hash", { reload: true }); - await flushPromises(); - - expect(reloadPage).toHaveBeenCalledTimes(1); - }); -}); From da0e72004e715a6b62826de832ca87c17d98574b Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:16:46 -0500 Subject: [PATCH 43/48] test(e2e): the React-boundary heuristic and the runtime slot reset, for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixture function literally named invokeGuardedCallbackDev throws, so the genuine stack carries the marker React's dev build leaves on boundary-handled errors — the overlay ignores it while a plain error right after proves the listeners live. The runtime slot resets through its real trigger: a clean rebuild's reporter clears the accumulated errors, and the next one starts a fresh slot with no pager. The jsdom runtime-errors describe is removed. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 71 ++++++++++++++++++++++++++++++++++++++++ test/overlay.test.js | 40 ---------------------- 2 files changed, 71 insertions(+), 40 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 5217d86b9..98a302de4 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -151,6 +151,77 @@ describe("error overlay (browser)", () => { expect(await frame.evaluate(() => globalThis.__xss)).toBeUndefined(); }); + it("ignores errors already caught by a React error boundary", async () => { + hotApp = await createHotApp({ + // The function name lands in the real stack — the same marker React's + // dev build leaves on errors its boundaries already handled. + code: ` + document.getElementById("app").textContent = "v1"; + globalThis.boomThroughBoundary = (message) => { + function invokeGuardedCallbackDev() { + throw new Error(message); + } + setTimeout(invokeGuardedCallbackDev, 0); + }; + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + }; + `, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + await page.evaluate(() => globalThis.boomThroughBoundary("handled")); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + + // The listeners are live: a plain error still lands in the overlay. + await page.evaluate(() => globalThis.boom("unhandled")); + const frame = await waitForOverlay(page); + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "unhandled", + ); + }); + + it("resets the runtime slot on a clean build", async () => { + hotApp = await createHotApp({ code: boomApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + await page.evaluate(() => globalThis.boom("boom-before")); + let frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("boom-before"), + ); + + // A clean rebuild clears the accumulated runtime errors through the + // reporter… + hotApp.edit(boomApp("v2")); + await waitForAppText(page, "v2"); + await waitForNoOverlay(page); + + // …so the next error starts a fresh slot instead of paging after the + // old one. + await page.evaluate(() => globalThis.boom("boom-after")); + frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("boom-after"), + ); + + const text = await frame.evaluate(() => document.body.textContent); + + expect(text).not.toContain("boom-before"); + expect(text).not.toContain("1 / 2"); + }); + it("shows build warnings (dev-server parity)", async () => { hotApp = await createHotApp({ // `require()` produces webpack's "Critical dependency" diff --git a/test/overlay.test.js b/test/overlay.test.js index 4a009733d..3a751ed05 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -93,46 +93,6 @@ describe("overlay", () => { }); }); - describe("runtime errors", () => { - it("ignores errors already caught by a React error boundary", () => { - configureOverlay({ catchRuntimeError: true }); - - const error = new Error("boundary"); - error.stack = - "Error: boundary\n at invokeGuardedCallbackDev (react-dom.js:1:1)"; - globalThis.dispatchEvent(new ErrorEvent("error", { error })); - - expect(getOverlay()).toBeNull(); - }); - - it("resets the accumulation when the runtime slot is cleared", () => { - configureOverlay({ catchRuntimeError: true }); - - globalThis.dispatchEvent( - new ErrorEvent("error", { - error: new Error("boom-before"), - message: "boom-before", - }), - ); - expect(getCard().textContent).toContain("boom-before"); - - // What the reporter does on a clean build. - clear("runtime"); - expect(getOverlay()).toBeNull(); - - globalThis.dispatchEvent( - new ErrorEvent("error", { - error: new Error("boom-after"), - message: "boom-after", - }), - ); - - expect(getCard().textContent).toContain("boom-after"); - expect(getCard().textContent).not.toContain("boom-before"); - expect(getCard().textContent).not.toContain("1 / 2"); - }); - }); - describe("configureOverlay", () => { it("applies custom overlay styles to the card", () => { configureOverlay({ overlayStyles: { maxWidth: "500px" } }); From 434e51b33725a6e00f31eae9d3b387174dc6862a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:21:18 -0500 Subject: [PATCH 44/48] test(e2e): assert the badge and file-path rendering on real errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ERROR/WARNING badges (text and background) and the highlighted file path are checked where Chrome actually renders them — on webpack's own parse error and critical-dependency warning. Three jsdom rendering tests leave. Co-Authored-By: Claude Fable 5 --- test/e2e/overlay.test.js | 21 +++++++++++++++++++++ test/overlay.test.js | 25 ------------------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 98a302de4..6787db8ad 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -37,6 +37,22 @@ describe("error overlay (browser)", () => { expect(text).toContain("Module parse failed"); + // The badge and the highlighted file path, as rendered. + expect( + await frame.evaluate(() => { + const badge = document.querySelector("span"); + return { text: badge.textContent, color: badge.style.backgroundColor }; + }), + ).toEqual({ text: "ERROR", color: "rgb(255, 51, 72)" }); + expect( + await frame.evaluate( + () => + [...document.querySelectorAll("span")].find( + (span) => span.textContent === "./app.js", + )?.style.color, + ), + ).toBe("rgb(141, 214, 249)"); + // webpack's own "See https://…" is linkified into a safe new-tab link. expect( await frame.evaluate(() => { @@ -238,6 +254,11 @@ describe("error overlay (browser)", () => { expect(text).toContain("WARNING"); expect(text).toContain("Critical dependency"); + expect( + await frame.evaluate( + () => document.querySelector("span").style.backgroundColor, + ), + ).toBe("rgb(255, 211, 14)"); // A build without the warning clears the overlay again. hotApp.edit(acceptedApp("fixed")); diff --git a/test/overlay.test.js b/test/overlay.test.js index 3a751ed05..2c5dd1646 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -45,31 +45,6 @@ describe("overlay", () => { }); describe("showProblems", () => { - it("renders an ERROR badge in the error color for errors", () => { - showProblems("errors", ["boom"]); - const badge = getCard().querySelector("span"); - expect(badge.textContent).toBe("ERROR"); - expect(badge.style.backgroundColor).toBe("rgb(255, 51, 72)"); - }); - - it("renders a WARNING badge in the warning color for warnings", () => { - showProblems("warnings", ["careful"]); - const badge = getCard().querySelector("span"); - expect(badge.textContent).toBe("WARNING"); - expect(badge.style.backgroundColor).toBe("rgb(255, 211, 14)"); - }); - - it("highlights the file path and leaves the location uncolored", () => { - showProblems("errors", ["./src/render.js 7:2\nModule parse failed"]); - const pathSpan = [...getCard().querySelectorAll("span")].find( - (span) => span.textContent === "./src/render.js", - ); - expect(pathSpan).toBeDefined(); - expect(pathSpan.style.color).toBe("rgb(141, 214, 249)"); - // The `7:2` location is rendered as plain text, not inside the span. - expect(getCard().textContent).toContain("./src/render.js 7:2"); - }); - it("highlights the offending code-frame line", () => { showProblems("errors", ["./a.js 1:1\n> 1 | const x =\n | ^"]); expect(findSpanByColor("rgb(255, 107, 107)")).toBeDefined(); From 8f13db311e75c9677fcfa6f4111bff1ef8184e12 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:33:57 -0500 Subject: [PATCH 45/48] test(e2e): retire the last jsdom suite The final overlay rendering details run in Chrome: the code-frame highlight on webpack's real parse error, the re-mount after something wiped the iframe (re-triggered by an unchanged rebuild, exercising the identical-set guard), clear() as a no-op with nothing shown, and the query-configured custom card styles and ansi colors driving the badge and border. With no jsdom consumer left, jest-environment-jsdom leaves the dev dependencies. Co-Authored-By: Claude Fable 5 --- package-lock.json | 536 --------------------------------------- package.json | 1 - test/e2e/overlay.test.js | 67 +++++ test/overlay.test.js | 90 ------- 4 files changed, 67 insertions(+), 627 deletions(-) delete mode 100644 test/overlay.test.js diff --git a/package-lock.json b/package-lock.json index 05d5bac0b..7c56bb8a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,6 @@ "hono": "^4.12.12", "husky": "^9.1.3", "jest": "^30.1.3", - "jest-environment-jsdom": "^30.4.1", "koa": "^3.0.0", "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", @@ -72,27 +71,6 @@ } } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/@babel/cli": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz", @@ -2896,121 +2874,6 @@ "node": ">=22.18.0" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@emnapi/core": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", @@ -4116,34 +3979,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", - "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", - "@types/jsdom": "^21.1.7", - "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/@jest/expect": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", @@ -5433,18 +5268,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jsdom": { - "version": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -5541,13 +5364,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -8285,20 +8101,6 @@ "node": ">=10" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -8309,57 +8111,6 @@ "node": ">= 14" } }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8439,13 +8190,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8904,19 +8648,6 @@ "node": ">=8.6" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", @@ -11605,19 +11336,6 @@ "dev": true, "license": "ISC" }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -12342,13 +12060,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -13102,29 +12813,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", - "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/environment-jsdom-abstract": "30.4.1", - "jsdom": "^26.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/jest-environment-node": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", @@ -13661,83 +13349,6 @@ "node": ">=20.0.0" } }, - "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -16054,13 +15665,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/nwsapi": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", - "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16474,19 +16078,6 @@ "dev": true, "license": "MIT" }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -17473,13 +17064,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -17620,19 +17204,6 @@ "dev": true, "license": "MIT" }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -18650,13 +18221,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", @@ -18894,26 +18458,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -18971,19 +18515,6 @@ "node": ">=0.6" } }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -19581,19 +19112,6 @@ "dev": true, "license": "MIT" }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -19702,43 +19220,6 @@ "node": ">=4.0" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -20012,23 +19493,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index f3d821f24..452e49383 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,6 @@ "hono": "^4.12.12", "husky": "^9.1.3", "jest": "^30.1.3", - "jest-environment-jsdom": "^30.4.1", "koa": "^3.0.0", "lint-staged": "^17.0.2", "npm-run-all": "^4.1.5", diff --git a/test/e2e/overlay.test.js b/test/e2e/overlay.test.js index 6787db8ad..fe3e5043f 100644 --- a/test/e2e/overlay.test.js +++ b/test/e2e/overlay.test.js @@ -52,6 +52,14 @@ describe("error overlay (browser)", () => { )?.style.color, ), ).toBe("rgb(141, 214, 249)"); + // The offending code-frame line is highlighted. + expect( + await frame.evaluate(() => + [...document.querySelectorAll("span")].some( + (span) => span.style.color === "rgb(255, 107, 107)", + ), + ), + ).toBe(true); // webpack's own "See https://…" is linkified into a safe new-tab link. expect( @@ -83,6 +91,32 @@ describe("error overlay (browser)", () => { expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); }); + it("re-mounts the overlay after something wiped it from the DOM", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken and wiped {{{"); + await waitForOverlay(page); + + // A framework re-rendering the page removes the iframe behind our back. + await page.evaluate( + (id) => document.getElementById(id).remove(), + OVERLAY_ID, + ); + + // An unchanged rebuild re-publishes the identical problem set — the + // unchanged-set guard must re-mount instead of skipping the render. + hotApp.instance.invalidate(); + + const frame = await waitForOverlay(page); + await frame.waitForFunction(() => + document.body.textContent.includes("Module parse failed"), + ); + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + }); + it("dismisses the overlay when pressing Escape on the host page", async () => { hotApp = await createHotApp({ code: acceptedApp("v1") }); ({ page, browser } = await runBrowser()); @@ -640,6 +674,37 @@ describe("error overlay (browser)", () => { expect(await frame.$("[data-open-file]")).toBeNull(); }); + it("applies custom card styles and ansi colors from the query", async () => { + hotApp = await createHotApp({ + query: + '?overlay={"styles":{"maxWidth":"500px"},"ansiColors":{"red":"00ff00"}}', + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + + hotApp.edit("broken in green {{{"); + const frame = await waitForOverlay(page); + + // The remapped red drives the problem color; the style override lands + // on the card. + expect( + await frame.evaluate((id) => { + const card = document.getElementById(id); + return { + maxWidth: card.style.maxWidth, + border: card.style.borderTopColor, + badge: card.querySelector("span").style.backgroundColor, + }; + }, CARD_ID), + ).toEqual({ + maxWidth: "500px", + border: "rgb(0, 255, 0)", + badge: "rgb(0, 255, 0)", + }); + }); + it("renders under an enforced Trusted Types CSP with the configured policy", async () => { hotApp = await createHotApp({ query: '?overlay={"trustedTypesPolicyName":"wdm-test"}', @@ -917,6 +982,8 @@ describe("overlay shared state across bundled copies (browser)", () => { it("fills state fields missing from an older copy's shape", async () => { await start(); + // Clearing with nothing shown is a no-op (a throw would reject here). + await page.evaluate(() => globalThis.overlayA?.clear()); // An older package version created a leaner shared state before the // bundles load. await page.evaluateOnNewDocument((key) => { diff --git a/test/overlay.test.js b/test/overlay.test.js deleted file mode 100644 index 2c5dd1646..000000000 --- a/test/overlay.test.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import configureOverlay, { clear, showProblems } from "../client-src/overlay"; - -// eslint-disable-next-line jsdoc/reject-any-type -/** @typedef {any} EXPECTED_ANY */ - -const OVERLAY_ID = "webpack-dev-middleware-hot-overlay"; - -/** - * @returns {HTMLIFrameElement | null} the overlay iframe (the backdrop), if mounted - */ -function getOverlay() { - return /** @type {HTMLIFrameElement | null} */ ( - document.getElementById(OVERLAY_ID) - ); -} - -/** - * @returns {HTMLElement} the visible card element inside the iframe - */ -function getCard() { - return /** @type {HTMLElement} */ ( - /** @type {Document} */ ( - /** @type {HTMLIFrameElement} */ (getOverlay()).contentDocument - ).getElementById(`${OVERLAY_ID}-card`) - ); -} - -/** - * @param {string} color expected normalized color - * @returns {HTMLElement | undefined} the first span rendered in that text color - */ -function findSpanByColor(color) { - return [...getCard().querySelectorAll("span")].find( - (span) => span.style.color === color, - ); -} - -describe("overlay", () => { - afterEach(() => { - clear(); - }); - - describe("showProblems", () => { - it("highlights the offending code-frame line", () => { - showProblems("errors", ["./a.js 1:1\n> 1 | const x =\n | ^"]); - expect(findSpanByColor("rgb(255, 107, 107)")).toBeDefined(); - }); - - it("re-mounts the overlay when the iframe was removed without clear()", () => { - showProblems("errors", ["boom"]); - // A framework wiping `document.body` removes the iframe behind our back. - getOverlay().remove(); - // Re-publishing the identical set must re-mount, not hit the - // unchanged-set guard. - showProblems("errors", ["boom"]); - expect(getOverlay()).not.toBeNull(); - expect(getCard().textContent).toContain("boom"); - }); - }); - - describe("clear", () => { - it("is a no-op when nothing is shown", () => { - expect(() => clear()).not.toThrow(); - }); - }); - - describe("configureOverlay", () => { - it("applies custom overlay styles to the card", () => { - configureOverlay({ overlayStyles: { maxWidth: "500px" } }); - showProblems("errors", ["boom"]); - expect(getCard().style.maxWidth).toBe("500px"); - }); - - it("honors custom ansi colors for the problem color", () => { - configureOverlay({ ansiColors: { red: "00ff00" } }); - showProblems("errors", ["boom"]); - expect(getCard().querySelector("span").style.backgroundColor).toBe( - "rgb(0, 255, 0)", - ); - expect(getCard().style.borderTopColor).toBe("rgb(0, 255, 0)"); - - // Restore the default so module-level state does not leak. - configureOverlay({ ansiColors: { red: "ff3348" } }); - }); - }); -}); From d6167b3790fe743ba6664f14bb39c1d15459b240 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:42:10 -0500 Subject: [PATCH 46/48] ci: collect server-side coverage from the e2e phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating server behavior to the browser suites moved its execution out of the coverage run — the heartbeat write, the SSE handshake, and every hot.js path only e2e exercises showed as uncovered. The e2e step now instruments src/ too (the middleware runs inside the jest process even under a browser), writes to its own directory, and the artifact carries both lcov files for codecov to merge. Co-Authored-By: Claude Fable 5 --- .github/workflows/nodejs.yml | 6 ++++-- .gitignore | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 281deee63..0e0b7a015 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -88,13 +88,15 @@ jobs: run: npm run test:coverage -- --ci --shard=${{ matrix.shard }} - name: Run browser e2e tests - run: npm run test:e2e -- --ci --shard=${{ matrix.shard }} + run: npm run test:e2e -- --ci --shard=${{ matrix.shard }} --collectCoverageFrom="src/**/*.js" --coverage --coverageDirectory=coverage-e2e - name: Upload coverage artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ matrix.os }}-${{ matrix.node-version }}-${{ strategy.job-index }} - path: coverage/lcov.info + path: | + coverage/lcov.info + coverage-e2e/lcov.info upload-coverage: name: Upload coverage to codecov diff --git a/.gitignore b/.gitignore index 3d7f92f20..86a87207c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ Thumbs.db *.sublime-workspace yarn.lock /test/fixtures/js3 +/coverage-e2e From 3863108e90e04a703ef1feb0d49525c656bdb542 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 26 Jul 2026 00:55:56 -0500 Subject: [PATCH 47/48] test(e2e): let the watcher settle before frame-level assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS fired a spurious startup rebuild whose unchanged hash broadcast a legitimate sync to every client — indistinguishable from a re-sent catch-up at the frame level. The helper's settle() drains builds until the watcher goes quiet, and the catch-up test runs after it. Co-Authored-By: Claude Fable 5 --- test/e2e/protocol.test.js | 3 +++ test/helpers/hot-app.js | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/test/e2e/protocol.test.js b/test/e2e/protocol.test.js index 3ed1cd266..ce207a7a1 100644 --- a/test/e2e/protocol.test.js +++ b/test/e2e/protocol.test.js @@ -94,6 +94,9 @@ describe("SSE protocol (raw)", () => { it("catch-up syncs only the newly connecting client", async () => { app = await createHotApp({ code: "document.title = 'x';" }); + // A spurious startup rebuild would broadcast a legitimate sync to every + // client and fake a re-sent catch-up. + await app.settle(); const early = await openSseReader(`${app.url}__webpack_hmr`); readers.push(early); diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js index fa8427baf..aba7600e7 100644 --- a/test/helpers/hot-app.js +++ b/test/helpers/hot-app.js @@ -234,6 +234,30 @@ async function createHotApp({ }); }, + /** + * Wait until the watcher goes quiet — spurious startup rebuilds (file + * timestamp granularity, fsevents) broadcast syncs that pollute + * frame-level assertions. + * @param {number=} quietMs how long without builds counts as quiet + * @returns {Promise} resolved when no build happened for quietMs + */ + async settle(quietMs = 1000) { + for (;;) { + const built = await Promise.race([ + new Promise((resolve) => { + buildWaiters.push(() => resolve(true)); + }), + new Promise((resolve) => { + setTimeout(() => resolve(false), quietMs); + }), + ]); + + if (!built) { + return; + } + } + }, + /** * Stop only the HTTP server (the compiler keeps watching), severing * every open connection so clients see a disconnect. From 37cc3ff0077b6e43a7c61950b99b1880cb0cdc69 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Wed, 29 Jul 2026 11:15:16 -0500 Subject: [PATCH 48/48] perf(client): use a Set for renewed module ids in logUpdates (#2375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces Array#includes inside the filter over updated modules (O(n·m) → O(n+m)) — noticeable on updates touching hundreds of modules. --- client-src/process-update.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client-src/process-update.js b/client-src/process-update.js index 2a5637c6d..ba051df77 100644 --- a/client-src/process-update.js +++ b/client-src/process-update.js @@ -122,8 +122,9 @@ export default function applyUpdate(hash, options, name) { * @param {(string | number)[] | null | undefined} renewedModules ids of modules that were successfully renewed */ function logUpdates(updatedModules, renewedModules) { + const renewedIds = new Set(renewedModules || []); const unacceptedModules = updatedModules.filter( - (moduleId) => !renewedModules || !renewedModules.includes(moduleId), + (moduleId) => !renewedIds.has(moduleId), ); if (unacceptedModules.length > 0) {