diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 1e9970ca3..b74c82ea1 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -30,5 +30,6 @@ jobs:
- run: npm run lint
# The local binary, not npx, which would fetch on demand.
- run: ./node_modules/.bin/tsc -b
+ - run: npm test
- run: npm run build:app
- run: npm audit --audit-level=high
diff --git a/web/README.md b/web/README.md
index 819f8de3f..a86e64803 100644
--- a/web/README.md
+++ b/web/README.md
@@ -11,7 +11,7 @@ npm install
npm run dev # http://localhost:5173, API proxied to the backend
```
-`npm run lint` and `npx tsc -b` are what CI checks.
+`npm run lint`, `npx tsc -b`, and `npm test` are what CI checks.
## Building for the platform
diff --git a/web/package-lock.json b/web/package-lock.json
index bc736cb04..8a380196e 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -29,13 +29,266 @@
"tailwindcss": "^4.3.2"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.8",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
+ "jsdom": "^30.1.3",
"oxlint": "^1.88.0",
"typescript": "~7.0.2",
- "vite": "^8.3.4"
+ "vite": "^8.3.4",
+ "vitest": "^5.0.5"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.7.tgz",
+ "integrity": "sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.3",
+ "@csstools/css-color-parser": "^4.2.6",
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "10.0.5",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
+ "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
+ "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "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.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "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.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "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.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "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.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "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.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "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/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "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/@floating-ui/core": {
@@ -1950,6 +2203,138 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "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/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.8",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz",
+ "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "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",
+ "peer": true
+ },
+ "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/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.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
@@ -2350,6 +2735,79 @@
}
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
+ "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.4.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9 || ^3.0.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
+ "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "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",
+ "peer": true,
+ "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",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -2362,16 +2820,56 @@
"node": ">=10"
}
},
- "node_modules/class-variance-authority": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
- "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "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": {
- "clsx": "^2.1.1"
- },
- "funding": {
- "url": "https://polar.sh/cva"
+ "dequal": "^2.0.3"
+ }
+ },
+ "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/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
@@ -2389,6 +2887,27 @@
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
"license": "MIT"
},
+ "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/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2396,6 +2915,37 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
+ "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.2"
+ },
+ "engines": {
+ "node": "^22.14.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/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",
@@ -2411,6 +2961,14 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"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",
+ "peer": true
+ },
"node_modules/enhanced-resolve": {
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz",
@@ -2424,6 +2982,46 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "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",
@@ -2493,6 +3091,36 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/isbot": {
"version": "5.1.44",
"resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.44.tgz",
@@ -2511,6 +3139,54 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "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",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "30.1.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
+ "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.1.3",
+ "@asamuzakjp/dom-selector": "^10.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
+ "@exodus/bytes": "^1.16.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^8.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.12.1",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.2.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2772,6 +3448,16 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/lucide-react": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
@@ -2787,6 +3473,17 @@
}
}
},
+ "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",
+ "peer": true,
+ "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",
@@ -2796,6 +3493,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "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/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/motion": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-14.3.0.tgz",
@@ -2851,6 +3565,20 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/obug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+ "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/oxlint": {
"version": "1.88.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.88.0.tgz",
@@ -2900,6 +3628,19 @@
}
}
},
+ "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/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2946,6 +3687,32 @@
"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",
+ "peer": true,
+ "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/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/react": {
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
@@ -2967,6 +3734,14 @@
"react": "^19.3.1"
}
},
+ "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",
+ "peer": true
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -3036,6 +3811,30 @@
}
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/rolldown": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz",
@@ -3069,6 +3868,19 @@
"@rolldown/binding-win32-x64-msvc": "1.2.14"
}
},
+ "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/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -3105,6 +3917,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/tailwind-merge": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
@@ -3134,6 +3966,26 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
+ "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3150,6 +4002,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.19",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
+ "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.19"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz",
+ "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
+ "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3191,6 +4089,16 @@
"@typescript/typescript-win32-x64": "7.0.2"
}
},
+ "node_modules/undici": {
+ "version": "8.12.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
@@ -3587,6 +4495,177 @@
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
+ },
+ "node_modules/vitest": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
+ "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.5",
+ "chai": "^6.3.0",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.4.3",
+ "obug": "^3.0.0",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.3.0",
+ "tinybench": "^6.2.2",
+ "tinyexec": "^1.3.1",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.5",
+ "@vitest/browser-preview": "5.0.5",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.5",
+ "@vitest/coverage-v8": "5.0.5",
+ "@vitest/ui": "5.0.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.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/vitest/node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "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/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": "17.3.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
+ "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.16.0",
+ "tr46": "^7.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "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"
}
}
}
diff --git a/web/package.json b/web/package.json
index 5714eb782..05ed66207 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,7 +9,8 @@
"build:app": "tsc -b && vite build --base=/app/",
"build:demo": "vite build --base=./ --mode demo",
"lint": "oxlint --deny-warnings",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -33,12 +34,17 @@
"tailwindcss": "^4.3.2"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.8",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
+ "jsdom": "^30.1.3",
"oxlint": "^1.88.0",
"typescript": "~7.0.2",
- "vite": "^8.3.4"
+ "vite": "^8.3.4",
+ "vitest": "^5.0.5"
}
}
diff --git a/web/src/components/DiffDrawer.test.tsx b/web/src/components/DiffDrawer.test.tsx
new file mode 100644
index 000000000..8089fae20
--- /dev/null
+++ b/web/src/components/DiffDrawer.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { DiffDrawer, type DiffTarget } from "@/components/DiffDrawer";
+import { createWrapper } from "@/test/query";
+
+const target: DiffTarget = {
+ runId: 21,
+ sampleId: 14,
+ regressionId: 7,
+ outputId: 3,
+ command: "-autoprogram",
+ sampleName: "pal.ts",
+};
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("DiffDrawer", () => {
+ it("renders a unified diff for a failing output", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/diff")) {
+ return jsonResponse(200, {
+ content: "--- expected\n+++ actual\n@@ -1 +1 @@\n-old caption\n+new caption\n",
+ });
+ }
+ return jsonResponse(404, {});
+ }),
+ );
+
+ const { wrapper } = createWrapper();
+ render(, { wrapper });
+
+ expect(screen.getByText("Test #7")).toBeInTheDocument();
+ expect(screen.getByText(/pal\.ts/)).toBeInTheDocument();
+ expect(await screen.findByText("-old caption")).toBeInTheDocument();
+ expect(screen.getByText("+new caption")).toBeInTheDocument();
+ });
+
+ it("explains when the output file is missing from storage", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(404, { message: "not stored" })));
+
+ const { wrapper } = createWrapper();
+ render(, { wrapper });
+
+ expect(
+ await screen.findByText(/output file for this result isn't present in storage/i),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/web/src/components/Login.test.tsx b/web/src/components/Login.test.tsx
new file mode 100644
index 000000000..0c52bc80e
--- /dev/null
+++ b/web/src/components/Login.test.tsx
@@ -0,0 +1,69 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { Login } from "@/components/Login";
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("Login", () => {
+ it("shows the same vague error on a 401", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(401, { message: "nope" })));
+
+ render();
+ await user.type(screen.getByLabelText("Email"), "dev@example.com");
+ await user.type(screen.getByLabelText("Password"), "wrong");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+
+ expect(await screen.findByText("Email or password is incorrect.")).toBeInTheDocument();
+ });
+
+ it("uses identical copy for a reset whether or not the address exists", async () => {
+ const user = userEvent.setup();
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(200, { ok: true })));
+
+ render();
+ await user.click(screen.getByRole("button", { name: /forgot your password/i }));
+ await user.type(screen.getByLabelText("Email"), "dev@example.com");
+ await user.click(screen.getByRole("button", { name: /email me a reset link/i }));
+
+ expect(
+ await screen.findByText("If that address has an account, a reset link is on its way."),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onDone after a successful sign-in", async () => {
+ const user = userEvent.setup();
+ const onDone = vi.fn();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/auth/tokens")) {
+ return jsonResponse(200, { token: "minted", expires_at: "2099-01-01T00:00:00Z" });
+ }
+ if (url.endsWith("/auth/me")) {
+ return jsonResponse(200, { role: "user" });
+ }
+ return jsonResponse(404, {});
+ }),
+ );
+
+ render();
+ await user.type(screen.getByLabelText("Email"), "dev@example.com");
+ await user.type(screen.getByLabelText("Password"), "secret");
+ await user.click(screen.getByRole("button", { name: /sign in/i }));
+
+ await waitFor(() => expect(onDone).toHaveBeenCalled());
+ });
+});
diff --git a/web/src/components/StatusBadge.test.tsx b/web/src/components/StatusBadge.test.tsx
new file mode 100644
index 000000000..9c750fde9
--- /dev/null
+++ b/web/src/components/StatusBadge.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { RunStatusBadge } from "@/components/StatusBadge";
+
+describe("RunStatusBadge", () => {
+ it("labels failed and passed runs", () => {
+ const { rerender } = render();
+ expect(screen.getByText("Failed")).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText("Passed")).toBeInTheDocument();
+ });
+
+ it("treats completed as a finished success, not still-running", () => {
+ render();
+ expect(screen.getByText("Completed")).toBeInTheDocument();
+ });
+});
diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts
new file mode 100644
index 000000000..034d06117
--- /dev/null
+++ b/web/src/lib/api.test.ts
@@ -0,0 +1,308 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ categoryHealth,
+ deriveCategories,
+ healthOf,
+ useRegressionTests,
+ useRuns,
+ useSamples,
+} from "@/lib/api";
+import type { RegressionTest } from "@/lib/types";
+import { createWrapper } from "@/test/query";
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+const SHA = "abcdef0123456789abcdef0123456789abcdef01";
+
+function liveRun(overrides: Record = {}) {
+ return {
+ run_id: 10,
+ status: "pass",
+ platform: "linux",
+ test_type: "commit",
+ repository: "CCExtractor/ccextractor",
+ branch: "master",
+ commit_sha: SHA,
+ pr_number: null,
+ created_at: "2026-10-01T00:00:00Z",
+ queued_at: null,
+ started_at: null,
+ completed_at: null,
+ github_link: "https://github.com/CCExtractor/ccextractor/commit/" + SHA,
+ ...overrides,
+ };
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("useRuns grouping", () => {
+ it("folds linux and windows for the same commit into one logical run", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/v1/runs?")) {
+ return jsonResponse(200, {
+ data: [
+ liveRun({ run_id: 21, platform: "windows", status: "fail" }),
+ liveRun({ run_id: 20, platform: "linux", status: "pass" }),
+ ],
+ });
+ }
+ return jsonResponse(404, {});
+ }),
+ );
+
+ const { wrapper } = createWrapper();
+ const { result, unmount } = renderHook(() => useRuns(), { wrapper });
+ await waitFor(() => expect(result.current.data).toHaveLength(1));
+
+ const run = result.current.data![0];
+ expect(run.id).toBe("21");
+ expect(run.commit).toBe(SHA.slice(0, 9));
+ expect(run.fork).toBe("CCExtractor/ccextractor");
+ expect(run.platforms.map((p) => p.platform)).toEqual(["linux", "windows"]);
+ expect(run.platforms.find((p) => p.platform === "windows")?.status).toBe("fail");
+ unmount();
+ });
+
+ it("keeps a PR run distinct from a commit run on the same SHA", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/v1/runs?")) {
+ return jsonResponse(200, {
+ data: [
+ liveRun({ run_id: 5, test_type: "pr", pr_number: 2406 }),
+ liveRun({ run_id: 4, test_type: "commit", pr_number: null }),
+ ],
+ });
+ }
+ return jsonResponse(404, {});
+ }),
+ );
+
+ const { wrapper } = createWrapper();
+ const { result, unmount } = renderHook(() => useRuns(), { wrapper });
+ await waitFor(() => expect(result.current.data).toHaveLength(2));
+ expect(result.current.data!.map((r) => r.pr_nr).sort()).toEqual([2406, null].sort());
+ unmount();
+ });
+});
+
+describe("useSamples mapping", () => {
+ it("normalizes extensions and keeps sample ids/shas from the API", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/v1/samples?")) {
+ return jsonResponse(200, {
+ data: [
+ {
+ sample_id: 14,
+ sha: "abc",
+ extension: "ts",
+ original_name: "pal.ts",
+ tags: ["pal"],
+ regression_test_count: 3,
+ },
+ {
+ sample_id: 121,
+ sha: "def",
+ extension: ".mkv",
+ original_name: "film.mkv",
+ tags: [],
+ regression_test_count: 1,
+ },
+ ],
+ pagination: { total: 2, next_offset: null },
+ });
+ }
+ return jsonResponse(404, {});
+ }),
+ );
+
+ const { wrapper } = createWrapper();
+ const { result, unmount } = renderHook(() => useSamples(), { wrapper });
+ await waitFor(() => expect(result.current.data).toHaveLength(2));
+ expect(result.current.data).toEqual([
+ {
+ id: 14,
+ sha: "abc",
+ extension: ".ts",
+ original_name: "pal.ts",
+ tags: ["pal"],
+ test_count: 3,
+ },
+ {
+ id: 121,
+ sha: "def",
+ extension: ".mkv",
+ original_name: "film.mkv",
+ tags: [],
+ test_count: 1,
+ },
+ ]);
+ unmount();
+ });
+});
+
+describe("useRegressionTests", () => {
+ it("joins sample SHA and ignores PR runs when folding history", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/regression-tests?active=true")) {
+ return jsonResponse(200, {
+ data: [
+ {
+ regression_test_id: 7,
+ sample_id: 14,
+ sample_name: "pal.ts",
+ command: "-autoprogram",
+ input_type: "ts",
+ output_type: "srt",
+ expected_rc: 0,
+ active: true,
+ description: null,
+ categories: ["General"],
+ },
+ ],
+ pagination: { total: 1, next_offset: null },
+ });
+ }
+ if (url.includes("/regression-tests?active=false")) {
+ return jsonResponse(200, {
+ data: [],
+ pagination: { total: 0, next_offset: null },
+ });
+ }
+ if (url.includes("/runs/30/samples")) {
+ return jsonResponse(200, {
+ data: [{ regression_test_id: 7, status: "fail", runtime_ms: 100 }],
+ pagination: { total: 1, next_offset: null },
+ });
+ }
+ if (url.includes("/runs/31/samples")) {
+ return jsonResponse(200, {
+ data: [{ regression_test_id: 7, status: "pass", runtime_ms: 10 }],
+ pagination: { total: 1, next_offset: null },
+ });
+ }
+ if (url.includes("/api/v1/samples?")) {
+ return jsonResponse(200, {
+ data: [
+ {
+ sample_id: 14,
+ sha: "deadbeef",
+ extension: ".ts",
+ original_name: "pal.ts",
+ tags: [],
+ regression_test_count: 1,
+ },
+ ],
+ pagination: { total: 1, next_offset: null },
+ });
+ }
+ if (url.includes("/api/v1/runs?")) {
+ return jsonResponse(200, {
+ data: [
+ liveRun({ run_id: 30, test_type: "commit", platform: "linux" }),
+ liveRun({
+ run_id: 31,
+ test_type: "pr",
+ pr_number: 99,
+ platform: "linux",
+ }),
+ ],
+ });
+ }
+ return jsonResponse(404, { message: url });
+ }),
+ );
+
+ const { wrapper } = createWrapper();
+ const { result, unmount } = renderHook(() => useRegressionTests(), { wrapper });
+ await waitFor(() => expect(result.current.data).toHaveLength(1));
+ expect(result.current.data[0].sample_sha).toBe("deadbeef");
+ expect(result.current.data[0].id).toBe(7);
+ // PR run 31 must not contribute: only the commit failure is history.
+ await waitFor(() => expect(result.current.data[0].recent_results).toEqual(["fail"]));
+ expect(result.current.data[0].avg_runtime_ms).toBe(100);
+ unmount();
+ });
+});
+
+describe("401 handling", () => {
+ it("clears the session and reloads when the API says the token is dead", async () => {
+ localStorage.setItem(
+ "sp-session",
+ JSON.stringify({
+ token: "stale",
+ email: "dev@example.com",
+ role: "user",
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
+ }),
+ );
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { reload: vi.fn() },
+ });
+ vi.stubGlobal("fetch", vi.fn(async () => jsonResponse(401, { message: "expired" })));
+
+ const { wrapper } = createWrapper();
+ const { result, unmount } = renderHook(() => useSamples(), { wrapper });
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+ expect(localStorage.getItem("sp-session")).toBeNull();
+ unmount();
+ });
+});
+
+describe("healthOf / deriveCategories", () => {
+ const testRow = (overrides: Partial = {}): RegressionTest => ({
+ id: 1,
+ sample_id: 1,
+ sample_name: "a.ts",
+ sample_sha: "x",
+ command: "-autoprogram",
+ input_type: "ts",
+ output_type: "srt",
+ expected_rc: 0,
+ active: true,
+ description: "",
+ categories: ["General"],
+ avg_runtime_ms: null,
+ recent_results: [],
+ ...overrides,
+ });
+
+ it("uses the latest non-skip result so a customized-run gap is not a failure", () => {
+ expect(healthOf(testRow({ recent_results: ["fail", "skip"] }))).toBe("fail");
+ expect(healthOf(testRow({ recent_results: ["fail", "pass"] }))).toBe("pass");
+ expect(healthOf(testRow({ recent_results: ["skip"] }))).toBe("skip");
+ });
+
+ it("counts failing tests per category", () => {
+ const tests = [
+ testRow({ id: 1, categories: ["General"], recent_results: ["fail"] }),
+ testRow({ id: 2, categories: ["General", "Options"], recent_results: ["pass"] }),
+ ];
+ expect(categoryHealth("General", tests)).toEqual({ total: 2, failing: 1 });
+ expect(deriveCategories(tests).map((c) => c.name)).toEqual(["General", "Options"]);
+ });
+});
diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts
new file mode 100644
index 000000000..b2677e0b2
--- /dev/null
+++ b/web/src/lib/auth.test.ts
@@ -0,0 +1,135 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { canManage, canOperateCi, getSession, login, logout, type Session } from "@/lib/auth";
+
+const KEY = "sp-session";
+
+function session(overrides: Partial = {}): Session {
+ return {
+ token: "tok",
+ email: "dev@example.com",
+ role: "user",
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
+ ...overrides,
+ };
+}
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("getSession", () => {
+ it("returns null when nothing is stored", () => {
+ expect(getSession()).toBeNull();
+ });
+
+ it("drops an expired session", () => {
+ localStorage.setItem(
+ KEY,
+ JSON.stringify(session({ expires_at: new Date(Date.now() - 1000).toISOString() })),
+ );
+ expect(getSession()).toBeNull();
+ expect(localStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("returns a still-valid session", () => {
+ const s = session();
+ localStorage.setItem(KEY, JSON.stringify(s));
+ expect(getSession()).toEqual(s);
+ });
+
+ it("returns null for corrupt JSON", () => {
+ localStorage.setItem(KEY, "{not-json");
+ expect(getSession()).toBeNull();
+ });
+});
+
+describe("role gates", () => {
+ it("lets admins and contributors manage the suite", () => {
+ expect(canManage(session({ role: "admin" }))).toBe(true);
+ expect(canManage(session({ role: "contributor" }))).toBe(true);
+ expect(canManage(session({ role: "tester" }))).toBe(false);
+ expect(canManage(session({ role: "user" }))).toBe(false);
+ expect(canManage(null)).toBe(false);
+ });
+
+ it("lets testers queue CI but not manage tests", () => {
+ const tester = session({ role: "tester" });
+ expect(canOperateCi(tester)).toBe(true);
+ expect(canManage(tester)).toBe(false);
+ expect(canOperateCi(session({ role: "user" }))).toBe(false);
+ });
+});
+
+describe("login", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("stores the session after minting a token and reading /auth/me", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/auth/tokens") && init?.method === "POST") {
+ return jsonResponse(200, {
+ token: "minted",
+ expires_at: "2099-01-01T00:00:00Z",
+ });
+ }
+ if (url.endsWith("/auth/me")) {
+ return jsonResponse(200, { role: "contributor" });
+ }
+ return jsonResponse(404, { message: "unexpected" });
+ }),
+ );
+
+ const s = await login("dev@example.com", "secret");
+ expect(s.token).toBe("minted");
+ expect(s.role).toBe("contributor");
+ expect(s.email).toBe("dev@example.com");
+ expect(getSession()?.token).toBe("minted");
+ });
+
+ it("does not reveal whether the email exists on 401", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => jsonResponse(401, { message: "no such user" })),
+ );
+
+ await expect(login("dev@example.com", "wrong")).rejects.toThrow(
+ "Email or password is incorrect.",
+ );
+ expect(getSession()).toBeNull();
+ });
+});
+
+describe("logout", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => jsonResponse(204, {})),
+ );
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { reload: vi.fn() },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("clears storage and reloads once even if called twice", async () => {
+ localStorage.setItem(KEY, JSON.stringify(session()));
+ logout();
+ logout();
+ await vi.waitFor(() => {
+ expect(window.location.reload).toHaveBeenCalledTimes(1);
+ });
+ expect(localStorage.getItem(KEY)).toBeNull();
+ });
+});
diff --git a/web/src/lib/cues.test.ts b/web/src/lib/cues.test.ts
new file mode 100644
index 000000000..76949d111
--- /dev/null
+++ b/web/src/lib/cues.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+
+import { clock, cuesToVtt, parseCues } from "@/lib/cues";
+
+const SRT = `1
+00:00:01,000 --> 00:00:02,500
+Hello
+
+2
+00:00:03,000 --> 00:00:04,000
+World
+`;
+
+const VTT = `WEBVTT
+
+00:00:01.000 --> 00:00:02.500
+Hello
+`;
+
+describe("parseCues", () => {
+ it("reads SubRip commas and WebVTT dots as the same timings", () => {
+ const srt = parseCues(SRT);
+ const vtt = parseCues(VTT);
+ expect(srt).toEqual([
+ { start: 1, end: 2.5, text: "Hello" },
+ { start: 3, end: 4, text: "World" },
+ ]);
+ expect(vtt).toEqual([{ start: 1, end: 2.5, text: "Hello" }]);
+ });
+
+ it("yields nothing for a plain transcript so the caller can show the file", () => {
+ expect(parseCues("just some text\nno timings")).toEqual([]);
+ });
+});
+
+describe("clock / cuesToVtt", () => {
+ it("formats the cue gutter as m:ss", () => {
+ expect(clock(0)).toBe("0:00");
+ expect(clock(65)).toBe("1:05");
+ });
+
+ it("round-trips cues into a WebVTT track", () => {
+ const vtt = cuesToVtt([{ start: 1, end: 2.5, text: "Hello" }]);
+ expect(vtt.startsWith("WEBVTT\n\n")).toBe(true);
+ expect(vtt).toContain("00:00:01.000 --> 00:00:02.500");
+ expect(vtt).toContain("Hello");
+ });
+});
diff --git a/web/src/lib/validate.test.ts b/web/src/lib/validate.test.ts
new file mode 100644
index 000000000..257a6cb46
--- /dev/null
+++ b/web/src/lib/validate.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ githubUrl,
+ httpsUrl,
+ isBranchName,
+ isCommitSha,
+ isRepoSlug,
+} from "@/lib/validate";
+
+describe("githubUrl", () => {
+ it("keeps a real github.com https URL", () => {
+ expect(githubUrl("https://github.com/CCExtractor/ccextractor")).toBe(
+ "https://github.com/CCExtractor/ccextractor",
+ );
+ });
+
+ it("drops javascript: and non-github hrefs so they never reach ", () => {
+ expect(githubUrl("javascript:alert(1)")).toBeNull();
+ expect(githubUrl("https://evil.example/CCExtractor/ccextractor")).toBeNull();
+ expect(githubUrl("http://github.com/CCExtractor/ccextractor")).toBeNull();
+ expect(githubUrl(null)).toBeNull();
+ });
+});
+
+describe("httpsUrl", () => {
+ it("allows signed https downloads and rejects data/javascript", () => {
+ expect(httpsUrl("https://storage.googleapis.com/bucket/file")).toBe(
+ "https://storage.googleapis.com/bucket/file",
+ );
+ expect(httpsUrl("javascript:alert(1)")).toBeNull();
+ expect(httpsUrl("data:text/html,hi")).toBeNull();
+ });
+});
+
+describe("form gates", () => {
+ it("accepts a full 40-hex SHA and a owner/repo slug", () => {
+ expect(isCommitSha("a".repeat(40))).toBe(true);
+ expect(isCommitSha("a".repeat(39))).toBe(false);
+ expect(isRepoSlug("CCExtractor/sample-platform")).toBe(true);
+ expect(isRepoSlug("not-a-slug")).toBe(false);
+ });
+
+ it("rejects branch names that would be unsafe to forward to CI", () => {
+ expect(isBranchName("fix/restart-clears-gcp")).toBe(true);
+ expect(isBranchName("-leading-dash")).toBe(false);
+ expect(isBranchName("has space")).toBe(false);
+ expect(isBranchName("bad;rm -rf")).toBe(false);
+ });
+});
diff --git a/web/src/test/query.tsx b/web/src/test/query.tsx
new file mode 100644
index 000000000..e88f70e67
--- /dev/null
+++ b/web/src/test/query.tsx
@@ -0,0 +1,15 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+
+/** Isolated query client so tests never share cache or retry. */
+export function createWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
+ });
+ return {
+ client,
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ };
+}
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
new file mode 100644
index 000000000..83abbca86
--- /dev/null
+++ b/web/src/test/setup.ts
@@ -0,0 +1,9 @@
+import "@testing-library/jest-dom/vitest";
+import { afterEach } from "vitest";
+
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 20c1198ba..efaf366e3 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -1,14 +1,14 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
+import { defineConfig } from "vitest/config";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
- "@": path.resolve(__dirname, "./src"),
+ "@": path.resolve(import.meta.dirname, "./src"),
},
},
server: {
@@ -21,4 +21,10 @@ export default defineConfig({
},
},
},
+ test: {
+ environment: "jsdom",
+ setupFiles: "./src/test/setup.ts",
+ restoreMocks: true,
+ css: false,
+ },
});