diff --git a/.github/workflows/visual-parity.yml b/.github/workflows/visual-parity.yml new file mode 100644 index 00000000..43d5bb79 --- /dev/null +++ b/.github/workflows/visual-parity.yml @@ -0,0 +1,72 @@ +name: Visual Parity + +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +jobs: + visual-parity: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # Angular 9 + webpack 4 need the legacy OpenSSL provider on Node 17+. + NODE_OPTIONS: --openssl-legacy-provider + ANGULAR_BASE_URL: http://localhost:4200 + REACT_BASE_URL: http://localhost:5173 + + steps: + - name: Checkout React app (this branch) + uses: actions/checkout@v4 + with: + path: react-app + + - name: Checkout Angular app (master) + uses: actions/checkout@v4 + with: + ref: master + path: angular-app + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Angular dependencies + working-directory: angular-app + run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps + + - name: Install React dependencies + working-directory: react-app + run: npm ci || npm install + + - name: Install Playwright browser + working-directory: react-app + run: npx playwright install --with-deps chromium + + - name: Start Angular app (port 4200) + working-directory: angular-app + run: npx ng serve --host 0.0.0.0 --port 4200 & + + - name: Start React app (port 5173) + working-directory: react-app + run: npm run dev -- --host 0.0.0.0 --port 5173 & + + - name: Wait for both apps to be healthy + working-directory: react-app + run: npx wait-on -t 300000 http-get://localhost:4200 http-get://localhost:5173 + + - name: Run cross-app parity suite + working-directory: react-app + run: npm run test:parity + + - name: Upload screenshots and report + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-parity-screenshots + path: | + react-app/screenshots/ + react-app/playwright-report/ + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index b81011e6..baa1767b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,10 @@ Thumbs.db # Vite *.local + +# Playwright / visual-parity artifacts +/screenshots +/playwright-report +/test-results +/blob-report +/playwright/.cache diff --git a/package-lock.json b/package-lock.json index 88f82ffa..2cd1615a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,20 @@ "react-router-dom": "^6.26.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "sass": "^1.80.0", + "ts-node": "^10.9.2", + "tsx": "^4.22.4", "typescript": "^5.6.3", "vite": "^6.0.0", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "wait-on": "^9.0.10" } }, "node_modules/@babel/code-frame": { @@ -1596,6 +1603,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2038,6 +2069,60 @@ "node": ">=18" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2419,6 +2504,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -2905,6 +3006,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -2921,6 +3029,34 @@ "node": ">=12" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2979,12 +3115,20 @@ "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3048,6 +3192,45 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -3063,6 +3246,13 @@ "uri-js": "^4.2.2" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3157,9 +3347,7 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -3223,6 +3411,36 @@ "optional": true, "peer": true }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -3449,8 +3667,6 @@ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3514,6 +3730,13 @@ "optional": true, "peer": true }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3743,8 +3966,6 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=0.4.0" } @@ -3760,6 +3981,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4131,6 +4362,27 @@ "node": ">=10" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4559,6 +4811,20 @@ "npm": ">=1.3.7" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5116,6 +5382,25 @@ "node": ">=10" } }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5289,6 +5574,13 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -5335,6 +5627,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5351,8 +5650,6 @@ "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -5363,8 +5660,6 @@ "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "mime-db": "1.40.0" }, @@ -5387,6 +5682,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -5637,6 +5942,76 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz", + "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5672,6 +6047,16 @@ "asap": "~2.0.3" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -6002,6 +6387,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -6646,19 +7041,6 @@ "node": ">=10" } }, - "node_modules/terser/node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/terser/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6751,8 +7133,562 @@ "node": ">=6" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, @@ -6904,9 +7840,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -7064,6 +7998,13 @@ "uuid": "bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -7234,6 +8175,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -7719,6 +8680,16 @@ "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 6bae309d..fe574df5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "build": "tsc -b && vite build", "preview": "vite preview", "lint": "tsc --noEmit", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:visual:angular": "ANGULAR_BASE_URL=http://localhost:4200 npx playwright test --project=angular", + "test:visual:react": "REACT_BASE_URL=http://localhost:5173 npx playwright test --project=react", + "test:visual:compare": "tsx tests/compare-screenshots.ts", + "test:parity": "npm run test:visual:angular && npm run test:visual:react && npm run test:visual:compare" }, "dependencies": { "react": "^18.3.1", @@ -16,13 +20,20 @@ "react-router-dom": "^6.26.2" }, "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "pixelmatch": "^7.2.0", + "pngjs": "^7.0.0", "sass": "^1.80.0", + "ts-node": "^10.9.2", + "tsx": "^4.22.4", "typescript": "^5.6.3", "vite": "^6.0.0", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "wait-on": "^9.0.10" }, "prettier": { "trailingComma": "es5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..aaceea56 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test'; + +/** + * Cross-app visual parity configuration. + * + * Two projects run the same specs against the two apps: + * - `angular` -> the original Angular app (default http://localhost:4200) + * - `react` -> the migrated React app (default http://localhost:5173) + * + * Each spec additionally exercises desktop (1280x720) and mobile (375x812, + * iPhone X) viewports by setting the viewport per test. + */ + +const ANGULAR_BASE_URL = process.env.ANGULAR_BASE_URL || 'http://localhost:4200'; +const REACT_BASE_URL = process.env.REACT_BASE_URL || 'http://localhost:5173'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + headless: true, + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + trace: 'on-first-retry', + }, + projects: [ + { name: 'angular', use: { baseURL: ANGULAR_BASE_URL } }, + { name: 'react', use: { baseURL: REACT_BASE_URL } }, + ], +}); diff --git a/tests/compare-screenshots.ts b/tests/compare-screenshots.ts new file mode 100644 index 00000000..cfe67781 --- /dev/null +++ b/tests/compare-screenshots.ts @@ -0,0 +1,260 @@ +/** + * Screenshot comparison + report generator (Phase 12c). + * + * Assumes the Playwright visual suite has already been run against both apps, + * populating `screenshots/angular/` and `screenshots/react/` (see the + * `test:visual:angular` / `test:visual:react` npm scripts). This script then: + * + * 1. Pairs up screenshots by filename. + * 2. Compares each pair with pixelmatch. + * 3. Writes a diff image to `screenshots/diffs/`. + * 4. Classifies each pair PASS (<0.5%) / WARN (0.5-2%) / FAIL (>2%). + * 5. Emits an HTML report (`screenshots/report.html`) showing + * Angular / React / Diff side-by-side. + * + * Exits non-zero if any pair is classified FAIL so CI can gate on it. + */ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { PNG } from 'pngjs'; +import pixelmatch from 'pixelmatch'; + +const ROOT = process.cwd(); +const SCREENSHOTS_DIR = path.join(ROOT, 'screenshots'); +const ANGULAR_DIR = path.join(SCREENSHOTS_DIR, 'angular'); +const REACT_DIR = path.join(SCREENSHOTS_DIR, 'react'); +const DIFFS_DIR = path.join(SCREENSHOTS_DIR, 'diffs'); +const REPORT_PATH = path.join(SCREENSHOTS_DIR, 'report.html'); + +const PASS_THRESHOLD = 0.5; // percent +const WARN_THRESHOLD = 2.0; // percent + +type Status = 'PASS' | 'WARN' | 'FAIL' | 'MISSING'; + +interface Result { + name: string; + width: number; + height: number; + diffPixels: number; + totalPixels: number; + percent: number; + status: Status; + angular: boolean; + react: boolean; +} + +function classify(percent: number): Status { + if (percent < PASS_THRESHOLD) return 'PASS'; + if (percent <= WARN_THRESHOLD) return 'WARN'; + return 'FAIL'; +} + +async function listPngs(dir: string): Promise { + try { + return (await fs.readdir(dir)).filter((f) => f.endsWith('.png')); + } catch { + return []; + } +} + +/** Copy a source PNG's pixels into a (possibly larger) RGBA canvas. */ +function pad(src: PNG, width: number, height: number): Buffer { + const out = Buffer.alloc(width * height * 4); // transparent by default + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + const si = (src.width * y + x) * 4; + const di = (width * y + x) * 4; + out[di] = src.data[si]; + out[di + 1] = src.data[si + 1]; + out[di + 2] = src.data[si + 2]; + out[di + 3] = src.data[si + 3]; + } + } + return out; +} + +async function comparePair(name: string): Promise { + const aPath = path.join(ANGULAR_DIR, name); + const rPath = path.join(REACT_DIR, name); + const hasA = await fs + .access(aPath) + .then(() => true) + .catch(() => false); + const hasR = await fs + .access(rPath) + .then(() => true) + .catch(() => false); + + if (!hasA || !hasR) { + return { + name, + width: 0, + height: 0, + diffPixels: 0, + totalPixels: 0, + percent: 100, + status: 'MISSING', + angular: hasA, + react: hasR, + }; + } + + const a = PNG.sync.read(await fs.readFile(aPath)); + const r = PNG.sync.read(await fs.readFile(rPath)); + const width = Math.max(a.width, r.width); + const height = Math.max(a.height, r.height); + + const aBuf = a.width === width && a.height === height ? a.data : pad(a, width, height); + const rBuf = r.width === width && r.height === height ? r.data : pad(r, width, height); + + const diff = new PNG({ width, height }); + const diffPixels = pixelmatch(aBuf, rBuf, diff.data, width, height, { + threshold: 0.1, + includeAA: false, + }); + + await fs.mkdir(DIFFS_DIR, { recursive: true }); + await fs.writeFile(path.join(DIFFS_DIR, name), PNG.sync.write(diff)); + + const totalPixels = width * height; + const percent = (diffPixels / totalPixels) * 100; + + return { + name, + width, + height, + diffPixels, + totalPixels, + percent, + status: classify(percent), + angular: true, + react: true, + }; +} + +function badge(status: Status): string { + const colors: Record = { + PASS: '#1a7f37', + WARN: '#9a6700', + FAIL: '#cf222e', + MISSING: '#57606a', + }; + return `${status}`; +} + +function renderHtml(results: Result[]): string { + const counts = results.reduce( + (acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, + {} as Record, + ); + + const rows = results + .map((r) => { + const cell = (sub: string) => + r[sub as 'angular' | 'react'] || sub === 'diffs' + ? `${sub} ${r.name}` + : '
missing
'; + return ` + ${r.name}
${badge(r.status)} +
${r.percent.toFixed(3)}% (${r.diffPixels.toLocaleString()} px)
${r.width}×${r.height}
+ + ${cell('angular')} + ${cell('react')} + ${r.status === 'MISSING' ? '
n/a
' : cell('diffs')} + `; + }) + .join('\n'); + + return ` + + + + +Angular vs React — Visual Parity Report + + + +

Angular vs React — Visual Parity Report

+
+ Total: ${results.length} + ${badge('PASS')} ${counts.PASS || 0} + ${badge('WARN')} ${counts.WARN || 0} + ${badge('FAIL')} ${counts.FAIL || 0} + ${badge('MISSING')} ${counts.MISSING || 0} + PASS < ${PASS_THRESHOLD}% · WARN ≤ ${WARN_THRESHOLD}% · FAIL > ${WARN_THRESHOLD}% +
+ + + + + +${rows} + +
Test caseAngularReactDiff
+ +`; +} + +async function main(): Promise { + const angularFiles = await listPngs(ANGULAR_DIR); + const reactFiles = await listPngs(REACT_DIR); + const names = Array.from(new Set([...angularFiles, ...reactFiles])).sort(); + + if (names.length === 0) { + console.error( + 'No screenshots found. Run `npm run test:visual:angular` and `npm run test:visual:react` first.', + ); + process.exit(1); + } + + const results: Result[] = []; + for (const name of names) { + results.push(await comparePair(name)); + } + + await fs.writeFile(REPORT_PATH, renderHtml(results)); + + // Console summary table. + console.log('\nVisual parity comparison'); + console.log('='.repeat(72)); + for (const r of results) { + const pct = r.status === 'MISSING' ? ' n/a' : `${r.percent.toFixed(3)}%`.padStart(8); + console.log(`${r.status.padEnd(8)} ${pct} ${r.name}`); + } + console.log('='.repeat(72)); + + const counts = results.reduce( + (acc, r) => ((acc[r.status] = (acc[r.status] || 0) + 1), acc), + {} as Record, + ); + console.log( + `PASS ${counts.PASS || 0} WARN ${counts.WARN || 0} FAIL ${counts.FAIL || 0} MISSING ${counts.MISSING || 0}`, + ); + console.log(`Report: ${path.relative(ROOT, REPORT_PATH)}`); + + const failures = (counts.FAIL || 0) + (counts.MISSING || 0); + if (failures > 0) { + console.error(`\n${failures} comparison(s) exceeded the 2% FAIL threshold (or were missing).`); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/dom-parity.spec.ts b/tests/dom-parity.spec.ts new file mode 100644 index 00000000..d442a34e --- /dev/null +++ b/tests/dom-parity.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, gotoAndWait, setTheme, openSettings, THEMES, STORY_ID, USER_ID } from './helpers'; + +/** + * Structural (DOM) parity assertions (Phase 12d). + * + * The same canonical assertions run against both the `angular` and `react` + * projects. Both apps are fed identical mocked data, so any structural drift + * surfaces as a failing assertion in one project but not the other. + */ + +test.beforeEach(async ({ page }) => { + await mockApi(page); +}); + +test('feed renders 30 list items', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + await expect(page.locator('ol > li')).toHaveCount(30); + await expect(page.locator('ol > li.post')).toHaveCount(30); +}); + +test('each feed item exposes the expected structure', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + const items = page.locator('ol > li.post'); + const count = await items.count(); + expect(count).toBe(30); + + for (let i = 0; i < count; i++) { + const li = items.nth(i); + await expect(li.locator('.title')).toHaveCount(1); + await expect(li.locator('.domain')).toHaveCount(1); // fixtures are external links + await expect(li.locator('.subtext-palm')).toHaveCount(1); + await expect(li.locator('.subtext-laptop')).toHaveCount(1); + } + + // Comment count text follows the "N comment(s)" / "discuss" format. + await expect(items.first().locator('.subtext-palm .comment-number')).toContainText(/comment|discuss/); +}); + +test('item details comment list matches comment count', async ({ page }) => { + await gotoAndWait(page, `/item/${STORY_ID}`); + // storyFixture has two top-level comments (alice + carol). + await expect(page.locator('.comment-list > li')).toHaveCount(2); +}); + +test('nested comment subtree count matches replies', async ({ page }) => { + await gotoAndWait(page, `/item/${STORY_ID}`); + // The first top-level comment (alice) has exactly one reply (bob). Target + // alice's own subtree (the first ul.subtree) and count its direct children, + // which is robust to each app's wrapper element nesting. + const firstComment = page.locator('.comment-list > li').first(); + await expect(firstComment.locator('ul.subtree').first().locator('> li')).toHaveCount(1); +}); + +test('settings exposes 3 radios, 2 number inputs, 1 checkbox', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + await openSettings(page); + + const radios = page.locator('#popup1 input[type="radio"]'); + await expect(radios).toHaveCount(3); + for (const value of THEMES) { + await expect(page.locator(`#popup1 input[type="radio"][value="${value}"]`)).toHaveCount(1); + } + await expect(page.locator('#popup1 input[type="number"]')).toHaveCount(2); + await expect(page.locator('#popup1 input[type="checkbox"]')).toHaveCount(1); +}); + +test('user profile exposes name, karma and age', async ({ page }) => { + await gotoAndWait(page, `/user/${USER_ID}`); + await expect(page.locator('.profile .name')).toHaveCount(1); + await expect(page.locator('.profile .right')).toHaveCount(1); + await expect(page.locator('.profile .age')).toHaveCount(1); +}); + +test('wrapper carries the active theme class after switching', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + for (const theme of THEMES) { + await setTheme(page, theme); + const wrapper = page.locator('.body-cover').locator('xpath=..'); + await expect(wrapper).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`)); + } +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 00000000..3b0bb888 --- /dev/null +++ b/tests/fixtures.ts @@ -0,0 +1,157 @@ +/** + * Deterministic Hacker News API fixtures. + * + * Both the Angular and React apps fetch from the same upstream + * (`node-hnapi.herokuapp.com`). For cross-app parity to be meaningful the two + * apps must render *identical* content, otherwise live-data drift dominates the + * pixel diff. These fixtures are served via Playwright request interception so + * both apps see byte-identical responses. + */ + +export const STORY_ID = 8863; +export const POLL_ID = 126809; +export const USER_ID = 'parityuser'; +export const INVALID_ID = 99999999; + +export const FEED_PAGE_SIZE = 30; + +export interface FixtureComment { + id: number; + level: number; + user: string; + time: number; + time_ago: string; + content: string; + deleted: boolean; + comments: FixtureComment[]; +} + +function story(i: number) { + return { + id: 1000 + i, + title: `Parity Test Story ${i}`, + points: 100 + i, + user: `user${i}`, + time: 1700000000, + time_ago: `${i} hour${i === 1 ? '' : 's'} ago`, + type: 'link', + url: `https://example.com/article-${i}`, + domain: 'example.com', + comments_count: i, + comments: [], + }; +} + +function job(i: number) { + return { + id: 2000 + i, + title: `Parity Test Job ${i}`, + points: null, + user: null, + time: 1700000000, + time_ago: `${i} hour${i === 1 ? '' : 's'} ago`, + type: 'job', + url: `https://example.com/job-${i}`, + domain: 'example.com', + comments_count: 0, + comments: [], + }; +} + +export function feedFixture(count = FEED_PAGE_SIZE) { + return Array.from({ length: count }, (_, idx) => story(idx + 1)); +} + +export function jobsFixture(count = FEED_PAGE_SIZE) { + return Array.from({ length: count }, (_, idx) => job(idx + 1)); +} + +export function storyFixture() { + const comments: FixtureComment[] = [ + { + id: 1, + level: 0, + user: 'alice', + time: 1700000001, + time_ago: '4 hours ago', + content: '

Top level comment from alice.

', + deleted: false, + comments: [ + { + id: 2, + level: 1, + user: 'bob', + time: 1700000002, + time_ago: '3 hours ago', + content: '

Nested reply from bob.

', + deleted: false, + comments: [], + }, + ], + }, + { + id: 3, + level: 0, + user: 'carol', + time: 1700000003, + time_ago: '2 hours ago', + content: '

Another top level comment.

', + deleted: false, + comments: [], + }, + ]; + + return { + id: STORY_ID, + title: 'Parity Test: Story With Comments', + points: 256, + user: 'storyauthor', + time: 1700000000, + time_ago: '5 hours ago', + type: 'link', + url: 'https://example.com/story', + domain: 'example.com', + comments_count: 3, + content: '', + comments, + }; +} + +export function pollFixture() { + return { + id: POLL_ID, + title: 'Parity Test: Poll', + points: 50, + user: 'pollauthor', + time: 1700000000, + time_ago: '6 hours ago', + type: 'poll', + url: '', + domain: '', + comments_count: 0, + content: '

Poll description for parity testing.

', + // length drives how many poll-option sub-items the apps fetch + poll: [ + { points: 0, content: '' }, + { points: 0, content: '' }, + { points: 0, content: '' }, + ], + comments: [], + }; +} + +export function pollOptionFixture(optionIndex: number) { + const points = [30, 20, 10][optionIndex - 1] ?? 5; + return { points, content: `

Poll option ${optionIndex}

` }; +} + +export function userFixture() { + return { + id: USER_ID, + crated_time: 1161172800, + created: 'October 18, 2006', + karma: 12345, + avg: 5, + about: '

This is the about section for the parity test user.

', + }; +} diff --git a/tests/functional-parity.spec.ts b/tests/functional-parity.spec.ts new file mode 100644 index 00000000..105d0d23 --- /dev/null +++ b/tests/functional-parity.spec.ts @@ -0,0 +1,130 @@ +import { test, expect } from '@playwright/test'; +import { + mockApi, + gotoAndWait, + stabilize, + setTheme, + setNumberSetting, + setOpenLinksInNewTab, + openSettings, + closeSettings, + getThemeClass, + STORY_ID, +} from './helpers'; + +/** + * Functional / behavioural parity (Phase 12f). + * + * Each behaviour is asserted independently against both the `angular` and + * `react` projects, so a behaviour that regresses in one app fails there only. + */ + +test.beforeEach(async ({ page }) => { + await mockApi(page); +}); + +test('settings persist across reload via localStorage', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + + await setTheme(page, 'night'); + await setNumberSetting(page, 'font', '20'); + await setNumberSetting(page, 'spacing', '5'); + await setOpenLinksInNewTab(page, true); + await closeSettings(page); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('.loading-section').first().waitFor({ state: 'detached' }).catch(() => undefined); + await stabilize(page); + + expect(await getThemeClass(page)).toMatch(/night/); + + await openSettings(page); + await expect(page.locator('#popup1 input[type="number"]').nth(0)).toHaveValue('20'); + await expect(page.locator('#popup1 input[type="number"]').nth(1)).toHaveValue('5'); + await expect(page.locator('#popup1 input[type="checkbox"]').first()).toBeChecked(); +}); + +test('pagination moves between pages', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + + await page.locator('.nav .more').click(); + await page.waitForURL(/\/news\/2$/); + await expect(page.locator('ol')).toHaveAttribute('start', '31'); + + await page.locator('.nav .prev').click(); + await page.waitForURL(/\/news\/1$/); + await expect(page.locator('ol')).toHaveAttribute('start', '1'); +}); + +test('header navigation loads each feed', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + + const links: Array<[string, RegExp]> = [ + ['a[href="/newest/1"]', /\/newest\/1$/], + ['a[href="/show/1"]', /\/show\/1$/], + ['a[href="/ask/1"]', /\/ask\/1$/], + ['a[href="/jobs/1"]', /\/jobs\/1$/], + ]; + for (const [selector, urlRe] of links) { + await page.locator(`.header-nav ${selector}`).click(); + await page.waitForURL(urlRe); + await page.locator('.loading-section').first().waitFor({ state: 'detached' }).catch(() => undefined); + await expect(page.locator('.main-content')).toBeVisible(); + } + + // Home logo returns to the news feed. + await page.locator('.home-link').click(); + await page.waitForURL(/\/news\/1$/); + await expect(page.locator('ol > li.post').first()).toBeVisible(); +}); + +test('comment collapse hides content and expand restores it', async ({ page }) => { + await gotoAndWait(page, `/item/${STORY_ID}`); + // Scope to the first top-level comment so `.first()` stays pinned to it even + // when React removes the collapsed content from the DOM (Angular only hides it). + const firstComment = page.locator('.comment-list > li').first(); + const firstText = firstComment.locator('.comment-text').first(); + const toggle = firstComment.locator('.collapse').first(); + + await expect(firstText).toBeVisible(); + await toggle.click(); + await expect(firstText).toBeHidden(); + + await toggle.click(); + await expect(firstComment.locator('.comment-text').first()).toBeVisible(); +}); + +test('external link target/rel toggles with the setting', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + const title = page.locator('ol > li.post .title').first(); + + // Default: links open in the same tab (no target/rel). + expect(await title.getAttribute('target')).toBeNull(); + expect(await title.getAttribute('rel')).toBeNull(); + + await setOpenLinksInNewTab(page, true); + await closeSettings(page); + + await expect(title).toHaveAttribute('target', '_blank'); + await expect(title).toHaveAttribute('rel', /noopener/); +}); + +test('mobile back button returns to the previous page', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await gotoAndWait(page, '/news/1'); + + // Navigate into an item via the in-app comment link, then go back. + await page.locator('ol > li.post .subtext-palm .comment-number').first().click(); + await page.waitForURL(/\/item\/\d+$/); + await page.locator('.mobile .back-button').waitFor(); + + await page.locator('.mobile .back-button').click(); + await page.waitForURL(/\/news\/1$/); + await expect(page.locator('ol > li.post').first()).toBeVisible(); +}); + +test('dark color-scheme auto-selects the night theme', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + await gotoAndWait(page, '/news/1'); + expect(await getThemeClass(page)).toMatch(/night/); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 00000000..565375b4 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,179 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { Page, TestInfo, expect } from '@playwright/test'; +import { + feedFixture, + jobsFixture, + storyFixture, + pollFixture, + pollOptionFixture, + userFixture, + STORY_ID, + POLL_ID, + INVALID_ID, +} from './fixtures'; + +export type AppName = 'angular' | 'react'; +export type ThemeName = 'default' | 'night' | 'amoledblack'; +export type ViewportName = 'desktop' | 'mobile'; + +export const VIEWPORTS: Record = { + desktop: { width: 1280, height: 720 }, + mobile: { width: 375, height: 812 }, // iPhone X +}; + +export const THEMES: ThemeName[] = ['default', 'night', 'amoledblack']; + +// Resolved against the project root (Playwright + the compare script both run +// from there). Avoids __dirname, which is undefined under "type": "module". +export const SCREENSHOTS_DIR = path.join(process.cwd(), 'screenshots'); + +/** Known fixture ids re-exported for spec readability. */ +export { STORY_ID, POLL_ID, INVALID_ID }; +export const USER_ID = 'parityuser'; + +/** The Playwright project name encodes which app is under test. */ +export function appName(testInfo: TestInfo): AppName { + return testInfo.project.name as AppName; +} + +/** + * Intercept every Hacker News API call (made by either app) and serve + * deterministic fixtures so Angular and React render identical content. + */ +export async function mockApi(page: Page): Promise { + await page.route('**/node-hnapi.herokuapp.com/**', async (route) => { + const url = new URL(route.request().url()); + const pathname = url.pathname; + + // Simulate an upstream failure for the invalid id to exercise error UI. + if (pathname === `/item/${INVALID_ID}`) { + await route.abort(); + return; + } + + let body: unknown = null; + const itemMatch = pathname.match(/^\/item\/(\d+)$/); + const userMatch = pathname.match(/^\/user\/(.+)$/); + const feedMatch = pathname.match(/^\/(news|newest|show|ask|jobs)$/); + + if (feedMatch) { + body = feedMatch[1] === 'jobs' ? jobsFixture() : feedFixture(); + } else if (itemMatch) { + const id = Number(itemMatch[1]); + if (id === POLL_ID) body = pollFixture(); + else if (id > POLL_ID && id <= POLL_ID + 3) body = pollOptionFixture(id - POLL_ID); + else body = storyFixture(); + } else if (userMatch) { + body = userFixture(); + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + }); +} + +/** Disable animations/transitions so screenshots are stable. */ +export async function stabilize(page: Page): Promise { + await page.addStyleTag({ + content: `*, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + caret-color: transparent !important; + }`, + }); + await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => undefined); +} + +/** Navigate to a route and wait for the loader to disappear. */ +export async function gotoAndWait(page: Page, route: string): Promise { + await page.goto(route, { waitUntil: 'domcontentloaded' }); + await page + .locator('.loading-section') + .first() + .waitFor({ state: 'detached', timeout: 15000 }) + .catch(() => undefined); + await stabilize(page); +} + +/** Open the settings popup (idempotent). */ +export async function openSettings(page: Page): Promise { + if (await page.locator('#popup1').count()) return; + await page.locator('img.settings').click(); + await page.locator('#popup1').waitFor({ state: 'visible' }); +} + +export async function closeSettings(page: Page): Promise { + if (!(await page.locator('#popup1').count())) return; + await page.locator('#popup1 .close').click(); + await page.locator('#popup1').waitFor({ state: 'detached' }).catch(() => undefined); +} + +/** Switch the active theme through the settings popup, like a real user. */ +export async function setTheme(page: Page, theme: ThemeName): Promise { + await openSettings(page); + await page.locator(`#popup1 input[name="theme"][value="${theme}"]`).check(); + await closeSettings(page); + await stabilize(page); +} + +/** The class on the top-level wrapper reflects the active theme. */ +export async function getThemeClass(page: Page): Promise { + return page.evaluate(() => { + const cover = document.querySelector('.body-cover'); + return cover && cover.parentElement ? cover.parentElement.className.trim() : ''; + }); +} + +/** Build a screenshot filename encoding page, theme and viewport. */ +export function screenshotName(pageKey: string, theme: ThemeName, viewport: ViewportName): string { + return `${pageKey}__${theme}__${viewport}.png`; +} + +/** Capture a stable full-page screenshot into screenshots//. */ +export async function capture( + page: Page, + testInfo: TestInfo, + pageKey: string, + theme: ThemeName, + viewport: ViewportName, +): Promise { + const dir = path.join(SCREENSHOTS_DIR, appName(testInfo)); + await fs.mkdir(dir, { recursive: true }); + const file = path.join(dir, screenshotName(pageKey, theme, viewport)); + await page.screenshot({ path: file, fullPage: true, animations: 'disabled' }); + await testInfo.attach(screenshotName(pageKey, theme, viewport), { + path: file, + contentType: 'image/png', + }); +} + +/** Set a numeric settings input in a way that fires both apps' handlers. */ +export async function setNumberSetting(page: Page, type: 'font' | 'spacing', value: string): Promise { + await openSettings(page); + const inputs = page.locator('#popup1 input[type="number"]'); + const input = type === 'font' ? inputs.nth(0) : inputs.nth(1); + await input.click(); + await input.fill(''); + // pressSequentially dispatches keydown/keyup (Angular listens on keyup) as + // well as input/change (React listens on change). + await input.pressSequentially(value); + await input.blur(); +} + +export async function setOpenLinksInNewTab(page: Page, enabled: boolean): Promise { + await openSettings(page); + const checkbox = page.locator('#popup1 input[type="checkbox"]').first(); + if (enabled) await checkbox.check(); + else await checkbox.uncheck(); +} + +/** Assert helper shared by parity specs to keep messages descriptive. */ +export async function expectVisible(page: Page, selector: string): Promise { + await expect(page.locator(selector).first()).toBeVisible(); +} diff --git a/tests/style-parity.spec.ts b/tests/style-parity.spec.ts new file mode 100644 index 00000000..ac284d6e --- /dev/null +++ b/tests/style-parity.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, gotoAndWait, setTheme, setNumberSetting, closeSettings, THEMES, ThemeName, VIEWPORTS } from './helpers'; + +/** + * Computed-style parity assertions (Phase 12e). + * + * Canonical expected values were captured from the original Angular app; the + * same spec runs against both projects, so the React app must resolve to the + * identical computed values to pass. + */ + +const HEADER_BG: Record = { + default: 'rgb(185, 43, 39)', // #b92b27 + night: 'rgb(38, 50, 56)', // #263238 + amoledblack: 'rgb(0, 0, 0)', // #000000 +}; + +// The default theme uses a slightly different wrapper background on mobile +// (white) vs desktop (light grey); the dark themes are viewport-independent. +const WRAPPER_BG: Record<'desktop' | 'mobile', Record> = { + desktop: { + default: 'rgb(245, 245, 245)', + night: 'rgb(38, 50, 56)', + amoledblack: 'rgb(0, 0, 0)', + }, + mobile: { + default: 'rgb(255, 255, 255)', + night: 'rgb(38, 50, 56)', + amoledblack: 'rgb(0, 0, 0)', + }, +}; + +const LINK_COLOR: Record = { + default: 'rgb(0, 0, 0)', + night: 'rgba(255, 255, 255, 0.7)', + amoledblack: 'rgba(255, 255, 255, 0.75)', +}; + +function computed(page: import('@playwright/test').Page, selector: string, prop: string) { + return page.evaluate( + ([sel, p]) => { + const el = document.querySelector(sel); + return el ? getComputedStyle(el).getPropertyValue(p) : null; + }, + [selector, prop] as const, + ); +} + +test.beforeEach(async ({ page }) => { + await mockApi(page); +}); + +test('header background-color per theme', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + for (const theme of THEMES) { + await setTheme(page, theme); + expect(await computed(page, '#header', 'background-color')).toBe(HEADER_BG[theme]); + } +}); + +test('wrapper background-color per theme (desktop and mobile)', async ({ page }) => { + for (const [vpName, vp] of Object.entries(VIEWPORTS) as [keyof typeof WRAPPER_BG, { width: number; height: number }][]) { + await page.setViewportSize(vp); + await gotoAndWait(page, '/news/1'); + for (const theme of THEMES) { + await setTheme(page, theme); + expect(await computed(page, '.wrapper', 'background-color')).toBe(WRAPPER_BG[vpName][theme]); + } + } +}); + +test('wrapper font-family includes Helvetica Neue', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + const font = await computed(page, '.wrapper', 'font-family'); + expect(font).toContain('Helvetica Neue'); +}); + +test('link color per theme', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + for (const theme of THEMES) { + await setTheme(page, theme); + const color = await computed(page, 'ol > li.post .title', 'color'); + expect(color).toBe(LINK_COLOR[theme]); + } +}); + +test('.body-cover is a full-viewport fixed overlay', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + const info = await page.evaluate(() => { + const el = document.querySelector('.body-cover') as HTMLElement; + const cs = getComputedStyle(el); + return { + position: cs.position, + coversWidth: el.offsetWidth === window.innerWidth, + coversHeight: el.offsetHeight === window.innerHeight, + }; + }); + expect(info.position).toBe('fixed'); + expect(info.coversWidth).toBe(true); + expect(info.coversHeight).toBe(true); +}); + +test('item title font-size reflects the settings value', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + // Default font size is 16px. + expect(await computed(page, 'ol > li.post .title', 'font-size')).toBe('16px'); + + // Changing the setting updates the inline font-size on every title. + await setNumberSetting(page, 'font', '22'); + await closeSettings(page); + expect(await computed(page, 'ol > li.post .title', 'font-size')).toBe('22px'); +}); + +test('item list-spacing reflects the settings value', async ({ page }) => { + await gotoAndWait(page, '/news/1'); + await setNumberSetting(page, 'spacing', '12'); + await closeSettings(page); + // Both apps apply the spacing as an inline margin-bottom on the item wrapper. + const margin = await computed(page, 'ol > li.post [style*="margin-bottom"]', 'margin-bottom'); + expect(margin).toBe('12px'); +}); diff --git a/tests/visual-parity.spec.ts b/tests/visual-parity.spec.ts new file mode 100644 index 00000000..4b8ddb1b --- /dev/null +++ b/tests/visual-parity.spec.ts @@ -0,0 +1,188 @@ +import { test, expect } from '@playwright/test'; +import { + mockApi, + gotoAndWait, + capture, + setTheme, + openSettings, + VIEWPORTS, + THEMES, + ViewportName, + STORY_ID, + POLL_ID, + INVALID_ID, + USER_ID, +} from './helpers'; + +test.beforeEach(async ({ page }) => { + await mockApi(page); +}); + +const viewports = Object.keys(VIEWPORTS) as ViewportName[]; + +for (const vp of viewports) { + test.describe(`visual parity [${vp}]`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(VIEWPORTS[vp]); + }); + + // 1. Feed (news) — captured for all three themes. + for (const theme of THEMES) { + test(`feed news (${theme})`, async ({ page }, testInfo) => { + await gotoAndWait(page, '/news/1'); + if (theme !== 'default') await setTheme(page, theme); + + await expect(page.locator('ol > li.post')).toHaveCount(30); + await expect(page.locator('ol > li.post').first().locator('.title')).toBeVisible(); + await expect(page.locator('.nav .more')).toBeVisible(); + + await capture(page, testInfo, 'feed-news', theme, vp); + }); + } + + // 2. Feed (jobs) — job items omit points/user/comment count. + test('feed jobs', async ({ page }, testInfo) => { + await gotoAndWait(page, '/jobs/1'); + + await expect(page.locator('.job-header')).toBeVisible(); + await expect(page.locator('.job-header')).toContainText('These are jobs at startups'); + await expect(page.locator('ol > li.post')).toHaveCount(30); + // Job rows never render the points/user ".name" detail. + await expect(page.locator('ol > li.post .name')).toHaveCount(0); + + await capture(page, testInfo, 'feed-jobs', 'default', vp); + }); + + // 3. Feed page 2 — Prev visible and
    is 31. + test('feed page 2', async ({ page }, testInfo) => { + await gotoAndWait(page, '/news/2'); + + await expect(page.locator('.nav .prev')).toBeVisible(); + await expect(page.locator('ol')).toHaveAttribute('start', '31'); + + await capture(page, testInfo, 'feed-page2', 'default', vp); + }); + + // 4. Item details (story with comments) — captured for all three themes. + for (const theme of THEMES) { + test(`item details story (${theme})`, async ({ page }, testInfo) => { + await gotoAndWait(page, `/item/${STORY_ID}`); + if (theme !== 'default') await setTheme(page, theme); + + // The title is rendered twice (mobile header + laptop block); + // exactly one is visible depending on viewport. + const titleSelector = vp === 'mobile' ? '.mobile .title' : '.laptop .title'; + await expect(page.locator(titleSelector).first()).toBeVisible(); + await expect(page.locator('.comment-list > li').first()).toBeVisible(); + if (vp === 'mobile') { + await expect(page.locator('.mobile .back-button')).toBeVisible(); + } else { + await expect(page.locator('.laptop .domain')).toBeVisible(); + } + + await capture(page, testInfo, 'item-story', theme, vp); + }); + } + + // 5. Item details (poll) — percentage-width poll bars. + test('item details poll', async ({ page }, testInfo) => { + await gotoAndWait(page, `/item/${POLL_ID}`); + await page.locator('.pollResults .pollBar').first().waitFor(); + + const bars = page.locator('.pollResults .pollBar'); + await expect(bars).toHaveCount(3); + const width = await bars.first().evaluate((el) => (el as HTMLElement).style.width); + expect(width).toMatch(/%$/); + + await capture(page, testInfo, 'item-poll', 'default', vp); + }); + + // 6. Comment collapse / expand. + test('comment collapse and expand', async ({ page }, testInfo) => { + await gotoAndWait(page, `/item/${STORY_ID}`); + const toggle = page.locator('.comment-list .collapse').first(); + await expect(toggle).toHaveText('[-]'); + + await toggle.click(); + await expect(toggle).toHaveText('[+]'); + await capture(page, testInfo, 'comment-collapsed', 'default', vp); + + await toggle.click(); + await expect(toggle).toHaveText('[-]'); + await capture(page, testInfo, 'comment-expanded', 'default', vp); + }); + + // 7. User profile — captured for all three themes. + for (const theme of THEMES) { + test(`user profile (${theme})`, async ({ page }, testInfo) => { + await gotoAndWait(page, `/user/${USER_ID}`); + if (theme !== 'default') await setTheme(page, theme); + + // On mobile the identity moves into the mobile header; the + // .main-details block is hidden (same CSS in both apps). + if (vp === 'mobile') { + await expect(page.locator('.mobile.item-header .title-block')).toContainText('Profile:'); + await expect(page.locator('.mobile .back-button')).toBeVisible(); + } else { + await expect(page.locator('.main-details .name')).toBeVisible(); + } + await expect(page.locator('.profile .right')).toContainText('★'); + await expect(page.locator('.profile .age')).toContainText('Created'); + await expect(page.locator('.profile .other-details, .profile p')).not.toHaveCount(0); + + await capture(page, testInfo, 'user-profile', theme, vp); + }); + } + + // 8. Settings popup — captured for all three themes. + for (const theme of THEMES) { + test(`settings popup (${theme})`, async ({ page }, testInfo) => { + await gotoAndWait(page, '/news/1'); + if (theme !== 'default') await setTheme(page, theme); + await openSettings(page); + + await expect(page.locator('#popup1.overlay')).toBeVisible(); + await expect(page.locator('#popup1 input[type="radio"]')).toHaveCount(3); + await expect(page.locator('#popup1 input[type="number"]')).toHaveCount(2); + await expect(page.locator('#popup1 input[type="checkbox"]')).toHaveCount(1); + + await capture(page, testInfo, 'settings', theme, vp); + }); + } + + // 9. Error state — invalid id renders the skull graphic + message. + test('error state', async ({ page }, testInfo) => { + await gotoAndWait(page, `/item/${INVALID_ID}`); + + await expect(page.locator('.error-section .skull')).toBeVisible(); + await expect(page.locator('.error-section .strong')).toBeVisible(); + + await capture(page, testInfo, 'error', 'default', vp); + }); + + // 10. Header active states. + test('header active states', async ({ page }, testInfo) => { + const routes: Array<[string, string]> = [ + ['/newest/1', 'a[href="/newest/1"]'], + ['/show/1', 'a[href="/show/1"]'], + ['/ask/1', 'a[href="/ask/1"]'], + ['/jobs/1', 'a[href="/jobs/1"]'], + ]; + for (const [route, selector] of routes) { + await gotoAndWait(page, route); + await expect(page.locator(`.header-nav ${selector}`)).toHaveClass(/active/); + } + await capture(page, testInfo, 'header-active-jobs', 'default', vp); + }); + + // 11. Footer. + test('footer', async ({ page }, testInfo) => { + await gotoAndWait(page, '/news/1'); + const footerLink = page.locator('#footer a'); + await expect(footerLink).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn'); + await expect(page.locator('#footer')).toContainText('GitHub'); + + await capture(page, testInfo, 'footer', 'default', vp); + }); + }); +}