From f9e5fb7de3c7cc9945fd926d8ed84398dceabedf Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 14 May 2026 03:13:52 +0200 Subject: [PATCH 1/7] test(recommendations): scaffolding for E2E smoke (WIP for #167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up the Playwright infrastructure for the #160 smoke suite without yet writing the actual smoke spec. The spec itself is the remaining work and is tracked in the draft-PR description. What this lays down: - frontend/package.json: @playwright/test 1.50.x + serve 14.x as devDependencies; `test:e2e` + `test:e2e:install` npm scripts. - frontend/playwright.config.ts: Chromium-only, hermetic (boots `npx serve` against the production webpack bundle in dist/), `page.route`-based API mocking, retain-on-failure trace/screenshot/ video, single worker on CI for predictable artefacts. - frontend/tests-e2e/fixtures/recs.ts (301 lines): canned API fixtures covering 3 providers x 4 services x 2 terms with savings spanning [10, 50, 100, 150, 250, 1200] so numeric filter expressions narrow to known row counts. `mockApi(page)` wires `/api/recommendations`, `/api/recommendations/freshness`, `/api/recommendations/refresh`, `/api/accounts`, `/api/session`, `/api/dashboard`, `/api/plans`, `/api/purchases/execute` and returns a `{ calls }` recorder for per-test assertions. - frontend/jest config: excludes tests-e2e/ from jest's pickup so the unit-test suite stays fast. - .gitignore: ignores playwright-report/, test-results/, tests-e2e/.cache/ — generated per run, not committed. Remaining work (kept as a WIP commit because the predecessor agent hit the stream-idle watchdog before reaching this step): - Write tests-e2e/recommendations.spec.ts that exercises the #160 flow end-to-end: load page, apply a column filter, verify row count narrows correctly, select rows, verify sticky bottom action box surfaces the right summary + dispatches the correct POST. - (Optionally) wire test:e2e into a CI workflow alongside the test:unit job. --- .gitignore | 5 + frontend/package-lock.json | 810 +++++++++++++++++++++++++++- frontend/package.json | 10 +- frontend/playwright.config.ts | 82 +++ frontend/tests-e2e/fixtures/recs.ts | 301 +++++++++++ 5 files changed, 1179 insertions(+), 29 deletions(-) create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/tests-e2e/fixtures/recs.ts diff --git a/.gitignore b/.gitignore index da9e22820..8465ec5af 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,11 @@ frontend/dist/ frontend/coverage/ frontend/node_modules/ +# Frontend Playwright artefacts — generated per run, not committed +frontend/playwright-report/ +frontend/test-results/ +frontend/tests-e2e/.cache/ + # Temporary files tmp/ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca224a9ff..3725480a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,33 +13,35 @@ "qrcode": "1.5.4" }, "devDependencies": { - "@babel/core": "7.29.7", - "@babel/preset-env": "7.28.5", - "@babel/preset-typescript": "7.28.5", - "@testing-library/dom": "9.3.4", - "@testing-library/jest-dom": "6.9.1", - "@types/chart.js": "2.9.41", - "@types/jest": "29.5.14", - "@types/jsdom": "21.1.7", - "@typescript-eslint/eslint-plugin": "8.62.1", - "@typescript-eslint/parser": "8.62.1", - "@ungap/structured-clone": "1.3.1", - "babel-loader": "9.2.1", - "copy-webpack-plugin": "14.0.0", - "css-loader": "6.11.0", - "css-minimizer-webpack-plugin": "8.0.0", - "eslint": "8.57.1", - "html-webpack-plugin": "5.6.5", - "jest": "29.7.0", - "jest-environment-jsdom": "29.7.0", - "jsdom": "22.1.0", - "mini-css-extract-plugin": "2.9.4", - "style-loader": "3.3.4", - "ts-jest": "29.4.6", - "ts-loader": "9.5.4", - "typescript": "5.9.3", - "webpack": "5.108.3", - "webpack-cli": "5.1.4" + "@babel/core": "^7.23.0", + "@babel/preset-env": "^7.23.0", + "@babel/preset-typescript": "^7.23.0", + "@playwright/test": "^1.50.0", + "@testing-library/dom": "^9.3.0", + "@testing-library/jest-dom": "^6.1.0", + "@types/chart.js": "^2.9.41", + "@types/jest": "^29.5.0", + "@types/jsdom": "^21.1.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@ungap/structured-clone": "^1.3.1", + "babel-loader": "^9.1.0", + "copy-webpack-plugin": "^14.0.0", + "css-loader": "^6.8.0", + "css-minimizer-webpack-plugin": "^8.0.0", + "eslint": "^8.50.0", + "html-webpack-plugin": "^5.5.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^22.1.0", + "mini-css-extract-plugin": "^2.7.0", + "serve": "^14.2.0", + "style-loader": "^3.3.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.5.0", + "typescript": "^5.3.0", + "webpack": "^5.88.0", + "webpack-cli": "^5.1.0" } }, "node_modules/@adobe/css-tools": { @@ -2534,6 +2536,22 @@ "node": ">= 8" } }, + "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/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3369,6 +3387,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -3509,6 +3534,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3576,6 +3611,34 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "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/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3836,6 +3899,153 @@ "dev": true, "license": "ISC" }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/boxen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", @@ -3924,6 +4134,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4055,6 +4275,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -4123,6 +4359,37 @@ "node": ">= 10.0" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4226,6 +4493,55 @@ "dev": true, "license": "ISC" }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4233,6 +4549,16 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4837,6 +5163,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5065,6 +5401,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.385", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz", @@ -6317,6 +6660,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6463,6 +6813,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6568,6 +6934,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "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", @@ -6701,6 +7080,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -8470,6 +8862,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -8609,6 +9011,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8780,6 +9192,13 @@ "node": ">=0.10.0" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -8797,6 +9216,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8931,6 +9367,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -9769,6 +10252,52 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -9862,6 +10391,30 @@ "node": ">=4" } }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", @@ -10186,6 +10739,118 @@ "node": ">=20.0.0" } }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/serve/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/serve/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -11170,6 +11835,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -11220,6 +11896,16 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -11547,6 +12233,76 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/widest-line/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/widest-line/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 066abb539..a66f15602 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "watch": "webpack --mode development --watch", "test": "jest --coverage", "test:watch": "jest --watch", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install --with-deps chromium", "lint": "eslint src/**/*.ts", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" @@ -22,6 +24,7 @@ "@types/chart.js": "2.9.41", "@types/jest": "29.5.14", "@types/jsdom": "21.1.7", + "@playwright/test": "^1.50.0", "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@ungap/structured-clone": "1.3.1", @@ -35,6 +38,7 @@ "jest-environment-jsdom": "29.7.0", "jsdom": "22.1.0", "mini-css-extract-plugin": "2.9.4", + "serve": "^14.2.0", "style-loader": "3.3.4", "ts-jest": "29.4.6", "ts-loader": "9.5.4", @@ -59,7 +63,8 @@ "testPathIgnorePatterns": [ "/node_modules/", "/src/__tests__/setup.ts", - "/src/__tests__/mocks/" + "/src/__tests__/mocks/", + "/tests-e2e/" ], "moduleNameMapper": { "\\.(css|less|scss|sass)$": "/src/__tests__/mocks/styleMock.ts" @@ -67,7 +72,8 @@ "collectCoverageFrom": [ "src/**/*.ts", "!src/__tests__/**", - "!src/**/*.d.ts" + "!src/**/*.d.ts", + "!tests-e2e/**" ], "coverageDirectory": "coverage", "coverageThreshold": { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..886074163 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,82 @@ +/** + * Playwright config for the frontend end-to-end smoke suite. + * + * Scope: real-browser smoke against the production webpack bundle (`dist/`), + * with API responses mocked at the network layer via `page.route`. Catches + * CSS / layout / sticky-positioning / IntersectionObserver edges that the + * jsdom-based jest suite cannot simulate (see issue #167). + * + * The suite stays hermetic on purpose: + * - No live cloud account, no Go backend boot. + * - The `webServer` block boots `npx serve` against `dist/` so the test + * hits the same minified bundle CI ships to production. + * - Mocks are wired per-test in tests-e2e/fixtures/recs.ts so individual + * tests get fresh `{ calls }` capture arrays (no shared mutable state). + * + * Browsers: Chromium only. Per the plan in issue #167, multi-browser + * coverage (Firefox / WebKit) is explicitly out of scope — the CSS / + * sticky-positioning concerns the smoke targets are Chromium-faithful in + * practice, and the single-project setup keeps CI under ~90s. + */ + +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4173; +const HOST = '127.0.0.1'; + +export default defineConfig({ + testDir: 'tests-e2e', + testMatch: '**/*.spec.ts', + + /* Run tests in parallel within a single browser project. */ + fullyParallel: true, + + /* Fail the build on `test.only` left in source. */ + forbidOnly: !!process.env.CI, + + /* Retry once on CI to absorb transient flakes (e.g. webServer warmup). */ + retries: process.env.CI ? 1 : 0, + + /* Use a single worker on CI for predictable artefacts; full parallel locally. */ + workers: process.env.CI ? 1 : undefined, + + reporter: process.env.CI + ? [['list'], ['html', { open: 'never' }]] + : [['list']], + + use: { + baseURL: `http://${HOST}:${PORT}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + actionTimeout: 10_000, + navigationTimeout: 15_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /** + * Boot a static file server against the built `dist/` bundle before + * the tests run, and tear it down after. + * + * `serve -s` ("single-page-app" mode) falls back to `index.html` for + * any unmatched path. That matters because the SPA may push routes + * the static server otherwise wouldn't recognise. + * + * `reuseExistingServer: !process.env.CI` — locally, leave a running + * `npx serve` alone so iteration is fast; on CI always boot fresh. + */ + webServer: { + command: `npx serve -s dist -l ${PORT} --no-clipboard`, + url: `http://${HOST}:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts new file mode 100644 index 000000000..82ede3dd9 --- /dev/null +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -0,0 +1,301 @@ +/** + * Canned API fixtures + page.route helper for the Recommendations smoke spec + * (issue #167). + * + * The fixture seeds ~19 recommendations spanning the matrix needed by every + * checklist assertion: + * - 3 providers (aws / azure / gcp) + * - 4 services (ec2 / rds / aks / gce) + * - 2 terms (1y / 3y) + * - monthly savings spanning [10, 50, 100, 150, 250, 1200] so numeric + * expressions like ">100", "50..200", "5, 50..200, >1000" narrow to + * specific counts the assertions can pin against. + * + * `mockApi(page)` wires `page.route` against every `/api/*` endpoint the + * Opportunities tab touches on load, plus the POST endpoints the bottom- + * action box invokes (`/purchases/execute`, `/plans`). Each call is recorded + * on the returned `calls` array so tests can assert on URLs and POST bodies + * without leaning on `expect.assertions`-style counts. + * + * The fixture is intentionally stateless across tests — each test calls + * `mockApi(page)` for a fresh `{ calls }` record. Tests are free to + * post-hoc override individual routes with their own `page.route` calls + * (which take precedence over the catch-all installed here). + */ + +import type { Page, Route } from '@playwright/test'; + +// Mirrors api.Provider (frontend/src/api/types.ts) — kept inline to avoid +// pulling the full type graph into a Playwright test bundle. +export type Provider = 'aws' | 'azure' | 'gcp'; + +// LocalRecommendation subset used by the smoke. The full type ships extra +// optional fields the table renders defensively, so we set only what the +// table reads. +export interface SmokeRec { + id: string; + provider: Provider; + cloud_account_id: string; + service: string; + resource_type: string; + region: string; + count: number; + term: 1 | 3; + payment: 'all-upfront' | 'partial-upfront' | 'no-upfront' | 'monthly'; + savings: number; + upfront_cost: number; + monthly_cost: number; + on_demand_monthly: number; + effective_savings_pct: number; +} + +/** + * 19 rows — chosen so: + * - `Provider = AWS` narrows to 11. + * - `Service = ec2` narrows to 6. + * - `Monthly Savings > 100` narrows to 9 (savings in {150, 250, 1200}). + * - `Monthly Savings 50..200` narrows to 9 (savings in {50, 100, 150}). + * - `Monthly Savings 5, 50..200, >1000` narrows to 11 (adds the {10, 1200}). + * - Two different terms (1y, 3y) present in any single-provider slice so + * multi-term selections produce >1 fan-out bucket on Purchase. + */ +export const RECS: SmokeRec[] = [ + { id: 'r01', provider: 'aws', cloud_account_id: 'acct-001', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 4, term: 1, payment: 'all-upfront', savings: 150, upfront_cost: 2400, monthly_cost: 350, on_demand_monthly: 500, effective_savings_pct: 30 }, + { id: 'r02', provider: 'aws', cloud_account_id: 'acct-001', service: 'ec2', resource_type: 't3.large', region: 'us-east-1', count: 2, term: 3, payment: 'no-upfront', savings: 250, upfront_cost: 0, monthly_cost: 280, on_demand_monthly: 530, effective_savings_pct: 47 }, + { id: 'r03', provider: 'aws', cloud_account_id: 'acct-001', service: 'ec2', resource_type: 'm5.xlarge', region: 'us-west-2', count: 1, term: 1, payment: 'partial-upfront', savings: 50, upfront_cost: 600, monthly_cost: 200, on_demand_monthly: 250, effective_savings_pct: 20 }, + { id: 'r04', provider: 'aws', cloud_account_id: 'acct-002', service: 'ec2', resource_type: 'm5.large', region: 'eu-west-1', count: 3, term: 3, payment: 'all-upfront', savings: 1200, upfront_cost: 8400, monthly_cost: 0, on_demand_monthly: 1200, effective_savings_pct: 60 }, + { id: 'r05', provider: 'aws', cloud_account_id: 'acct-002', service: 'ec2', resource_type: 'c5.large', region: 'eu-west-1', count: 2, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 1600, monthly_cost: 0, on_demand_monthly: 200, effective_savings_pct: 50 }, + { id: 'r06', provider: 'aws', cloud_account_id: 'acct-002', service: 'ec2', resource_type: 'r5.large', region: 'us-east-1', count: 1, term: 1, payment: 'no-upfront', savings: 10, upfront_cost: 0, monthly_cost: 150, on_demand_monthly: 160, effective_savings_pct: 6 }, + { id: 'r07', provider: 'aws', cloud_account_id: 'acct-001', service: 'rds', resource_type: 'db.t3.medium', region: 'us-east-1', count: 1, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 800, monthly_cost: 130, on_demand_monthly: 180, effective_savings_pct: 28 }, + { id: 'r08', provider: 'aws', cloud_account_id: 'acct-001', service: 'rds', resource_type: 'db.m5.large', region: 'us-west-2', count: 2, term: 3, payment: 'all-upfront', savings: 150, upfront_cost: 3600, monthly_cost: 0, on_demand_monthly: 290, effective_savings_pct: 52 }, + { id: 'r09', provider: 'aws', cloud_account_id: 'acct-002', service: 'rds', resource_type: 'db.r5.xlarge', region: 'eu-west-1', count: 1, term: 1, payment: 'no-upfront', savings: 100, upfront_cost: 0, monthly_cost: 450, on_demand_monthly: 550, effective_savings_pct: 18 }, + { id: 'r10', provider: 'aws', cloud_account_id: 'acct-002', service: 'rds', resource_type: 'db.m5.xlarge', region: 'us-east-1', count: 1, term: 3, payment: 'partial-upfront', savings: 150, upfront_cost: 1800, monthly_cost: 280, on_demand_monthly: 450, effective_savings_pct: 33 }, + { id: 'r11', provider: 'aws', cloud_account_id: 'acct-001', service: 'rds', resource_type: 'db.r5.large', region: 'us-west-2', count: 2, term: 1, payment: 'all-upfront', savings: 50, upfront_cost: 700, monthly_cost: 220, on_demand_monthly: 280, effective_savings_pct: 21 }, + { id: 'r12', provider: 'azure', cloud_account_id: 'acct-100', service: 'aks', resource_type: 'Standard_D2_v3', region: 'eastus', count: 3, term: 1, payment: 'all-upfront', savings: 100, upfront_cost: 1500, monthly_cost: 200, on_demand_monthly: 320, effective_savings_pct: 31 }, + { id: 'r13', provider: 'azure', cloud_account_id: 'acct-100', service: 'aks', resource_type: 'Standard_D4_v3', region: 'eastus', count: 1, term: 3, payment: 'all-upfront', savings: 150, upfront_cost: 3000, monthly_cost: 0, on_demand_monthly: 280, effective_savings_pct: 53 }, + { id: 'r14', provider: 'azure', cloud_account_id: 'acct-101', service: 'aks', resource_type: 'Standard_F2_v2', region: 'westeurope', count: 2, term: 1, payment: 'no-upfront', savings: 50, upfront_cost: 0, monthly_cost: 140, on_demand_monthly: 190, effective_savings_pct: 26 }, + { id: 'r15', provider: 'azure', cloud_account_id: 'acct-100', service: 'aks', resource_type: 'Standard_E4_v3', region: 'eastus', count: 1, term: 3, payment: 'partial-upfront', savings: 250, upfront_cost: 4200, monthly_cost: 120, on_demand_monthly: 470, effective_savings_pct: 53 }, + { id: 'r16', provider: 'gcp', cloud_account_id: 'acct-200', service: 'gce', resource_type: 'n2-standard-2', region: 'us-central1', count: 4, term: 1, payment: 'monthly', savings: 100, upfront_cost: 0, monthly_cost: 240, on_demand_monthly: 340, effective_savings_pct: 29 }, + { id: 'r17', provider: 'gcp', cloud_account_id: 'acct-200', service: 'gce', resource_type: 'n2-standard-4', region: 'us-central1', count: 1, term: 3, payment: 'all-upfront', savings: 250, upfront_cost: 5400, monthly_cost: 0, on_demand_monthly: 410, effective_savings_pct: 61 }, + { id: 'r18', provider: 'gcp', cloud_account_id: 'acct-201', service: 'gce', resource_type: 'e2-small', region: 'europe-west1', count: 2, term: 1, payment: 'monthly', savings: 10, upfront_cost: 0, monthly_cost: 80, on_demand_monthly: 95, effective_savings_pct: 11 }, + { id: 'r19', provider: 'gcp', cloud_account_id: 'acct-201', service: 'gce', resource_type: 'c2-standard-4', region: 'europe-west1', count: 1, term: 3, payment: 'no-upfront', savings: 150, upfront_cost: 0, monthly_cost: 220, on_demand_monthly: 380, effective_savings_pct: 42 }, +]; + +export const ACCOUNTS = [ + { id: 'acct-001', name: 'AWS Prod (acct-001)', provider: 'aws', external_id: '111111111111' }, + { id: 'acct-002', name: 'AWS Staging (acct-002)', provider: 'aws', external_id: '222222222222' }, + { id: 'acct-100', name: 'Azure Prod (acct-100)', provider: 'azure', external_id: 'sub-aaaa' }, + { id: 'acct-101', name: 'Azure Dev (acct-101)', provider: 'azure', external_id: 'sub-bbbb' }, + { id: 'acct-200', name: 'GCP Prod (acct-200)', provider: 'gcp', external_id: 'project-zzzz' }, + { id: 'acct-201', name: 'GCP Sandbox (acct-201)', provider: 'gcp', external_id: 'project-yyyy' }, +]; + +export const SUMMARY = { + total_recommendations: RECS.length, + total_upfront_cost: RECS.reduce((s, r) => s + r.upfront_cost, 0), + potential_monthly_savings: RECS.reduce((s, r) => s + r.savings, 0), + avg_payback_months: 6, +}; + +/** + * Record of an intercepted HTTP call. The smoke spec asserts on these to + * confirm e.g. that toggling Provider triggers a backend re-fetch, while + * toggling Service does not. + */ +export interface ApiCall { + url: string; + method: string; + postData: string | null; +} + +export interface MockHandle { + /** All intercepted /api/* calls in the order they fired. */ + calls: ApiCall[]; + /** + * Add a one-shot delay (ms) to the next `/api/recommendations*` GET so a + * test can observe `aria-busy="true"` between the request and its response. + * Cleared after one use. + */ + delayNextRecommendationsFetch(ms: number): void; +} + +/** + * Build a JSON response with sensible defaults. + */ +function jsonRoute(route: Route, body: unknown, status = 200): Promise { + return route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +/** + * Wire `page.route` against every `/api/*` endpoint the Opportunities tab + * exercises. Returns a `MockHandle` whose `.calls` array captures the URL, + * method, and POST body of every intercepted request. + */ +export async function mockApi(page: Page): Promise { + const calls: ApiCall[] = []; + let pendingDelayMs = 0; + + const record = (route: Route): void => { + const request = route.request(); + calls.push({ + url: request.url(), + method: request.method(), + postData: request.postData(), + }); + }; + + // Public info — admin already exists, login modal does not appear. + await page.route('**/api/public-info', async (route) => { + record(route); + await jsonRoute(route, { + admin_exists: true, + api_key_secret_url: '', + version: 'smoke-test', + }); + }); + + // Auth me — returns a logged-in user so updateUserUI populates header. + await page.route('**/api/auth/me', async (route) => { + record(route); + await jsonRoute(route, { + id: 'user-smoke', + email: 'smoke@example.com', + role: 'admin', + }); + }); + + // Config — empty global defaults so cachedGlobalDefault* stays at fallback. + await page.route('**/api/config', async (route) => { + record(route); + await jsonRoute(route, { global: {}, services: {} }); + }); + + // Accounts — feeds accountNamesCache so categorical popovers show names. + await page.route('**/api/accounts**', async (route) => { + record(route); + if (route.request().method() !== 'GET') { + await route.fulfill({ status: 405, body: '' }); + return; + } + await jsonRoute(route, ACCOUNTS); + }); + + // Freshness — fresh, no error, so auto-refresh-on-stale stays quiet. + await page.route('**/api/recommendations/freshness', async (route) => { + record(route); + await jsonRoute(route, { + last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + last_collection_error: null, + }); + }); + + // Refresh — synchronous-success path. Tests that need to assert on refresh + // behaviour can override this with their own page.route. + await page.route('**/api/recommendations/refresh', async (route) => { + record(route); + await jsonRoute(route, { + started_at: new Date().toISOString(), + last_collected_at: new Date().toISOString(), + }); + }); + + // Main recommendations list. Filtered server-side by the `provider` query + // param (the only filter the frontend still pushes down — Service / Region + // / numerics are all client-side via applyColumnFilters). + await page.route('**/api/recommendations*', async (route) => { + record(route); + if (route.request().method() !== 'GET') { + await route.fulfill({ status: 405, body: '' }); + return; + } + const u = new URL(route.request().url()); + const provider = u.searchParams.get('provider'); + let rows = RECS; + if (provider && provider !== 'all' && provider !== '') { + rows = RECS.filter((r) => r.provider === provider); + } + if (pendingDelayMs > 0) { + const wait = pendingDelayMs; + pendingDelayMs = 0; + await new Promise((res) => setTimeout(res, wait)); + } + await jsonRoute(route, { + recommendations: rows, + summary: SUMMARY, + regions: ['us-east-1', 'us-west-2', 'eu-west-1', 'eastus', 'westeurope', 'us-central1', 'europe-west1'], + }); + }); + + // Purchase execute — POST. We always return a fake execution id so the + // toast path resolves; tests assert on the recorded POST body. + await page.route('**/api/purchases/execute', async (route) => { + record(route); + await jsonRoute(route, { + execution_id: `exec-${Date.now()}`, + status: 'completed', + total_savings: 0, + total_upfront: 0, + results: [], + }); + }); + + // Plans CRUD — POST creates, GET lists. The smoke only exercises POST + // from the bottom-action-box "Create Plan" flow. + await page.route('**/api/plans**', async (route) => { + record(route); + const method = route.request().method(); + if (method === 'POST') { + await jsonRoute(route, { id: `plan-${Date.now()}`, name: 'smoke-plan' }); + return; + } + if (method === 'GET') { + await jsonRoute(route, []); + return; + } + await route.fulfill({ status: 405, body: '' }); + }); + + // Per-id detail — covers the row-click drawer. Returns a benign empty + // payload so accidental row clicks (e.g. while clicking a checkbox) + // do not crash on a 404. + await page.route('**/api/recommendations/*/detail', async (route) => { + record(route); + await jsonRoute(route, { + id: 'detail-smoke', + usage_history: [], + confidence_bucket: 'low', + provenance_note: '', + }); + }); + + // Catch-all for any /api/* not covered above — fail fast on tests' first + // unexpected call so we notice when the SPA adds a new dependency. + await page.route('**/api/**', async (route) => { + record(route); + await jsonRoute(route, {}, 404); + }); + + return { + calls, + delayNextRecommendationsFetch(ms: number): void { + pendingDelayMs = ms; + }, + }; +} + +/** + * Seed sessionStorage with an auth token before page navigation so the + * SPA's `isAuthenticated()` returns true and the login modal stays hidden. + * + * Must be called BEFORE `page.goto(...)`. + */ +export async function seedAuth(page: Page): Promise { + await page.addInitScript(() => { + sessionStorage.setItem('authToken', 'smoke-token'); + sessionStorage.setItem('csrfToken', 'smoke-csrf'); + }); +} From bc8033428eb3f1d4623d0b9691bd66ce4d640e99 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 5 Jun 2026 15:45:50 +0200 Subject: [PATCH 2/7] test(e2e/recs): fix catch-all route precedence + update fixture counts Playwright executes page.route handlers in reverse-registration order (last registered = first executed). The catch-all '**/api/**' was registered last, causing it to intercept all requests before the specific handlers could run. Move it to be registered first so specific routes (public-info, auth/me, recommendations, etc.) correctly take precedence. Also correct the RECS fixture comment cardinalities that were out of sync with actual data: - "50..200" (inclusive range per parseNumericFilter): 13 rows, not 9 - "5, 50..200, >1000": 14 rows, not 11 (literal "5" matches no rows; ">1000" adds r04 with savings=1200 to the 13-row set) Bump minimum version bounds for devDependencies to current latest: - @playwright/test: ^1.50.0 -> ^1.60.0 - serve: ^14.2.0 -> ^14.2.6 --- frontend/package.json | 4 ++-- frontend/tests-e2e/fixtures/recs.ts | 33 +++++++++++++++++------------ 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index a66f15602..ba7b0dff3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,7 @@ "@types/chart.js": "2.9.41", "@types/jest": "29.5.14", "@types/jsdom": "21.1.7", - "@playwright/test": "^1.50.0", + "@playwright/test": "^1.60.0", "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@ungap/structured-clone": "1.3.1", @@ -38,7 +38,7 @@ "jest-environment-jsdom": "29.7.0", "jsdom": "22.1.0", "mini-css-extract-plugin": "2.9.4", - "serve": "^14.2.0", + "serve": "^14.2.6", "style-loader": "3.3.4", "ts-jest": "29.4.6", "ts-loader": "9.5.4", diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts index 82ede3dd9..8d36e8fd0 100644 --- a/frontend/tests-e2e/fixtures/recs.ts +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -17,10 +17,11 @@ * on the returned `calls` array so tests can assert on URLs and POST bodies * without leaning on `expect.assertions`-style counts. * - * The fixture is intentionally stateless across tests — each test calls + * The fixture is intentionally stateless across tests - each test calls * `mockApi(page)` for a fresh `{ calls }` record. Tests are free to * post-hoc override individual routes with their own `page.route` calls - * (which take precedence over the catch-all installed here). + * (which take precedence over the catch-all installed here, because Playwright + * executes handlers in reverse-registration order). */ import type { Page, Route } from '@playwright/test'; @@ -50,12 +51,14 @@ export interface SmokeRec { } /** - * 19 rows — chosen so: + * 19 rows - chosen so: * - `Provider = AWS` narrows to 11. * - `Service = ec2` narrows to 6. * - `Monthly Savings > 100` narrows to 9 (savings in {150, 250, 1200}). - * - `Monthly Savings 50..200` narrows to 9 (savings in {50, 100, 150}). - * - `Monthly Savings 5, 50..200, >1000` narrows to 11 (adds the {10, 1200}). + * - `Monthly Savings 50..200` narrows to 13 (inclusive range; savings in + * {50, 100, 150}). + * - `Monthly Savings 5, 50..200, >1000` narrows to 14 (adds row r04 with + * savings=1200 to the 50..200 set; the literal "5" matches no rows). * - Two different terms (1y, 3y) present in any single-provider slice so * multi-term selections produce >1 fan-out bucket on Purchase. */ @@ -148,7 +151,18 @@ export async function mockApi(page: Page): Promise { }); }; - // Public info — admin already exists, login modal does not appear. + // Catch-all for any /api/* not covered by specific handlers below. + // Registered FIRST so specific handlers (registered after this) take + // precedence: Playwright executes handlers in reverse-registration order, + // so the last-registered handler wins for any URL that matches multiple + // patterns. Registering the catch-all first ensures it only fires for + // truly unmatched requests. + await page.route('**/api/**', async (route) => { + record(route); + await jsonRoute(route, {}, 404); + }); + + // Public info - admin already exists, login modal does not appear. await page.route('**/api/public-info', async (route) => { record(route); await jsonRoute(route, { @@ -272,13 +286,6 @@ export async function mockApi(page: Page): Promise { }); }); - // Catch-all for any /api/* not covered above — fail fast on tests' first - // unexpected call so we notice when the SPA adds a new dependency. - await page.route('**/api/**', async (route) => { - record(route); - await jsonRoute(route, {}, 404); - }); - return { calls, delayNextRecommendationsFetch(ms: number): void { From eea7cba68c4c6dd40de079856073b56f3a8ffd74 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 11 Jun 2026 00:20:07 -0700 Subject: [PATCH 3/7] test(e2e/recs): sync package-lock root ranges after dep bumps The previous commit bumped @playwright/test to ^1.60.0 and serve to ^14.2.6 in package.json, but package-lock.json's root package entry still recorded the old ^1.50.0 / ^14.2.0 ranges, which makes `npm ci` fail its package.json/lockfile sync check. The resolved versions were already 1.60.0 / 14.2.6, so this is a metadata-only sync via `npm install --package-lock-only`. Verified: npm ci, npx tsc --noEmit (incl. playwright.config.ts and tests-e2e/fixtures/recs.ts), npm test (77 suites / 2553 tests pass, jest still excludes tests-e2e/), go build ./... after rebasing onto origin/main. --- frontend/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3725480a4..6942a66bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,7 +16,7 @@ "@babel/core": "^7.23.0", "@babel/preset-env": "^7.23.0", "@babel/preset-typescript": "^7.23.0", - "@playwright/test": "^1.50.0", + "@playwright/test": "^1.60.0", "@testing-library/dom": "^9.3.0", "@testing-library/jest-dom": "^6.1.0", "@types/chart.js": "^2.9.41", @@ -35,7 +35,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdom": "^22.1.0", "mini-css-extract-plugin": "^2.7.0", - "serve": "^14.2.0", + "serve": "^14.2.6", "style-loader": "^3.3.0", "ts-jest": "^29.1.0", "ts-loader": "^9.5.0", From b1e3fdfa0c9409f0f31e29f6506217302e0bea34 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 16:50:24 +0200 Subject: [PATCH 4/7] fix(e2e/recs): register freshness and refresh routes after recommendations wildcard **/api/recommendations/freshness and .../refresh were registered before **/api/recommendations* in mockApi(). Playwright executes handlers in reverse-registration order, so the wildcard (registered later) was winning and returning the full recs list instead of the freshness/refresh payloads. Move both specific handlers to after the wildcard so they take precedence as intended. --- frontend/tests-e2e/fixtures/recs.ts | 47 +++++++++++++++++------------ 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts index 8d36e8fd0..d6196a693 100644 --- a/frontend/tests-e2e/fixtures/recs.ts +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -198,28 +198,14 @@ export async function mockApi(page: Page): Promise { await jsonRoute(route, ACCOUNTS); }); - // Freshness — fresh, no error, so auto-refresh-on-stale stays quiet. - await page.route('**/api/recommendations/freshness', async (route) => { - record(route); - await jsonRoute(route, { - last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), - last_collection_error: null, - }); - }); - - // Refresh — synchronous-success path. Tests that need to assert on refresh - // behaviour can override this with their own page.route. - await page.route('**/api/recommendations/refresh', async (route) => { - record(route); - await jsonRoute(route, { - started_at: new Date().toISOString(), - last_collected_at: new Date().toISOString(), - }); - }); - // Main recommendations list. Filtered server-side by the `provider` query // param (the only filter the frontend still pushes down — Service / Region // / numerics are all client-side via applyColumnFilters). + // + // Registered before the more-specific /freshness, /refresh, and /*/detail + // handlers below: Playwright runs handlers in reverse-registration order, so + // those later-registered patterns take precedence over this wildcard when the + // URL matches both. await page.route('**/api/recommendations*', async (route) => { record(route); if (route.request().method() !== 'GET') { @@ -244,6 +230,29 @@ export async function mockApi(page: Page): Promise { }); }); + // Freshness — fresh, no error, so auto-refresh-on-stale stays quiet. + // Registered AFTER **/api/recommendations* so it takes precedence over the + // wildcard for freshness-specific URLs. + await page.route('**/api/recommendations/freshness', async (route) => { + record(route); + await jsonRoute(route, { + last_collected_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + last_collection_error: null, + }); + }); + + // Refresh — synchronous-success path. Tests that need to assert on refresh + // behaviour can override this with their own page.route. + // Registered AFTER **/api/recommendations* so it takes precedence over the + // wildcard for refresh-specific URLs. + await page.route('**/api/recommendations/refresh', async (route) => { + record(route); + await jsonRoute(route, { + started_at: new Date().toISOString(), + last_collected_at: new Date().toISOString(), + }); + }); + // Purchase execute — POST. We always return a fake execution id so the // toast path resolves; tests assert on the recorded POST body. await page.route('**/api/purchases/execute', async (route) => { From 7deb03f1a7ddffb3c2c2384d7f573b462538adf0 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 19 Jul 2026 23:24:35 +0200 Subject: [PATCH 5/7] test(recommendations): add Playwright smoke coverage Exercise filtering, loading, selection, purchase, and plan flows through the production frontend bundle. Run the Chromium smoke suite for frontend pull requests and retain browser artifacts when it fails. --- .github/workflows/frontend-e2e.yml | 61 +++++++++ frontend/tests-e2e/fixtures/recs.ts | 22 +++- frontend/tests-e2e/recommendations.spec.ts | 136 +++++++++++++++++++++ 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/frontend-e2e.yml create mode 100644 frontend/tests-e2e/recommendations.spec.ts diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml new file mode 100644 index 000000000..946330d2b --- /dev/null +++ b/.github/workflows/frontend-e2e.yml @@ -0,0 +1,61 @@ +name: Frontend E2E (PR) + +on: + pull_request: + branches: + - main + paths: + - "frontend/**" + - ".github/workflows/frontend-e2e.yml" + +permissions: + contents: read + +concurrency: + group: frontend-e2e-pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright Chromium + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: frontend + env: + CI: "true" + + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright failure artifacts + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: playwright-report-${{ github.run_id }} + path: | + frontend/playwright-report/ + frontend/test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts index d6196a693..e6bda3562 100644 --- a/frontend/tests-e2e/fixtures/recs.ts +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -178,7 +178,23 @@ export async function mockApi(page: Page): Promise { await jsonRoute(route, { id: 'user-smoke', email: 'smoke@example.com', - role: 'admin', + groups: [ + '00000000-0000-5000-8000-000000000001', + '00000000-0000-5000-8000-000000000007', + ], + }); + }); + + // Effective permissions mirror the Administrator + Purchaser group union. + // The purchase verb is deliberately explicit because it is carved out of + // admin:* in the production authorization model. + await page.route('**/api/auth/me/permissions', async (route) => { + record(route); + await jsonRoute(route, { + permissions: [ + { action: 'admin', resource: '*' }, + { action: 'execute', resource: 'purchases' }, + ], }); }); @@ -279,6 +295,10 @@ export async function mockApi(page: Page): Promise { await jsonRoute(route, []); return; } + if (method === 'PUT' && new URL(route.request().url()).pathname.endsWith('/accounts')) { + await jsonRoute(route, {}); + return; + } await route.fulfill({ status: 405, body: '' }); }); diff --git a/frontend/tests-e2e/recommendations.spec.ts b/frontend/tests-e2e/recommendations.spec.ts new file mode 100644 index 000000000..4e3e632fb --- /dev/null +++ b/frontend/tests-e2e/recommendations.spec.ts @@ -0,0 +1,136 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { mockApi, RECS, seedAuth, type MockHandle, type SmokeRec } from './fixtures/recs'; + +async function openOpportunities(page: Page): Promise { + await seedAuth(page); + await page.goto('/'); + await page.getByRole('tab', { name: 'Opportunities' }).click(); + await expect(page.locator('.recommendations-filter-live')).toContainText( + `Showing ${RECS.length} of ${RECS.length} recommendations`, + ); +} + +async function selectRecommendation(page: Page, id: string): Promise { + const row = page.locator(`tr.recommendation-row[data-rec-id="${id}"]`); + await row.getByRole('checkbox', { name: 'Select recommendation' }).check(); +} + +function callsTo(handle: MockHandle, suffix: string): Array> { + return handle.calls + .filter((call) => call.method === 'POST' && new URL(call.url).pathname === suffix) + .map((call) => JSON.parse(call.postData ?? '{}') as Record); +} + +test('filters rows by a numeric expression derived from the fixture', async ({ page }) => { + await mockApi(page); + await openOpportunities(page); + + const expected = RECS.filter((rec) => rec.savings > 100); + await page.getByRole('button', { name: 'Filter Monthly Savings' }).click(); + const expression = page.getByRole('dialog', { name: 'Filter Monthly Savings' }) + .getByLabel('Expression'); + await expression.fill('>100'); + await expression.press('Enter'); + + await expect(page.locator('tr.recommendation-row')).toHaveCount(expected.length); + await expect(page.locator('.recommendations-filter-live')).toHaveText( + `Showing ${expected.length} of ${RECS.length} recommendations`, + ); +}); + +test('shows loading and zero-result filter states without losing filter controls', async ({ page }) => { + const api = await mockApi(page); + await openOpportunities(page); + await page.getByRole('tab', { name: 'Home' }).click(); + api.delayNextRecommendationsFetch(3_000); + + await Promise.all([ + expect(page.locator('#recommendations-list .skeleton-row')).toHaveCount(5), + page.getByRole('tab', { name: 'Opportunities' }).click(), + ]); + await expect(page.locator('.recommendations-filter-live')).toContainText(`Showing ${RECS.length}`); + + await page.getByRole('button', { name: 'Filter Monthly Savings' }).click(); + const expression = page.getByRole('dialog', { name: 'Filter Monthly Savings' }) + .getByLabel('Expression'); + await expression.fill('>999999'); + await expression.press('Enter'); + + await expect(page.locator('#recommendations-list thead')).toBeVisible(); + await expect(page.locator('#recommendations-list tbody .empty')).toHaveText( + 'No rows match these filters.', + ); + await expect(page.locator('#recommendations-action-summary')).toHaveText( + '(0 visible — adjust filters)', + ); +}); + +test('selection updates the sticky action summary and submits the selected purchase', async ({ page }) => { + const api = await mockApi(page); + await openOpportunities(page); + const selected = RECS.find((rec) => rec.id === 'r01') as SmokeRec; + + await selectRecommendation(page, selected.id); + await expect(page.locator('.recommendations-filter-live')).toContainText('1 selected'); + await expect(page.locator('#recommendations-action-summary')).toContainText('across 1 cell'); + await page.getByRole('button', { name: 'Purchase 1 selected' }).click(); + await expect(page.getByRole('dialog', { name: 'Configure Purchase' })).toBeVisible(); + await page.getByRole('button', { name: 'Send for Approval' }).click(); + await page.locator('.modal-confirm-backdrop') + .getByRole('button', { name: 'Send for approval' }) + .click(); + + await expect.poll(() => callsTo(api, '/api/purchases/execute').length).toBe(1); + const body = callsTo(api, '/api/purchases/execute')[0] as { + recommendations: Array>; + }; + expect(body.recommendations).toHaveLength(1); + expect(body.recommendations[0]).toMatchObject({ + id: selected.id, + provider: selected.provider, + service: selected.service, + term: selected.term, + payment: selected.payment, + count: selected.count, + recommended_count: selected.count, + selected: true, + purchased: false, + }); +}); + +test('submits a plan with the selected recommendation snapshot', async ({ page }) => { + const api = await mockApi(page); + await openOpportunities(page); + const selected = RECS.find((rec) => rec.id === 'r01') as SmokeRec; + + await selectRecommendation(page, selected.id); + await page.getByRole('button', { name: 'Plan from 1 selected' }).click(); + const modal = page.getByRole('dialog', { name: 'Create Purchase Plan' }); + await expect(modal).toBeVisible(); + await expect(modal.getByLabel('Provider:')).toHaveValue(selected.provider); + await expect(modal.getByLabel('Service:')).toHaveValue(selected.service); + await expect(modal.getByLabel('Term:')).toHaveValue(String(selected.term)); + await expect(modal.getByLabel('Payment:')).toHaveValue(selected.payment); + await expect(modal.locator('#plan-account-ids')).toHaveValue(selected.cloud_account_id); + await modal.getByRole('button', { name: 'Save Plan' }).click(); + + await expect.poll(() => callsTo(api, '/api/plans').length).toBe(1); + const body = callsTo(api, '/api/plans')[0] as { + provider: string; + service: string; + term: number; + payment: string; + target_accounts: string[]; + recommendations: SmokeRec[]; + }; + expect(body).toMatchObject({ + provider: selected.provider, + service: selected.service, + term: selected.term, + payment: selected.payment, + target_accounts: [selected.cloud_account_id], + }); + expect(body.recommendations).toEqual([selected]); + await expect(modal).toBeHidden(); +}); From 2d105151050f35bcd332cf570f24e535ea1afc3f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 20 Jul 2026 16:50:28 +0200 Subject: [PATCH 6/7] fix(e2e): sync lockfile after rebase Regenerate the frontend lockfile against the dependency versions pinned on main so npm ci can install the rebased Playwright test dependencies. --- frontend/package-lock.json | 95 +++++++++++++++++++------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6942a66bc..4be6aa88e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,35 +13,35 @@ "qrcode": "1.5.4" }, "devDependencies": { - "@babel/core": "^7.23.0", - "@babel/preset-env": "^7.23.0", - "@babel/preset-typescript": "^7.23.0", + "@babel/core": "7.29.7", + "@babel/preset-env": "7.28.5", + "@babel/preset-typescript": "7.28.5", "@playwright/test": "^1.60.0", - "@testing-library/dom": "^9.3.0", - "@testing-library/jest-dom": "^6.1.0", - "@types/chart.js": "^2.9.41", - "@types/jest": "^29.5.0", - "@types/jsdom": "^21.1.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@ungap/structured-clone": "^1.3.1", - "babel-loader": "^9.1.0", - "copy-webpack-plugin": "^14.0.0", - "css-loader": "^6.8.0", - "css-minimizer-webpack-plugin": "^8.0.0", - "eslint": "^8.50.0", - "html-webpack-plugin": "^5.5.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "jsdom": "^22.1.0", - "mini-css-extract-plugin": "^2.7.0", + "@testing-library/dom": "9.3.4", + "@testing-library/jest-dom": "6.9.1", + "@types/chart.js": "2.9.41", + "@types/jest": "29.5.14", + "@types/jsdom": "21.1.7", + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@ungap/structured-clone": "1.3.1", + "babel-loader": "9.2.1", + "copy-webpack-plugin": "14.0.0", + "css-loader": "6.11.0", + "css-minimizer-webpack-plugin": "8.0.0", + "eslint": "8.57.1", + "html-webpack-plugin": "5.6.5", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "jsdom": "22.1.0", + "mini-css-extract-plugin": "2.9.4", "serve": "^14.2.6", - "style-loader": "^3.3.0", - "ts-jest": "^29.1.0", - "ts-loader": "^9.5.0", - "typescript": "^5.3.0", - "webpack": "^5.88.0", - "webpack-cli": "^5.1.0" + "style-loader": "3.3.4", + "ts-jest": "29.4.6", + "ts-loader": "9.5.4", + "typescript": "5.9.3", + "webpack": "5.108.3", + "webpack-cli": "5.1.4" } }, "node_modules/@adobe/css-tools": { @@ -9223,16 +9223,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10252,16 +10242,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -10614,6 +10594,27 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", From ab4eb36f0aa60280b1edc2dc1c7f1412ba0a52e8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 21 Jul 2026 22:08:52 +0200 Subject: [PATCH 7/7] ci(e2e): set persist-credentials false on frontend-e2e checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright job runs `npm ci`, `npm run build`, and `npx playwright install` — none of these need the actions/checkout token. Setting `persist-credentials: false` scrubs the credential from `.git/config` so the token is not accessible to third-party install scripts that run in the same job. Addresses the CodeRabbit / zizmor `artipacked` finding. --- .github/workflows/frontend-e2e.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml index 946330d2b..902f7d9c1 100644 --- a/.github/workflows/frontend-e2e.yml +++ b/.github/workflows/frontend-e2e.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0