diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 913694014..0e0b7a015 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,35 @@ 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 }} --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 + coverage-e2e/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/.gitignore b/.gitignore index 6b5d2aaf1..86a87207c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ Thumbs.db *.sublime-project *.sublime-workspace yarn.lock +/test/fixtures/js3 +/coverage-e2e 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/client-src/utils/log.js b/client-src/utils/log.js index 56f1d82d4..4e8b99d3e 100644 --- a/client-src/utils/log.js +++ b/client-src/utils/log.js @@ -15,4 +15,32 @@ export function setLogLevel(level) { setLogLevel(DEFAULT_LEVEL); -export const log = logger.getLogger(LOGGER_NAME); +const rawLog = logger.getLogger(LOGGER_NAME); + +/** + * 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 guarded(method) { + return (...args) => { + try { + rawLog[method](...args); + } catch { + // Logging is unavailable (e.g. Trusted Types enforcement). + } + }; +} + +export const log = { + error: guarded("error"), + warn: guarded("warn"), + info: guarded("info"), + log: guarded("log"), + groupCollapsed: guarded("groupCollapsed"), + groupEnd: guarded("groupEnd"), +}; 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-lock.json b/package-lock.json index f5d835bde..7c56bb8a6 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", @@ -45,11 +45,11 @@ "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", "prettier": "^3.6.0", + "puppeteer": "^22.15.0", "router": "^2.2.0", "supertest": "^7.0.0", "typescript": "^6.0.2", @@ -71,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", @@ -2895,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", @@ -4115,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", @@ -5125,6 +4961,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 +5098,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", @@ -5389,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", @@ -5497,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", @@ -5528,6 +5388,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 +6575,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 +6652,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 +6825,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 +6944,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,17 +7084,52 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/builtin-modules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", - "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", + "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", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", + "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", "dev": true, "license": "MIT", "engines": { @@ -7318,6 +7368,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 +7803,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", @@ -7989,69 +8101,14 @@ "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-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==", + "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", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, "engines": { - "node": ">=18" + "node": ">= 14" } }, "node_modules/data-view-buffer": { @@ -8133,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", @@ -8229,6 +8279,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 +8490,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 +8610,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", @@ -8566,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", @@ -8833,6 +8902,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 +9753,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 +10204,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 +10271,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 +10489,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 +11021,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", @@ -11166,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", @@ -11365,6 +11522,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 +11691,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", @@ -11872,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", @@ -12632,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", @@ -13183,89 +13341,12 @@ }, "node_modules/jsdoc-type-pratt-parser": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", - "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", - "dev": true, - "license": "MIT", - "engines": { - "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==", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", "dev": true, "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, "node_modules/jsesc": { @@ -15209,6 +15290,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 +15354,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", @@ -15567,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", @@ -15887,6 +15978,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", @@ -15953,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", @@ -16054,6 +16166,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 +16413,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 +16466,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 +16524,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", @@ -16849,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", @@ -16996,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", @@ -17381,6 +17576,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 +17600,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 +17851,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", @@ -17973,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", @@ -18010,6 +18251,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 +18354,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 +18393,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", @@ -18162,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", @@ -18239,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", @@ -18526,6 +18789,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 +19034,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", @@ -18831,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", @@ -18952,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", @@ -19262,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", @@ -19366,6 +19580,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..452e49383 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,11 @@ "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: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" @@ -95,11 +96,11 @@ "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", "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 deleted file mode 100644 index dfb887fca..000000000 --- a/test/__snapshots__/client.test.js.snap.webpack5 +++ /dev/null @@ -1,66 +0,0 @@ -// 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`] = ` -[ - [ - "[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 deleted file mode 100644 index 057ddbd34..000000000 --- a/test/client.test.js +++ /dev/null @@ -1,1160 +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; -}); - -/** - * @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 - */ -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 default options", () => { - 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(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); - client = loadClient(); - }); - - afterEach(() => { - 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" }), - ); - 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); - 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({ - action: "built", - time: 100, - hash: "1234567890abcdef", - errors: ["Something broke"], - warnings: [], - modules: [], - }), - ); - 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( - 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 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( - 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 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("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( - 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 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; - - 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("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( - 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 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 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(); - - 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; - - 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("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"); - 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 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("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(); - - 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; - - 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); - }); - - 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); - }); - - 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("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; - 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("disconnect() during the reconnect window cancels the pending reconnect", () => { - 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(); - 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 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; - 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 new file mode 100644 index 000000000..449ed3f8a --- /dev/null +++ b/test/e2e/__snapshots__/client.test.js.snap.webpack5 @@ -0,0 +1,58 @@ +// 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", + "[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", + "[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 ( 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) 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", + "[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", +] +`; 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..236816084 --- /dev/null +++ b/test/e2e/__snapshots__/logging.test.js.snap.webpack5 @@ -0,0 +1,48 @@ +// 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) +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 +> 1 | broken {{{ + | ^", +] +`; + +exports[`client logging (browser) logging=log adds the collapsed per-module detail 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] 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`] = ` +[ + "[webpack-dev-middleware] bundle has 1 warnings", + "[webpack-dev-middleware] ./app.js 5:6-18 +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 ( 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..6e1eb0777 --- /dev/null +++ b/test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5 @@ -0,0 +1,31 @@ +// 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' 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", + "[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 ( 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...", + "[webpack-dev-middleware] Hot updated 1 modules.", + "[webpack-dev-middleware] App is up to date.", +] +`; 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..bf925b822 --- /dev/null +++ b/test/e2e/__snapshots__/overlay.test.js.snap.webpack5 @@ -0,0 +1,35 @@ +// 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:6-18 +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", + "[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 +> 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 +> 1 | broken b {{{ + | ^", +] +`; 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..bcc740da0 --- /dev/null +++ b/test/e2e/__snapshots__/process-update.test.js.snap.webpack5 @@ -0,0 +1,37 @@ +// 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", + "[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/client.test.js b/test/e2e/client.test.js new file mode 100644 index 000000000..8ee4fb8b0 --- /dev/null +++ b/test/e2e/client.test.js @@ -0,0 +1,568 @@ +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"; + +jest.setTimeout(400000); + +const CLIENT_ENTRY = require.resolve("../../client-src/index.js"); + +/** + * 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 () => { + ({ browser, app } = await closeE2e(browser, app)); + }); + + 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("broadcasts one build to every connected page", async () => { + 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 published 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()); + + 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); + + // 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 + // 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")); + 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("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); + }); + + // 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"); + 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("applies updates that carry warnings", async () => { + 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("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/", + 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("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: ` + const hotClient = require(${JSON.stringify(CLIENT_ENTRY)}); + globalThis.__all = []; + hotClient.subscribeAll((payload) => { + globalThis.__all.push(payload.action); + }); + 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", + ); + + // 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 () => { + 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..a0289ec9e --- /dev/null +++ b/test/e2e/indicator.test.js @@ -0,0 +1,236 @@ +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_ENTRY = require.resolve("../../client-src/indicator.js"); +const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; + +/** + * 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); +} + +describe("building indicator (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("shows the badge during a rebuild and removes it afterwards", async () => { + hotApp = await createHotApp({ code: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(acceptedApp("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: acceptedApp("v1"), + hot: { progress: true }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(acceptedApp("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: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await installBadgeSampler(page); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + + 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({ + query: "?progress=false", + apps: [ + { name: "a", code: exposeIndicator("indicatorA") }, + { name: "b", code: exposeIndicator("indicatorB") }, + ], + }); + ({ page, browser } = await runBrowser()); + }; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + 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/e2e/logging.test.js b/test/e2e/logging.test.js new file mode 100644 index 000000000..5c83b2ee4 --- /dev/null +++ b/test/e2e/logging.test.js @@ -0,0 +1,116 @@ +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); + +describe("client logging (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ 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: acceptedApp("v1") }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(acceptedApp("v2")); + await waitForAppText(page, "v2"); + await console_.waitFor("App is up to date"); + + expect(normalizeConsole(console_.messages)).toMatchSnapshot(); + }); + + it("logging=log adds the collapsed per-module detail", async () => { + hotApp = await createHotApp({ + query: "?logging=log", + code: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + await console_.waitFor("connected"); + + hotApp.edit(acceptedApp("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: acceptedApp("v1"), + }); + ({ page, browser } = await runBrowser()); + const console_ = collectConsole(page); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + hotApp.edit(acceptedApp("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: warningApp("v1"), + }); + ({ 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: acceptedApp("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..d28b957a0 --- /dev/null +++ b/test/e2e/multi-compiler.test.js @@ -0,0 +1,206 @@ +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); + +/** + * 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(); + } + `; +} + +describe("multi-compiler (browser)", () => { + let app; + let browser; + let page; + + afterEach(async () => { + ({ browser, app } = await closeE2e(browser, app)); + }); + + 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 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) { + 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(); + } + `; + + // 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: bundleApp("widget", "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"); + 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 warning. + app.edit("app", bundleApp("app", "app-v2")); + await waitForText(page, "out-app", "app-v2"); + await console_.waitForCount("App is up to date", 2); + + // 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", 3); + + 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: [ + { 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..fe3e5043f --- /dev/null +++ b/test/e2e/overlay.test.js @@ -0,0 +1,1002 @@ +import collectConsole, { normalizeConsole } from "../helpers/console-collector"; +import { + CARD_ID, + OVERLAY_ID, + acceptedApp, + boomApp, + clickInFrame, + closeE2e, + waitForAppText, + waitForNoOverlay, + waitForOverlay, + warningApp, +} from "../helpers/e2e"; +import createHotApp from "../helpers/hot-app"; +import runBrowser from "../helpers/run-browser"; + +jest.setTimeout(400000); + +describe("error overlay (browser)", () => { + let hotApp; + let browser; + let page; + + afterEach(async () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + it("shows build errors and clears when the build recovers", async () => { + hotApp = await createHotApp({ code: acceptedApp("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"); + + // 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)"); + // 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( + 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(acceptedApp("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("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()); + + await page.goto(hotApp.url); + + hotApp.edit("also broken {{{"); + 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); + + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("dismisses on backdrop and close-button clicks, but not inside the card", async () => { + hotApp = await createHotApp({ code: acceptedApp("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 clickInFrame(page, frame, `#${CARD_ID}`); + expect(await page.$(`#${OVERLAY_ID}`)).not.toBeNull(); + + // …clicking the backdrop (top-left corner, away from the centered card) + // dismisses it. + await page.mouse.click(5, 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 clickInFrame(page, frame, '[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 + // inside evaluate() surface to window.onerror as opaque "Script error". + code: boomApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "v1"); + + // The message doubles as an XSS probe: it must render as text. + await page.evaluate(() => { + 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); + // The onerror payload would run in the iframe's realm — check it there. + 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" + // warning without failing the build. + code: warningApp("v1"), + }); + ({ 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"); + 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")); + await waitForNoOverlay(page); + expect(await page.$(`#${OVERLAY_ID}`)).toBeNull(); + }); + + it("escalates a warning overlay to an error overlay", async () => { + hotApp = await createHotApp({ + code: warningApp("v1"), + }); + ({ 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: boomApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "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}', + code: warningApp("v1"), + }); + ({ 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(); + 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: warningApp("v1"), + }); + ({ 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 () => { + 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 () => { + 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()); + 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"), + ); + + await frame.click('[aria-label="Next problem"]'); + await frame.waitForFunction(() => + 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"), + ); + + // 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", + ); + }); + + 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: boomApp("v1"), + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "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 waitForAppText(page, "v1"); + + await page.evaluate(() => globalThis.rejectSoon("rejected-boom")); + + const frame = await waitForOverlay(page); + + expect(await frame.evaluate(() => document.body.textContent)).toContain( + "rejected-boom", + ); + }); + + 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("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"}', + 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. + pageHeaders: { + "Content-Security-Policy": + "require-trusted-types-for 'script'; trusted-types wdm-test", + }, + }); + ({ page, browser } = await runBrowser()); + + await page.goto(hotApp.url); + await waitForAppText(page, "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: acceptedApp("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(); + }); +}); + +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__"; + + 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)}); + globalThis.boom = (message) => { + setTimeout(() => { + throw new Error(message); + }, 0); + };`; + + 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 () => { + ({ browser, app: hotApp } = await closeE2e(browser, hotApp)); + }); + + 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("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("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(); + // 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) => { + 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/e2e/process-update.test.js b/test/e2e/process-update.test.js new file mode 100644 index 000000000..f8214e605 --- /dev/null +++ b/test/e2e/process-update.test.js @@ -0,0 +1,298 @@ +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("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";', + 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/e2e/protocol.test.js b/test/e2e/protocol.test.js new file mode 100644 index 000000000..ce207a7a1 --- /dev/null +++ b/test/e2e/protocol.test.js @@ -0,0 +1,113 @@ +import http from "node:http"; + +import createHotApp from "../helpers/hot-app"; + +jest.setTimeout(400000); + +/** + * 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';" }); + // 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); + 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/helpers/console-collector.js b/test/helpers/console-collector.js new file mode 100644 index 000000000..d3e1e5a79 --- /dev/null +++ b/test/helpers/console-collector.js @@ -0,0 +1,71 @@ +/** + * 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. 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 + .filter((text) => !text.startsWith("Failed to load resource")) + .map((text) => + text + .replaceAll(/\d+\s?ms/g, "Xms") + .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)", + ), + ); +} + +module.exports = collectConsole; +module.exports.normalizeConsole = normalizeConsole; diff --git a/test/helpers/e2e.js b/test/helpers/e2e.js new file mode 100644 index 000000000..0663ee7d4 --- /dev/null +++ b/test/helpers/e2e.js @@ -0,0 +1,177 @@ +/* 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); + }; + `; +} + +/** + * 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. + * @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, + clickInFrame, + closeE2e, + unacceptedApp, + waitForAppText, + waitForNoOverlay, + waitForOverlay, + waitForText, + warningApp, +}; diff --git a/test/helpers/hot-app.js b/test/helpers/hot-app.js new file mode 100644 index 000000000..aba7600e7 --- /dev/null +++ b/test/helpers/hot-app.js @@ -0,0 +1,322 @@ +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 "") + * @param {string=} publicPath output public path + * @param {boolean=} hmrPlugin include HotModuleReplacementPlugin + * @returns {EXPECTED_ANY} webpack configuration + */ +function makeConfig( + name, + dir, + appFile, + query, + publicPath = "/", + hmrPlugin = true, +) { + 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: hmrPlugin ? [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`. `pageHeaders` are sent with the HTML page + * (e.g. a Content-Security-Policy). + * @param {{ query?: string, code?: string, files?: Record, apps?: { name: string, code: string }[], hot?: EXPECTED_ANY, pageHeaders?: Record, publicPath?: string, setup?: (server: EXPECTED_ANY) => void, hmrPlugin?: boolean }} options options + * @returns {Promise} handles for the running app + */ +async function createHotApp({ + query = "", + code, + files = {}, + apps, + hot = true, + pageHeaders = {}, + publicPath = "/", + setup, + hmrPlugin = 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, + publicPath, + hmrPlugin, + ); + scripts = [`${publicPath}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"); + for (const [name, value] of Object.entries(pageHeaders)) { + res.setHeader(name, value); + } + res.end(pageHtml(scripts)); + }); + if (setup) { + // Extra routes (e.g. an open-editor endpoint) mount before the + // middleware. + setup(app); + } + 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); + }); + }, + + /** + * 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. + * @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/hot.test.js b/test/hot.test.js index f7a4c45d8..62e4b5dc0 100644 --- a/test/hot.test.js +++ b/test/hot.test.js @@ -165,84 +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("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" }); - 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" }); @@ -250,25 +172,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 +220,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 +253,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 +280,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, {}); @@ -556,60 +335,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, {}); - 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, {}); @@ -669,35 +394,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} */ 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(); - }); -}); 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/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, + ); }); }); }); diff --git a/test/overlay.test.js b/test/overlay.test.js deleted file mode 100644 index 05be7497a..000000000 --- a/test/overlay.test.js +++ /dev/null @@ -1,557 +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("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"); - 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("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( - (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(); - }); - - 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("shows a dismiss hint", () => { - showProblems("errors", ["boom"]); - expect(getCard().textContent).toContain( - "Click outside, press Esc, or fix the code to dismiss.", - ); - }); - - 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. - 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("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(); - 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 }); - - 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 }); - - 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"); - }); - - 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", () => { - 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("pagination", () => { - afterEach(() => { - // Restore the default. - 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"]); - - 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("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"]); - - 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"); - }); - - 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("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({}); - 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"]); - 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" } }); - }); - }); - - 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]; - }); - }); -}); diff --git a/test/process-update.test.js b/test/process-update.test.js deleted file mode 100644 index 36010bf33..000000000 --- a/test/process-update.test.js +++ /dev/null @@ -1,316 +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("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"; - - // 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("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 }); - - 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 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. - 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); - }); - - 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(); - }); - }); -});