From cde619e7308e2a583ad24cec62cd2e3bc06f69b2 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Wed, 6 May 2026 22:17:55 -0500 Subject: [PATCH] Add Vitest unit tests and Playwright e2e suite for frontend Replaces the ad-hoc test-e2e.mjs scripts with a proper test stack: Vitest covers $lib (api, auth, signalr) with mocked fetch/SignalR/navigation, and @playwright/test covers auth, stores, lists, recipes, and SignalR realtime sync between two browser contexts. Tests use uniqueName() for every entity since the backend has no per-test reset. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 7 + src/frontend/package-lock.json | 1390 ++++++++++++++++- src/frontend/package.json | 15 +- src/frontend/playwright.config.ts | 26 + .../src/tests/mocks/app-navigation.ts | 12 + src/frontend/src/tests/mocks/app-state.ts | 13 + src/frontend/src/tests/setup.ts | 1 + src/frontend/src/tests/unit/api.test.ts | 206 +++ .../src/tests/unit/auth.svelte.test.ts | 61 + src/frontend/src/tests/unit/signalr.test.ts | 166 ++ src/frontend/tests/e2e/auth.spec.ts | 75 + src/frontend/tests/e2e/helpers.ts | 18 + src/frontend/tests/e2e/lists.spec.ts | 145 ++ src/frontend/tests/e2e/realtime-sync.spec.ts | 97 ++ src/frontend/tests/e2e/recipes.spec.ts | 161 ++ src/frontend/tests/e2e/stores.spec.ts | 92 ++ src/frontend/vitest.config.ts | 28 + test-e2e-multiuser.mjs | 147 -- test-e2e.mjs | 147 -- 19 files changed, 2508 insertions(+), 299 deletions(-) create mode 100644 src/frontend/playwright.config.ts create mode 100644 src/frontend/src/tests/mocks/app-navigation.ts create mode 100644 src/frontend/src/tests/mocks/app-state.ts create mode 100644 src/frontend/src/tests/setup.ts create mode 100644 src/frontend/src/tests/unit/api.test.ts create mode 100644 src/frontend/src/tests/unit/auth.svelte.test.ts create mode 100644 src/frontend/src/tests/unit/signalr.test.ts create mode 100644 src/frontend/tests/e2e/auth.spec.ts create mode 100644 src/frontend/tests/e2e/helpers.ts create mode 100644 src/frontend/tests/e2e/lists.spec.ts create mode 100644 src/frontend/tests/e2e/realtime-sync.spec.ts create mode 100644 src/frontend/tests/e2e/recipes.spec.ts create mode 100644 src/frontend/tests/e2e/stores.spec.ts create mode 100644 src/frontend/vitest.config.ts delete mode 100644 test-e2e-multiuser.mjs delete mode 100644 test-e2e.mjs diff --git a/.gitignore b/.gitignore index 52c652a..a6746e9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,13 @@ node_modules/ build/ .svelte-kit/ +## Playwright +playwright-report/ +test-results/ + +## Claude Code (local agent state) +.claude/ + ## Environment .env diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 07d891e..b2d3632 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -14,13 +14,314 @@ "tailwindcss": "^4.2.4" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.57.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@sveltejs/vite-plugin-svelte": "^7.1.1", + "@testing-library/svelte": "^5.3.1", + "@vitest/coverage-v8": "^4.1.5", + "jsdom": "^29.1.1", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", - "vite": "^8.0.7" + "vite": "^8.0.7", + "vitest": "^4.1.5" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { @@ -54,6 +355,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -139,6 +458,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1176,6 +1511,76 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1186,12 +1591,37 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1210,6 +1640,160 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -1234,6 +1818,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1243,6 +1850,38 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1252,6 +1891,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1283,6 +1942,13 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1292,6 +1958,79 @@ "node": ">= 0.6" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1301,6 +2040,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1316,6 +2065,13 @@ "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -1329,6 +2085,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1338,6 +2107,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1385,6 +2161,16 @@ "node": ">=12.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1447,6 +2233,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -1459,6 +2255,26 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -1480,6 +2296,13 @@ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "license": "MIT" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1489,6 +2312,45 @@ "@types/estree": "^1.0.6" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1498,6 +2360,105 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1762,6 +2723,26 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1771,6 +2752,41 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1838,12 +2854,32 @@ ], "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1862,6 +2898,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "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/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -1890,6 +2973,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -1917,6 +3015,13 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1931,6 +3036,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -2048,12 +3163,45 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -2077,6 +3225,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2140,6 +3315,13 @@ "typescript": ">=5.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", @@ -2159,6 +3341,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -2175,6 +3374,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -2226,6 +3455,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -2341,12 +3580,125 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -2357,6 +3709,23 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -2378,6 +3747,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index bf264f9..12172ab 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -9,16 +9,25 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.57.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@sveltejs/vite-plugin-svelte": "^7.1.1", + "@testing-library/svelte": "^5.3.1", + "@vitest/coverage-v8": "^4.1.5", + "jsdom": "^29.1.1", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "typescript": "^6.0.2", - "vite": "^8.0.7" + "vite": "^8.0.7", + "vitest": "^4.1.5" }, "dependencies": { "@microsoft/signalr": "^10.0.0", diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts new file mode 100644 index 0000000..6ca4921 --- /dev/null +++ b/src/frontend/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: 'html', + timeout: 30_000, + expect: { + timeout: 8_000 + }, + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry' + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Pixel 5'], + viewport: { width: 390, height: 844 } + } + } + ] +}); diff --git a/src/frontend/src/tests/mocks/app-navigation.ts b/src/frontend/src/tests/mocks/app-navigation.ts new file mode 100644 index 0000000..b77a0a0 --- /dev/null +++ b/src/frontend/src/tests/mocks/app-navigation.ts @@ -0,0 +1,12 @@ +import { vi } from 'vitest'; + +export const goto = vi.fn(); +export const invalidate = vi.fn(); +export const invalidateAll = vi.fn(); +export const preloadCode = vi.fn(); +export const preloadData = vi.fn(); +export const pushState = vi.fn(); +export const replaceState = vi.fn(); +export const beforeNavigate = vi.fn(); +export const afterNavigate = vi.fn(); +export const onNavigate = vi.fn(); diff --git a/src/frontend/src/tests/mocks/app-state.ts b/src/frontend/src/tests/mocks/app-state.ts new file mode 100644 index 0000000..9110df0 --- /dev/null +++ b/src/frontend/src/tests/mocks/app-state.ts @@ -0,0 +1,13 @@ +export const page = { + params: {}, + url: new URL('http://localhost/'), + route: { id: null }, + status: 200, + error: null, + data: {}, + form: null, + state: {} +}; + +export const navigating = null; +export const updated = { current: false, check: () => Promise.resolve(false) }; diff --git a/src/frontend/src/tests/setup.ts b/src/frontend/src/tests/setup.ts new file mode 100644 index 0000000..6667ee7 --- /dev/null +++ b/src/frontend/src/tests/setup.ts @@ -0,0 +1 @@ +import '@testing-library/svelte'; diff --git a/src/frontend/src/tests/unit/api.test.ts b/src/frontend/src/tests/unit/api.test.ts new file mode 100644 index 0000000..897391b --- /dev/null +++ b/src/frontend/src/tests/unit/api.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock $app/navigation before importing api +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +// Mock auth module +vi.mock('$lib/auth.svelte', () => ({ + setLoggedIn: vi.fn() +})); + +import { goto } from '$app/navigation'; +import { setLoggedIn } from '$lib/auth.svelte'; +import { getToken, setToken, clearToken, api } from '$lib/api'; + +const mockGoto = vi.mocked(goto); +const mockSetLoggedIn = vi.mocked(setLoggedIn); + +beforeEach(() => { + localStorage.clear(); + // Reset the module-level token cache by clearing storage before each test + vi.resetModules(); +}); + +afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); +}); + +describe('getToken', () => { + it('returns null when localStorage has no token', () => { + // The module caches token in a module-level variable, so we test + // the observable behavior: after clearToken, getToken returns null. + clearToken(); + expect(getToken()).toBeNull(); + }); + + it('returns the token stored via setToken', () => { + setToken('abc123'); + expect(getToken()).toBe('abc123'); + }); +}); + +describe('setToken', () => { + it('persists token to localStorage', () => { + setToken('my-jwt'); + expect(localStorage.getItem('token')).toBe('my-jwt'); + }); + + it('calls setLoggedIn(true)', () => { + setToken('my-jwt'); + expect(mockSetLoggedIn).toHaveBeenCalledWith(true); + }); +}); + +describe('clearToken', () => { + it('removes token from localStorage', () => { + setToken('my-jwt'); + clearToken(); + expect(localStorage.getItem('token')).toBeNull(); + }); + + it('makes getToken return null', () => { + setToken('my-jwt'); + clearToken(); + expect(getToken()).toBeNull(); + }); + + it('calls setLoggedIn(false)', () => { + clearToken(); + expect(mockSetLoggedIn).toHaveBeenCalledWith(false); + }); +}); + +describe('api()', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeFetchResponse(status: number, body?: unknown) { + const responseBody = body !== undefined ? JSON.stringify(body) : ''; + return { + status, + ok: status >= 200 && status < 300, + json: vi.fn().mockResolvedValue(body ?? {}), + text: vi.fn().mockResolvedValue(responseBody) + }; + } + + it('attaches Authorization header when token is set', async () => { + setToken('tok-xyz'); + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeFetchResponse(200, { id: 1 }) as unknown as Response); + + await api('/api/test'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/test', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer tok-xyz' + }) + }) + ); + }); + + it('does not attach Authorization header when no token', async () => { + clearToken(); + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeFetchResponse(200, { id: 1 }) as unknown as Response); + + await api('/api/test'); + + const calledHeaders = mockFetch.mock.calls[0][1]?.headers as Record; + expect(calledHeaders?.Authorization).toBeUndefined(); + }); + + it('returns parsed JSON on 200', async () => { + setToken('tok'); + vi.mocked(fetch).mockResolvedValueOnce( + makeFetchResponse(200, { name: 'Kroger' }) as unknown as Response + ); + + const result = await api<{ name: string }>('/api/stores/1'); + + expect(result).toEqual({ name: 'Kroger' }); + }); + + it('returns undefined on 204 No Content', async () => { + setToken('tok'); + vi.mocked(fetch).mockResolvedValueOnce(makeFetchResponse(204) as unknown as Response); + + const result = await api('/api/lists/1/items/5'); + + expect(result).toBeUndefined(); + }); + + it('on 401 clears token, calls goto("/login"), and throws', async () => { + setToken('expired-tok'); + vi.mocked(fetch).mockResolvedValueOnce(makeFetchResponse(401) as unknown as Response); + + await expect(api('/api/protected')).rejects.toThrow('Unauthorized'); + expect(getToken()).toBeNull(); + expect(mockGoto).toHaveBeenCalledWith('/login'); + }); + + it('on non-OK response throws error with body.error message', async () => { + setToken('tok'); + vi.mocked(fetch).mockResolvedValueOnce( + makeFetchResponse(422, { error: 'Name already taken' }) as unknown as Response + ); + + await expect(api('/api/auth/register')).rejects.toThrow('Name already taken'); + }); + + it('on non-OK response without body.error falls back to generic message', async () => { + setToken('tok'); + const response = makeFetchResponse(500, {}) as unknown as Response; + vi.mocked(fetch).mockResolvedValueOnce(response); + + await expect(api('/api/crash')).rejects.toThrow('Request failed: 500'); + }); + + it('on non-OK response where JSON parse fails falls back to generic message', async () => { + setToken('tok'); + const badResponse = { + status: 503, + ok: false, + json: vi.fn().mockRejectedValue(new SyntaxError('bad json')), + text: vi.fn().mockResolvedValue('') + }; + vi.mocked(fetch).mockResolvedValueOnce(badResponse as unknown as Response); + + await expect(api('/api/down')).rejects.toThrow('Request failed: 503'); + }); + + it('sends Content-Type: application/json by default', async () => { + setToken('tok'); + vi.mocked(fetch).mockResolvedValueOnce( + makeFetchResponse(200, {}) as unknown as Response + ); + + await api('/api/test'); + + const calledHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record; + expect(calledHeaders['Content-Type']).toBe('application/json'); + }); + + it('merges caller-provided headers with defaults', async () => { + setToken('tok'); + vi.mocked(fetch).mockResolvedValueOnce( + makeFetchResponse(200, {}) as unknown as Response + ); + + await api('/api/test', { headers: { 'X-Custom': 'yes' } }); + + const calledHeaders = vi.mocked(fetch).mock.calls[0][1]?.headers as Record; + expect(calledHeaders['X-Custom']).toBe('yes'); + expect(calledHeaders['Content-Type']).toBe('application/json'); + }); +}); diff --git a/src/frontend/src/tests/unit/auth.svelte.test.ts b/src/frontend/src/tests/unit/auth.svelte.test.ts new file mode 100644 index 0000000..e69e12d --- /dev/null +++ b/src/frontend/src/tests/unit/auth.svelte.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { initAuth, setLoggedIn, isLoggedIn } from '$lib/auth.svelte'; + +// This file must be named *.svelte.test.ts so the Svelte vite plugin +// compiles it with runes enabled (matches the *.svelte.ts source file). + +beforeEach(() => { + localStorage.clear(); + // Reset state to a known baseline before each test + setLoggedIn(false); +}); + +describe('initAuth()', () => { + it('sets logged-in state to true when a token exists in localStorage', () => { + localStorage.setItem('token', 'some-jwt'); + + initAuth(); + + expect(isLoggedIn()).toBe(true); + }); + + it('sets logged-in state to false when localStorage has no token', () => { + setLoggedIn(true); // start in logged-in state + localStorage.removeItem('token'); + + initAuth(); + + expect(isLoggedIn()).toBe(false); + }); + + it('treats an empty-string token as falsy (not logged in)', () => { + localStorage.setItem('token', ''); + + initAuth(); + + expect(isLoggedIn()).toBe(false); + }); +}); + +describe('setLoggedIn()', () => { + it('sets state to true', () => { + setLoggedIn(true); + expect(isLoggedIn()).toBe(true); + }); + + it('sets state to false', () => { + setLoggedIn(true); + setLoggedIn(false); + expect(isLoggedIn()).toBe(false); + }); +}); + +describe('isLoggedIn()', () => { + it('reflects current state without side-effects', () => { + setLoggedIn(true); + expect(isLoggedIn()).toBe(true); + + setLoggedIn(false); + expect(isLoggedIn()).toBe(false); + }); +}); diff --git a/src/frontend/src/tests/unit/signalr.test.ts b/src/frontend/src/tests/unit/signalr.test.ts new file mode 100644 index 0000000..9192ca5 --- /dev/null +++ b/src/frontend/src/tests/unit/signalr.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.hoisted() runs before vi.mock() factories, making these safe to reference. +const { mockStart, mockStop, mockBuild, mockWithAutomaticReconnect, mockWithUrl, mockStateContainer } = + vi.hoisted(() => { + const mockStateContainer = { value: 'Disconnected' }; + const mockStop = vi.fn().mockResolvedValue(undefined); + const mockStart = vi.fn().mockResolvedValue(undefined); + const mockBuild = vi.fn(); + const mockWithAutomaticReconnect = vi.fn(); + const mockWithUrl = vi.fn(); + return { mockStart, mockStop, mockBuild, mockWithAutomaticReconnect, mockStateContainer, mockWithUrl }; + }); + +vi.mock('@microsoft/signalr', () => { + const mockConnection = { + get state() { + return mockStateContainer.value; + }, + start: mockStart, + stop: mockStop, + on: vi.fn(), + off: vi.fn() + }; + + mockBuild.mockReturnValue(mockConnection); + mockWithAutomaticReconnect.mockReturnValue({ build: mockBuild }); + + class HubConnectionBuilder { + withUrl(url: string, opts: { accessTokenFactory?: () => string }) { + return mockWithUrl(url, opts); + } + } + + return { + HubConnectionState: { + Disconnected: 'Disconnected', + Connected: 'Connected', + Connecting: 'Connecting', + Reconnecting: 'Reconnecting', + Disconnecting: 'Disconnecting' + }, + HubConnectionBuilder + }; +}); + +vi.mock('$lib/api', () => ({ + getToken: vi.fn().mockReturnValue('test-token') +})); + +import { getToken } from '$lib/api'; + +// Each test imports signalr fresh so the module-level refCount and connection +// singleton are reset between tests. +async function freshSignalr() { + vi.resetModules(); + const mod = await import('$lib/signalr'); + return mod; +} + +let capturedAccessTokenFactory: (() => string) | null = null; + +beforeEach(() => { + vi.clearAllMocks(); + mockStateContainer.value = 'Disconnected'; + capturedAccessTokenFactory = null; + mockStart.mockResolvedValue(undefined); + mockStop.mockResolvedValue(undefined); + vi.mocked(getToken).mockReturnValue('test-token'); + + mockWithUrl.mockImplementation((_url: string, opts: { accessTokenFactory?: () => string }) => { + capturedAccessTokenFactory = opts?.accessTokenFactory ?? null; + return { withAutomaticReconnect: mockWithAutomaticReconnect }; + }); + mockWithAutomaticReconnect.mockReturnValue({ build: mockBuild }); +}); + +describe('startConnection()', () => { + it('starts the hub connection when disconnected', async () => { + const { startConnection } = await freshSignalr(); + await startConnection(); + expect(mockStart).toHaveBeenCalledOnce(); + }); + + it('returns the connection object', async () => { + const { startConnection } = await freshSignalr(); + const conn = await startConnection(); + expect(conn.start).toBe(mockStart); + expect(conn.stop).toBe(mockStop); + }); + + it('passes getToken() via accessTokenFactory', async () => { + const { startConnection } = await freshSignalr(); + await startConnection(); + expect(capturedAccessTokenFactory).not.toBeNull(); + expect(capturedAccessTokenFactory!()).toBe('test-token'); + }); + + it('does not call start() again when connection is already active', async () => { + const { startConnection } = await freshSignalr(); + await startConnection(); + expect(mockStart).toHaveBeenCalledOnce(); + + mockStateContainer.value = 'Connected'; + await startConnection(); + + expect(mockStart).toHaveBeenCalledOnce(); + }); +}); + +describe('stopConnection()', () => { + it('calls stop() when refcount reaches zero', async () => { + const { startConnection, stopConnection } = await freshSignalr(); + await startConnection(); + mockStateContainer.value = 'Connected'; + + await stopConnection(); + + expect(mockStop).toHaveBeenCalledOnce(); + }); + + it('does not call stop() while more than one consumer holds a reference', async () => { + const { startConnection, stopConnection } = await freshSignalr(); + await startConnection(); + mockStateContainer.value = 'Connected'; + await startConnection(); + + await stopConnection(); + + expect(mockStop).not.toHaveBeenCalled(); + }); + + it('stops only after all consumers release', async () => { + const { startConnection, stopConnection } = await freshSignalr(); + await startConnection(); + mockStateContainer.value = 'Connected'; + await startConnection(); + + await stopConnection(); + expect(mockStop).not.toHaveBeenCalled(); + + await stopConnection(); + expect(mockStop).toHaveBeenCalledOnce(); + }); + + it('does not call stop() when connection is already disconnected', async () => { + const { startConnection, stopConnection } = await freshSignalr(); + await startConnection(); + // mockStateContainer.value stays 'Disconnected' + + await stopConnection(); + + expect(mockStop).not.toHaveBeenCalled(); + }); +}); + +describe('accessTokenFactory', () => { + it('returns empty string when getToken() returns null', async () => { + vi.mocked(getToken).mockReturnValueOnce(null); + const { startConnection } = await freshSignalr(); + await startConnection(); + + expect(capturedAccessTokenFactory).not.toBeNull(); + expect(capturedAccessTokenFactory!()).toBe(''); + }); +}); diff --git a/src/frontend/tests/e2e/auth.spec.ts b/src/frontend/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..b60d92c --- /dev/null +++ b/src/frontend/tests/e2e/auth.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; + +const FAMILY_CODE = 'dev-family-code'; + +function uniqueName(prefix: string) { + return `${prefix}-${Date.now()}`; +} + +test.describe('Authentication', () => { + test('unauthenticated visit to app root redirects to /login', async ({ page }) => { + await page.goto('/'); + await page.waitForURL('**/login'); + await expect(page).toHaveURL(/\/login/); + }); + + test('registration with valid credentials lands on /lists', async ({ page }) => { + const name = uniqueName('Chef'); + + await page.goto('/'); + await page.waitForURL('**/login'); + + await page.getByText('Create account').click(); + await page.getByPlaceholder('Name').fill(name); + await page.getByPlaceholder('Password').fill('password123'); + await page.getByPlaceholder('Family code').fill(FAMILY_CODE); + await page.getByRole('button', { name: 'Create account' }).click(); + + await page.waitForURL('**/lists'); + await expect(page).toHaveURL(/\/lists/); + }); + + test('login with valid credentials lands on /lists', async ({ page }) => { + const name = uniqueName('LoginUser'); + + // Register first + await page.goto('/'); + await page.waitForURL('**/login'); + await page.getByText('Create account').click(); + await page.getByPlaceholder('Name').fill(name); + await page.getByPlaceholder('Password').fill('password123'); + await page.getByPlaceholder('Family code').fill(FAMILY_CODE); + await page.getByRole('button', { name: 'Create account' }).click(); + await page.waitForURL('**/lists'); + + // Sign out + await page.getByText('Sign out').click(); + await page.waitForURL('**/login'); + + // Log back in + await page.getByPlaceholder('Name').fill(name); + await page.getByPlaceholder('Password').fill('password123'); + await page.getByRole('button', { name: /sign in|log in/i }).click(); + + await page.waitForURL('**/lists'); + await expect(page).toHaveURL(/\/lists/); + }); + + test('sign out clears session and redirects to /login', async ({ page }) => { + const name = uniqueName('SignOutUser'); + + await page.goto('/'); + await page.waitForURL('**/login'); + await page.getByText('Create account').click(); + await page.getByPlaceholder('Name').fill(name); + await page.getByPlaceholder('Password').fill('password123'); + await page.getByPlaceholder('Family code').fill(FAMILY_CODE); + await page.getByRole('button', { name: 'Create account' }).click(); + await page.waitForURL('**/lists'); + + await page.getByText('Sign out').click(); + + await page.waitForURL('**/login'); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/src/frontend/tests/e2e/helpers.ts b/src/frontend/tests/e2e/helpers.ts new file mode 100644 index 0000000..13633f1 --- /dev/null +++ b/src/frontend/tests/e2e/helpers.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test'; + +export const FAMILY_CODE = 'dev-family-code'; + +export function uniqueName(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10000)}`; +} + +export async function registerAndLogin(page: Page, name: string): Promise { + await page.goto('/'); + await page.waitForURL('**/login'); + await page.getByText('Create account').click(); + await page.getByPlaceholder('Name').fill(name); + await page.getByPlaceholder('Password').fill('password123'); + await page.getByPlaceholder('Family code').fill(FAMILY_CODE); + await page.getByRole('button', { name: 'Create account' }).click(); + await page.waitForURL('**/lists'); +} diff --git a/src/frontend/tests/e2e/lists.spec.ts b/src/frontend/tests/e2e/lists.spec.ts new file mode 100644 index 0000000..570a3ac --- /dev/null +++ b/src/frontend/tests/e2e/lists.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from '@playwright/test'; +import { uniqueName, registerAndLogin } from './helpers'; + +test.describe('Shopping Lists', () => { + let storeName: string; + + test.beforeEach(async ({ page }) => { + storeName = uniqueName('Kroger'); + + await registerAndLogin(page, uniqueName('ListUser')); + + // Create a store so the list creation form has an option to select + await page.getByRole('link', { name: 'Stores' }).click(); + await page.waitForURL('**/stores'); + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + await page.getByRole('link', { name: 'Lists' }).click(); + await page.waitForURL('**/lists'); + }); + + test('displays the Shopping Lists heading', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Shopping Lists' })).toBeVisible(); + }); + + test('creates a new list and shows it on the overview', async ({ page }) => { + const listName = uniqueName('Weekly Groceries'); + + await page.getByRole('button', { name: '+ New list' }).click(); + await page.getByPlaceholder('List name').fill(listName); + await page.getByRole('combobox').selectOption({ label: storeName }); + await page.getByRole('button', { name: 'Create' }).click(); + + await expect(page.getByText(listName)).toBeVisible(); + }); + + test('opens a list detail page when clicked', async ({ page }) => { + const listName = uniqueName('Weekly Groceries'); + + await page.getByRole('button', { name: '+ New list' }).click(); + await page.getByPlaceholder('List name').fill(listName); + await page.getByRole('combobox').selectOption({ label: storeName }); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText(listName)).toBeVisible(); + + await page.getByText(listName).click(); + + await page.waitForURL(/\/lists\/\d+/); + await expect(page.getByRole('heading', { name: listName })).toBeVisible(); + }); + + test.describe('List detail', () => { + let listName: string; + + test.beforeEach(async ({ page }) => { + listName = uniqueName('Weekly Groceries'); + + await page.getByRole('button', { name: '+ New list' }).click(); + await page.getByPlaceholder('List name').fill(listName); + await page.getByRole('combobox').selectOption({ label: storeName }); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText(listName)).toBeVisible(); + await page.getByText(listName).click(); + await page.waitForURL(/\/lists\/\d+/); + }); + + test('shows empty state when list has no items', async ({ page }) => { + await expect(page.getByText('No items yet — add some above')).toBeVisible(); + }); + + test('adds items to the list', async ({ page }) => { + const milk = uniqueName('Milk'); + const eggs = uniqueName('Eggs'); + const bread = uniqueName('Bread'); + + for (const item of [milk, eggs, bread]) { + await page.getByPlaceholder('Add an item...').fill(item); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(item)).toBeVisible(); + } + }); + + test('checking an item moves it to the checked section', async ({ page }) => { + const milk = uniqueName('Milk'); + + await page.getByPlaceholder('Add an item...').fill(milk); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(milk)).toBeVisible(); + + await page.getByRole('button', { name: `Check ${milk}` }).click(); + + await expect(page.getByText('Checked (1)')).toBeVisible(); + }); + + test('checking multiple items increments the checked count', async ({ page }) => { + const milk = uniqueName('Milk'); + const eggs = uniqueName('Eggs'); + + for (const item of [milk, eggs]) { + await page.getByPlaceholder('Add an item...').fill(item); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(item)).toBeVisible(); + } + + await page.getByRole('button', { name: `Check ${milk}` }).click(); + await expect(page.getByText('Checked (1)')).toBeVisible(); + + await page.getByRole('button', { name: `Check ${eggs}` }).click(); + await expect(page.getByText('Checked (2)')).toBeVisible(); + }); + + test('unchecking a checked item decrements the checked count', async ({ page }) => { + const milk = uniqueName('Milk'); + + await page.getByPlaceholder('Add an item...').fill(milk); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(milk)).toBeVisible(); + + await page.getByRole('button', { name: `Check ${milk}` }).click(); + await expect(page.getByText('Checked (1)')).toBeVisible(); + + await page.getByRole('button', { name: `Uncheck ${milk}` }).click(); + await expect(page.getByText('Checked (1)')).not.toBeVisible(); + }); + + test('removing an item removes it from the list', async ({ page }) => { + const broccoli = uniqueName('Broccoli'); + + await page.getByPlaceholder('Add an item...').fill(broccoli); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(broccoli)).toBeVisible(); + + await page.getByRole('button', { name: `Remove ${broccoli}` }).click(); + + await expect(page.getByText(broccoli)).not.toBeVisible(); + }); + + test('back button returns to lists overview', async ({ page }) => { + await page.getByText('← Back').click(); + await page.waitForURL('**/lists'); + await expect(page).toHaveURL(/\/lists$/); + }); + }); +}); diff --git a/src/frontend/tests/e2e/realtime-sync.spec.ts b/src/frontend/tests/e2e/realtime-sync.spec.ts new file mode 100644 index 0000000..feb7428 --- /dev/null +++ b/src/frontend/tests/e2e/realtime-sync.spec.ts @@ -0,0 +1,97 @@ +import { test, expect, chromium } from '@playwright/test'; +import { uniqueName, registerAndLogin, FAMILY_CODE } from './helpers'; + +// This spec uses two independent browser contexts to verify that SignalR +// pushes list mutations from one user to another in real time. +// It creates its own browser instance so both contexts can run in the same test. + +test('real-time sync: two users see each other\'s list changes live', async () => { + const browser = await chromium.launch(); + + const contextA = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const contextB = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const pageA = await contextA.newPage(); + const pageB = await contextB.newPage(); + + const nameA = uniqueName('Mom'); + const nameB = uniqueName('Dad'); + const storeName = uniqueName('Kroger'); + const listName = uniqueName('Weekly Groceries'); + + try { + // Register both users + await registerAndLogin(pageA, nameA); + await registerAndLogin(pageB, nameB); + + // Mom creates a store + await pageA.getByRole('link', { name: 'Stores' }).click(); + await pageA.waitForURL('**/stores'); + await pageA.getByPlaceholder('New store name').fill(storeName); + await pageA.getByRole('button', { name: 'Add' }).click(); + await expect(pageA.getByText(storeName)).toBeVisible(); + + // Both navigate to lists overview + await pageA.getByRole('link', { name: 'Lists' }).click(); + await pageA.waitForURL('**/lists'); + + // Dad is also on the lists overview + await pageB.getByRole('link', { name: 'Lists' }).click(); + await pageB.waitForURL('**/lists'); + + // Mom creates a list — Dad should see it appear via SignalR without navigating + await pageA.getByRole('button', { name: '+ New list' }).click(); + await pageA.getByPlaceholder('List name').fill(listName); + await pageA.getByRole('combobox').selectOption({ label: storeName }); + await pageA.getByRole('button', { name: 'Create' }).click(); + await expect(pageA.getByText(listName)).toBeVisible(); + + await expect(pageB.getByText(listName)).toBeVisible({ timeout: 8000 }); + + // Both open the list + await pageA.getByText(listName).click(); + await pageA.waitForURL(/\/lists\/\d+/); + + await pageB.getByText(listName).click(); + await pageB.waitForURL(/\/lists\/\d+/); + + // Mom adds items — both users see each one appear via SignalR + for (const item of ['Milk', 'Eggs', 'Bread']) { + await pageA.getByPlaceholder('Add an item...').fill(item); + await pageA.getByRole('button', { name: 'Add' }).click(); + // Both pages receive the update via SignalR (the adding user's own + // view also updates via the hub broadcast, not the API response) + await expect(pageA.getByText(item)).toBeVisible({ timeout: 8000 }); + await expect(pageB.getByText(item)).toBeVisible({ timeout: 8000 }); + } + + // Dad checks Milk — Mom sees Checked (1) + await pageB.getByRole('button', { name: 'Check Milk' }).click(); + await expect(pageB.getByText('Checked (1)')).toBeVisible(); + await expect(pageA.getByText('Checked (1)')).toBeVisible({ timeout: 8000 }); + + // Mom checks Eggs — Dad sees Checked (2) + await pageA.getByRole('button', { name: 'Check Eggs' }).click(); + await expect(pageA.getByText('Checked (2)')).toBeVisible(); + await expect(pageB.getByText('Checked (2)')).toBeVisible({ timeout: 8000 }); + + // Dad unchecks Milk — Mom sees count drop to 1 + await pageB.getByRole('button', { name: 'Uncheck Milk' }).click(); + await expect(pageB.getByText('Checked (1)')).toBeVisible(); + await expect(pageA.getByText('Checked (1)')).toBeVisible({ timeout: 8000 }); + + // Dad adds an item — Mom sees it + await pageB.getByPlaceholder('Add an item...').fill('Butter'); + await pageB.getByRole('button', { name: 'Add' }).click(); + await expect(pageB.getByText('Butter')).toBeVisible(); + await expect(pageA.getByText('Butter')).toBeVisible({ timeout: 8000 }); + + // Mom removes Bread — Dad sees it disappear + await pageA.getByRole('button', { name: 'Remove Bread' }).click(); + await expect(pageA.getByText('Bread')).not.toBeVisible(); + await expect(pageB.getByText('Bread')).not.toBeVisible({ timeout: 8000 }); + } finally { + await contextA.close(); + await contextB.close(); + await browser.close(); + } +}); diff --git a/src/frontend/tests/e2e/recipes.spec.ts b/src/frontend/tests/e2e/recipes.spec.ts new file mode 100644 index 0000000..2ee6f95 --- /dev/null +++ b/src/frontend/tests/e2e/recipes.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from '@playwright/test'; +import { uniqueName, registerAndLogin } from './helpers'; + +test.describe('Recipes and add-to-list flow', () => { + test.beforeEach(async ({ page }) => { + await registerAndLogin(page, uniqueName('RecipeUser')); + }); + + test('displays the Recipes heading', async ({ page }) => { + await page.getByRole('link', { name: 'Recipes' }).click(); + await page.waitForURL('**/recipes'); + + await expect(page.getByRole('heading', { name: 'Recipes' })).toBeVisible(); + }); + + test('creates a recipe and lands on the detail page', async ({ page }) => { + const recipeName = uniqueName('Chicken Stir Fry'); + + await page.getByRole('link', { name: 'Recipes' }).click(); + await page.waitForURL('**/recipes'); + await page.getByRole('link', { name: '+ New recipe' }).click(); + await page.waitForURL('**/recipes/new'); + + await page.getByPlaceholder('Recipe title').fill(recipeName); + await page.getByPlaceholder('Short description (optional)').fill('Quick weeknight dinner'); + + const qtyInputs = page.getByPlaceholder('Qty'); + const nameInputs = page.getByPlaceholder('Ingredient name'); + await qtyInputs.nth(0).fill('2 lbs'); + await nameInputs.nth(0).fill('chicken breast'); + + await page.getByRole('button', { name: '+ Add ingredient' }).click(); + await qtyInputs.nth(1).fill('1 head'); + await nameInputs.nth(1).fill('broccoli'); + + await page.getByRole('button', { name: '+ Add ingredient' }).click(); + await qtyInputs.nth(2).fill('3 tbsp'); + await nameInputs.nth(2).fill('soy sauce'); + + await page.getByPlaceholder('Instructions (optional)').fill('1. Cut chicken\n2. Stir fry\n3. Add sauce'); + + await page.getByRole('button', { name: 'Save Recipe' }).click(); + await page.waitForURL(/\/recipes\/\d+/); + + await expect(page.getByRole('heading', { name: recipeName })).toBeVisible(); + await expect(page.getByText('Quick weeknight dinner')).toBeVisible(); + await expect(page.getByText('chicken breast')).toBeVisible(); + await expect(page.getByText('broccoli')).toBeVisible(); + await expect(page.getByText('soy sauce')).toBeVisible(); + }); + + test('recipe appears on the recipes list after creation', async ({ page }) => { + const recipeName = uniqueName('Pasta Primavera'); + + await page.getByRole('link', { name: 'Recipes' }).click(); + await page.waitForURL('**/recipes'); + await page.getByRole('link', { name: '+ New recipe' }).click(); + await page.getByPlaceholder('Recipe title').fill(recipeName); + await page.getByRole('button', { name: 'Save Recipe' }).click(); + await page.waitForURL(/\/recipes\/\d+/); + + await page.getByText('← Back').click(); + await page.waitForURL('**/recipes'); + + await expect(page.getByText(recipeName)).toBeVisible(); + }); + + test('adds recipe ingredients to a shopping list and redirects to that list', async ({ page }) => { + const storeName = uniqueName('Kroger'); + const listName = uniqueName('Weekly Groceries'); + const recipeName = uniqueName('Chicken Stir Fry'); + + // Set up: create a store and a list + await page.getByRole('link', { name: 'Stores' }).click(); + await page.waitForURL('**/stores'); + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + await page.getByRole('link', { name: 'Lists' }).click(); + await page.waitForURL('**/lists'); + await page.getByRole('button', { name: '+ New list' }).click(); + await page.getByPlaceholder('List name').fill(listName); + await page.getByRole('combobox').selectOption({ label: storeName }); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText(listName)).toBeVisible(); + + // Create a recipe + await page.getByRole('link', { name: 'Recipes' }).click(); + await page.waitForURL('**/recipes'); + await page.getByRole('link', { name: '+ New recipe' }).click(); + await page.getByPlaceholder('Recipe title').fill(recipeName); + + const qtyInputs = page.getByPlaceholder('Qty'); + const nameInputs = page.getByPlaceholder('Ingredient name'); + await qtyInputs.nth(0).fill('2 lbs'); + await nameInputs.nth(0).fill('chicken breast'); + + await page.getByRole('button', { name: '+ Add ingredient' }).click(); + await qtyInputs.nth(1).fill('1 head'); + await nameInputs.nth(1).fill('broccoli'); + + await page.getByRole('button', { name: '+ Add ingredient' }).click(); + await qtyInputs.nth(2).fill('3 tbsp'); + await nameInputs.nth(2).fill('soy sauce'); + + await page.getByRole('button', { name: 'Save Recipe' }).click(); + await page.waitForURL(/\/recipes\/\d+/); + + // Add ingredients to list + await page.getByRole('button', { name: 'Add to list' }).click(); + await expect(page.getByText('Choose a list:')).toBeVisible(); + await page.getByRole('button', { name: new RegExp(listName) }).click(); + + await page.waitForURL(/\/lists\/\d+/); + + const body = page.locator('body'); + await expect(body).toContainText('2 lbs'); + await expect(body).toContainText('chicken breast'); + await expect(body).toContainText('1 head'); + await expect(body).toContainText('broccoli'); + await expect(body).toContainText('3 tbsp'); + await expect(body).toContainText('soy sauce'); + }); + + test('recipe ingredients are labeled with their source recipe on the list', async ({ page }) => { + const storeName = uniqueName('Kroger'); + const listName = uniqueName('Weekly Groceries'); + const recipeName = uniqueName('Chicken Stir Fry'); + + // Set up store and list + await page.getByRole('link', { name: 'Stores' }).click(); + await page.waitForURL('**/stores'); + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + await page.getByRole('link', { name: 'Lists' }).click(); + await page.waitForURL('**/lists'); + await page.getByRole('button', { name: '+ New list' }).click(); + await page.getByPlaceholder('List name').fill(listName); + await page.getByRole('combobox').selectOption({ label: storeName }); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText(listName)).toBeVisible(); + + // Create recipe and add to list + await page.getByRole('link', { name: 'Recipes' }).click(); + await page.waitForURL('**/recipes'); + await page.getByRole('link', { name: '+ New recipe' }).click(); + await page.getByPlaceholder('Recipe title').fill(recipeName); + await page.getByPlaceholder('Ingredient name').fill('chicken breast'); + await page.getByRole('button', { name: 'Save Recipe' }).click(); + await page.waitForURL(/\/recipes\/\d+/); + + await page.getByRole('button', { name: 'Add to list' }).click(); + await page.getByRole('button', { name: new RegExp(listName) }).click(); + await page.waitForURL(/\/lists\/\d+/); + + await expect(page.getByText(`from ${recipeName}`)).toBeVisible(); + }); +}); diff --git a/src/frontend/tests/e2e/stores.spec.ts b/src/frontend/tests/e2e/stores.spec.ts new file mode 100644 index 0000000..4539178 --- /dev/null +++ b/src/frontend/tests/e2e/stores.spec.ts @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; +import { uniqueName, registerAndLogin } from './helpers'; + +test.describe('Stores', () => { + test.beforeEach(async ({ page }) => { + await registerAndLogin(page, uniqueName('StoreUser')); + await page.getByRole('link', { name: 'Stores' }).click(); + await page.waitForURL('**/stores'); + }); + + test('displays the Stores heading', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Stores' })).toBeVisible(); + }); + + test('adds a new store and shows it in the list', async ({ page }) => { + const storeName = uniqueName('Kroger'); + + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + + await expect(page.getByText(storeName)).toBeVisible(); + }); + + test('adds multiple stores and shows them all', async ({ page }) => { + const kroger = uniqueName('Kroger'); + const costco = uniqueName('Costco'); + + await page.getByPlaceholder('New store name').fill(kroger); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(kroger)).toBeVisible(); + + await page.getByPlaceholder('New store name').fill(costco); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(costco)).toBeVisible(); + + await expect(page.getByText(kroger)).toBeVisible(); + }); + + test('edit inline renames the store', async ({ page }) => { + const storeName = uniqueName('Target'); + const renamedName = uniqueName('Target-Express'); + + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + // Click Edit on the specific store row; after clicking, that row enters + // edit mode and its text content changes (the name is now in an input + // value, not a text node), so we re-query for the in-progress edit form + // rather than filtering by text again. + const storeRow = page.locator('li').filter({ hasText: storeName }); + await storeRow.getByRole('button', { name: 'Edit' }).click(); + + // Only one row is in edit mode at a time — find the active edit form + const editForm = page.locator('li form'); + await editForm.locator('input').fill(renamedName); + await editForm.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByText(renamedName)).toBeVisible(); + await expect(page.getByText(storeName, { exact: true })).not.toBeVisible(); + }); + + test('cancel edit restores the original name', async ({ page }) => { + const storeName = uniqueName('Safeway'); + + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + const storeRow = page.locator('li').filter({ hasText: storeName }); + await storeRow.getByRole('button', { name: 'Edit' }).click(); + + // Only one row is in edit mode at a time + const editForm = page.locator('li form'); + await editForm.getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.getByText(storeName)).toBeVisible(); + }); + + test('delete removes the store from the list', async ({ page }) => { + const storeName = uniqueName('Aldi'); + + await page.getByPlaceholder('New store name').fill(storeName); + await page.getByRole('button', { name: 'Add' }).click(); + await expect(page.getByText(storeName)).toBeVisible(); + + const storeRow = page.locator('li').filter({ hasText: storeName }); + await storeRow.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText(storeName)).not.toBeVisible(); + }); +}); diff --git a/src/frontend/vitest.config.ts b/src/frontend/vitest.config.ts new file mode 100644 index 0000000..6bd726e --- /dev/null +++ b/src/frontend/vitest.config.ts @@ -0,0 +1,28 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vitest/config'; +import { svelteTesting } from '@testing-library/svelte/vite'; +import path from 'path'; + +export default defineConfig({ + plugins: [ + svelte({ + compilerOptions: { + runes: true + } + }), + svelteTesting() + ], + resolve: { + alias: { + $lib: path.resolve(__dirname, 'src/lib'), + '$app/navigation': path.resolve(__dirname, 'src/tests/mocks/app-navigation.ts'), + '$app/state': path.resolve(__dirname, 'src/tests/mocks/app-state.ts') + } + }, + test: { + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{ts,svelte.ts}', 'src/**/*.svelte.test.ts'], + globals: true, + setupFiles: ['src/tests/setup.ts'] + } +}); diff --git a/test-e2e-multiuser.mjs b/test-e2e-multiuser.mjs deleted file mode 100644 index 4507d5b..0000000 --- a/test-e2e-multiuser.mjs +++ /dev/null @@ -1,147 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 200 }); - -const contextA = await browser.newContext({ viewport: { width: 390, height: 844 } }); -const contextB = await browser.newContext({ viewport: { width: 390, height: 844 } }); -const pageA = await contextA.newPage(); -const pageB = await contextB.newPage(); - -const BASE = 'http://localhost:5173'; - -console.log('⏳ Arrange the two browser windows side by side. Starting in 8 seconds...'); -await new Promise(r => setTimeout(r, 8000)); - -async function step(name, fn) { - process.stdout.write(`▶ ${name}... `); - try { - await fn(); - console.log('✅'); - } catch (e) { - console.log(`❌ ${e.message}`); - throw e; - } -} - -async function register(page, name) { - await page.goto(BASE); - await page.waitForURL('**/login', { timeout: 5000 }); - await page.click('text=Create account'); - await page.fill('input[placeholder="Name"]', name); - await page.fill('input[placeholder="Password"]', 'password123'); - await page.fill('input[placeholder="Family code"]', 'dev-family-code'); - await page.click('button[type="submit"]'); - await page.waitForURL('**/lists', { timeout: 5000 }); -} - -try { - await step('Register User A (Mom)', async () => { - await register(pageA, 'Mom'); - }); - - await step('Register User B (Dad)', async () => { - await register(pageB, 'Dad'); - }); - - // Mom creates a store - await step('Mom creates store "Kroger"', async () => { - await pageA.click('a:has-text("Stores")'); - await pageA.waitForURL('**/stores'); - await pageA.fill('input[placeholder="New store name"]', 'Kroger'); - await pageA.click('button:has-text("Add")'); - await pageA.waitForSelector('text=Kroger'); - }); - - // Both on lists overview — Mom creates a list, Dad should see it appear via SignalR - await step('Mom navigates to Lists', async () => { - await pageA.click('a:has-text("Lists")'); - await pageA.waitForURL('**/lists'); - }); - - await step('Mom creates list → Dad sees it appear on overview', async () => { - await pageA.click('text=+ New list'); - await pageA.fill('input[placeholder="List name"]', 'Weekly Groceries'); - await pageA.selectOption('select', { label: 'Kroger' }); - await pageA.click('button:has-text("Create")'); - await pageA.waitForSelector('text=Weekly Groceries'); - // Dad should see it appear without navigating away - await pageB.waitForSelector('text=Weekly Groceries', { timeout: 5000 }); - }); - - // Both open the list - await step('Mom opens the list', async () => { - await pageA.click('text=Weekly Groceries'); - await pageA.waitForURL(/\/lists\/\d+/); - }); - - await step('Dad opens the same list', async () => { - await pageB.click('text=Weekly Groceries'); - await pageB.waitForURL(/\/lists\/\d+/); - }); - - // Mom adds items — Dad should see them appear in real-time - await step('Mom adds "Milk" → Dad sees it appear', async () => { - await pageA.fill('input[placeholder="Add an item..."]', 'Milk'); - await pageA.click('button:has-text("Add")'); - await pageA.waitForSelector('text=Milk'); - await pageB.waitForSelector('text=Milk', { timeout: 5000 }); - }); - - await step('Mom adds "Eggs" → Dad sees it appear', async () => { - await pageA.fill('input[placeholder="Add an item..."]', 'Eggs'); - await pageA.click('button:has-text("Add")'); - await pageA.waitForSelector('text=Eggs'); - await pageB.waitForSelector('text=Eggs', { timeout: 5000 }); - }); - - await step('Mom adds "Bread" → Dad sees it appear', async () => { - await pageA.fill('input[placeholder="Add an item..."]', 'Bread'); - await pageA.click('button:has-text("Add")'); - await pageA.waitForSelector('text=Bread'); - await pageB.waitForSelector('text=Bread', { timeout: 5000 }); - }); - - // Dad checks off Milk — Mom should see it checked - await step('Dad checks "Milk" → Mom sees it checked', async () => { - await pageB.click('button[aria-label="Check Milk"]'); - await pageB.waitForSelector('text=Checked (1)'); - await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 }); - }); - - // Mom checks off Eggs — Dad should see it - await step('Mom checks "Eggs" → Dad sees it checked', async () => { - await pageA.click('button[aria-label="Check Eggs"]'); - await pageA.waitForSelector('text=Checked (2)'); - await pageB.waitForSelector('text=Checked (2)', { timeout: 5000 }); - }); - - // Dad unchecks Milk — Mom should see it unchecked - await step('Dad unchecks "Milk" → Mom sees it unchecked', async () => { - await pageB.click('button[aria-label="Uncheck Milk"]'); - await pageB.waitForSelector('text=Checked (1)'); - await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 }); - }); - - // Dad adds an item — Mom should see it - await step('Dad adds "Butter" → Mom sees it appear', async () => { - await pageB.fill('input[placeholder="Add an item..."]', 'Butter'); - await pageB.click('button:has-text("Add")'); - await pageB.waitForSelector('text=Butter'); - await pageA.waitForSelector('text=Butter', { timeout: 5000 }); - }); - - // Mom removes Bread — Dad should see it disappear - await step('Mom removes "Bread" → Dad sees it disappear', async () => { - await pageA.click('button[aria-label="Remove Bread"]'); - await pageA.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 3000 }); - await pageB.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 5000 }); - }); - - console.log('\n🎉 All multi-user tests passed! Real-time sync works.'); -} catch (e) { - console.error(`\n💥 Test failed: ${e.message}`); - process.exitCode = 1; -} finally { - await new Promise(r => setTimeout(r, 2000)); - await browser.close(); -} diff --git a/test-e2e.mjs b/test-e2e.mjs deleted file mode 100644 index 0f90e46..0000000 --- a/test-e2e.mjs +++ /dev/null @@ -1,147 +0,0 @@ -import { chromium } from 'playwright'; - -const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 300 }); -const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); -const page = await context.newPage(); - -const BASE = 'http://localhost:5173'; -let failed = false; - -async function step(name, fn) { - process.stdout.write(`▶ ${name}... `); - try { - await fn(); - console.log('✅'); - } catch (e) { - console.log(`❌ ${e.message}`); - failed = true; - throw e; - } -} - -try { - await step('Navigate to app → should redirect to /login', async () => { - await page.goto(BASE); - await page.waitForURL('**/login', { timeout: 5000 }); - }); - - await step('Register a new account', async () => { - await page.click('text=Create account'); - await page.fill('input[placeholder="Name"]', 'TestChef'); - await page.fill('input[placeholder="Password"]', 'password123'); - await page.fill('input[placeholder="Family code"]', 'dev-family-code'); - await page.click('button[type="submit"]'); - await page.waitForURL('**/lists', { timeout: 5000 }); - }); - - await step('Create store "Kroger"', async () => { - await page.click('a:has-text("Stores")'); - await page.waitForURL('**/stores'); - await page.fill('input[placeholder="New store name"]', 'Kroger'); - await page.click('button:has-text("Add")'); - await page.waitForSelector('text=Kroger'); - }); - - await step('Create store "Costco"', async () => { - await page.fill('input[placeholder="New store name"]', 'Costco'); - await page.click('button:has-text("Add")'); - await page.waitForSelector('text=Costco'); - }); - - await step('Create shopping list "Weekly Groceries"', async () => { - await page.click('a:has-text("Lists")'); - await page.waitForURL('**/lists'); - await page.click('text=+ New list'); - await page.fill('input[placeholder="List name"]', 'Weekly Groceries'); - await page.selectOption('select', { label: 'Kroger' }); - await page.click('button:has-text("Create")'); - await page.waitForSelector('text=Weekly Groceries'); - }); - - await step('Open list and add 5 items', async () => { - await page.click('text=Weekly Groceries'); - await page.waitForURL(/\/lists\/\d+/); - - for (const item of ['Milk', 'Eggs', 'Bread', 'Chicken breast', 'Broccoli']) { - await page.fill('input[placeholder="Add an item..."]', item); - await page.click('button:has-text("Add")'); - await page.waitForSelector(`text=${item}`); - } - }); - - await step('Check off Milk and Eggs', async () => { - await page.click('button[aria-label="Check Milk"]'); - await page.waitForSelector('text=Checked (1)'); - await page.click('button[aria-label="Check Eggs"]'); - await page.waitForSelector('text=Checked (2)'); - }); - - await step('Uncheck Milk', async () => { - await page.click('button[aria-label="Uncheck Milk"]'); - await page.waitForSelector('text=Checked (1)'); - }); - - await step('Remove Broccoli', async () => { - await page.click('button[aria-label="Remove Broccoli"]'); - await page.waitForFunction(() => !document.body.textContent.includes('Broccoli'), { timeout: 3000 }); - }); - - await step('Create recipe "Chicken Stir Fry"', async () => { - await page.click('a:has-text("Recipes")'); - await page.waitForURL('**/recipes'); - await page.click('text=+ New recipe'); - await page.waitForURL('**/recipes/new'); - await page.fill('input[placeholder="Recipe title"]', 'Chicken Stir Fry'); - await page.fill('textarea[placeholder*="description"]', 'Quick weeknight dinner'); - - const qtyInputs = page.locator('input[placeholder="Qty"]'); - const nameInputs = page.locator('input[placeholder="Ingredient name"]'); - await qtyInputs.nth(0).fill('2 lbs'); - await nameInputs.nth(0).fill('chicken breast'); - - await page.click('text=+ Add ingredient'); - await qtyInputs.nth(1).fill('1 head'); - await nameInputs.nth(1).fill('broccoli'); - - await page.click('text=+ Add ingredient'); - await qtyInputs.nth(2).fill('3 tbsp'); - await nameInputs.nth(2).fill('soy sauce'); - - await page.fill('textarea[placeholder*="Instructions"]', '1. Cut chicken\n2. Stir fry\n3. Add sauce'); - await page.click('button:has-text("Save Recipe")'); - await page.waitForURL(/\/recipes\/\d+/); - }); - - await step('Add recipe ingredients to shopping list', async () => { - await page.click('button:has-text("Add to list")'); - await page.waitForSelector('text=Choose a list'); - await page.click('text=Weekly Groceries'); - await page.waitForURL(/\/lists\/\d+/); - await page.waitForSelector('text=chicken breast'); - }); - - await step('Verify list has recipe items', async () => { - const content = await page.textContent('body'); - for (const item of ['2 lbs chicken breast', '1 head broccoli', '3 tbsp soy sauce']) { - if (!content.includes(item)) throw new Error(`Missing recipe item: ${item}`); - } - }); - - await step('Navigate back to lists overview', async () => { - await page.click('text=← Back'); - await page.waitForURL('**/lists'); - }); - - await step('Sign out', async () => { - await page.click('text=Sign out'); - await page.waitForURL('**/login'); - }); - - console.log('\n🎉 All tests passed!'); -} catch (e) { - console.error(`\n💥 Test suite stopped at failure: ${e.message}`); - process.exitCode = 1; -} finally { - await new Promise(r => setTimeout(r, 2000)); - await browser.close(); -}