From 013b28d77f507e5a6da9e5405343f71609202af3 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 12:54:04 +0000 Subject: [PATCH] Story 26: Establish TDD workflow and quality gates Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + .story_kit/specs/tech/STACK.md | 1 + .../26_establish_tdd_workflow_and_gates.md | 42 + .../26_establish_tdd_workflow_and_gates.md | 15 - CLAUDE.md | 1 + Cargo.lock | 1 + frontend/package.json | 11 +- frontend/playwright.config.ts | 27 + frontend/pnpm-lock.yaml | 1094 ++++++++++++++--- frontend/src/api/workflow.ts | 101 ++ frontend/src/components/Chat.test.tsx | 393 ++++++ frontend/src/components/Chat.tsx | 424 ++++--- frontend/src/components/ChatHeader.tsx | 242 ++++ frontend/src/components/GatePanel.tsx | 182 +++ frontend/src/components/ReviewPanel.tsx | 324 +++++ frontend/src/setupTests.ts | 1 + frontend/tests/e2e/review-panel.spec.ts | 13 + frontend/tests/e2e/tdd-gates.spec.ts | 344 ++++++ frontend/tsconfig.json | 3 +- frontend/tsconfig.node.json | 5 +- frontend/vitest.config.ts | 13 + server/Cargo.toml | 1 + server/src/http/context.rs | 2 + server/src/http/mod.rs | 15 +- server/src/http/workflow.rs | 319 +++++ server/src/io/fs.rs | 60 +- server/src/io/mod.rs | 1 + server/src/io/shell.rs | 51 + server/src/io/story_metadata.rs | 111 ++ server/src/main.rs | 4 + server/src/workflow.rs | 242 ++++ 31 files changed, 3627 insertions(+), 417 deletions(-) create mode 100644 .story_kit/stories/archived/26_establish_tdd_workflow_and_gates.md delete mode 100644 .story_kit/stories/current/26_establish_tdd_workflow_and_gates.md create mode 100644 CLAUDE.md create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/api/workflow.ts create mode 100644 frontend/src/components/Chat.test.tsx create mode 100644 frontend/src/components/ChatHeader.tsx create mode 100644 frontend/src/components/GatePanel.tsx create mode 100644 frontend/src/components/ReviewPanel.tsx create mode 100644 frontend/src/setupTests.ts create mode 100644 frontend/tests/e2e/review-panel.spec.ts create mode 100644 frontend/tests/e2e/tdd-gates.spec.ts create mode 100644 frontend/vitest.config.ts create mode 100644 server/src/http/workflow.rs create mode 100644 server/src/io/story_metadata.rs create mode 100644 server/src/workflow.rs diff --git a/.gitignore b/.gitignore index 7a36b32..0f7523f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ server/target *.njsproj *.sln *.sw? +/test-results/.last-run.json diff --git a/.story_kit/specs/tech/STACK.md b/.story_kit/specs/tech/STACK.md index a10ddc7..dd19f82 100644 --- a/.story_kit/specs/tech/STACK.md +++ b/.story_kit/specs/tech/STACK.md @@ -9,6 +9,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React * **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints. * **Frontend:** TypeScript + React * **Build Tool:** Vite + * **Package Manager:** pnpm (required) * **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules) * **State Management:** React Context / Hooks * **Chat UI:** Rendered Markdown with syntax highlighting. diff --git a/.story_kit/stories/archived/26_establish_tdd_workflow_and_gates.md b/.story_kit/stories/archived/26_establish_tdd_workflow_and_gates.md new file mode 100644 index 0000000..58f1cdd --- /dev/null +++ b/.story_kit/stories/archived/26_establish_tdd_workflow_and_gates.md @@ -0,0 +1,42 @@ +--- +name: Establish the TDD Workflow and Gates +test_plan: approved +--- + +# Story 26: Establish the TDD Workflow and Gates + +## User Story +As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked. + +## Acceptance Criteria +- [ ] A test-first workflow is defined and enforced before implementation begins. +- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout). +- [ ] A test plan is produced and approved before any code changes. +- [ ] Stories cannot be accepted unless all required tests pass. +- [ ] The system warns when multiple tests fail and blocks acceptance until all required tests pass. + +## Test Plan (Approved) + + +### Backend (Rust) — Unit + Integration +- AC1/AC3: Block write/exec when no approved test plan exists. +- AC2: Enforce presence of both unit + integration test categories before a story can proceed. +- AC4: Block story acceptance unless all required test results are passing. +- AC5: Allow only one failing test at a time (reject registering a second failure). + +**Integration coverage:** +- Attempt to write before test plan approval → expect rejection. +- Add/approve test plan → write succeeds. +- Attempt acceptance with failing/missing tests → expect rejection. +- Acceptance with all passing tests → expect success. +- Register second failing test while one is red → expect rejection. + +### Frontend (React) — Vitest + Playwright +- AC1/AC3: Gate status shown in story view; tools blocked until test plan approved. +- AC4: Acceptance action disabled when required tests are failing or missing. +- AC5: UI surfaces “red test count” and blocks when more than one failing test is present. +- E2E: Attempt blocked actions show a visible banner/toast and do not execute. + +## Out of Scope +- Backfilling tests for legacy code (covered by a separate story). +- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`. \ No newline at end of file diff --git a/.story_kit/stories/current/26_establish_tdd_workflow_and_gates.md b/.story_kit/stories/current/26_establish_tdd_workflow_and_gates.md deleted file mode 100644 index 3024de4..0000000 --- a/.story_kit/stories/current/26_establish_tdd_workflow_and_gates.md +++ /dev/null @@ -1,15 +0,0 @@ -# Story 26: Establish the TDD Workflow and Gates - -## User Story -As a user, I want a clear, enforceable TDD workflow with quality gates, so development is test-first and regressions are blocked. - -## Acceptance Criteria -- [ ] A test-first workflow is defined and enforced before implementation begins. -- [ ] Each story requires both unit tests and integration tests (standard Rust `tests/` layout). -- [ ] A test plan is produced and approved before any code changes. -- [ ] Stories cannot be accepted unless all required tests pass. -- [ ] Only one failing test is allowed at a time during red-green-refactor. - -## Out of Scope -- Backfilling tests for legacy code (covered by a separate story). -- Adding new test frameworks beyond those defined in `specs/tech/STACK.md`. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..519f8a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read .story_kit/README.md to see our dev process. diff --git a/Cargo.lock b/Cargo.lock index d2935d4..4f0fe0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1894,6 +1894,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "serde_yaml", "tempfile", "tokio", "uuid", diff --git a/frontend/package.json b/frontend/package.json index bcfaca6..fac3ed8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "server": "cargo run --manifest-path server/Cargo.toml", - "test": "jest" + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@types/react-syntax-highlighter": "^15.5.13", @@ -18,15 +20,20 @@ "react-syntax-highlighter": "^16.1.0" }, "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.47.2", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/node": "^25.0.0", "@vitejs/plugin-react": "^4.6.0", "jest": "^29.0.0", + "jsdom": "^28.1.0", "ts-jest": "^29.0.0", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^5.4.21", + "vitest": "^2.1.4" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..d24a003 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const configDir = dirname(fileURLToPath(new URL(import.meta.url))); +const frontendRoot = resolve(configDir, "."); + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + use: { + baseURL: "http://127.0.0.1:41700", + trace: "on-first-retry", + }, + webServer: { + command: + "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort", + url: "http://127.0.0.1:41700/@vite/client", + reuseExistingServer: false, + timeout: 120_000, + cwd: frontendRoot, + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4762441..e5c87f8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^16.1.0 version: 16.1.0(react@19.2.3) devDependencies: + '@biomejs/biome': + specifier: ^2.4.2 + version: 2.4.2 + '@playwright/test': + specifier: ^1.47.2 + version: 1.58.2 '@testing-library/jest-dom': specifier: ^6.0.0 version: 6.9.1 @@ -33,6 +39,9 @@ importers: '@testing-library/user-event': specifier: ^14.4.3 version: 14.6.1(@testing-library/dom@9.3.4) + '@types/node': + specifier: ^25.0.0 + version: 25.0.3 '@types/react': specifier: ^19.1.8 version: 19.2.7 @@ -41,10 +50,13 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.0(@types/node@25.0.3)) + version: 4.7.0(vite@5.4.21(@types/node@25.0.3)) jest: specifier: ^29.0.0 version: 29.7.0(@types/node@25.0.3) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 ts-jest: specifier: ^29.0.0 version: 29.4.6(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.0.3))(typescript@5.8.3) @@ -52,14 +64,29 @@ importers: specifier: ~5.8.3 version: 5.8.3 vite: - specifier: ^7.0.4 - version: 7.3.0(@types/node@25.0.3) + specifier: ^5.4.21 + version: 5.4.21(@types/node@25.0.3) + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@25.0.3)(jsdom@28.1.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -241,162 +268,241 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} - engines: {node: '>=18'} + '@biomejs/biome@2.4.2': + resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.2': + resolution: {integrity: sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.2': + resolution: {integrity: sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.2': + resolution: {integrity: sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.2': + resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.2': + resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.2': + resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.2': + resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.2': + resolution: {integrity: sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.1': + resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.1': + resolution: {integrity: sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': + resolution: {integrity: sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} - engines: {node: '>=18'} + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} - engines: {node: '>=18'} + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} - engines: {node: '>=18'} + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} - engines: {node: '>=18'} + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} - engines: {node: '>=18'} + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} - engines: {node: '>=18'} + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} - engines: {node: '>=18'} + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} - engines: {node: '>=18'} + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} - engines: {node: '>=18'} + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} - engines: {node: '>=18'} + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} - engines: {node: '>=18'} + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} - engines: {node: '>=18'} + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} - engines: {node: '>=18'} + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} - engines: {node: '>=18'} + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} - engines: {node: '>=18'} + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} - engines: {node: '>=18'} + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} - engines: {node: '>=18'} + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} - engines: {node: '>=18'} + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} - engines: {node: '>=18'} + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} - engines: {node: '>=18'} + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} - engines: {node: '>=18'} + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} - engines: {node: '>=18'} + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} cpu: [x64] os: [win32] + '@exodus/bytes@1.14.1': + resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} + 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 + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -487,6 +593,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -721,6 +832,39 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -755,6 +899,10 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -794,6 +942,9 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -816,6 +967,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -846,6 +1001,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -866,6 +1025,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -909,12 +1072,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@6.0.1: + resolution: {integrity: sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -924,6 +1099,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -935,6 +1113,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-equal@2.2.3: resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} engines: {node: '>= 0.4'} @@ -986,6 +1168,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1000,13 +1186,16 @@ packages: es-get-iterator@1.1.3: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} - engines: {node: '>=18'} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} hasBin: true escalade@3.2.0: @@ -1025,6 +1214,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1033,6 +1225,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1049,15 +1245,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1077,6 +1264,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1169,12 +1361,24 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -1273,6 +1477,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1471,6 +1678,15 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1505,9 +1721,16 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1515,6 +1738,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1553,6 +1779,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1711,6 +1940,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1726,6 +1958,13 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1733,10 +1972,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1745,6 +1980,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -1772,6 +2017,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -1827,6 +2076,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -1853,6 +2106,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1897,6 +2154,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@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1928,6 +2188,12 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1981,13 +2247,37 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -1996,6 +2286,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2054,6 +2352,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2088,27 +2390,27 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} - engines: {node: ^20.19.0 || >=22.12.0} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 peerDependenciesMeta: '@types/node': optional: true - jiti: - optional: true less: optional: true lightningcss: @@ -2123,14 +2425,51 @@ packages: optional: true terser: optional: true - tsx: + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': optional: true - yaml: + '@types/node': optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.0: + resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2148,6 +2487,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -2162,6 +2506,13 @@ packages: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2186,8 +2537,28 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + '@adobe/css-tools@4.4.4': {} + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2389,84 +2760,138 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@esbuild/aix-ppc64@0.27.2': + '@biomejs/biome@2.4.2': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.2 + '@biomejs/cli-darwin-x64': 2.4.2 + '@biomejs/cli-linux-arm64': 2.4.2 + '@biomejs/cli-linux-arm64-musl': 2.4.2 + '@biomejs/cli-linux-x64': 2.4.2 + '@biomejs/cli-linux-x64-musl': 2.4.2 + '@biomejs/cli-win32-arm64': 2.4.2 + '@biomejs/cli-win32-x64': 2.4.2 + + '@biomejs/cli-darwin-arm64@2.4.2': optional: true - '@esbuild/android-arm64@0.27.2': + '@biomejs/cli-darwin-x64@2.4.2': optional: true - '@esbuild/android-arm@0.27.2': + '@biomejs/cli-linux-arm64-musl@2.4.2': optional: true - '@esbuild/android-x64@0.27.2': + '@biomejs/cli-linux-arm64@2.4.2': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@biomejs/cli-linux-x64-musl@2.4.2': optional: true - '@esbuild/darwin-x64@0.27.2': + '@biomejs/cli-linux-x64@2.4.2': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@biomejs/cli-win32-arm64@2.4.2': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@biomejs/cli-win32-x64@2.4.2': optional: true - '@esbuild/linux-arm64@0.27.2': + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.1.0 + + '@csstools/color-helpers@6.0.1': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.1 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.27': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@esbuild/aix-ppc64@0.21.5': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/android-arm64@0.21.5': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/android-arm@0.21.5': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/android-x64@0.21.5': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/darwin-arm64@0.21.5': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/freebsd-x64@0.21.5': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-arm64@0.21.5': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/linux-arm@0.21.5': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/linux-ia32@0.21.5': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/linux-loong64@0.21.5': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/linux-mips64el@0.21.5': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/linux-ppc64@0.21.5': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/linux-riscv64@0.21.5': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/linux-s390x@0.21.5': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/linux-x64@0.21.5': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@exodus/bytes@1.14.1': {} + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -2658,6 +3083,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.54.0': @@ -2863,7 +3292,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@7.3.0(@types/node@25.0.3))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.0.3))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -2871,10 +3300,52 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.0(@types/node@25.0.3) + vite: 5.4.21(@types/node@25.0.3) transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.0.3))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.0.3) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + agent-base@7.1.4: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -2907,6 +3378,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -2972,6 +3445,10 @@ snapshots: baseline-browser-mapping@2.9.11: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2999,6 +3476,8 @@ snapshots: buffer-from@1.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3026,6 +3505,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3041,6 +3528,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + ci-info@3.9.0: {} cjs-module-lexer@1.4.3: {} @@ -3088,20 +3577,43 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + css.escape@1.5.1: {} + cssstyle@6.0.1: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.0.27 + css-tree: 3.1.0 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 dedent@1.7.1: {} + deep-eql@5.0.2: {} + deep-equal@2.2.3: dependencies: array-buffer-byte-length: 1.0.2 @@ -3163,6 +3675,8 @@ snapshots: emoji-regex@8.0.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -3183,38 +3697,37 @@ snapshots: isarray: 2.0.5 stop-iteration-iterator: 1.1.0 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - esbuild@0.27.2: + esbuild@0.21.5: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 escalade@3.2.0: {} @@ -3224,6 +3737,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -3238,6 +3755,8 @@ snapshots: exit@0.1.2: {} + expect-type@1.3.0: {} + expect@29.7.0: dependencies: '@jest/expect-utils': 29.7.0 @@ -3258,10 +3777,6 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3279,6 +3794,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3392,10 +3910,30 @@ snapshots: highlightjs-vue@1.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} import-local@3.2.0: @@ -3481,6 +4019,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3874,6 +4414,33 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.14.1 + cssstyle: 6.0.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -3894,17 +4461,25 @@ snapshots: longest-streak@3.1.0: {} + loupe@3.2.1: {} + lowlight@1.20.0: dependencies: fault: 1.0.4 highlight.js: 10.7.3 + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 lz-string@1.5.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: dependencies: semver: 7.7.3 @@ -4006,6 +4581,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.12.2: {} + merge-stream@2.0.0: {} micromark-core-commonmark@2.0.3: @@ -4231,6 +4808,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -4239,18 +4820,28 @@ snapshots: path-parse@1.0.7: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} - picomatch@4.0.3: {} - pirates@4.0.7: {} pkg-dir@4.2.0: dependencies: find-up: 4.1.0 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.5.6: @@ -4280,6 +4871,8 @@ snapshots: property-information@7.1.0: {} + punycode@2.3.1: {} + pure-rand@6.1.0: {} react-dom@19.2.3(react@19.2.3): @@ -4363,6 +4956,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -4411,6 +5006,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4467,6 +5066,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} sisteransi@1.0.5: {} @@ -4490,6 +5091,10 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4543,16 +5148,29 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 minimatch: 3.1.2 - tinyglobby@0.2.15: + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@7.0.23: {} + + tldts@7.0.23: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + tldts-core: 7.0.23 tmpl@1.0.5: {} @@ -4560,6 +5178,14 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -4597,6 +5223,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.22.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -4652,22 +5280,89 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.0(@types/node@25.0.3): + vite-node@2.1.9(@types/node@25.0.3): dependencies: - esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@25.0.3) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.0.3): + dependencies: + esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.54.0 - tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.0.3 fsevents: 2.3.3 + vitest@2.1.9(@types/node@25.0.3)(jsdom@28.1.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.0.3)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@25.0.3) + vite-node: 2.1.9(@types/node@25.0.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.3 + jsdom: 28.1.0 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4697,6 +5392,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wordwrap@1.0.0: {} wrap-ansi@7.0.0: @@ -4712,6 +5412,10 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 3.0.7 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts new file mode 100644 index 0000000..5a565d0 --- /dev/null +++ b/frontend/src/api/workflow.ts @@ -0,0 +1,101 @@ +export type TestStatus = "pass" | "fail"; + +export interface TestCasePayload { + name: string; + status: TestStatus; + details?: string | null; +} + +export interface RecordTestsPayload { + story_id: string; + unit: TestCasePayload[]; + integration: TestCasePayload[]; +} + +export interface AcceptanceRequest { + story_id: string; +} + +export interface TestRunSummaryResponse { + total: number; + passed: number; + failed: number; +} + +export interface AcceptanceResponse { + can_accept: boolean; + reasons: string[]; + warning?: string | null; + summary: TestRunSummaryResponse; + missing_categories: string[]; +} + +export interface ReviewStory { + story_id: string; + can_accept: boolean; + reasons: string[]; + warning?: string | null; + summary: TestRunSummaryResponse; + missing_categories: string[]; +} + +export interface ReviewListResponse { + stories: ReviewStory[]; +} + +const DEFAULT_API_BASE = "/api"; + +function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { + return `${baseUrl}${path}`; +} + +async function requestJson( + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, +): Promise { + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } + + return res.json() as Promise; +} + +export const workflowApi = { + recordTests(payload: RecordTestsPayload, baseUrl?: string) { + return requestJson( + "/workflow/tests/record", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, + getAcceptance(payload: AcceptanceRequest, baseUrl?: string) { + return requestJson( + "/workflow/acceptance", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, + getReviewQueue(baseUrl?: string) { + return requestJson("/workflow/review", {}, baseUrl); + }, + getReviewQueueAll(baseUrl?: string) { + return requestJson("/workflow/review/all", {}, baseUrl); + }, + ensureAcceptance(payload: AcceptanceRequest, baseUrl?: string) { + return requestJson( + "/workflow/acceptance/ensure", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, +}; diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx new file mode 100644 index 0000000..fbd2d0d --- /dev/null +++ b/frontend/src/components/Chat.test.tsx @@ -0,0 +1,393 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api/client"; +import type { ReviewStory } from "../api/workflow"; +import { workflowApi } from "../api/workflow"; +import { Chat } from "./Chat"; + +vi.mock("../api/client", () => { + const api = { + getOllamaModels: vi.fn(), + getAnthropicApiKeyExists: vi.fn(), + getAnthropicModels: vi.fn(), + getModelPreference: vi.fn(), + setModelPreference: vi.fn(), + cancelChat: vi.fn(), + setAnthropicApiKey: vi.fn(), + }; + class ChatWebSocket { + connect() {} + close() {} + sendChat() {} + cancel() {} + } + return { api, ChatWebSocket }; +}); + +vi.mock("../api/workflow", () => { + return { + workflowApi: { + getAcceptance: vi.fn(), + getReviewQueue: vi.fn(), + getReviewQueueAll: vi.fn(), + ensureAcceptance: vi.fn(), + }, + }; +}); + +const mockedApi = { + getOllamaModels: vi.mocked(api.getOllamaModels), + getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), + getAnthropicModels: vi.mocked(api.getAnthropicModels), + getModelPreference: vi.mocked(api.getModelPreference), + setModelPreference: vi.mocked(api.setModelPreference), + cancelChat: vi.mocked(api.cancelChat), + setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), +}; + +const mockedWorkflow = { + getAcceptance: vi.mocked(workflowApi.getAcceptance), + getReviewQueue: vi.mocked(workflowApi.getReviewQueue), + getReviewQueueAll: vi.mocked(workflowApi.getReviewQueueAll), + ensureAcceptance: vi.mocked(workflowApi.ensureAcceptance), +}; + +describe("Chat review panel", () => { + beforeEach(() => { + mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); + mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); + mockedApi.getAnthropicModels.mockResolvedValue([]); + mockedApi.getModelPreference.mockResolvedValue(null); + mockedApi.setModelPreference.mockResolvedValue(true); + mockedApi.cancelChat.mockResolvedValue(true); + mockedApi.setAnthropicApiKey.mockResolvedValue(true); + + mockedWorkflow.getAcceptance.mockResolvedValue({ + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }); + mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] }); + mockedWorkflow.ensureAcceptance.mockResolvedValue(true); + }); + + it("shows an empty review queue state", async () => { + render(); + + expect( + await screen.findByText("Stories Awaiting Review"), + ).toBeInTheDocument(); + expect(await screen.findByText("0 ready / 0 total")).toBeInTheDocument(); + expect( + await screen.findByText("No stories waiting for review."), + ).toBeInTheDocument(); + + const updatedLabels = await screen.findAllByText(/Updated/i); + expect(updatedLabels.length).toBeGreaterThanOrEqual(2); + }); + + it("renders review stories and proceeds", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: true, + reasons: [], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + }; + + mockedWorkflow.getReviewQueueAll + .mockResolvedValueOnce({ stories: [story] }) + .mockResolvedValueOnce({ stories: [] }); + + render(); + + expect(await screen.findByText(story.story_id)).toBeInTheDocument(); + + const proceedButton = screen.getByRole("button", { name: "Proceed" }); + await userEvent.click(proceedButton); + + await waitFor(() => { + expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({ + story_id: story.story_id, + }); + }); + + expect( + await screen.findByText("No stories waiting for review."), + ).toBeInTheDocument(); + }); + + it("shows a review error when the queue fails to load", async () => { + mockedWorkflow.getReviewQueueAll.mockRejectedValueOnce( + new Error("Review queue failed"), + ); + + render(); + + expect(await screen.findByText(/Review queue failed/i)).toBeInTheDocument(); + expect( + await screen.findByText(/Use Refresh to try again\./i), + ).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Retry" }), + ).toBeInTheDocument(); + }); + + it("refreshes the review queue when clicking refresh", async () => { + mockedWorkflow.getReviewQueueAll + .mockResolvedValueOnce({ stories: [] }) + .mockResolvedValueOnce({ stories: [] }); + + render(); + + const refreshButtons = await screen.findAllByRole("button", { + name: "Refresh", + }); + const refreshButton = refreshButtons[0]; + + await userEvent.click(refreshButton); + + await waitFor(() => { + expect(mockedWorkflow.getReviewQueueAll).toHaveBeenCalled(); + }); + }); + + it("disables proceed when a story is blocked", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["Missing unit tests"], + warning: null, + summary: { total: 1, passed: 0, failed: 1 }, + missing_categories: ["unit"], + }; + + mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({ + stories: [story], + }); + + render(); + + expect(await screen.findByText(story.story_id)).toBeInTheDocument(); + + const blockedButton = screen.getByRole("button", { name: "Blocked" }); + expect(blockedButton).toBeDisabled(); + + expect(await screen.findByText("Missing: unit")).toBeInTheDocument(); + expect(await screen.findByText("Missing unit tests")).toBeInTheDocument(); + }); + + it("shows gate panel blocked status with reasons (AC1/AC3)", async () => { + mockedWorkflow.getAcceptance.mockResolvedValueOnce({ + can_accept: false, + reasons: ["No approved test plan for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }); + + render(); + + expect(await screen.findByText("Blocked")).toBeInTheDocument(); + expect( + await screen.findByText("No approved test plan for the story."), + ).toBeInTheDocument(); + expect( + await screen.findByText("Missing: unit, integration"), + ).toBeInTheDocument(); + expect( + await screen.findByText(/0\/0 passing, 0 failing/), + ).toBeInTheDocument(); + }); + + it("shows gate panel ready status when all tests pass (AC1/AC3)", async () => { + mockedWorkflow.getAcceptance.mockResolvedValueOnce({ + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + }); + + render(); + + expect(await screen.findByText("Ready to accept")).toBeInTheDocument(); + expect( + await screen.findByText(/5\/5 passing, 0 failing/), + ).toBeInTheDocument(); + }); + + it("shows failing badge and count in review panel (AC4/AC5)", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["3 tests are failing."], + warning: "Multiple tests failing — fix one at a time.", + summary: { total: 5, passed: 2, failed: 3 }, + missing_categories: [], + }; + + mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({ + stories: [story], + }); + + render(); + + expect(await screen.findByText("Failing 3")).toBeInTheDocument(); + expect(await screen.findByText("Warning")).toBeInTheDocument(); + expect( + await screen.findByText("Multiple tests failing — fix one at a time."), + ).toBeInTheDocument(); + expect(await screen.findByText("3 tests are failing.")).toBeInTheDocument(); + expect( + await screen.findByText(/2\/5 passing, 3 failing/), + ).toBeInTheDocument(); + + const blockedButton = screen.getByRole("button", { name: "Blocked" }); + expect(blockedButton).toBeDisabled(); + }); + + it("shows gate warning when multiple tests fail (AC5)", async () => { + mockedWorkflow.getAcceptance.mockResolvedValueOnce({ + can_accept: false, + reasons: ["2 tests are failing."], + warning: "Multiple tests failing — fix one at a time.", + summary: { total: 4, passed: 2, failed: 2 }, + missing_categories: [], + }); + + render(); + + expect(await screen.findByText("Blocked")).toBeInTheDocument(); + expect( + await screen.findByText("Multiple tests failing — fix one at a time."), + ).toBeInTheDocument(); + expect( + await screen.findByText(/2\/4 passing, 2 failing/), + ).toBeInTheDocument(); + expect(await screen.findByText("2 tests are failing.")).toBeInTheDocument(); + }); + + it("does not call ensureAcceptance when clicking a blocked proceed button (AC4)", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["Tests are failing."], + warning: null, + summary: { total: 3, passed: 1, failed: 2 }, + missing_categories: [], + }; + + mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({ + stories: [story], + }); + + render(); + + const blockedButton = await screen.findByRole("button", { + name: "Blocked", + }); + expect(blockedButton).toBeDisabled(); + + // Clear any prior calls then attempt click on disabled button + mockedWorkflow.ensureAcceptance.mockClear(); + await userEvent.click(blockedButton); + + expect(mockedWorkflow.ensureAcceptance).not.toHaveBeenCalled(); + }); + + it("shows proceed error when ensureAcceptance fails", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: true, + reasons: [], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + }; + + mockedWorkflow.getReviewQueueAll.mockResolvedValueOnce({ + stories: [story], + }); + mockedWorkflow.ensureAcceptance.mockRejectedValueOnce( + new Error("Acceptance blocked: tests still failing"), + ); + + render(); + + const proceedButton = await screen.findByRole("button", { + name: "Proceed", + }); + await userEvent.click(proceedButton); + + expect( + await screen.findByText("Acceptance blocked: tests still failing"), + ).toBeInTheDocument(); + }); + + it("shows gate error when acceptance endpoint fails", async () => { + mockedWorkflow.getAcceptance.mockRejectedValueOnce( + new Error("Server unreachable"), + ); + + render(); + + expect(await screen.findByText("Server unreachable")).toBeInTheDocument(); + + const retryButtons = await screen.findAllByRole("button", { + name: "Retry", + }); + expect(retryButtons.length).toBeGreaterThanOrEqual(1); + }); + + it("refreshes gate status after proceeding on the current story", async () => { + const story: ReviewStory = { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: true, + reasons: [], + warning: null, + summary: { total: 2, passed: 2, failed: 0 }, + missing_categories: [], + }; + + mockedWorkflow.getAcceptance + .mockResolvedValueOnce({ + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }) + .mockResolvedValueOnce({ + can_accept: true, + reasons: [], + warning: null, + summary: { total: 2, passed: 2, failed: 0 }, + missing_categories: [], + }); + + mockedWorkflow.getReviewQueueAll + .mockResolvedValueOnce({ stories: [story] }) + .mockResolvedValueOnce({ stories: [] }); + + render(); + + const proceedButton = await screen.findByRole("button", { + name: "Proceed", + }); + await userEvent.click(proceedButton); + + await waitFor(() => { + expect(mockedWorkflow.ensureAcceptance).toHaveBeenCalledWith({ + story_id: story.story_id, + }); + }); + + expect(await screen.findByText("Ready to accept")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 22e4214..9442348 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -3,7 +3,12 @@ import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { api, ChatWebSocket } from "../api/client"; +import type { ReviewStory } from "../api/workflow"; +import { workflowApi } from "../api/workflow"; import type { Message, ProviderConfig, ToolCall } from "../types"; +import { ChatHeader } from "./ChatHeader"; +import { GatePanel } from "./GatePanel"; +import { ReviewPanel } from "./ReviewPanel"; const { useCallback, useEffect, useRef, useState } = React; @@ -12,6 +17,18 @@ interface ChatProps { onCloseProject: () => void; } +interface GateState { + canAccept: boolean; + reasons: string[]; + warning: string | null; + summary: { + total: number; + passed: number; + failed: number; + }; + missingCategories: string[]; +} + export function Chat({ projectPath, onCloseProject }: ChatProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); @@ -24,6 +41,31 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(""); const [hasAnthropicKey, setHasAnthropicKey] = useState(false); + const [gateState, setGateState] = useState(null); + const [gateError, setGateError] = useState(null); + const [isGateLoading, setIsGateLoading] = useState(false); + const [reviewQueue, setReviewQueue] = useState([]); + const [reviewError, setReviewError] = useState(null); + const [isReviewLoading, setIsReviewLoading] = useState(false); + const [proceedingStoryId, setProceedingStoryId] = useState( + null, + ); + const [proceedError, setProceedError] = useState(null); + const [proceedSuccess, setProceedSuccess] = useState(null); + const [lastReviewRefresh, setLastReviewRefresh] = useState(null); + const [lastGateRefresh, setLastGateRefresh] = useState(null); + + const storyId = "26_establish_tdd_workflow_and_gates"; + const gateStatusColor = isGateLoading + ? "#aaa" + : gateState?.canAccept + ? "#7ee787" + : "#ff7b72"; + const gateStatusLabel = isGateLoading + ? "Checking..." + : gateState?.canAccept + ? "Ready to accept" + : "Blocked"; const wsRef = useRef(null); const messagesEndRef = useRef(null); @@ -76,12 +118,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const contextUsage = calculateContextUsage(); - const getContextEmoji = (percentage: number): string => { - if (percentage >= 90) return "🔴"; - if (percentage >= 75) return "🟡"; - return "🟢"; - }; - useEffect(() => { api .getOllamaModels() @@ -134,6 +170,146 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }); }, []); + useEffect(() => { + let active = true; + setIsGateLoading(true); + setGateError(null); + + workflowApi + .getAcceptance({ story_id: storyId }) + .then((response) => { + if (!active) return; + setGateState({ + canAccept: response.can_accept, + reasons: response.reasons, + warning: response.warning ?? null, + summary: response.summary, + missingCategories: response.missing_categories, + }); + setLastGateRefresh(new Date()); + }) + .catch((error) => { + if (!active) return; + const message = + error instanceof Error + ? error.message + : "Failed to load workflow gates."; + setGateError(message); + setGateState(null); + }) + .finally(() => { + if (active) { + setIsGateLoading(false); + } + }); + + return () => { + active = false; + }; + }, [storyId]); + + useEffect(() => { + let active = true; + setIsReviewLoading(true); + setReviewError(null); + + workflowApi + .getReviewQueueAll() + .then((response) => { + if (!active) return; + setReviewQueue(response.stories); + setLastReviewRefresh(new Date()); + }) + .catch((error) => { + if (!active) return; + const message = + error instanceof Error + ? error.message + : "Failed to load review queue."; + setReviewError(message); + setReviewQueue([]); + }) + .finally(() => { + if (active) { + setIsReviewLoading(false); + } + }); + + return () => { + active = false; + }; + }, []); + + const refreshGateState = async (targetStoryId: string = storyId) => { + setIsGateLoading(true); + setGateError(null); + + try { + const response = await workflowApi.getAcceptance({ + story_id: targetStoryId, + }); + setGateState({ + canAccept: response.can_accept, + reasons: response.reasons, + warning: response.warning ?? null, + summary: response.summary, + missingCategories: response.missing_categories, + }); + setLastGateRefresh(new Date()); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to load workflow gates."; + setGateError(message); + setGateState(null); + } finally { + setIsGateLoading(false); + } + }; + + const refreshReviewQueue = async () => { + setIsReviewLoading(true); + setReviewError(null); + + try { + const response = await workflowApi.getReviewQueueAll(); + setReviewQueue(response.stories); + setLastReviewRefresh(new Date()); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load review queue."; + setReviewError(message); + setReviewQueue([]); + } finally { + setIsReviewLoading(false); + } + }; + + const handleProceed = async (storyIdToProceed: string) => { + setProceedingStoryId(storyIdToProceed); + setProceedError(null); + setProceedSuccess(null); + try { + await workflowApi.ensureAcceptance({ + story_id: storyIdToProceed, + }); + setProceedSuccess(`Proceeding with ${storyIdToProceed}.`); + await refreshReviewQueue(); + if (storyIdToProceed === storyId) { + await refreshGateState(storyId); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to proceed with review."; + setProceedError(message); + } finally { + setProceedingStoryId(null); + } + }; + useEffect(() => { const ws = new ChatWebSocket(); wsRef.current = ws; @@ -321,209 +497,61 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { color: "#ececec", }} > + { + setModel(newModel); + api.setModelPreference(newModel).catch(console.error); + }} + enableTools={enableTools} + onToggleTools={setEnableTools} + /> +
-
- {projectPath} -
- -
+ -
-
- {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} - % -
- - - {availableModels.length > 0 || claudeModels.length > 0 ? ( - - ) : ( - { - const newModel = e.target.value; - setModel(newModel); - api.setModelPreference(newModel).catch(console.error); - }} - placeholder="Model" - style={{ - padding: "6px 12px", - borderRadius: "99px", - border: "none", - fontSize: "0.9em", - background: "#2f2f2f", - color: "#ececec", - outline: "none", - }} - /> - )} - + refreshGateState(storyId)} + />
diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx new file mode 100644 index 0000000..18567b9 --- /dev/null +++ b/frontend/src/components/ChatHeader.tsx @@ -0,0 +1,242 @@ +interface ContextUsage { + used: number; + total: number; + percentage: number; +} + +interface ChatHeaderProps { + projectPath: string; + onCloseProject: () => void; + contextUsage: ContextUsage; + onClearSession: () => void; + model: string; + availableModels: string[]; + claudeModels: string[]; + hasAnthropicKey: boolean; + onModelChange: (model: string) => void; + enableTools: boolean; + onToggleTools: (enabled: boolean) => void; +} + +const getContextEmoji = (percentage: number): string => { + if (percentage >= 90) return "🔴"; + if (percentage >= 75) return "🟡"; + return "🟢"; +}; + +export function ChatHeader({ + projectPath, + onCloseProject, + contextUsage, + onClearSession, + model, + availableModels, + claudeModels, + hasAnthropicKey, + onModelChange, + enableTools, + onToggleTools, +}: ChatHeaderProps) { + const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; + + return ( +
+
+
+ {projectPath} +
+ +
+ +
+
+ {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}% +
+ + + + {hasModelOptions ? ( + + ) : ( + onModelChange(e.target.value)} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + + +
+
+ ); +} diff --git a/frontend/src/components/GatePanel.tsx b/frontend/src/components/GatePanel.tsx new file mode 100644 index 0000000..0255d1f --- /dev/null +++ b/frontend/src/components/GatePanel.tsx @@ -0,0 +1,182 @@ +interface GateState { + canAccept: boolean; + reasons: string[]; + warning: string | null; + summary: { + total: number; + passed: number; + failed: number; + }; + missingCategories: string[]; +} + +interface GatePanelProps { + gateState: GateState | null; + gateStatusLabel: string; + gateStatusColor: string; + isGateLoading: boolean; + gateError: string | null; + lastGateRefresh: Date | null; + onRefresh: () => void; +} + +const formatTimestamp = (value: Date | null): string => { + if (!value) return "—"; + return value.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +export function GatePanel({ + gateState, + gateStatusLabel, + gateStatusColor, + isGateLoading, + gateError, + lastGateRefresh, + onRefresh, +}: GatePanelProps) { + return ( +
+
+
+
Workflow Gates
+ +
+
+
{gateStatusLabel}
+
+ Updated {formatTimestamp(lastGateRefresh)} +
+
+
+ + {isGateLoading ? ( +
+ Loading workflow gates... +
+ ) : gateError ? ( +
+ {gateError} + +
+ ) : gateState ? ( +
+
+ Summary: {gateState.summary.passed}/{gateState.summary.total}{" "} + passing, {gateState.summary.failed} failing +
+ {gateState.missingCategories.length > 0 && ( +
+ Missing: {gateState.missingCategories.join(", ")} +
+ )} + {gateState.warning && ( +
+ {gateState.warning} +
+ )} + {gateState.reasons.length > 0 && ( +
    + {gateState.reasons.map((reason) => ( +
  • {reason}
  • + ))} +
+ )} +
+ ) : ( +
+ No workflow data yet. +
+ )} +
+ ); +} diff --git a/frontend/src/components/ReviewPanel.tsx b/frontend/src/components/ReviewPanel.tsx new file mode 100644 index 0000000..6c9ca37 --- /dev/null +++ b/frontend/src/components/ReviewPanel.tsx @@ -0,0 +1,324 @@ +import type { ReviewStory } from "../api/workflow"; + +interface ReviewPanelProps { + reviewQueue: ReviewStory[]; + isReviewLoading: boolean; + reviewError: string | null; + proceedingStoryId: string | null; + storyId: string; + isGateLoading: boolean; + proceedError: string | null; + proceedSuccess: string | null; + lastReviewRefresh: Date | null; + onRefresh: () => void; + onProceed: (storyId: string) => Promise; +} + +const formatTimestamp = (value: Date | null): string => { + if (!value) return "—"; + return value.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +}; + +export function ReviewPanel({ + reviewQueue, + isReviewLoading, + reviewError, + proceedingStoryId, + storyId, + isGateLoading, + proceedError, + proceedSuccess, + lastReviewRefresh, + onRefresh, + onProceed, +}: ReviewPanelProps) { + return ( +
+
+
+
Stories Awaiting Review
+ +
+
+
+ {reviewQueue.filter((story) => story.can_accept).length} ready /{" "} + {reviewQueue.length} total +
+
+ Updated {formatTimestamp(lastReviewRefresh)} +
+
+
+ + {isReviewLoading ? ( +
+ Loading review queue... +
+ ) : reviewError ? ( +
+ {reviewError} Use Refresh to try again. + +
+ ) : reviewQueue.length === 0 ? ( +
+ No stories waiting for review. +
+ ) : ( +
+ {reviewQueue.map((story) => ( +
+
+
+
{story.story_id}
+ + {story.can_accept ? "Ready" : "Blocked"} + + {story.summary.failed > 0 && ( + + Failing {story.summary.failed} + + )} + {story.warning && ( + + Warning + + )} + {story.missing_categories.length > 0 && ( + + Missing + + )} +
+ +
+
+ Summary: {story.summary.passed}/{story.summary.total} passing,{" "} + {` ${story.summary.failed}`} failing +
+ {story.missing_categories.length > 0 && ( +
+ Missing: {story.missing_categories.join(", ")} +
+ )} + {story.reasons.length > 0 && ( +
    + {story.reasons.map((reason) => ( +
  • + {reason} +
  • + ))} +
+ )} + {story.warning && ( +
+ {story.warning} +
+ )} +
+ ))} +
+ )} + + {proceedError && ( +
+ {proceedError} +
+ )} + {proceedSuccess && ( +
+ {proceedSuccess} +
+ )} +
+ ); +} diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/frontend/src/setupTests.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/frontend/tests/e2e/review-panel.spec.ts b/frontend/tests/e2e/review-panel.spec.ts new file mode 100644 index 0000000..9d3636f --- /dev/null +++ b/frontend/tests/e2e/review-panel.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test.describe("App boot smoke test", () => { + test("renders the project selection screen", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByText("StorkIt")).toBeVisible(); + await expect(page.getByPlaceholder("/path/to/project")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open Project" }), + ).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/tdd-gates.spec.ts b/frontend/tests/e2e/tdd-gates.spec.ts new file mode 100644 index 0000000..c511a26 --- /dev/null +++ b/frontend/tests/e2e/tdd-gates.spec.ts @@ -0,0 +1,344 @@ +import { expect, test } from "@playwright/test"; +import type { + AcceptanceResponse, + ReviewListResponse, +} from "../../src/api/workflow"; + +/** + * Helper: mock all API routes needed to render the Chat view. + * Accepts overrides for the workflow-specific endpoints. + */ +function mockChatApis( + page: import("@playwright/test").Page, + overrides: { + acceptance?: AcceptanceResponse; + reviewQueue?: ReviewListResponse; + ensureAcceptance?: { status: number; body: unknown }; + } = {}, +) { + const acceptance: AcceptanceResponse = overrides.acceptance ?? { + can_accept: false, + reasons: ["No test results recorded for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }; + + const reviewQueue: ReviewListResponse = overrides.reviewQueue ?? { + stories: [], + }; + + const ensureResp = overrides.ensureAcceptance ?? { + status: 200, + body: true, + }; + + return Promise.all([ + // Selection screen APIs + page.route("**/api/projects", (route) => + route.fulfill({ json: ["/tmp/test-project"] }), + ), + page.route("**/api/io/fs/home", (route) => route.fulfill({ json: "/tmp" })), + page.route("**/api/project", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: "/tmp/test-project" }); + } + if (route.request().method() === "DELETE") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + // Chat init APIs + page.route("**/api/ollama/models**", (route) => + route.fulfill({ json: ["llama3.1"] }), + ), + page.route("**/api/anthropic/key/exists", (route) => + route.fulfill({ json: false }), + ), + page.route("**/api/anthropic/models", (route) => + route.fulfill({ json: [] }), + ), + page.route("**/api/model", (route) => { + if (route.request().method() === "POST") { + return route.fulfill({ json: true }); + } + return route.fulfill({ json: null }); + }), + // Workflow APIs + page.route("**/api/workflow/acceptance", (route) => { + if (route.request().url().includes("/ensure")) return route.fallback(); + return route.fulfill({ json: acceptance }); + }), + page.route("**/api/workflow/review/all", (route) => + route.fulfill({ json: reviewQueue }), + ), + page.route("**/api/workflow/acceptance/ensure", (route) => + route.fulfill({ + status: ensureResp.status, + json: ensureResp.body, + }), + ), + page.route("**/api/io/fs/list/absolute**", (route) => + route.fulfill({ json: [] }), + ), + ]); +} + +/** Navigate past the selection screen into the Chat view. */ +async function openProject(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.getByPlaceholder("/path/to/project").fill("/tmp/test-project"); + await page.getByRole("button", { name: "Open Project" }).click(); + // Wait for the Chat view to appear + await expect(page.getByText("Workflow Gates", { exact: true })).toBeVisible(); +} + +test.describe("TDD gate panel (AC1/AC3)", () => { + test("shows Blocked status with reasons when no test plan is approved", async ({ + page, + }) => { + await mockChatApis(page, { + acceptance: { + can_accept: false, + reasons: ["No approved test plan for the story."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }, + }); + + await openProject(page); + + await expect( + page.getByText("Workflow Gates", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Blocked").first()).toBeVisible(); + await expect( + page.getByText("No approved test plan for the story."), + ).toBeVisible(); + await expect( + page.getByText("Missing: unit, integration").first(), + ).toBeVisible(); + }); + + test("shows Ready to accept when all gates pass", async ({ page }) => { + await mockChatApis(page, { + acceptance: { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 5, passed: 5, failed: 0 }, + missing_categories: [], + }, + }); + + await openProject(page); + + await expect(page.getByText("Ready to accept")).toBeVisible(); + await expect(page.getByText(/5\/5 passing, 0 failing/)).toBeVisible(); + }); +}); + +test.describe("Acceptance blocked by failing tests (AC4)", () => { + test("Proceed button is disabled and shows Blocked when tests fail", async ({ + page, + }) => { + await mockChatApis(page, { + acceptance: { + can_accept: false, + reasons: ["Tests are failing."], + warning: null, + summary: { total: 4, passed: 2, failed: 2 }, + missing_categories: [], + }, + reviewQueue: { + stories: [ + { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["Tests are failing."], + warning: null, + summary: { total: 4, passed: 2, failed: 2 }, + missing_categories: [], + }, + ], + }, + }); + + await openProject(page); + + const blockedButton = page.getByRole("button", { name: "Blocked" }); + await expect(blockedButton).toBeVisible(); + await expect(blockedButton).toBeDisabled(); + + await expect(page.getByText("Tests are failing.").first()).toBeVisible(); + await expect(page.getByText(/2\/4 passing/).first()).toBeVisible(); + }); + + test("Proceed button is disabled when missing test categories", async ({ + page, + }) => { + await mockChatApis(page, { + reviewQueue: { + stories: [ + { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["Missing required test categories."], + warning: null, + summary: { total: 0, passed: 0, failed: 0 }, + missing_categories: ["unit", "integration"], + }, + ], + }, + }); + + await openProject(page); + + const blockedButton = page.getByRole("button", { name: "Blocked" }); + await expect(blockedButton).toBeDisabled(); + await expect(page.getByText("Missing").first()).toBeVisible(); + }); +}); + +test.describe("Red test count and warnings (AC5)", () => { + test("shows Failing badge with count when tests fail", async ({ page }) => { + await mockChatApis(page, { + reviewQueue: { + stories: [ + { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["3 tests are failing."], + warning: "Multiple tests failing — fix one at a time.", + summary: { total: 5, passed: 2, failed: 3 }, + missing_categories: [], + }, + ], + }, + }); + + await openProject(page); + + await expect(page.getByText("Failing 3")).toBeVisible(); + await expect(page.getByText("Warning")).toBeVisible(); + await expect( + page.getByText("Multiple tests failing — fix one at a time."), + ).toBeVisible(); + }); + + test("gate panel shows warning for multiple failing tests", async ({ + page, + }) => { + await mockChatApis(page, { + acceptance: { + can_accept: false, + reasons: ["2 tests are failing."], + warning: "Multiple tests failing — fix one at a time.", + summary: { total: 4, passed: 2, failed: 2 }, + missing_categories: [], + }, + }); + + await openProject(page); + + await expect( + page.getByText("Multiple tests failing — fix one at a time."), + ).toBeVisible(); + await expect(page.getByText(/2\/4 passing, 2 failing/)).toBeVisible(); + }); +}); + +test.describe("Blocked actions do not execute (E2E)", () => { + test("clicking a blocked Proceed button does not call ensureAcceptance", async ({ + page, + }) => { + let ensureCalled = false; + + await mockChatApis(page, { + reviewQueue: { + stories: [ + { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: false, + reasons: ["Tests are failing."], + warning: null, + summary: { total: 3, passed: 1, failed: 2 }, + missing_categories: [], + }, + ], + }, + }); + + // Override the ensure route to track calls + await page.route("**/api/workflow/acceptance/ensure", (route) => { + ensureCalled = true; + return route.fulfill({ json: true }); + }); + + await openProject(page); + + const blockedButton = page.getByRole("button", { name: "Blocked" }); + await expect(blockedButton).toBeDisabled(); + + // Force-click to attempt bypass — should still not fire + await blockedButton.click({ force: true }); + + // Give a moment for any potential network call + await page.waitForTimeout(500); + + expect(ensureCalled).toBe(false); + }); + + test("ready story proceeds successfully", async ({ page }) => { + let ensureCalled = false; + + await mockChatApis(page, { + acceptance: { + can_accept: true, + reasons: [], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + }, + reviewQueue: { + stories: [ + { + story_id: "26_establish_tdd_workflow_and_gates", + can_accept: true, + reasons: [], + warning: null, + summary: { total: 3, passed: 3, failed: 0 }, + missing_categories: [], + }, + ], + }, + ensureAcceptance: { status: 200, body: true }, + }); + + // Intercept ensure to track it was called + await page.route("**/api/workflow/acceptance/ensure", (route) => { + ensureCalled = true; + return route.fulfill({ json: true }); + }); + + await openProject(page); + + const proceedButton = page.getByRole("button", { name: "Proceed" }); + await expect(proceedButton).toBeEnabled(); + + // Before clicking, swap review queue to return empty on next fetch + await page.unroute("**/api/workflow/review/all"); + await page.route("**/api/workflow/review/all", (route) => + route.fulfill({ json: { stories: [] } }), + ); + + await proceedButton.click(); + + // After proceed, the review queue refreshes — now returns empty + await expect( + page.getByText("No stories waiting for review."), + ).toBeVisible(); + expect(ensureCalled).toBe(true); + }); +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a7fc6fb..6d545f5 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -20,6 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src"] } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 42872c5..32626a4 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -4,7 +4,8 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "types": ["node"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts"] } diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..0b9a89e --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/setupTests.ts"], + css: true, + exclude: ["tests/e2e/**", "node_modules/**"], + }, +}); diff --git a/server/Cargo.toml b/server/Cargo.toml index 21562e2..df452ef 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,6 +21,7 @@ eventsource-stream = { workspace = true } rust-embed = { workspace = true } mime_guess = { workspace = true } homedir = { workspace = true } +serde_yaml = "0.9" [dev-dependencies] diff --git a/server/src/http/context.rs b/server/src/http/context.rs index ca75df4..bfd6f58 100644 --- a/server/src/http/context.rs +++ b/server/src/http/context.rs @@ -1,5 +1,6 @@ use crate::state::SessionState; use crate::store::JsonFileStore; +use crate::workflow::WorkflowState; use poem::http::StatusCode; use std::sync::Arc; @@ -7,6 +8,7 @@ use std::sync::Arc; pub struct AppContext { pub state: Arc, pub store: Arc, + pub workflow: Arc>, } pub type OpenApiResult = poem::Result; diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 12a5506..80d7b9a 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -5,6 +5,7 @@ pub mod context; pub mod health; pub mod io; pub mod model; +pub mod workflow; pub mod project; pub mod ws; @@ -19,6 +20,7 @@ use poem::{Route, get}; use poem_openapi::OpenApiService; use project::ProjectApi; use std::sync::Arc; +use workflow::WorkflowApi; pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); @@ -36,7 +38,14 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { .data(ctx_arc) } -type ApiTuple = (ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi); +type ApiTuple = ( + ProjectApi, + ModelApi, + AnthropicApi, + IoApi, + ChatApi, + WorkflowApi, +); type ApiService = OpenApiService; @@ -48,6 +57,7 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, + WorkflowApi { ctx: ctx.clone() }, ); let api_service = @@ -58,7 +68,8 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { ModelApi { ctx: ctx.clone() }, AnthropicApi::new(ctx.clone()), IoApi { ctx: ctx.clone() }, - ChatApi { ctx }, + ChatApi { ctx: ctx.clone() }, + WorkflowApi { ctx }, ); let docs_service = diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs new file mode 100644 index 0000000..b3b9e92 --- /dev/null +++ b/server/src/http/workflow.rs @@ -0,0 +1,319 @@ +use crate::http::context::{AppContext, OpenApiResult, bad_request}; +use crate::io::story_metadata::{StoryMetadata, parse_front_matter}; +use crate::workflow::{ + StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results, +}; +use poem_openapi::{Object, OpenApi, Tags, payload::Json}; +use serde::Deserialize; +use std::collections::BTreeSet; +use std::fs; +use std::sync::Arc; + +#[derive(Tags)] +enum WorkflowTags { + Workflow, +} + +#[derive(Deserialize, Object)] +struct TestCasePayload { + pub name: String, + pub status: String, + pub details: Option, +} + +#[derive(Deserialize, Object)] +struct RecordTestsPayload { + pub story_id: String, + pub unit: Vec, + pub integration: Vec, +} + +#[derive(Deserialize, Object)] +struct AcceptanceRequest { + pub story_id: String, +} + +#[derive(Object)] +struct TestRunSummaryResponse { + pub total: usize, + pub passed: usize, + pub failed: usize, +} + +#[derive(Object)] +struct AcceptanceResponse { + pub can_accept: bool, + pub reasons: Vec, + pub warning: Option, + pub summary: TestRunSummaryResponse, + pub missing_categories: Vec, +} + +#[derive(Object)] +struct ReviewStory { + pub story_id: String, + pub can_accept: bool, + pub reasons: Vec, + pub warning: Option, + pub summary: TestRunSummaryResponse, + pub missing_categories: Vec, +} + +#[derive(Object)] +struct ReviewListResponse { + pub stories: Vec, +} + +fn load_current_story_metadata(ctx: &AppContext) -> Result, String> { + let root = ctx.state.get_project_root()?; + let current_dir = root.join(".story_kit").join("stories").join("current"); + + if !current_dir.exists() { + return Ok(Vec::new()); + } + + let mut stories = Vec::new(); + for entry in fs::read_dir(¤t_dir) + .map_err(|e| format!("Failed to read current stories directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + let story_id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| "Invalid story file name.".to_string())? + .to_string(); + let contents = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; + let metadata = parse_front_matter(&contents) + .map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?; + stories.push((story_id, metadata)); + } + + Ok(stories) +} + +fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory { + let decision = evaluate_acceptance(results); + let summary = summarize_results(results); + + let mut missing_categories = Vec::new(); + let mut reasons = decision.reasons; + + if results.unit.is_empty() { + missing_categories.push("unit".to_string()); + reasons.push("Missing unit test results.".to_string()); + } + if results.integration.is_empty() { + missing_categories.push("integration".to_string()); + reasons.push("Missing integration test results.".to_string()); + } + + let can_accept = decision.can_accept && missing_categories.is_empty(); + + ReviewStory { + story_id: story_id.to_string(), + can_accept, + reasons, + warning: decision.warning, + summary: TestRunSummaryResponse { + total: summary.total, + passed: summary.passed, + failed: summary.failed, + }, + missing_categories, + } +} + +pub struct WorkflowApi { + pub ctx: Arc, +} + +#[OpenApi(tag = "WorkflowTags::Workflow")] +impl WorkflowApi { + /// Record test results for a story (unit + integration). + #[oai(path = "/workflow/tests/record", method = "post")] + async fn record_tests(&self, payload: Json) -> OpenApiResult> { + let unit = payload + .0 + .unit + .into_iter() + .map(to_test_case) + .collect::, String>>() + .map_err(bad_request)?; + let integration = payload + .0 + .integration + .into_iter() + .map(to_test_case) + .collect::, String>>() + .map_err(bad_request)?; + + let mut workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + workflow + .record_test_results_validated(payload.0.story_id, unit, integration) + .map_err(bad_request)?; + + Ok(Json(true)) + } + + /// Evaluate acceptance readiness for a story. + #[oai(path = "/workflow/acceptance", method = "post")] + async fn acceptance( + &self, + payload: Json, + ) -> OpenApiResult> { + let results = { + let workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + workflow + .results + .get(&payload.0.story_id) + .cloned() + .unwrap_or_default() + }; + + let decision = evaluate_acceptance(&results); + let summary = summarize_results(&results); + + let mut missing_categories = Vec::new(); + let mut reasons = decision.reasons; + + if results.unit.is_empty() { + missing_categories.push("unit".to_string()); + reasons.push("Missing unit test results.".to_string()); + } + if results.integration.is_empty() { + missing_categories.push("integration".to_string()); + reasons.push("Missing integration test results.".to_string()); + } + + let can_accept = decision.can_accept && missing_categories.is_empty(); + + Ok(Json(AcceptanceResponse { + can_accept, + reasons, + warning: decision.warning, + summary: TestRunSummaryResponse { + total: summary.total, + passed: summary.passed, + failed: summary.failed, + }, + missing_categories, + })) + } + + /// List stories that are ready for human review. + #[oai(path = "/workflow/review", method = "get")] + async fn review_queue(&self) -> OpenApiResult> { + let stories = { + let workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + workflow + .results + .iter() + .map(|(story_id, results)| to_review_story(story_id, results)) + .filter(|story| story.can_accept) + .collect::>() + }; + + Ok(Json(ReviewListResponse { stories })) + } + + /// List stories in the review queue, including blocked items and current stories. + #[oai(path = "/workflow/review/all", method = "get")] + async fn review_queue_all(&self) -> OpenApiResult> { + let current_stories = + load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?; + let stories = { + let mut workflow = self + .ctx + .workflow + .lock() + .map_err(|e| bad_request(e.to_string()))?; + + if !current_stories.is_empty() { + workflow.load_story_metadata(current_stories); + } + + let mut story_ids = BTreeSet::new(); + + for story_id in workflow.results.keys() { + story_ids.insert(story_id.clone()); + } + for story_id in workflow.stories.keys() { + story_ids.insert(story_id.clone()); + } + + story_ids + .into_iter() + .map(|story_id| { + let results = workflow.results.get(&story_id).cloned().unwrap_or_default(); + to_review_story(&story_id, &results) + }) + .collect::>() + }; + + Ok(Json(ReviewListResponse { stories })) + } + + /// Ensure a story can be accepted; returns an error when gates fail. + #[oai(path = "/workflow/acceptance/ensure", method = "post")] + async fn ensure_acceptance( + &self, + payload: Json, + ) -> OpenApiResult> { + let response = self.acceptance(payload).await?.0; + if response.can_accept { + return Ok(Json(true)); + } + + let mut parts = Vec::new(); + if !response.reasons.is_empty() { + parts.push(response.reasons.join("; ")); + } + if let Some(warning) = response.warning { + parts.push(warning); + } + + let message = if parts.is_empty() { + "Acceptance is blocked.".to_string() + } else { + format!("Acceptance is blocked: {}", parts.join("; ")) + }; + + Err(bad_request(message)) + } +} + +fn to_test_case(input: TestCasePayload) -> Result { + let status = parse_test_status(&input.status)?; + Ok(TestCaseResult { + name: input.name, + status, + details: input.details, + }) +} + +fn parse_test_status(value: &str) -> Result { + match value { + "pass" => Ok(TestStatus::Pass), + "fail" => Ok(TestStatus::Fail), + other => Err(format!( + "Invalid test status '{other}'. Use 'pass' or 'fail'." + )), + } +} diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index fa969ca..a776df9 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -1,3 +1,4 @@ +use crate::io::story_metadata::{TestPlanStatus, parse_front_matter}; use crate::state::SessionState; use crate::store::StoreOps; use serde::Serialize; @@ -276,6 +277,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React * **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints. * **Frontend:** TypeScript + React * **Build Tool:** Vite + * **Package Manager:** pnpm (required) * **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules) * **State Management:** React Context / Hooks * **Chat UI:** Rendered Markdown with syntax highlighting. @@ -394,6 +396,34 @@ fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result bool { + path == ".story_kit" || path.starts_with(".story_kit/") +} + +async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> { + let approved = tokio::task::spawn_blocking(move || { + let story_path = root + .join(".story_kit") + .join("stories") + .join("current") + .join("26_establish_tdd_workflow_and_gates.md"); + let contents = fs::read_to_string(&story_path) + .map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?; + let metadata = parse_front_matter(&contents) + .map_err(|e| format!("Failed to parse story front matter: {e:?}"))?; + + Ok::(matches!(metadata.test_plan, Some(TestPlanStatus::Approved))) + }) + .await + .map_err(|e| format!("Task failed: {e}"))??; + + if approved { + Ok(()) + } else { + Err("Test plan is not approved for the current story.".to_string()) + } +} + /// Resolves a relative path against the active project root. /// Returns error if no project is open or if path attempts traversal (..). fn resolve_path(state: &SessionState, relative_path: &str) -> Result { @@ -597,7 +627,11 @@ async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), Stri } pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> { - let full_path = resolve_path(state, &path)?; + let root = state.get_project_root()?; + if !is_story_kit_path(&path) { + ensure_test_plan_approved(root.clone()).await?; + } + let full_path = resolve_path_impl(root, &path)?; write_file_impl(full_path, content).await } @@ -658,3 +692,27 @@ pub async fn create_directory_absolute(path: String) -> Result { .await .map_err(|e| format!("Task failed: {}", e))? } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn write_file_requires_approved_test_plan() { + let dir = tempdir().expect("tempdir"); + let state = SessionState::default(); + + { + let mut root = state.project_root.lock().expect("lock project root"); + *root = Some(dir.path().to_path_buf()); + } + + let result = write_file("notes.txt".to_string(), "hello".to_string(), &state).await; + + assert!( + result.is_err(), + "expected write to be blocked when test plan is not approved" + ); + } +} diff --git a/server/src/io/mod.rs b/server/src/io/mod.rs index 71e6bf5..ba8c0b8 100644 --- a/server/src/io/mod.rs +++ b/server/src/io/mod.rs @@ -1,3 +1,4 @@ pub mod fs; pub mod search; pub mod shell; +pub mod story_metadata; diff --git a/server/src/io/shell.rs b/server/src/io/shell.rs index 1ec4d68..82c8249 100644 --- a/server/src/io/shell.rs +++ b/server/src/io/shell.rs @@ -1,5 +1,7 @@ +use crate::io::story_metadata::{TestPlanStatus, parse_front_matter}; use crate::state::SessionState; use serde::Serialize; +use std::fs; use std::path::PathBuf; use std::process::Command; @@ -8,6 +10,30 @@ fn get_project_root(state: &SessionState) -> Result { state.get_project_root() } +async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> { + let approved = tokio::task::spawn_blocking(move || { + let story_path = root + .join(".story_kit") + .join("stories") + .join("current") + .join("26_establish_tdd_workflow_and_gates.md"); + let contents = fs::read_to_string(&story_path) + .map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?; + let metadata = parse_front_matter(&contents) + .map_err(|e| format!("Failed to parse story front matter: {e:?}"))?; + + Ok::(matches!(metadata.test_plan, Some(TestPlanStatus::Approved))) + }) + .await + .map_err(|e| format!("Task failed: {e}"))??; + + if approved { + Ok(()) + } else { + Err("Test plan is not approved for the current story.".to_string()) + } +} + #[derive(Serialize, Debug, poem_openapi::Object)] pub struct CommandOutput { pub stdout: String, @@ -54,5 +80,30 @@ pub async fn exec_shell( state: &SessionState, ) -> Result { let root = get_project_root(state)?; + ensure_test_plan_approved(root.clone()).await?; exec_shell_impl(command, args, root).await } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn exec_shell_requires_approved_test_plan() { + let dir = tempdir().expect("tempdir"); + let state = SessionState::default(); + + { + let mut root = state.project_root.lock().expect("lock project root"); + *root = Some(dir.path().to_path_buf()); + } + + let result = exec_shell("ls".to_string(), Vec::new(), &state).await; + + assert!( + result.is_err(), + "expected shell execution to be blocked when test plan is not approved" + ); + } +} diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs new file mode 100644 index 0000000..86d2237 --- /dev/null +++ b/server/src/io/story_metadata.rs @@ -0,0 +1,111 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TestPlanStatus { + Approved, + WaitingForApproval, + Unknown(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct StoryMetadata { + pub name: Option, + pub test_plan: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoryMetaError { + MissingFrontMatter, + InvalidFrontMatter(String), +} + +#[derive(Debug, Deserialize)] +struct FrontMatter { + name: Option, + test_plan: Option, +} + +pub fn parse_front_matter(contents: &str) -> Result { + let mut lines = contents.lines(); + + let first = lines.next().unwrap_or_default().trim(); + if first != "---" { + return Err(StoryMetaError::MissingFrontMatter); + } + + let mut front_lines = Vec::new(); + for line in &mut lines { + let trimmed = line.trim(); + if trimmed == "---" { + let raw = front_lines.join("\n"); + let front: FrontMatter = serde_yaml::from_str(&raw) + .map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?; + return Ok(build_metadata(front)); + } + front_lines.push(line); + } + + Err(StoryMetaError::InvalidFrontMatter( + "Missing closing front matter delimiter".to_string(), + )) +} + +fn build_metadata(front: FrontMatter) -> StoryMetadata { + let test_plan = front.test_plan.as_deref().map(parse_test_plan_status); + + StoryMetadata { + name: front.name, + test_plan, + } +} + +fn parse_test_plan_status(value: &str) -> TestPlanStatus { + match value { + "approved" => TestPlanStatus::Approved, + "waiting_for_approval" => TestPlanStatus::WaitingForApproval, + other => TestPlanStatus::Unknown(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_front_matter_metadata() { + let input = r#"--- +name: Establish the TDD Workflow and Gates +test_plan: approved +workflow: tdd +--- +# Story 26 +"#; + + let meta = parse_front_matter(input).expect("front matter"); + assert_eq!( + meta, + StoryMetadata { + name: Some("Establish the TDD Workflow and Gates".to_string()), + test_plan: Some(TestPlanStatus::Approved), + } + ); + } + + #[test] + fn rejects_missing_front_matter() { + let input = "# Story 26\n"; + assert_eq!( + parse_front_matter(input), + Err(StoryMetaError::MissingFrontMatter) + ); + } + + #[test] + fn rejects_unclosed_front_matter() { + let input = "---\nname: Test\n"; + assert!(matches!( + parse_front_matter(input), + Err(StoryMetaError::InvalidFrontMatter(_)) + )); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index f7b6fa6..e63f0bb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3,11 +3,13 @@ mod io; mod llm; mod state; mod store; +mod workflow; use crate::http::build_routes; use crate::http::context::AppContext; use crate::state::SessionState; use crate::store::JsonFileStore; +use crate::workflow::WorkflowState; use poem::Server; use poem::listener::TcpListener; use std::path::PathBuf; @@ -19,10 +21,12 @@ async fn main() -> Result<(), std::io::Error> { let store = Arc::new( JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, ); + let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default())); let ctx = AppContext { state: app_state, store, + workflow, }; let app = build_routes(ctx); diff --git a/server/src/workflow.rs b/server/src/workflow.rs new file mode 100644 index 0000000..aad5a55 --- /dev/null +++ b/server/src/workflow.rs @@ -0,0 +1,242 @@ +//! Workflow module: story gating and test result tracking. +//! +//! This module provides the in-memory primitives for: +//! - reading story metadata (front matter) for gating decisions +//! - tracking test run results +//! - evaluating acceptance readiness +//! +//! NOTE: This is a naive, local-only implementation that will be +//! refactored later into orchestration-aware components. + +use crate::io::story_metadata::{StoryMetadata, TestPlanStatus}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TestStatus { + Pass, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestCaseResult { + pub name: String, + pub status: TestStatus, + pub details: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TestRunSummary { + pub total: usize, + pub passed: usize, + pub failed: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptanceDecision { + pub can_accept: bool, + pub reasons: Vec, + pub warning: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct StoryTestResults { + pub unit: Vec, + pub integration: Vec, +} + +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] +pub struct WorkflowState { + pub stories: HashMap, + pub results: HashMap, +} + +#[allow(dead_code)] +impl WorkflowState { + pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) { + self.stories.insert(story_id, metadata); + } + + pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) { + for (story_id, metadata) in stories { + self.stories.insert(story_id, metadata); + } + } + + pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool { + match self.stories.get(&story_id) { + Some(existing) if existing == &metadata => false, + _ => { + self.stories.insert(story_id, metadata); + true + } + } + } + + pub fn record_test_results( + &mut self, + story_id: String, + unit: Vec, + integration: Vec, + ) { + let _ = self.record_test_results_validated(story_id, unit, integration); + } + + pub fn record_test_results_validated( + &mut self, + story_id: String, + unit: Vec, + integration: Vec, + ) -> Result<(), String> { + let failures = unit + .iter() + .chain(integration.iter()) + .filter(|test| test.status == TestStatus::Fail) + .count(); + + if failures > 1 { + return Err(format!( + "Multiple failing tests detected ({failures}); register failures one at a time." + )); + } + + self.results + .insert(story_id, StoryTestResults { unit, integration }); + + Ok(()) + } +} + +#[allow(dead_code)] +pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> { + match metadata.test_plan { + Some(TestPlanStatus::Approved) => Ok(()), + Some(TestPlanStatus::WaitingForApproval) => { + Err("Test plan is waiting for approval; implementation is blocked.".to_string()) + } + Some(TestPlanStatus::Unknown(ref value)) => Err(format!( + "Test plan state is unknown ({value}); implementation is blocked." + )), + None => Err("Missing test plan status; implementation is blocked.".to_string()), + } +} + +pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary { + let mut total = 0; + let mut passed = 0; + let mut failed = 0; + + for test in results.unit.iter().chain(results.integration.iter()) { + total += 1; + match test.status { + TestStatus::Pass => passed += 1, + TestStatus::Fail => failed += 1, + } + } + + TestRunSummary { + total, + passed, + failed, + } +} + +pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision { + let summary = summarize_results(results); + + if summary.failed == 0 && summary.total > 0 { + return AcceptanceDecision { + can_accept: true, + reasons: Vec::new(), + warning: None, + }; + } + + let mut reasons = Vec::new(); + if summary.total == 0 { + reasons.push("No test results recorded for the story.".to_string()); + } + if summary.failed > 0 { + reasons.push(format!( + "{} test(s) are failing; acceptance is blocked.", + summary.failed + )); + } + + let warning = if summary.failed > 1 { + Some(format!( + "Multiple tests are failing ({} failures).", + summary.failed + )) + } else { + None + }; + + AcceptanceDecision { + can_accept: false, + reasons, + warning, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn warns_when_multiple_tests_fail() { + let results = StoryTestResults { + unit: vec![ + TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Fail, + details: None, + }, + TestCaseResult { + name: "unit-2".to_string(), + status: TestStatus::Fail, + details: None, + }, + ], + integration: vec![TestCaseResult { + name: "integration-1".to_string(), + status: TestStatus::Pass, + details: None, + }], + }; + + let decision = evaluate_acceptance(&results); + + assert!(!decision.can_accept); + assert_eq!( + decision.warning, + Some("Multiple tests are failing (2 failures).".to_string()) + ); + } + + #[test] + fn rejects_recording_multiple_failures() { + let mut state = WorkflowState::default(); + let unit = vec![ + TestCaseResult { + name: "unit-1".to_string(), + status: TestStatus::Fail, + details: None, + }, + TestCaseResult { + name: "unit-2".to_string(), + status: TestStatus::Fail, + details: None, + }, + ]; + let integration = vec![TestCaseResult { + name: "integration-1".to_string(), + status: TestStatus::Pass, + details: None, + }]; + + let result = state.record_test_results_validated("story-26".to_string(), unit, integration); + + assert!(result.is_err()); + } +}