From fec29d6f84729f1e35030bd05ffbbf81f430f4f4 Mon Sep 17 00:00:00 2001 From: Naitik Date: Sat, 24 Oct 2026 10:59:14 +0530 Subject: [PATCH] [TEST] Add Vitest coverage for the web console --- .github/workflows/web.yml | 1 + web/README.md | 2 +- web/package-lock.json | 1097 ++++++++++++++++++++++- web/package.json | 10 +- web/src/components/DiffDrawer.test.tsx | 61 ++ web/src/components/Login.test.tsx | 69 ++ web/src/components/StatusBadge.test.tsx | 18 + web/src/lib/api.test.ts | 308 +++++++ web/src/lib/auth.test.ts | 135 +++ web/src/lib/cues.test.ts | 48 + web/src/lib/validate.test.ts | 50 ++ web/src/test/query.tsx | 15 + web/src/test/setup.ts | 9 + web/vite.config.ts | 10 +- 14 files changed, 1819 insertions(+), 14 deletions(-) create mode 100644 web/src/components/DiffDrawer.test.tsx create mode 100644 web/src/components/Login.test.tsx create mode 100644 web/src/components/StatusBadge.test.tsx create mode 100644 web/src/lib/api.test.ts create mode 100644 web/src/lib/auth.test.ts create mode 100644 web/src/lib/cues.test.ts create mode 100644 web/src/lib/validate.test.ts create mode 100644 web/src/test/query.tsx create mode 100644 web/src/test/setup.ts 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, + }, });