From 4efc8d404fad850664e7f74657303b0ea409d6ee Mon Sep 17 00:00:00 2001 From: yangzheli <43645580+yangzheli@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:00:43 +0800 Subject: [PATCH] feat(frontend): set up Vitest frontend testing infrastructure with CI workflow (#2147) * feat: set up Vitest frontend testing infrastructure with CI workflow Migrate existing 4 frontend test files from Node.js native test runner (node:test + node:assert/strict) to Vitest, reorganize test directory structure under tests/unit/ mirroring src/ layout, and add a dedicated CI workflow for frontend unit tests. - Add vitest as devDependency, remove tsx - Create vitest.config.ts with @/ path alias - Migrate tests to Vitest API (test/expect/vi) - Rename .mjs test files to .ts - Move tests from src/ to tests/unit/ (mirrors src/ layout) - Add frontend/Makefile `test` target - Add .github/workflows/frontend-unit-tests.yml (parallel to backend) - Update CONTRIBUTING.md, README.md, AGENTS.md, CLAUDE.md Co-Authored-By: Claude Opus 4.6 * style: fix the lint error * style: fix the lint error --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Willem Jiang --- .github/workflows/frontend-unit-tests.yml | 43 +++ CONTRIBUTING.md | 12 +- frontend/AGENTS.md | 4 +- frontend/CLAUDE.md | 3 +- frontend/Makefile | 3 + frontend/README.md | 8 +- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 316 ++++++++++++++++-- frontend/src/core/api/stream-mode.test.ts | 43 --- frontend/src/core/threads/utils.test.ts | 54 --- .../core/uploads/prompt-input-files.test.mjs | 150 --------- .../tests/unit/core/api/stream-mode.test.ts | 40 +++ .../tests/unit/core/threads/utils.test.ts | 46 +++ .../core/uploads/file-validation.test.ts} | 32 +- .../core/uploads/prompt-input-files.test.ts | 120 +++++++ frontend/vitest.config.ts | 14 + 16 files changed, 594 insertions(+), 298 deletions(-) create mode 100644 .github/workflows/frontend-unit-tests.yml delete mode 100644 frontend/src/core/api/stream-mode.test.ts delete mode 100644 frontend/src/core/threads/utils.test.ts delete mode 100644 frontend/src/core/uploads/prompt-input-files.test.mjs create mode 100644 frontend/tests/unit/core/api/stream-mode.test.ts create mode 100644 frontend/tests/unit/core/threads/utils.test.ts rename frontend/{src/core/uploads/file-validation.test.mjs => tests/unit/core/uploads/file-validation.test.ts} (58%) create mode 100644 frontend/tests/unit/core/uploads/prompt-input-files.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/.github/workflows/frontend-unit-tests.yml b/.github/workflows/frontend-unit-tests.yml new file mode 100644 index 000000000..61fa5e42c --- /dev/null +++ b/.github/workflows/frontend-unit-tests.yml @@ -0,0 +1,43 @@ +name: Frontend Unit Tests + +on: + push: + branches: [ 'main' ] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: frontend-unit-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + frontend-unit-tests: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Enable Corepack + run: corepack enable + + - name: Use pinned pnpm version + run: corepack prepare pnpm@10.26.2 --activate + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Run unit tests of frontend + working-directory: frontend + run: make test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6f8d4a13..ca7b05a33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -298,19 +298,19 @@ Nginx (port 2026) ← Unified entry point ```bash # Backend tests cd backend -uv run pytest +make test -# Frontend checks +# Frontend tests cd frontend -pnpm check +make test ``` ### PR Regression Checks -Every pull request runs the backend regression workflow at [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml), including: +Every pull request triggers the following CI workflows: -- `tests/test_provisioner_kubeconfig.py` -- `tests/test_docker_sandbox_mode_detection.py` +- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml) +- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml) ## Code Style diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0aad01bf6..f2bdba8f3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -36,6 +36,8 @@ DeerFlow is built on a sophisticated agent-based architecture using the [LangGra ## Project Structure ``` +tests/ +└── unit/ # Unit tests (mirrors src/ layout, powered by Vitest) src/ ├── app/ # Next.js App Router pages │ ├── api/ # API routes @@ -96,7 +98,7 @@ When adding new agent features: 1. Follow the established project structure 2. Add comprehensive TypeScript types 3. Implement proper error handling -4. Write tests for new functionality +4. Write unit tests under `tests/unit/` (run with `pnpm test`) 5. Update this documentation 6. Follow the code style guide (ESLint + Prettier) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 7220c4af3..a6dbfcf03 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -17,10 +17,11 @@ DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It commu | `pnpm check` | Lint + type check (run before committing) | | `pnpm lint` | ESLint only | | `pnpm lint:fix` | ESLint with auto-fix | +| `pnpm test` | Run unit tests with Vitest | | `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) | | `pnpm start` | Start production server | -No test framework is configured. +Unit tests live under `tests/unit/` and mirror the `src/` layout (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Powered by Vitest; import source modules via the `@/` path alias. ## Architecture diff --git a/frontend/Makefile b/frontend/Makefile index 0de9d596f..c2b49861c 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -7,6 +7,9 @@ build: dev: pnpm dev +test: + pnpm test + lint: pnpm lint diff --git a/frontend/README.md b/frontend/README.md index c58789a8b..6ed73babf 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -35,7 +35,7 @@ pnpm dev # The app will be available at http://localhost:3000 ``` -### Build +### Build & Test ```bash # Type check @@ -50,6 +50,9 @@ pnpm format:write # Lint pnpm lint +# Run unit tests +pnpm test + # Build for production pnpm build @@ -82,6 +85,8 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL="http://localhost:2024" ## Project Structure ``` +tests/ +└── unit/ # Unit tests (mirrors src/ layout) src/ ├── app/ # Next.js App Router pages │ ├── api/ # API routes @@ -119,6 +124,7 @@ src/ | `pnpm dev` | Start development server with Turbopack | | `pnpm build` | Build for production | | `pnpm start` | Start production server | +| `pnpm test` | Run unit tests with Vitest | | `pnpm format` | Check formatting with Prettier | | `pnpm format:write` | Apply formatting with Prettier | | `pnpm lint` | Run ESLint | diff --git a/frontend/package.json b/frontend/package.json index 83f69b4e3..587be4e3f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "lint:fix": "eslint . --ext .ts,.tsx --fix", "preview": "next build && next start", "start": "next start", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -105,7 +106,8 @@ "tailwindcss": "^4.0.15", "tw-animate-css": "^1.4.0", "typescript": "^5.8.2", - "typescript-eslint": "^8.27.0" + "typescript-eslint": "^8.27.0", + "vitest": "^4.1.4" }, "ct3aMetadata": { "initVersion": "7.40.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 73b5ec763..366030210 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: 1.2.1 better-auth: specifier: ^1.3 - version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.28(typescript@5.9.3)) + version: 1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -270,6 +270,9 @@ importers: typescript-eslint: specifier: ^8.27.0 version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vitest: + specifier: ^4.1.4 + version: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) packages: @@ -738,89 +741,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -980,36 +999,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-arm64-musl@0.1.22': resolution: {integrity: sha512-MOs7fPyJiU/wqOpKzAOmOpxJ/TZfP4JwmvPad/cXTOWYwwyppMlXFRms3i98EU3HOazI/wMU2Ksfda3+TBluWA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/simple-git-linux-ppc64-gnu@0.1.22': resolution: {integrity: sha512-L59dR30VBShRUIZ5/cQHU25upNgKS0AMQ7537J6LCIUEFwwXrKORZKJ8ceR+s3Sr/4jempWVvMdjEpFDE4HYww==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-s390x-gnu@0.1.22': resolution: {integrity: sha512-4FHkPlCSIZUGC6HiADffbe6NVoTBMd65pIwcd40IDbtFKOgFMBA+pWRqKiQ21FERGH16Zed7XHJJoY3jpOqtmQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-x64-gnu@0.1.22': resolution: {integrity: sha512-Ei1tM5Ho/dwknF3pOzqkNW9Iv8oFzRxE8uOhrITcdlpxRxVrBVptUF6/0WPdvd7R9747D/q61QG/AVyWsWLFKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/simple-git-linux-x64-musl@0.1.22': resolution: {integrity: sha512-zRYxg7it0p3rLyEJYoCoL2PQJNgArVLyNavHW03TFUAYkYi5bxQ/UFNVpgxMaXohr5yu7qCBqeo9j4DWeysalg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/simple-git-win32-arm64-msvc@0.1.22': resolution: {integrity: sha512-XGFR1fj+Y9cWACcovV2Ey/R2xQOZKs8t+7KHPerYdJ4PtjVzGznI4c2EBHXtdOIYvkw7tL5rZ7FN1HJKdD5Quw==} @@ -1059,24 +1084,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.7': resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.7': resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.7': resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.7': resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} @@ -1707,24 +1736,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -1786,66 +1819,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1999,24 +2045,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2092,6 +2142,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2188,6 +2241,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2410,41 +2466,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2470,6 +2534,35 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} + + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} + + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} + + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} + + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} + + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} + '@vue/compiler-core@3.5.28': resolution: {integrity: sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==} @@ -2593,6 +2686,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2770,6 +2867,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2886,6 +2987,9 @@ packages: console-table-printer@2.15.0: resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} @@ -3232,6 +3336,9 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3435,6 +3542,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -4057,24 +4168,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4539,6 +4654,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -4657,18 +4775,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - picomatch@2.3.2: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -5098,6 +5208,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -5142,9 +5255,15 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5255,6 +5374,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -5267,6 +5389,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + title@4.0.1: resolution: {integrity: sha512-xRnPkJx9nvE5MF6LkB5e8QJjE2FW8269wTu/LQdf7zZqBgPly0QJPf/CWAo7srj5so4yXfoLEdCFgurlpi47zg==} hasBin: true @@ -5585,6 +5711,47 @@ packages: yaml: optional: true + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -5643,6 +5810,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wicked-good-xpath@1.3.0: resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} @@ -6666,7 +6838,7 @@ snapshots: rc9: 3.0.0 scule: 1.3.0 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ufo: 1.6.3 unctx: 2.5.0 untyped: 2.0.0 @@ -7553,13 +7725,18 @@ snapshots: dependencies: minimatch: 10.2.5 path-browserify: 1.0.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -7681,6 +7858,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -7975,6 +8154,47 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vitest/expect@4.1.4': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.4(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.4': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.4': + dependencies: + '@vitest/utils': 4.1.4 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.4': {} + + '@vitest/utils@4.1.4': + dependencies: + '@vitest/pretty-format': 4.1.4 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.28': dependencies: '@babel/parser': 7.29.2 @@ -8165,6 +8385,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} astring@1.9.0: {} @@ -8193,7 +8415,7 @@ snapshots: best-effort-json-parser@1.2.1: {} - better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vue@3.5.28(typescript@5.9.3)): + better-auth@1.4.18(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)))(vue@3.5.28(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8211,6 +8433,7 @@ snapshots: next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + vitest: 4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) vue: 3.5.28(typescript@5.9.3) better-call@1.1.8(zod@4.3.6): @@ -8288,6 +8511,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8406,6 +8631,8 @@ snapshots: dependencies: simple-wcswidth: 1.1.2 + convert-source-map@2.0.0: {} + cookie-es@1.2.2: {} cookie-es@1.2.3: {} @@ -8825,6 +9052,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -9165,6 +9394,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -9199,10 +9430,6 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -10431,7 +10658,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mimic-fn@4.0.0: {} @@ -10724,6 +10951,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -10849,12 +11078,8 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} - picomatch@2.3.2: {} - picomatch@4.0.3: {} - picomatch@4.0.4: {} pkg-types@1.3.1: @@ -11429,6 +11654,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-wcswidth@1.1.2: {} @@ -11465,8 +11692,12 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@4.0.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -11596,18 +11827,22 @@ snapshots: tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + title@4.0.1: dependencies: arg: 5.0.2 @@ -11812,7 +12047,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.15.0 - picomatch: 4.0.3 + picomatch: 4.0.4 webpack-virtual-modules: 0.6.2 unrs-resolver@1.11.1: @@ -11930,6 +12165,34 @@ snapshots: lightningcss: 1.30.2 yaml: 2.8.3 + vitest@4.1.4(@opentelemetry/api@1.9.0)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.33 + transitivePeerDependencies: + - msw + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -12008,6 +12271,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wicked-good-xpath@1.3.0: {} word-wrap@1.2.5: {} diff --git a/frontend/src/core/api/stream-mode.test.ts b/frontend/src/core/api/stream-mode.test.ts deleted file mode 100644 index 879cf03de..000000000 --- a/frontend/src/core/api/stream-mode.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -const { sanitizeRunStreamOptions } = await import( - new URL("./stream-mode.ts", import.meta.url).href -); - -void test("drops unsupported stream modes from array payloads", () => { - const sanitized = sanitizeRunStreamOptions({ - streamMode: [ - "values", - "messages-tuple", - "custom", - "updates", - "events", - "tools", - ], - }); - - assert.deepEqual(sanitized.streamMode, [ - "values", - "messages-tuple", - "custom", - "updates", - "events", - ]); -}); - -void test("drops unsupported stream modes from scalar payloads", () => { - const sanitized = sanitizeRunStreamOptions({ - streamMode: "tools", - }); - - assert.equal(sanitized.streamMode, undefined); -}); - -void test("keeps payloads without streamMode untouched", () => { - const options = { - streamSubgraphs: true, - }; - - assert.equal(sanitizeRunStreamOptions(options), options); -}); diff --git a/frontend/src/core/threads/utils.test.ts b/frontend/src/core/threads/utils.test.ts deleted file mode 100644 index 9d7f8b437..000000000 --- a/frontend/src/core/threads/utils.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -const { pathOfThread } = await import( - new URL("./utils.ts", import.meta.url).href -); - -void test("uses standard chat route when thread has no agent context", () => { - assert.equal(pathOfThread("thread-123"), "/workspace/chats/thread-123"); - assert.equal( - pathOfThread({ - thread_id: "thread-123", - }), - "/workspace/chats/thread-123", - ); -}); - -void test("uses agent chat route when thread context has agent_name", () => { - assert.equal( - pathOfThread({ - thread_id: "thread-123", - context: { agent_name: "researcher" }, - }), - "/workspace/agents/researcher/chats/thread-123", - ); -}); - -void test("uses provided context when pathOfThread is called with a thread id", () => { - assert.equal( - pathOfThread("thread-123", { agent_name: "ops agent" }), - "/workspace/agents/ops%20agent/chats/thread-123", - ); -}); - -void test("uses agent chat route when thread metadata has agent_name", () => { - assert.equal( - pathOfThread({ - thread_id: "thread-456", - metadata: { agent_name: "coder" }, - }), - "/workspace/agents/coder/chats/thread-456", - ); -}); - -void test("prefers context.agent_name over metadata.agent_name", () => { - assert.equal( - pathOfThread({ - thread_id: "thread-789", - context: { agent_name: "from-context" }, - metadata: { agent_name: "from-metadata" }, - }), - "/workspace/agents/from-context/chats/thread-789", - ); -}); diff --git a/frontend/src/core/uploads/prompt-input-files.test.mjs b/frontend/src/core/uploads/prompt-input-files.test.mjs deleted file mode 100644 index f6579539e..000000000 --- a/frontend/src/core/uploads/prompt-input-files.test.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -async function loadModule() { - try { - return await import("./prompt-input-files.ts"); - } catch (error) { - return { error }; - } -} - -test("exports the prompt-input file conversion helper", async () => { - const loaded = await loadModule(); - - assert.ok( - !("error" in loaded), - loaded.error instanceof Error - ? loaded.error.message - : "prompt-input-files module is missing", - ); - assert.equal(typeof loaded.promptInputFilePartToFile, "function"); -}); - -test("reuses the original File when a prompt attachment already has one", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - const file = new File(["hello"], "note.txt", { type: "text/plain" }); - const originalFetch = globalThis.fetch; - - globalThis.fetch = async () => { - throw new Error("fetch should not run when File is already present"); - }; - - try { - const converted = await promptInputFilePartToFile({ - type: "file", - filename: file.name, - mediaType: file.type, - url: "blob:http://localhost:2026/stale-preview-url", - file, - }); - - assert.equal(converted, file); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("reconstructs a File from a data URL when no original File is present", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - const converted = await promptInputFilePartToFile({ - type: "file", - filename: "note.txt", - mediaType: "text/plain", - url: "data:text/plain;base64,aGVsbG8=", - }); - - assert.ok(converted); - assert.equal(converted.name, "note.txt"); - assert.equal(converted.type, "text/plain"); - assert.equal(await converted.text(), "hello"); -}); - -test("rewraps the original File when the prompt metadata changes", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - const file = new File(["hello"], "note.txt", { type: "text/plain" }); - - const converted = await promptInputFilePartToFile({ - type: "file", - filename: "renamed.txt", - mediaType: "text/markdown", - file, - }); - - assert.ok(converted); - assert.notEqual(converted, file); - assert.equal(converted.name, "renamed.txt"); - assert.equal(converted.type, "text/markdown"); - assert.equal(await converted.text(), "hello"); -}); - -test("returns null when upload preparation is missing required data", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - - const converted = await promptInputFilePartToFile({ - type: "file", - mediaType: "text/plain", - }); - - assert.equal(converted, null); -}); - -test("returns null when the URL fallback fetch fails", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - const originalFetch = globalThis.fetch; - const originalWarn = console.warn; - const warnCalls = []; - - console.warn = (...args) => { - warnCalls.push(args); - }; - - globalThis.fetch = async () => { - throw new Error("network down"); - }; - - try { - const converted = await promptInputFilePartToFile({ - type: "file", - filename: "note.txt", - url: "blob:http://localhost:2026/missing-preview-url", - }); - - assert.equal(converted, null); - assert.equal(warnCalls.length, 1); - } finally { - globalThis.fetch = originalFetch; - console.warn = originalWarn; - } -}); - -test("returns null when the URL fallback fetch response is non-ok", async () => { - const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); - const originalFetch = globalThis.fetch; - const originalWarn = console.warn; - const warnCalls = []; - - console.warn = (...args) => { - warnCalls.push(args); - }; - - globalThis.fetch = async () => - new Response("missing", { - status: 404, - statusText: "Not Found", - }); - - try { - const converted = await promptInputFilePartToFile({ - type: "file", - filename: "note.txt", - url: "blob:http://localhost:2026/missing-preview-url", - }); - - assert.equal(converted, null); - assert.equal(warnCalls.length, 1); - } finally { - globalThis.fetch = originalFetch; - console.warn = originalWarn; - } -}); diff --git a/frontend/tests/unit/core/api/stream-mode.test.ts b/frontend/tests/unit/core/api/stream-mode.test.ts new file mode 100644 index 000000000..818906c31 --- /dev/null +++ b/frontend/tests/unit/core/api/stream-mode.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "vitest"; + +import { sanitizeRunStreamOptions } from "@/core/api/stream-mode"; + +test("drops unsupported stream modes from array payloads", () => { + const sanitized = sanitizeRunStreamOptions({ + streamMode: [ + "values", + "messages-tuple", + "custom", + "updates", + "events", + "tools", + ], + }); + + expect(sanitized.streamMode).toEqual([ + "values", + "messages-tuple", + "custom", + "updates", + "events", + ]); +}); + +test("drops unsupported stream modes from scalar payloads", () => { + const sanitized = sanitizeRunStreamOptions({ + streamMode: "tools", + }); + + expect(sanitized.streamMode).toBeUndefined(); +}); + +test("keeps payloads without streamMode untouched", () => { + const options = { + streamSubgraphs: true, + }; + + expect(sanitizeRunStreamOptions(options)).toBe(options); +}); diff --git a/frontend/tests/unit/core/threads/utils.test.ts b/frontend/tests/unit/core/threads/utils.test.ts new file mode 100644 index 000000000..c138f3d96 --- /dev/null +++ b/frontend/tests/unit/core/threads/utils.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "vitest"; + +import { pathOfThread } from "@/core/threads/utils"; + +test("uses standard chat route when thread has no agent context", () => { + expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123"); + expect( + pathOfThread({ + thread_id: "thread-123", + }), + ).toBe("/workspace/chats/thread-123"); +}); + +test("uses agent chat route when thread context has agent_name", () => { + expect( + pathOfThread({ + thread_id: "thread-123", + context: { agent_name: "researcher" }, + }), + ).toBe("/workspace/agents/researcher/chats/thread-123"); +}); + +test("uses provided context when pathOfThread is called with a thread id", () => { + expect(pathOfThread("thread-123", { agent_name: "ops agent" })).toBe( + "/workspace/agents/ops%20agent/chats/thread-123", + ); +}); + +test("uses agent chat route when thread metadata has agent_name", () => { + expect( + pathOfThread({ + thread_id: "thread-456", + metadata: { agent_name: "coder" }, + }), + ).toBe("/workspace/agents/coder/chats/thread-456"); +}); + +test("prefers context.agent_name over metadata.agent_name", () => { + expect( + pathOfThread({ + thread_id: "thread-789", + context: { agent_name: "from-context" }, + metadata: { agent_name: "from-metadata" }, + }), + ).toBe("/workspace/agents/from-context/chats/thread-789"); +}); diff --git a/frontend/src/core/uploads/file-validation.test.mjs b/frontend/tests/unit/core/uploads/file-validation.test.ts similarity index 58% rename from frontend/src/core/uploads/file-validation.test.mjs rename to frontend/tests/unit/core/uploads/file-validation.test.ts index 62a903e97..102ef990a 100644 --- a/frontend/src/core/uploads/file-validation.test.mjs +++ b/frontend/tests/unit/core/uploads/file-validation.test.ts @@ -1,20 +1,18 @@ -import assert from "node:assert/strict"; -import test from "node:test"; +import { expect, test } from "vitest"; import { MACOS_APP_BUNDLE_UPLOAD_MESSAGE, isLikelyMacOSAppBundle, splitUnsupportedUploadFiles, -} from "./file-validation.ts"; +} from "@/core/uploads/file-validation"; test("identifies Finder-style .app bundle uploads as unsupported", () => { - assert.equal( + expect( isLikelyMacOSAppBundle({ name: "Vibe Island.app", type: "application/octet-stream", }), - true, - ); + ).toBe(true); }); test("keeps normal files and reports rejected app bundles", () => { @@ -27,11 +25,11 @@ test("keeps normal files and reports rejected app bundles", () => { const result = splitUnsupportedUploadFiles(files); - assert.equal(result.accepted.length, 1); - assert.equal(result.accepted[0]?.name, "notes.txt"); - assert.equal(result.rejected.length, 1); - assert.equal(result.rejected[0]?.name, "Vibe Island.app"); - assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); + expect(result.accepted.length).toBe(1); + expect(result.accepted[0]?.name).toBe("notes.txt"); + expect(result.rejected.length).toBe(1); + expect(result.rejected[0]?.name).toBe("Vibe Island.app"); + expect(result.message).toBe(MACOS_APP_BUNDLE_UPLOAD_MESSAGE); }); test("treats empty MIME .app uploads as unsupported", () => { @@ -39,9 +37,9 @@ test("treats empty MIME .app uploads as unsupported", () => { new File(["demo"], "Another.app", { type: "" }), ]); - assert.equal(result.accepted.length, 0); - assert.equal(result.rejected.length, 1); - assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); + expect(result.accepted.length).toBe(0); + expect(result.rejected.length).toBe(1); + expect(result.message).toBe(MACOS_APP_BUNDLE_UPLOAD_MESSAGE); }); test("returns no message when every file is supported", () => { @@ -49,7 +47,7 @@ test("returns no message when every file is supported", () => { new File(["notes"], "notes.txt", { type: "text/plain" }), ]); - assert.equal(result.accepted.length, 1); - assert.equal(result.rejected.length, 0); - assert.equal(result.message, undefined); + expect(result.accepted.length).toBe(1); + expect(result.rejected.length).toBe(0); + expect(result.message).toBeUndefined(); }); diff --git a/frontend/tests/unit/core/uploads/prompt-input-files.test.ts b/frontend/tests/unit/core/uploads/prompt-input-files.test.ts new file mode 100644 index 000000000..28c578bd3 --- /dev/null +++ b/frontend/tests/unit/core/uploads/prompt-input-files.test.ts @@ -0,0 +1,120 @@ +import { afterEach, expect, test, vi } from "vitest"; + +import { + type PromptInputFilePart, + promptInputFilePartToFile, +} from "@/core/uploads/prompt-input-files"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test("exports the prompt-input file conversion helper", () => { + expect(typeof promptInputFilePartToFile).toBe("function"); +}); + +test("reuses the original File when a prompt attachment already has one", async () => { + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new Error("fetch should not run when File is already present"); + }), + ); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: file.name, + mediaType: file.type, + url: "blob:http://localhost:2026/stale-preview-url", + file, + }); + + expect(converted).toBe(file); +}); + +test("reconstructs a File from a data URL when no original File is present", async () => { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + mediaType: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + }); + + expect(converted).toBeTruthy(); + expect(converted!.name).toBe("note.txt"); + expect(converted!.type).toBe("text/plain"); + expect(await converted!.text()).toBe("hello"); +}); + +test("rewraps the original File when the prompt metadata changes", async () => { + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "renamed.txt", + mediaType: "text/markdown", + file, + } as PromptInputFilePart); + + expect(converted).toBeTruthy(); + expect(converted).not.toBe(file); + expect(converted!.name).toBe("renamed.txt"); + expect(converted!.type).toBe("text/markdown"); + expect(await converted!.text()).toBe("hello"); +}); + +test("returns null when upload preparation is missing required data", async () => { + const converted = await promptInputFilePartToFile({ + type: "file", + mediaType: "text/plain", + } as PromptInputFilePart); + + expect(converted).toBeNull(); +}); + +test("returns null when the URL fallback fetch fails", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => ({})); + + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + } as PromptInputFilePart); + + expect(converted).toBeNull(); + expect(warnSpy).toHaveBeenCalledOnce(); +}); + +test("returns null when the URL fallback fetch response is non-ok", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => ({})); + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("missing", { + status: 404, + statusText: "Not Found", + }), + ), + ); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + } as PromptInputFilePart); + + expect(converted).toBeNull(); + expect(warnSpy).toHaveBeenCalledOnce(); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 000000000..ec8b8de31 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + test: { + include: ["tests/unit/**/*.test.ts"], + }, +});