diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..68634ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + backend: + name: Backend tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Check installed dependencies + run: python -m pip check + + - name: Run tests + run: python -m pytest + + frontend: + name: Frontend checks + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: frontend + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/README.md b/README.md index 7273850..1b58aaf 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,28 @@ Don't hesitate to reach out for any questions regarding this! ## Tests +Frontend tests cover shared status/result formatting, API list handling, authentication state, +protected-route access, and toolkit and benchmark submission workflows: + +```bash +cd frontend +npm ci +npm test +``` + +Backend tests can be run with: + ```bash docker run --rm -v "$PWD:/core" -w /core python:3.11-slim \ sh -c "pip install -q -e '.[dev]' && pytest" ``` +## Continuous integration + +The GitHub Actions workflow in `.github/workflows/ci.yml` runs for pushes to every branch, pull +requests, and manual dispatches. It runs the core Python tests and tests and builds the shared +frontend. + ## Design Full architecture and rationale in [`docs/unified-platform.md`](docs/unified-platform.md). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d51fc2..0187294 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -27,12 +29,71 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", "vitest": "^3.2.4" } }, + "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": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -321,6 +382,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "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.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "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.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1066,6 +1280,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "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/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1778,6 +2010,99 @@ "win32" ] }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "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.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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/@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/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2104,16 +2429,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -2388,6 +2713,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2411,6 +2747,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2474,10 +2820,20 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2723,12 +3079,47 @@ "node": ">= 8" } }, + "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", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2746,6 +3137,13 @@ } } }, + "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/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2772,6 +3170,24 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2803,6 +3219,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3454,6 +3883,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3503,6 +3945,16 @@ "node": ">=0.8.19" } }, + "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-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3547,6 +3999,13 @@ "node": ">=0.10.0" } }, + "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3561,9 +4020,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3583,6 +4042,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3717,6 +4227,17 @@ "yallist": "^3.0.2" } }, + "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", @@ -3736,6 +4257,13 @@ "node": ">= 0.4" } }, + "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/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3757,6 +4285,16 @@ "node": ">= 0.6" } }, + "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/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3777,9 +4315,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3901,6 +4439,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3973,9 +4524,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -3993,7 +4544,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4011,6 +4562,44 @@ "node": ">= 0.8.0" } }, + "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/pretty-format/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/pretty-format/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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4085,9 +4674,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4107,12 +4696,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -4138,6 +4727,30 @@ "react-dom": ">=16.6.0" } }, + "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/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -4213,6 +4826,19 @@ "fsevents": "~2.3.2" } }, + "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.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4298,6 +4924,19 @@ "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/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4362,6 +5001,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4423,6 +5069,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.11" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", + "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": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4487,6 +5179,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -4706,6 +5408,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "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": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4749,6 +5499,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 75b7581..1372f4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -30,6 +32,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^29.1.1", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4", diff --git a/frontend/src/pages/BenchmarkSubmissionPage.tsx b/frontend/src/pages/BenchmarkSubmissionPage.tsx index 55766f7..5a0b81e 100644 --- a/frontend/src/pages/BenchmarkSubmissionPage.tsx +++ b/frontend/src/pages/BenchmarkSubmissionPage.tsx @@ -204,11 +204,11 @@ export default function BenchmarkSubmissionPage() { {/* A category variant (ARCH) submits a whole category from one repo, so there is no per-benchmark name; a name variant (VNN) names the single benchmark. */} - {!usesCategories && ( + {data && !usesCategories && ( setName(e.target.value)} required sx={{ mb: 3 }} /> )} - {usesCategories && ( + {data && usesCategories && ( setCategory(e.target.value)} required sx={{ mb: 3 }} helperText="One submission loads all of the category's benchmarks. Create categories on the Toolkit page."> {(data?.categories ?? []).map((c) => {c.name})} diff --git a/frontend/src/pages/ToolkitSubmissionPage.tsx b/frontend/src/pages/ToolkitSubmissionPage.tsx index 4eb4c3f..da05014 100644 --- a/frontend/src/pages/ToolkitSubmissionPage.tsx +++ b/frontend/src/pages/ToolkitSubmissionPage.tsx @@ -209,9 +209,11 @@ export default function ToolkitSubmissionPage() { {Object.keys(data?.benchmark_categories ?? {}).length === 0 && No categories with published benchmarks yet} )} - set({ run_networks: e.target.value })}> - {(data?.run_networks_options ?? []).map((o) => {o.label})} - + {data && ( + set({ run_networks: e.target.value })}> + {data.run_networks_options.map((o) => {o.label})} + + )} {/* ARCH: only the selected category's benchmarks; VNN: all, grouped by category. */} diff --git a/frontend/tests/api.test.ts b/frontend/tests/api.test.ts new file mode 100644 index 0000000..5b81dcf --- /dev/null +++ b/frontend/tests/api.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiClient = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock('../src/api/client', () => ({ default: apiClient })); + +import { categoriesApi, taskPage } from '../src/api'; + +describe('API list adapters', () => { + beforeEach(() => { + apiClient.get.mockReset(); + }); + + it('unwraps a paginated list response', async () => { + const categories = [{ id: 'vnn', name: 'VNN', result_fields: [], spec: {} }]; + apiClient.get.mockResolvedValue({ data: { count: 1, results: categories } }); + + await expect(categoriesApi.list()).resolves.toEqual(categories); + expect(apiClient.get).toHaveBeenCalledWith('/api/categories/'); + }); + + it('accepts a plain list response', async () => { + const categories = [{ id: 'arch', name: 'ARCH', result_fields: [], spec: {} }]; + apiClient.get.mockResolvedValue({ data: categories }); + + await expect(categoriesApi.list()).resolves.toEqual(categories); + }); + + it('constructs an encoded task-page query and preserves page metadata', async () => { + const response = { count: 0, results: [] }; + apiClient.get.mockResolvedValue({ data: response }); + + await expect(taskPage({ type: 'tool', page: 2, pageSize: 25, search: 'a b&c' })).resolves.toEqual(response); + expect(apiClient.get).toHaveBeenCalledWith('/api/tasks/?type=tool&page=2&page_size=25&search=a%20b%26c'); + }); +}); diff --git a/frontend/tests/auth-context.test.tsx b/frontend/tests/auth-context.test.tsx new file mode 100644 index 0000000..d533eef --- /dev/null +++ b/frontend/tests/auth-context.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '../src/api'; + +const authApi = vi.hoisted(() => ({ + getCurrentUser: vi.fn(), + login: vi.fn(), + signup: vi.fn(), + updateProfile: vi.fn(), + logout: vi.fn(), +})); + +vi.mock('../src/api', () => ({ authApi })); + +import { AuthProvider, useAuth } from '../src/context/AuthContext'; + +const currentUser: User = { + id: '1', email: 'ada@example.com', name: 'Ada', role: 'user', enabled: true, + is_admin: false, is_organizer: false, created_at: '2026-01-01T00:00:00Z', +}; + +function AuthConsumer() { + const { user, loading, login, logout } = useAuth(); + if (loading) return
Loading
; + return ( +
+ {user?.email ?? 'Guest'} + + +
+ ); +} + +describe('AuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + authApi.getCurrentUser.mockResolvedValue(null); + authApi.logout.mockResolvedValue(undefined); + }); + + it('loads the current session and stores its user profile', async () => { + authApi.getCurrentUser.mockResolvedValue(currentUser); + render(); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + expect(await screen.findByText('ada@example.com')).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem('auth_user') ?? 'null')).toEqual(currentUser); + }); + + it('updates state and storage after login and logout', async () => { + authApi.login.mockResolvedValue(currentUser); + render(); + await screen.findByText('Guest'); + + fireEvent.click(screen.getByRole('button', { name: 'Log in' })); + expect(await screen.findByText('ada@example.com')).toBeInTheDocument(); + expect(authApi.login).toHaveBeenCalledWith('ada@example.com', 'secret'); + expect(localStorage.getItem('auth_user')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Log out' })); + await waitFor(() => expect(screen.getByText('Guest')).toBeInTheDocument()); + expect(authApi.logout).toHaveBeenCalledOnce(); + expect(localStorage.getItem('auth_user')).toBeNull(); + }); + + it('clears a stale stored profile when session validation fails', async () => { + localStorage.setItem('auth_user', JSON.stringify(currentUser)); + authApi.getCurrentUser.mockRejectedValue(new Error('Unauthorized')); + render(); + + expect(JSON.parse(localStorage.getItem('auth_user') ?? 'null')).toEqual(currentUser); + expect(await screen.findByText('Guest')).toBeInTheDocument(); + expect(localStorage.getItem('auth_user')).toBeNull(); + }); +}); diff --git a/frontend/tests/benchmark-submission.test.tsx b/frontend/tests/benchmark-submission.test.tsx new file mode 100644 index 0000000..661483c --- /dev/null +++ b/frontend/tests/benchmark-submission.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BenchmarkFormData } from '../src/api'; + +const benchmarksApi = vi.hoisted(() => ({ + getFormData: vi.fn(), + submit: vi.fn(), +})); + +vi.mock('../src/api', () => ({ benchmarksApi })); + +import BenchmarkSubmissionPage from '../src/pages/BenchmarkSubmissionPage'; + +const formData = (overrides: Partial = {}): BenchmarkFormData => ({ + scheduler_enabled: true, + can_submit: true, + execution_backend: 'local_docker', + uses_categories: false, + categories: [ + { id: 'acasxu', name: 'ACAS Xu' }, + { id: 'cifar', name: 'CIFAR' }, + ], + benchmark_fields: [ + { name: 'vnnlib_version', type: 'select', options: ['2.0', '1.0'] }, + { name: 'seed', type: 'text' }, + ], + ...overrides, +}); + +function Destination() { + return
Destination: {useLocation().pathname}
; +} + +function renderPage(prefillData?: Record) { + return render( + + + } /> + } /> + + , + ); +} + +const submitForm = () => { + const button = screen.getByRole('button', { name: 'Submit benchmark' }); + const form = button.closest('form'); + if (!form) throw new Error('Benchmark submission form not found'); + fireEvent.submit(form); +}; + +describe('BenchmarkSubmissionPage', () => { + beforeEach(() => { + benchmarksApi.getFormData.mockReset(); + benchmarksApi.submit.mockReset(); + benchmarksApi.getFormData.mockResolvedValue(formData()); + benchmarksApi.submit.mockResolvedValue({ redirect_to: '456' }); + }); + + it('loads dynamic fields, submits the VNN payload, and navigates', async () => { + renderPage(); + + fireEvent.change(await screen.findByRole('textbox', { name: /Benchmark name/i }), { target: { value: 'New benchmark' } }); + fireEvent.change(screen.getByRole('textbox', { name: /Git repository URL/i }), { target: { value: 'https://example.com/benchmark.git' } }); + fireEvent.change(screen.getByLabelText('Commit hash (optional)'), { target: { value: 'deadbeef' } }); + fireEvent.change(screen.getByLabelText('Seed'), { target: { value: '42' } }); + expect(screen.getByRole('combobox', { name: /Vnnlib Version/i })).toHaveTextContent('2.0'); + submitForm(); + + await waitFor(() => expect(benchmarksApi.submit).toHaveBeenCalledWith({ + repository: 'https://example.com/benchmark.git', + hash: 'deadbeef', + vnnlib_version: '2.0', + seed: '42', + name: 'New benchmark', + })); + expect(await screen.findByText('Destination: /benchmark/submission/456')).toBeInTheDocument(); + }); + + it('submits category mode without a benchmark name', async () => { + benchmarksApi.getFormData.mockResolvedValue(formData({ uses_categories: true })); + renderPage({ + category: 'cifar', + repository: 'https://example.com/category.git', + hash: 'abc123', + fields: { vnnlib_version: '1.0', seed: '7' }, + }); + + expect(await screen.findByRole('combobox', { name: /Category/i })).toHaveTextContent('CIFAR'); + expect(screen.queryByRole('textbox', { name: /Benchmark name/i })).not.toBeInTheDocument(); + submitForm(); + + await waitFor(() => expect(benchmarksApi.submit).toHaveBeenCalledWith({ + repository: 'https://example.com/category.git', + hash: 'abc123', + vnnlib_version: '1.0', + seed: '7', + category: 'cifar', + })); + }); + + it('imports benchmark metadata from data.json', async () => { + const { container } = renderPage(); + await screen.findByRole('textbox', { name: /Benchmark name/i }); + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error('File input not found'); + const file = new File(['ignored'], 'data.json', { type: 'application/json' }); + Object.defineProperty(file, 'text', { + value: () => Promise.resolve(JSON.stringify({ + name: 'Imported benchmark', + repository: 'https://example.com/imported.git', + hash: 'feedface', + seed: '99', + })), + }); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(await screen.findByText('Imported data.json successfully.')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /Benchmark name/i })).toHaveValue('Imported benchmark'); + expect(screen.getByRole('textbox', { name: /Git repository URL/i })).toHaveValue('https://example.com/imported.git'); + expect(screen.getByLabelText('Commit hash (optional)')).toHaveValue('feedface'); + }); + + it('reports invalid imported JSON without changing the form', async () => { + const { container } = renderPage(); + const nameInput = await screen.findByRole('textbox', { name: /Benchmark name/i }); + fireEvent.change(nameInput, { target: { value: 'Existing name' } }); + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error('File input not found'); + const file = new File(['ignored'], 'broken.json', { type: 'application/json' }); + Object.defineProperty(file, 'text', { value: () => Promise.resolve('{invalid') }); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(await screen.findByText('The file is not valid JSON.')).toBeInTheDocument(); + expect(nameInput).toHaveValue('Existing name'); + }); + + it('displays backend submission errors', async () => { + benchmarksApi.submit.mockRejectedValue({ response: { data: 'Repository could not be cloned' } }); + renderPage({ name: 'Broken benchmark', repository: 'https://example.com/missing.git' }); + await screen.findByLabelText('Seed'); + submitForm(); + + expect(await screen.findByText('Repository could not be cloned')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/constants.test.ts b/frontend/tests/constants.test.ts new file mode 100644 index 0000000..c5ba4e5 --- /dev/null +++ b/frontend/tests/constants.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { benchmarkStateColor } from '../src/constants/benchmarks'; +import { canonicalVerdict, formatRuntime, resultColor } from '../src/constants/results'; +import { statusChip, statusGroupRank, stepStatus } from '../src/constants/status'; +import { isPauseKind } from '../src/constants/steps'; + +describe('status helpers', () => { + it.each([ + ['Done', 'Done', 'success', 'filled'], + ['Aborted', 'Aborted', 'warning', 'filled'], + ['Error', 'Error', 'error', 'filled'], + ['Timed out', 'Timed out', 'error', 'filled'], + ['Paused', 'Paused', 'primary', 'outlined'], + ['Waiting', 'Waiting', 'primary', 'outlined'], + ['Pending', 'Pending', 'default', 'filled'], + ['unexpected', 'Running', 'primary', 'filled'], + ])('maps %s to its canonical chip', (state, label, color, variant) => { + expect(statusChip(state)).toEqual({ label, color, variant }); + }); + + it('orders waiting, running, and terminal tasks by group', () => { + expect(statusGroupRank('Paused')).toBe(0); + expect(statusGroupRank('Running')).toBe(1); + expect(statusGroupRank('Done')).toBe(2); + }); + + it('uses terminal failure flags before active flags for a step', () => { + expect(stepStatus({ error: true, active: true })).toBe('Error'); + expect(stepStatus({ timed_out: true, done: true })).toBe('Timed out'); + expect(stepStatus({ aborted: true, active: true })).toBe('Aborted'); + }); + + it('distinguishes paused, waiting, running, and pending steps', () => { + expect(stepStatus({ paused: true, active: true })).toBe('Paused'); + expect(stepStatus({ waiting: true, active: true })).toBe('Waiting'); + expect(stepStatus({ active: true })).toBe('Running'); + expect(stepStatus({})).toBe('Pending'); + }); +}); + +describe('benchmark and result helpers', () => { + it.each([ + ['pending', 'default'], + ['running', 'primary'], + ['success', 'success'], + ['error', 'error'], + ['aborted', 'warning'], + ['timeout', 'warning'], + ])('maps benchmark state %s to %s', (state, color) => { + expect(benchmarkStateColor(state)).toBe(color); + }); + + it.each([ + ['unsat', 'holds'], + ['HOLDS', 'holds'], + ['sat', 'violated'], + ['violated', 'violated'], + ['unknown', 'unknown'], + ['run_instance_timeout', 'timeout'], + ['timeout-30', 'timeout'], + ['prepare_instance_timeout', 'error'], + ['unrecognized', 'error'], + ])('canonicalizes verdict %s as %s', (raw, verdict) => { + expect(canonicalVerdict(raw)).toBe(verdict); + }); + + it('formats runtime and derives its display color', () => { + expect(formatRuntime(null)).toBe('—'); + expect(formatRuntime(1.234)).toBe('1.23 s'); + expect(resultColor('sat')).toBe('success'); + expect(resultColor('error_exit_code_1')).toBe('error'); + }); +}); + +describe('step kinds', () => { + it('recognizes current and legacy pause kinds', () => { + expect(isPauseKind('pause')).toBe(true); + expect(isPauseKind('vnn_pause')).toBe(true); + expect(isPauseKind('run_benchmark')).toBe(false); + }); +}); diff --git a/frontend/tests/protected-route.test.tsx b/frontend/tests/protected-route.test.tsx new file mode 100644 index 0000000..5ab8a2e --- /dev/null +++ b/frontend/tests/protected-route.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '../src/api'; + +interface AuthState { + user: User | null; + loading: boolean; +} + +const useAuth = vi.hoisted(() => vi.fn<() => AuthState>()); +vi.mock('../src/context/AuthContext', () => ({ useAuth })); + +import { ProtectedRoute } from '../src/components/ProtectedRoute'; + +const user = (isAdmin = false): User => ({ + id: '1', email: 'user@example.com', name: 'User', role: isAdmin ? 'admin' : 'user', + enabled: true, is_admin: isAdmin, is_organizer: false, created_at: '2026-01-01T00:00:00Z', +}); + +function renderRoute(requireAdmin = false) { + return render( + + + Home page} /> + Login page} /> +
Private page
} /> +
+
, + ); +} + +describe('ProtectedRoute', () => { + beforeEach(() => { + useAuth.mockReset(); + }); + + it('shows a progress indicator while authentication is loading', () => { + useAuth.mockReturnValue({ user: null, loading: true }); + renderRoute(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('redirects signed-out users to login', () => { + useAuth.mockReturnValue({ user: null, loading: false }); + renderRoute(); + expect(screen.getByText('Login page')).toBeInTheDocument(); + }); + + it('renders protected content for an authenticated user', () => { + useAuth.mockReturnValue({ user: user(), loading: false }); + renderRoute(); + expect(screen.getByText('Private page')).toBeInTheDocument(); + }); + + it('redirects non-admin users away from admin routes', () => { + useAuth.mockReturnValue({ user: user(), loading: false }); + renderRoute(true); + expect(screen.getByText('Home page')).toBeInTheDocument(); + }); + + it('allows admins to access admin routes', () => { + useAuth.mockReturnValue({ user: user(true), loading: false }); + renderRoute(true); + expect(screen.getByText('Private page')).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..6c1577b --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); diff --git a/frontend/tests/toolkit-submission.test.tsx b/frontend/tests/toolkit-submission.test.tsx new file mode 100644 index 0000000..682245a --- /dev/null +++ b/frontend/tests/toolkit-submission.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ToolkitFormData } from '../src/api'; + +const toolkitApi = vi.hoisted(() => ({ + getFormData: vi.fn(), + submit: vi.fn(), +})); +const useAuth = vi.hoisted(() => vi.fn()); + +vi.mock('../src/api', () => ({ toolkitApi })); +vi.mock('../src/context/AuthContext', () => ({ useAuth })); + +import ToolkitSubmissionPage from '../src/pages/ToolkitSubmissionPage'; + +const formData = (overrides: Partial = {}): ToolkitFormData => ({ + can_submit: true, + scheduler_enabled: true, + execution_backend: 'local_docker', + instance_types: [{ value: 't2.large', label: 'Large', hardware: 'CPU', guidance: 'Testing' }], + ami_options: [{ value: 'ubuntu:22.04', label: 'Ubuntu' }], + run_networks_options: [{ value: 'all', label: 'All networks' }], + benchmark_categories: { + alpha: { + label: 'Alpha category', + benchmarks: [ + { id: 'benchmark-b', name: 'Beta benchmark' }, + { id: 'benchmark-a', name: 'Alpha benchmark' }, + ], + }, + beta: { + label: 'Beta category', + benchmarks: [{ id: 'benchmark-c', name: 'Gamma benchmark' }], + }, + }, + default_eni: 'eni-default', + uses_categories: false, + ...overrides, +}); + +function Destination() { + return
Destination: {useLocation().pathname}
; +} + +function renderPage(prefillData?: Record) { + return render( + + + } /> + } /> + + , + ); +} + +const submitForm = () => { + const button = screen.getByRole('button', { name: 'Submit toolkit' }); + const form = button.closest('form'); + if (!form) throw new Error('Toolkit submission form not found'); + fireEvent.submit(form); +}; + +describe('ToolkitSubmissionPage', () => { + beforeEach(() => { + toolkitApi.getFormData.mockReset(); + toolkitApi.submit.mockReset(); + useAuth.mockReset(); + useAuth.mockReturnValue({ user: null }); + toolkitApi.getFormData.mockResolvedValue(formData()); + toolkitApi.submit.mockResolvedValue({ redirect_to: '123' }); + }); + + it('loads defaults, submits the VNN payload, and navigates to the new task', async () => { + renderPage(); + + fireEvent.change(await screen.findByRole('textbox', { name: /Toolkit name/i }), { target: { value: 'My verifier' } }); + fireEvent.change(screen.getByRole('textbox', { name: /Git clone URL/i }), { target: { value: 'https://example.com/verifier.git' } }); + await waitFor(() => expect(screen.getByRole('textbox', { name: /Base Docker image/i })).toHaveValue('ubuntu:22.04')); + fireEvent.click(screen.getByRole('checkbox', { name: 'Alpha benchmark' })); + submitForm(); + + await waitFor(() => expect(toolkitApi.submit).toHaveBeenCalledWith({ + name: 'My verifier', + repository: 'https://example.com/verifier.git', + hash: '', + ami: 'ubuntu:22.04', + aws_instance_type: 't2.large', + scripts_dir: '.', + manual_installation_step: false, + run_installation_script_as_root: false, + run_post_installation_script_as_root: false, + run_toolkit_as_root: false, + benchmarks: ['benchmark-a'], + run_networks: 'all', + use_own_eni: false, + vnnlib_version: '1.0', + })); + expect(await screen.findByText('Destination: /toolkit/submission/123')).toBeInTheDocument(); + }); + + it('uses prefilled category data and omits the VNN-only field', async () => { + toolkitApi.getFormData.mockResolvedValue(formData({ uses_categories: true })); + renderPage({ + name: 'ARCH tool', + repository: 'https://example.com/arch.git', + hash: 'abc123', + scripts_dir: 'scripts', + benchmarks: ['benchmark-c'], + }); + + expect(await screen.findByRole('combobox', { name: /Category/i })).toHaveTextContent('Beta category'); + expect(screen.getByRole('checkbox', { name: 'Gamma benchmark' })).toBeChecked(); + expect(screen.queryByRole('combobox', { name: /Preferred VNNLIB version/i })).not.toBeInTheDocument(); + submitForm(); + + await waitFor(() => expect(toolkitApi.submit).toHaveBeenCalledOnce()); + const payload = toolkitApi.submit.mock.calls[0][0] as Record; + expect(payload).toMatchObject({ + name: 'ARCH tool', + repository: 'https://example.com/arch.git', + hash: 'abc123', + scripts_dir: 'scripts', + benchmarks: ['benchmark-c'], + }); + expect(payload).not.toHaveProperty('vnnlib_version'); + }); + + it('includes enabled admin-only options in the payload', async () => { + useAuth.mockReturnValue({ user: { is_admin: true } }); + renderPage({ + name: 'Admin tool', + repository: 'https://example.com/admin.git', + reverse_order: true, + split: 4, + export_results: true, + force_pause: true, + local_execution: true, + }); + + await screen.findByText('Admin Options'); + submitForm(); + + await waitFor(() => expect(toolkitApi.submit).toHaveBeenCalledOnce()); + expect(toolkitApi.submit.mock.calls[0][0]).toMatchObject({ + reverse_order: true, + split: 4, + export_results: true, + force_pause: true, + local_execution: true, + }); + }); + + it('disables submission and shows warnings when submissions are unavailable', async () => { + toolkitApi.getFormData.mockResolvedValue(formData({ can_submit: false, scheduler_enabled: false })); + renderPage(); + + expect(await screen.findByText('Submission is currently closed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Submit toolkit' })).toBeDisabled(); + expect(toolkitApi.submit).not.toHaveBeenCalled(); + }); + + it('shows structured backend validation errors', async () => { + toolkitApi.submit.mockRejectedValue({ + response: { data: { errors: { repository: ['Invalid URL'], benchmarks: ['Select at least one'] } } }, + }); + renderPage({ name: 'Broken tool', repository: 'invalid' }); + await screen.findByRole('textbox', { name: /Base Docker image/i }); + submitForm(); + + expect(await screen.findByText(/repository: Invalid URL benchmarks: Select at least one/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/utils.test.ts b/frontend/tests/utils.test.ts new file mode 100644 index 0000000..38a3264 --- /dev/null +++ b/frontend/tests/utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { formatDateTime } from '../src/utils/datetime'; +import { logTail } from '../src/utils/logTail'; +import { ownerParts, ownerText } from '../src/utils/owner'; + +describe('owner formatting', () => { + it('formats a distinct name and email', () => { + expect(ownerParts(' Ada ', ' ada@example.com ')).toEqual({ name: 'Ada', email: 'ada@example.com' }); + expect(ownerText('Ada', 'ada@example.com')).toBe('Ada (ada@example.com)'); + }); + + it('avoids repeating legacy accounts whose name is their email', () => { + expect(ownerText('ada@example.com', 'ada@example.com')).toBe('ada@example.com'); + }); + + it('handles partial and missing owner information', () => { + expect(ownerText(null, 'ada@example.com')).toBe('ada@example.com'); + expect(ownerText(null, null)).toBe('—'); + }); +}); + +describe('date formatting', () => { + it('returns an em dash for absent or invalid timestamps', () => { + expect(formatDateTime()).toBe('—'); + expect(formatDateTime('not-a-date')).toBe('—'); + }); + + it('formats a local timestamp consistently', () => { + expect(formatDateTime('2026-07-06T08:02:00')).toBe('Jul 6, 2026 - 08:02 AM'); + }); +}); + +describe('log tailing', () => { + it('leaves logs within the configured limit unchanged', () => { + expect(logTail('short log')).toBe('short log'); + }); + + it('keeps the end of oversized logs and reports truncation', () => { + const oversized = `${'a'.repeat(1_000_000)}THE END`; + const result = logTail(oversized); + + expect(result).toContain('showing the last 1000 KB'); + expect(result).toContain('of 1000 KB'); + expect(result.slice(-1_000_000)).toBe(oversized.slice(-1_000_000)); + expect(result.endsWith('THE END')).toBe(true); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..9868023 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.{test,spec}.{ts,tsx}'], + clearMocks: true, + restoreMocks: true, + }, +});