diff --git a/.github/workflows/frontend-e2e.yml b/.github/workflows/frontend-e2e.yml new file mode 100644 index 000000000..902f7d9c1 --- /dev/null +++ b/.github/workflows/frontend-e2e.yml @@ -0,0 +1,63 @@ +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 + with: + persist-credentials: false + + - 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/.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..4be6aa88e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@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.4", "@testing-library/jest-dom": "6.9.1", "@types/chart.js": "2.9.41", @@ -34,6 +35,7 @@ "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.4", "ts-jest": "29.4.6", "ts-loader": "9.5.4", @@ -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,13 @@ "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8931,6 +9357,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 +10242,42 @@ ], "license": "MIT" }, + "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 +10371,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", @@ -10061,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", @@ -10186,6 +10740,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 +11836,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 +11897,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 +12234,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..ba7b0dff3 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.60.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.6", "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..e6bda3562 --- /dev/null +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -0,0 +1,337 @@ +/** + * 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, because Playwright + * executes handlers in reverse-registration order). + */ + +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 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. + */ +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(), + }); + }; + + // 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, { + 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', + 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' }, + ], + }); + }); + + // 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); + }); + + // 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') { + 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'], + }); + }); + + // 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) => { + 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; + } + if (method === 'PUT' && new URL(route.request().url()).pathname.endsWith('/accounts')) { + 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: '', + }); + }); + + 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'); + }); +} 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(); +});