diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ec6b88a..edcb118 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -20,15 +20,42 @@ npm run lint # Check lint errors npm run check # Run all checks (CI) ``` -Pre-commit hooks via Husky will run Biome automatically on staged files. +Git hooks via Husky run automatically: +- **Pre-commit**: Biome (format/lint) + TypeScript type check +- **Pre-push**: Unit tests For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save. +## Testing + +Run tests before submitting PRs: + +```bash +npm run test # Unit tests (Vitest) +npm run test:e2e # E2E tests (Playwright) +``` + +E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`. + +To run a specific test file: +```bash +npx playwright test tests/e2e/diagram-generation.spec.ts +``` + +To run tests with UI mode: +```bash +npx playwright test --ui +``` + ## Pull Requests 1. Create a feature branch -2. Make changes and ensure `npm run check` passes -3. Submit PR against `main` with a clear description +2. Make changes (pre-commit runs lint + type check automatically) +3. Run E2E tests with `npm run test:e2e` +4. Push (pre-push runs unit tests automatically) +5. Submit PR against `main` with a clear description + +CI will run the full test suite on your PR. ## Issues diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8864ef8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,75 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + lint-and-unit: + name: Lint & Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run check + + - name: Run unit tests + run: npm run test -- --run + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + + - name: Install Playwright deps (cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + + - name: Build app + run: npm run build + + - name: Run E2E tests + run: npm run test:e2e + env: + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index e0596a2..62e579f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ packages/*/dist # testing /coverage +/playwright-report/ +/test-results/ # next.js /.next/ diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc5..ef9088d 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npx lint-staged +npx tsc --noEmit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..2674c5f --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +# Skip if node_modules not installed (e.g., on EC2 push server) +if [ -d "node_modules" ]; then + npm run test -- --run +fi diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 55dbbde..872b870 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -18,6 +18,11 @@ import { supportsPromptCaching, } from "@/lib/ai-providers" import { findCachedResponse } from "@/lib/cached-responses" +import { + isMinimalDiagram, + replaceHistoricalToolInputs, + validateFileParts, +} from "@/lib/chat-helpers" import { checkAndIncrementRequest, isQuotaEnabled, @@ -34,93 +39,6 @@ import { getUserIdFromRequest } from "@/lib/user-id" export const maxDuration = 120 -// File upload limits (must match client-side) -const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB -const MAX_FILES = 5 - -// Helper function to validate file parts in messages -function validateFileParts(messages: any[]): { - valid: boolean - error?: string -} { - const lastMessage = messages[messages.length - 1] - const fileParts = - lastMessage?.parts?.filter((p: any) => p.type === "file") || [] - - if (fileParts.length > MAX_FILES) { - return { - valid: false, - error: `Too many files. Maximum ${MAX_FILES} allowed.`, - } - } - - for (const filePart of fileParts) { - // Data URLs format: data:image/png;base64, - // Base64 increases size by ~33%, so we check the decoded size - if (filePart.url?.startsWith("data:")) { - const base64Data = filePart.url.split(",")[1] - if (base64Data) { - const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) - if (sizeInBytes > MAX_FILE_SIZE) { - return { - valid: false, - error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, - } - } - } - } - } - - return { valid: true } -} - -// Helper function to check if diagram is minimal/empty -function isMinimalDiagram(xml: string): boolean { - const stripped = xml.replace(/\s/g, "") - return !stripped.includes('id="2"') -} - -// Helper function to replace historical tool call XML with placeholders -// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) -// Also fixes invalid/undefined inputs from interrupted streaming -function replaceHistoricalToolInputs(messages: any[]): any[] { - return messages.map((msg) => { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) { - return msg - } - const replacedContent = msg.content - .map((part: any) => { - if (part.type === "tool-call") { - const toolName = part.toolName - // Fix invalid/undefined inputs from interrupted streaming - if ( - !part.input || - typeof part.input !== "object" || - Object.keys(part.input).length === 0 - ) { - // Skip tool calls with invalid inputs entirely - return null - } - if ( - toolName === "display_diagram" || - toolName === "edit_diagram" - ) { - return { - ...part, - input: { - placeholder: - "[XML content replaced - see current diagram XML in system context]", - }, - } - } - } - return part - }) - .filter(Boolean) // Remove null entries (invalid tool calls) - return { ...msg, content: replacedContent } - }) -} - // Helper function to create cached stream response function createCachedStreamResponse(xml: string): Response { const toolCallId = `cached-${Date.now()}` diff --git a/components/ai-elements/model-selector.tsx b/components/ai-elements/model-selector.tsx index 92f82fc..1b71cb7 100644 --- a/components/ai-elements/model-selector.tsx +++ b/components/ai-elements/model-selector.tsx @@ -134,6 +134,7 @@ export const ModelSelectorLogo = ({ } return ( + // biome-ignore lint/performance/noImgElement: External URL from models.dev {`${provider} setShowSettingsDialog(true)} className="hover:bg-accent" + data-testid="settings-button" > { - if (!selectedProvider) return + if (!selectedProvider || !selectedProviderId) return // Check credentials based on provider type const isBedrock = selectedProvider.provider === "bedrock" @@ -331,14 +332,14 @@ export function ModelConfigDialog({ const data = await response.json() if (data.valid) { - updateModel(selectedProviderId!, model.id, { + updateModel(selectedProviderId, model.id, { validated: true, validationError: undefined, }) } else { allValid = false errorCount++ - updateModel(selectedProviderId!, model.id, { + updateModel(selectedProviderId, model.id, { validated: false, validationError: data.error || "Validation failed", }) @@ -346,7 +347,7 @@ export function ModelConfigDialog({ } catch { allValid = false errorCount++ - updateModel(selectedProviderId!, model.id, { + updateModel(selectedProviderId, model.id, { validated: false, validationError: "Network error", }) @@ -357,7 +358,7 @@ export function ModelConfigDialog({ if (allValid) { setValidationStatus("success") - updateProvider(selectedProviderId!, { validated: true }) + updateProvider(selectedProviderId, { validated: true }) // Reset to idle after showing success briefly (with cleanup) if (validationResetTimeoutRef.current) { clearTimeout(validationResetTimeoutRef.current) @@ -1298,20 +1299,24 @@ export function ModelConfigDialog({ null, ) } - updateModel( - selectedProviderId!, - model.id, - { - modelId: - e - .target - .value, - validated: - undefined, - validationError: - undefined, - }, - ) + if ( + selectedProviderId + ) { + updateModel( + selectedProviderId, + model.id, + { + modelId: + e + .target + .value, + validated: + undefined, + validationError: + undefined, + }, + ) + } }} onKeyDown={( e, diff --git a/edge-functions/api/edgeai/chat/completions.ts b/edge-functions/api/edgeai/chat/completions.ts index fd62f80..eafd4de 100644 --- a/edge-functions/api/edgeai/chat/completions.ts +++ b/edge-functions/api/edgeai/chat/completions.ts @@ -99,7 +99,7 @@ function handleOptionsRequest(): Response { }) } -export async function onRequest({ request, env }: any) { +export async function onRequest({ request, env: _env }: any) { if (request.method === "OPTIONS") { return handleOptionsRequest() } diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 8e27be6..9f30db2 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -573,8 +573,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { const bedrockProvider = hasClientCredentials ? createAmazonBedrock({ region: bedrockRegion, - accessKeyId: overrides.awsAccessKeyId!, - secretAccessKey: overrides.awsSecretAccessKey!, + accessKeyId: overrides.awsAccessKeyId as string, + secretAccessKey: overrides.awsSecretAccessKey as string, ...(overrides?.awsSessionToken && { sessionToken: overrides.awsSessionToken, }), diff --git a/lib/chat-helpers.ts b/lib/chat-helpers.ts new file mode 100644 index 0000000..e9c054f --- /dev/null +++ b/lib/chat-helpers.ts @@ -0,0 +1,89 @@ +// Shared helper functions for chat route +// Exported for testing + +// File upload limits (must match client-side) +export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB +export const MAX_FILES = 5 + +// Helper function to validate file parts in messages +export function validateFileParts(messages: any[]): { + valid: boolean + error?: string +} { + const lastMessage = messages[messages.length - 1] + const fileParts = + lastMessage?.parts?.filter((p: any) => p.type === "file") || [] + + if (fileParts.length > MAX_FILES) { + return { + valid: false, + error: `Too many files. Maximum ${MAX_FILES} allowed.`, + } + } + + for (const filePart of fileParts) { + // Data URLs format: data:image/png;base64, + // Base64 increases size by ~33%, so we check the decoded size + if (filePart.url?.startsWith("data:")) { + const base64Data = filePart.url.split(",")[1] + if (base64Data) { + const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) + if (sizeInBytes > MAX_FILE_SIZE) { + return { + valid: false, + error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`, + } + } + } + } + } + + return { valid: true } +} + +// Helper function to check if diagram is minimal/empty +export function isMinimalDiagram(xml: string): boolean { + const stripped = xml.replace(/\s/g, "") + return !stripped.includes('id="2"') +} + +// Helper function to replace historical tool call XML with placeholders +// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) +// Also fixes invalid/undefined inputs from interrupted streaming +export function replaceHistoricalToolInputs(messages: any[]): any[] { + return messages.map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) { + return msg + } + const replacedContent = msg.content + .map((part: any) => { + if (part.type === "tool-call") { + const toolName = part.toolName + // Fix invalid/undefined inputs from interrupted streaming + if ( + !part.input || + typeof part.input !== "object" || + Object.keys(part.input).length === 0 + ) { + // Skip tool calls with invalid inputs entirely + return null + } + if ( + toolName === "display_diagram" || + toolName === "edit_diagram" + ) { + return { + ...part, + input: { + placeholder: + "[XML content replaced - see current diagram XML in system context]", + }, + } + } + } + return part + }) + .filter(Boolean) // Remove null entries (invalid tool calls) + return { ...msg, content: replacedContent } + }) +} diff --git a/package-lock.json b/package-lock.json index c2ff5d8..f5b23b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "next-ai-draw-io", - "version": "0.4.8", + "version": "0.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "next-ai-draw-io", - "version": "0.4.8", + "version": "0.4.9", "license": "Apache-2.0", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.1", @@ -47,7 +47,6 @@ "cmdk": "^1.1.1", "idb": "^8.0.3", "js-tiktoken": "^1.0.21", - "jsdom": "^27.0.0", "jsonrepair": "^3.13.1", "lucide-react": "^0.562.0", "motion": "^12.23.25", @@ -73,13 +72,19 @@ "devDependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@biomejs/biome": "^2.3.10", + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/negotiator": "^0.6.4", "@types/node": "^24.0.0", "@types/pako": "^2.0.3", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^4.0.16", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^39.2.7", @@ -88,10 +93,13 @@ "eslint": "9.39.2", "eslint-config-next": "16.1.1", "husky": "^9.1.7", + "jsdom": "^27.4.0", "lint-staged": "^16.2.7", "shx": "^0.4.0", "tailwindcss": "^4", "typescript": "^5", + "vite-tsconfig-paths": "^6.0.3", + "vitest": "^4.0.16", "wait-on": "^9.0.3", "wrangler": "4.54.0" }, @@ -105,6 +113,7 @@ "version": "0.9.30", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, "license": "MIT" }, "node_modules/@ai-sdk/amazon-bedrock": { @@ -317,6 +326,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.4", @@ -330,6 +340,7 @@ "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -339,6 +350,7 @@ "version": "6.7.6", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -352,6 +364,7 @@ "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -361,6 +374,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, "license": "MIT" }, "node_modules/@ast-grep/napi": { @@ -4437,6 +4451,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -4497,6 +4521,48 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -4545,6 +4611,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@biomejs/biome": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.10.tgz", @@ -4853,6 +4929,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, "funding": [ { "type": "github", @@ -4872,6 +4949,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, "funding": [ { "type": "github", @@ -4895,6 +4973,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, "funding": [ { "type": "github", @@ -4922,6 +5001,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, "funding": [ { "type": "github", @@ -4944,6 +5024,7 @@ "version": "1.0.22", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, "funding": [ { "type": "github", @@ -4963,6 +5044,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, "funding": [ { "type": "github", @@ -6332,6 +6414,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz", "integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==", + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -8512,6 +8595,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -9543,6 +9642,321 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -10672,6 +11086,78 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz", @@ -10699,6 +11185,58 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -10712,6 +11250,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -10721,6 +11270,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -11453,6 +12009,170 @@ "node": ">= 20" } }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.8", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", @@ -11561,6 +12281,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -11999,6 +12720,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -12006,6 +12737,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", @@ -12169,6 +12919,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -12634,6 +13385,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -13285,6 +14046,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.12.2", @@ -13311,6 +14073,7 @@ "version": "5.3.5", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.1.1", @@ -13338,6 +14101,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", @@ -13422,6 +14186,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, "license": "MIT" }, "node_modules/decode-named-character-reference": { @@ -13702,6 +14467,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", @@ -14078,6 +14850,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -14240,6 +15013,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -14832,6 +15612,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -15000,6 +15790,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -15774,6 +16574,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -16026,6 +16833,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -16034,6 +16842,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -16075,6 +16890,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -16102,6 +16918,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -16787,6 +17604,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -16989,6 +17807,60 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -17103,6 +17975,7 @@ "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.28", @@ -17708,6 +18581,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -17718,6 +18601,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -18129,6 +19040,7 @@ "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -19930,6 +20842,17 @@ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ollama-ai-provider-v2": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-2.0.0.tgz", @@ -20239,6 +21162,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -20383,6 +21307,53 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", @@ -20419,9 +21390,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -20439,7 +21410,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -20501,6 +21472,51 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prism-react-renderer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", @@ -20636,6 +21652,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20803,6 +21820,16 @@ "react": ">=18" } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", @@ -21046,6 +22073,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21211,6 +22239,48 @@ "node": ">=8.0" } }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -21374,6 +22444,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -21773,6 +22844,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -21967,6 +23045,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", @@ -21986,6 +23071,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -22413,6 +23505,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, "license": "MIT" }, "node_modules/tailwind-merge": { @@ -22651,6 +23744,23 @@ "semver": "bin/semver" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -22699,10 +23809,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.19" @@ -22715,6 +23836,7 @@ "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, "license": "MIT" }, "node_modules/tmp": { @@ -22763,6 +23885,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -22775,6 +23898,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -22842,6 +23966,27 @@ "integrity": "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ==", "license": "MIT" }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -23460,10 +24605,228 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.0.3.tgz", + "integrity": "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -23515,6 +24878,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -23524,6 +24888,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -23533,6 +24898,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, "license": "MIT", "dependencies": { "tr46": "^6.0.0", @@ -23646,6 +25012,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -24297,6 +25680,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24318,6 +25702,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -24337,6 +25722,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, "license": "MIT" }, "node_modules/y18n": { diff --git a/package.json b/package.json index 984d041..ef33436 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac", "dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win", "dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux", - "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux" + "dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux", + "test": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.1", @@ -66,7 +68,6 @@ "cmdk": "^1.1.1", "idb": "^8.0.3", "js-tiktoken": "^1.0.21", - "jsdom": "^27.0.0", "jsonrepair": "^3.13.1", "lucide-react": "^0.562.0", "motion": "^12.23.25", @@ -103,13 +104,19 @@ "devDependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "@biomejs/biome": "^2.3.10", + "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/negotiator": "^0.6.4", "@types/node": "^24.0.0", "@types/pako": "^2.0.3", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.2", + "@vitest/coverage-v8": "^4.0.16", "concurrently": "^9.2.1", "cross-env": "^10.1.0", "electron": "^39.2.7", @@ -118,10 +125,13 @@ "eslint": "9.39.2", "eslint-config-next": "16.1.1", "husky": "^9.1.7", + "jsdom": "^27.4.0", "lint-staged": "^16.2.7", "shx": "^0.4.0", "tailwindcss": "^4", "typescript": "^5", + "vite-tsconfig-paths": "^6.0.3", + "vitest": "^4.0.16", "wait-on": "^9.0.3", "wrangler": "4.54.0" }, diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..012b8cd --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [["list"], ["html"]] : "html", + webServer: { + command: process.env.CI ? "npm run start" : "npm run dev", + port: process.env.CI ? 6001 : 6002, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + use: { + baseURL: process.env.CI + ? "http://localhost:6001" + : "http://localhost:6002", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}) diff --git a/tests/e2e/chat.spec.ts b/tests/e2e/chat.spec.ts new file mode 100644 index 0000000..b2d125a --- /dev/null +++ b/tests/e2e/chat.spec.ts @@ -0,0 +1,22 @@ +import { expect, getIframe, test } from "./lib/fixtures" + +test.describe("Chat Panel", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("page has interactive elements", async ({ page }) => { + const buttons = page.locator("button") + const count = await buttons.count() + expect(count).toBeGreaterThan(0) + }) + + test("draw.io iframe is interactive", async ({ page }) => { + const iframe = getIframe(page) + await expect(iframe).toBeVisible() + + const src = await iframe.getAttribute("src") + expect(src).toBeTruthy() + }) +}) diff --git a/tests/e2e/copy-paste.spec.ts b/tests/e2e/copy-paste.spec.ts new file mode 100644 index 0000000..bfe8124 --- /dev/null +++ b/tests/e2e/copy-paste.spec.ts @@ -0,0 +1,137 @@ +import { SINGLE_BOX_XML } from "./fixtures/diagrams" +import { + expect, + getChatInput, + getIframe, + sendMessage, + test, +} from "./lib/fixtures" +import { createMockSSEResponse } from "./lib/helpers" + +test.describe("Copy/Paste Functionality", () => { + test("can paste text into chat input", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await chatInput.focus() + await page.keyboard.insertText("Create a flowchart diagram") + + await expect(chatInput).toHaveValue("Create a flowchart diagram") + }) + + test("can paste multiline text into chat input", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await chatInput.focus() + const multilineText = "Line 1\nLine 2\nLine 3" + await page.keyboard.insertText(multilineText) + + await expect(chatInput).toHaveValue(multilineText) + }) + + test("copy button copies response text", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "Here is your diagram with a test box.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Create a test box") + + // Wait for response + await expect( + page.locator('text="Here is your diagram with a test box."'), + ).toBeVisible({ timeout: 15000 }) + + // Find copy button in message + const copyButton = page.locator( + '[data-testid="copy-button"], button[aria-label*="Copy"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)', + ) + + // Copy button feature may not exist - skip if not available + const buttonCount = await copyButton.count() + if (buttonCount === 0) { + test.skip() + return + } + + await copyButton.first().click() + await expect( + page.locator('text="Copied"').or(page.locator("svg.lucide-check")), + ).toBeVisible({ timeout: 3000 }) + }) + + test("keyboard shortcuts work in chat input", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await chatInput.fill("Hello world") + await chatInput.press("ControlOrMeta+a") + await chatInput.fill("New text") + + await expect(chatInput).toHaveValue("New text") + }) + + test("can undo/redo in chat input", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await chatInput.fill("First text") + await chatInput.press("Tab") + + await chatInput.focus() + await chatInput.fill("Second text") + await chatInput.press("ControlOrMeta+z") + + // Verify page is still functional after undo + await expect(chatInput).toBeVisible() + }) + + test("chat input handles special characters", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + const specialText = "Test <>&\"' special chars 日本語 中文 🎉" + await chatInput.fill(specialText) + + await expect(chatInput).toHaveValue(specialText) + }) + + test("long text in chat input scrolls", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + const longText = "This is a very long text. ".repeat(50) + await chatInput.fill(longText) + + const value = await chatInput.inputValue() + expect(value.length).toBeGreaterThan(500) + }) +}) diff --git a/tests/e2e/diagram-generation.spec.ts b/tests/e2e/diagram-generation.spec.ts new file mode 100644 index 0000000..8162964 --- /dev/null +++ b/tests/e2e/diagram-generation.spec.ts @@ -0,0 +1,128 @@ +import { + CAT_DIAGRAM_XML, + FLOWCHART_XML, + NEW_NODE_XML, +} from "./fixtures/diagrams" +import { + createMultiTurnMock, + expect, + getChatInput, + sendMessage, + test, + waitForComplete, + waitForCompleteCount, +} from "./lib/fixtures" +import { createMockSSEResponse } from "./lib/helpers" + +test.describe("Diagram Generation", () => { + test.beforeEach(async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + CAT_DIAGRAM_XML, + "I'll create a diagram for you.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + }) + + test("generates and displays a diagram", async ({ page }) => { + await sendMessage(page, "Draw a cat") + await expect(page.locator('text="Generate Diagram"')).toBeVisible({ + timeout: 15000, + }) + await waitForComplete(page) + }) + + test("chat input clears after sending", async ({ page }) => { + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await chatInput.fill("Draw a cat") + await chatInput.press("ControlOrMeta+Enter") + + await expect(chatInput).toHaveValue("", { timeout: 5000 }) + }) + + test("user message appears in chat", async ({ page }) => { + await sendMessage(page, "Draw a cute cat") + await expect(page.locator('text="Draw a cute cat"')).toBeVisible({ + timeout: 10000, + }) + }) + + test("assistant text message appears in chat", async ({ page }) => { + await sendMessage(page, "Draw a cat") + await expect( + page.locator('text="I\'ll create a diagram for you."'), + ).toBeVisible({ timeout: 10000 }) + }) +}) + +test.describe("Diagram Edit", () => { + test.beforeEach(async ({ page }) => { + await page.route( + "**/api/chat", + createMultiTurnMock([ + { xml: FLOWCHART_XML, text: "I'll create a diagram for you." }, + { + xml: FLOWCHART_XML.replace("Process", "Updated Process"), + text: "I'll create a diagram for you.", + }, + ]), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + }) + + test("can edit an existing diagram", async ({ page }) => { + // First: create initial diagram + await sendMessage(page, "Create a flowchart") + await waitForComplete(page) + + // Second: edit the diagram + await sendMessage(page, "Change Process to Updated Process") + await waitForCompleteCount(page, 2) + }) +}) + +test.describe("Diagram Append", () => { + test.beforeEach(async ({ page }) => { + await page.route( + "**/api/chat", + createMultiTurnMock([ + { xml: FLOWCHART_XML, text: "I'll create a diagram for you." }, + { + xml: NEW_NODE_XML, + text: "I'll create a diagram for you.", + toolName: "append_diagram", + }, + ]), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + }) + + test("can append to an existing diagram", async ({ page }) => { + // First: create initial diagram + await sendMessage(page, "Create a flowchart") + await waitForComplete(page) + + // Second: append to diagram + await sendMessage(page, "Add a new node to the right") + await waitForCompleteCount(page, 2) + }) +}) diff --git a/tests/e2e/error-handling.spec.ts b/tests/e2e/error-handling.spec.ts new file mode 100644 index 0000000..e5dd5f2 --- /dev/null +++ b/tests/e2e/error-handling.spec.ts @@ -0,0 +1,136 @@ +import { TRUNCATED_XML } from "./fixtures/diagrams" +import { + createErrorMock, + expect, + getChatInput, + getIframe, + sendMessage, + test, +} from "./lib/fixtures" + +test.describe("Error Handling", () => { + test("displays error message when API returns 500", async ({ page }) => { + await page.route( + "**/api/chat", + createErrorMock(500, "Internal server error"), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Draw a cat") + + // Should show error indication + const errorIndicator = page + .locator('[role="alert"]') + .or(page.locator("[data-sonner-toast]")) + .or(page.locator("text=/error|failed|something went wrong/i")) + await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) + + // User should be able to type again + const chatInput = getChatInput(page) + await chatInput.fill("Retry message") + await expect(chatInput).toHaveValue("Retry message") + }) + + test("displays error message when API returns 429 rate limit", async ({ + page, + }) => { + await page.route( + "**/api/chat", + createErrorMock(429, "Rate limit exceeded"), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Draw a cat") + + // Should show error indication for rate limit + const errorIndicator = page + .locator('[role="alert"]') + .or(page.locator("[data-sonner-toast]")) + .or(page.locator("text=/rate limit|too many|try again/i")) + await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) + + // User should be able to type again + const chatInput = getChatInput(page) + await chatInput.fill("Retry after rate limit") + await expect(chatInput).toHaveValue("Retry after rate limit") + }) + + test("handles network timeout gracefully", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2000)) + await route.abort("timedout") + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Draw a cat") + + // Should show error indication for network failure + const errorIndicator = page + .locator('[role="alert"]') + .or(page.locator("[data-sonner-toast]")) + .or(page.locator("text=/error|failed|network|timeout/i")) + await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 }) + + // After timeout, user should be able to type again + const chatInput = getChatInput(page) + await chatInput.fill("Try again after timeout") + await expect(chatInput).toHaveValue("Try again after timeout") + }) + + test("shows truncated badge for incomplete XML", async ({ page }) => { + const toolCallId = `call_${Date.now()}` + const textId = `text_${Date.now()}` + const messageId = `msg_${Date.now()}` + + const events = [ + { type: "start", messageId }, + { type: "text-start", id: textId }, + { type: "text-delta", id: textId, delta: "Creating diagram..." }, + { type: "text-end", id: textId }, + { + type: "tool-input-start", + toolCallId, + toolName: "display_diagram", + }, + { + type: "tool-input-available", + toolCallId, + toolName: "display_diagram", + input: { xml: TRUNCATED_XML }, + }, + { + type: "tool-output-error", + toolCallId, + error: "XML validation failed", + }, + { type: "finish" }, + ] + + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: + events + .map((e) => `data: ${JSON.stringify(e)}\n\n`) + .join("") + "data: [DONE]\n\n", + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Draw something") + + // Should show truncated badge + await expect(page.locator('text="Truncated"')).toBeVisible({ + timeout: 15000, + }) + }) +}) diff --git a/tests/e2e/file-upload.spec.ts b/tests/e2e/file-upload.spec.ts new file mode 100644 index 0000000..6348a75 --- /dev/null +++ b/tests/e2e/file-upload.spec.ts @@ -0,0 +1,152 @@ +import { SINGLE_BOX_XML } from "./fixtures/diagrams" +import { + expect, + getChatInput, + getIframe, + sendMessage, + test, +} from "./lib/fixtures" +import { createMockSSEResponse } from "./lib/helpers" + +test.describe("File Upload", () => { + test("upload button opens file picker", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const uploadButton = page.locator( + 'button[aria-label="Upload file"], button:has(svg.lucide-image)', + ) + await expect(uploadButton.first()).toBeVisible({ timeout: 10000 }) + await expect(uploadButton.first()).toBeEnabled() + }) + + test("shows file preview after selecting image", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const fileInput = page.locator('input[type="file"]') + + await fileInput.setInputFiles({ + name: "test-image.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }) + + await expect( + page.locator('[role="alert"][data-type="error"]'), + ).not.toBeVisible({ timeout: 2000 }) + }) + + test("can remove uploaded file", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const fileInput = page.locator('input[type="file"]') + + await fileInput.setInputFiles({ + name: "test-image.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }) + + await expect( + page.locator('[role="alert"][data-type="error"]'), + ).not.toBeVisible({ timeout: 2000 }) + + const removeButton = page.locator( + '[data-testid="remove-file-button"], button[aria-label*="Remove"], button:has(svg.lucide-x)', + ) + + const removeButtonCount = await removeButton.count() + if (removeButtonCount === 0) { + test.skip() + return + } + + await removeButton.first().click() + await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 }) + }) + + test("sends file with message to API", async ({ page }) => { + let capturedRequest: any = null + + await page.route("**/api/chat", async (route) => { + capturedRequest = route.request() + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "Based on your image, here is a diagram:", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const fileInput = page.locator('input[type="file"]') + + await fileInput.setInputFiles({ + name: "architecture.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + }) + + await sendMessage(page, "Convert this to a diagram") + + await expect( + page.locator('text="Based on your image, here is a diagram:"'), + ).toBeVisible({ timeout: 15000 }) + + expect(capturedRequest).not.toBeNull() + }) + + test("shows error for oversized file", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const fileInput = page.locator('input[type="file"]') + const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x") + + await fileInput.setInputFiles({ + name: "large-image.png", + mimeType: "image/png", + buffer: largeBuffer, + }) + + await expect( + page.locator('[role="alert"], [data-sonner-toast]').first(), + ).toBeVisible({ timeout: 5000 }) + }) + + test("drag and drop file upload works", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatForm = page.locator("form").first() + + const dataTransfer = await page.evaluateHandle(() => { + const dt = new DataTransfer() + const file = new File(["test content"], "dropped-image.png", { + type: "image/png", + }) + dt.items.add(file) + return dt + }) + + await chatForm.dispatchEvent("dragover", { dataTransfer }) + await chatForm.dispatchEvent("drop", { dataTransfer }) + + await expect(getChatInput(page)).toBeVisible({ timeout: 3000 }) + }) +}) diff --git a/tests/e2e/fixtures/diagrams.ts b/tests/e2e/fixtures/diagrams.ts new file mode 100644 index 0000000..7009253 --- /dev/null +++ b/tests/e2e/fixtures/diagrams.ts @@ -0,0 +1,50 @@ +/** + * Shared XML diagram fixtures for E2E tests + */ + +// Simple cat diagram +export const CAT_DIAGRAM_XML = ` + + + + +` + +// Simple flowchart +export const FLOWCHART_XML = ` + + + + + + + +` + +// Simple single box +export const SINGLE_BOX_XML = ` + +` + +// Test node for iframe interaction tests +export const TEST_NODE_XML = ` + +` + +// Architecture box +export const ARCHITECTURE_XML = ` + +` + +// New node for append tests +export const NEW_NODE_XML = ` + +` + +// Truncated XML for error tests +export const TRUNCATED_XML = ` + + `` diff --git a/tests/e2e/history-restore.spec.ts b/tests/e2e/history-restore.spec.ts new file mode 100644 index 0000000..2fce515 --- /dev/null +++ b/tests/e2e/history-restore.spec.ts @@ -0,0 +1,215 @@ +import { SINGLE_BOX_XML } from "./fixtures/diagrams" +import { + expect, + expectBeforeAndAfterReload, + getChatInput, + getIframe, + getIframeContent, + openSettings, + sendMessage, + test, + waitForComplete, + waitForText, +} from "./lib/fixtures" +import { createMockSSEResponse } from "./lib/helpers" + +test.describe("History and Session Restore", () => { + test("new chat button clears conversation", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "Created your test diagram.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await test.step("create a conversation", async () => { + await sendMessage(page, "Create a test diagram") + await waitForText(page, "Created your test diagram.") + }) + + await test.step("click new chat button", async () => { + const newChatButton = page.locator( + '[data-testid="new-chat-button"]', + ) + await expect(newChatButton).toBeVisible({ timeout: 5000 }) + await newChatButton.click() + }) + + await test.step("verify conversation is cleared", async () => { + await expect( + page.locator('text="Created your test diagram."'), + ).not.toBeVisible({ timeout: 5000 }) + }) + }) + + test("chat history sidebar shows past conversations", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const historyButton = page.locator( + 'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])', + ) + + const buttonCount = await historyButton.count() + if (buttonCount === 0) { + test.skip() + return + } + + await historyButton.first().click() + await expect(getChatInput(page)).toBeVisible({ timeout: 3000 }) + }) + + test("conversation persists after page reload", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "This message should persist.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await test.step("create conversation", async () => { + await sendMessage(page, "Create persistent diagram") + await waitForText(page, "This message should persist.") + }) + + await test.step("verify message appears before reload", async () => { + await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) + await expect( + page.locator('text="This message should persist."'), + ).toBeVisible({ timeout: 10000 }) + }) + + // Note: After reload, mocked responses won't persist since we're not + // testing with real localStorage. We just verify the app loads correctly. + await test.step("verify app loads after reload", async () => { + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) + }) + }) + + test("diagram state persists after reload", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "Created a diagram that should be saved.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Create saveable diagram") + await waitForComplete(page) + + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const frame = getIframeContent(page) + await expect( + frame + .locator(".geMenubarContainer, .geDiagramContainer, canvas") + .first(), + ).toBeVisible({ timeout: 30000 }) + }) + + test("can restore from browser back/forward", async ({ page }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + SINGLE_BOX_XML, + "Testing browser navigation.", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Test navigation") + await waitForText(page, "Testing browser navigation.") + + await page.goto("/about", { waitUntil: "networkidle" }) + await page.goBack({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) + }) + + test("settings are restored after reload", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + await page.keyboard.press("Escape") + + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + }) + + test("model selection persists", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const modelSelector = page.locator( + 'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")', + ) + + const selectorCount = await modelSelector.count() + if (selectorCount === 0) { + test.skip() + return + } + + const initialModel = await modelSelector.first().textContent() + + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const modelAfterReload = await modelSelector.first().textContent() + expect(modelAfterReload).toBe(initialModel) + }) + + test("handles localStorage quota exceeded gracefully", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await page.evaluate(() => { + try { + const largeData = "x".repeat(5 * 1024 * 1024) + localStorage.setItem("test-large-data", largeData) + } catch { + // Expected to fail on some browsers + } + }) + + await expect(getChatInput(page)).toBeVisible({ timeout: 10000 }) + + await page.evaluate(() => { + localStorage.removeItem("test-large-data") + }) + }) +}) diff --git a/tests/e2e/history.spec.ts b/tests/e2e/history.spec.ts new file mode 100644 index 0000000..bbd55e6 --- /dev/null +++ b/tests/e2e/history.spec.ts @@ -0,0 +1,18 @@ +import { expect, getIframe, test } from "./lib/fixtures" + +test.describe("History Dialog", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("history button exists in UI", async ({ page }) => { + // History button may be disabled initially (no history) + // Just verify it exists in the DOM + const historyButton = page + .locator("button") + .filter({ has: page.locator("svg") }) + const count = await historyButton.count() + expect(count).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/iframe-interaction.spec.ts b/tests/e2e/iframe-interaction.spec.ts new file mode 100644 index 0000000..134dabd --- /dev/null +++ b/tests/e2e/iframe-interaction.spec.ts @@ -0,0 +1,122 @@ +import { TEST_NODE_XML } from "./fixtures/diagrams" +import { + expect, + getChatInput, + getIframe, + getIframeContent, + sendMessage, + test, + waitForComplete, +} from "./lib/fixtures" +import { createMockSSEResponse } from "./lib/helpers" + +test.describe("Iframe Interaction", () => { + test("draw.io iframe loads successfully", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + + const iframe = getIframe(page) + await expect(iframe).toBeVisible({ timeout: 30000 }) + + // iframe should have loaded draw.io content + const frame = getIframeContent(page) + await expect( + frame + .locator(".geMenubarContainer, .geDiagramContainer, canvas") + .first(), + ).toBeVisible({ timeout: 30000 }) + }) + + test("can interact with draw.io toolbar", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const frame = getIframeContent(page) + + // Draw.io menu items should be accessible + await expect( + frame + .locator('text="Diagram"') + .or(frame.locator('[title*="Diagram"]')), + ).toBeVisible({ timeout: 10000 }) + }) + + test("diagram XML is rendered in iframe after generation", async ({ + page, + }) => { + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + TEST_NODE_XML, + "Here is your diagram:", + ), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await sendMessage(page, "Create a test node") + await waitForComplete(page) + + // Give draw.io time to render + await page.waitForTimeout(1000) + }) + + test("zoom controls work in draw.io", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const frame = getIframeContent(page) + + // draw.io should be loaded and functional - check for diagram container + await expect( + frame.locator(".geDiagramContainer, canvas").first(), + ).toBeVisible({ timeout: 10000 }) + }) + + test("can resize the panel divider", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + // Find the resizer/divider between panels + const resizer = page.locator( + '[role="separator"], [data-panel-resize-handle-id], .resize-handle', + ) + + if ((await resizer.count()) > 0) { + await expect(resizer.first()).toBeVisible() + + const box = await resizer.first().boundingBox() + if (box) { + await page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2, + ) + await page.mouse.down() + await page.mouse.move(box.x + 50, box.y + box.height / 2) + await page.mouse.up() + } + } + }) + + test("iframe responds to window resize", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const iframe = getIframe(page) + const initialBox = await iframe.boundingBox() + + // Resize window + await page.setViewportSize({ width: 800, height: 600 }) + await page.waitForTimeout(500) + + const newBox = await iframe.boundingBox() + + expect(newBox).toBeDefined() + if (initialBox && newBox) { + expect(newBox.width).toBeLessThanOrEqual(800) + } + }) +}) diff --git a/tests/e2e/keyboard.spec.ts b/tests/e2e/keyboard.spec.ts new file mode 100644 index 0000000..236b298 --- /dev/null +++ b/tests/e2e/keyboard.spec.ts @@ -0,0 +1,26 @@ +import { expect, getIframe, openSettings, test } from "./lib/fixtures" + +test.describe("Keyboard Interactions", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("Escape closes settings dialog", async ({ page }) => { + await openSettings(page) + + const dialog = page.locator('[role="dialog"]') + await expect(dialog).toBeVisible({ timeout: 5000 }) + + await page.keyboard.press("Escape") + await expect(dialog).not.toBeVisible({ timeout: 2000 }) + }) + + test("page is keyboard accessible", async ({ page }) => { + const focusableElements = page.locator( + 'button, [tabindex="0"], input, textarea, a[href]', + ) + const count = await focusableElements.count() + expect(count).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/language.spec.ts b/tests/e2e/language.spec.ts new file mode 100644 index 0000000..145b855 --- /dev/null +++ b/tests/e2e/language.spec.ts @@ -0,0 +1,105 @@ +import { + expect, + expectBeforeAndAfterReload, + getChatInput, + getIframe, + openSettings, + sleep, + test, +} from "./lib/fixtures" + +test.describe("Language Switching", () => { + test("loads English by default", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + + await expect(page.locator('button:has-text("Send")')).toBeVisible() + }) + + test("can switch to Japanese", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await test.step("open settings and select Japanese", async () => { + await openSettings(page) + const languageSelector = page.locator('button:has-text("English")') + await languageSelector.first().click() + await page.locator('text="日本語"').click() + }) + + await test.step("verify UI is in Japanese", async () => { + await expect(page.locator('button:has-text("送信")')).toBeVisible({ + timeout: 5000, + }) + }) + }) + + test("can switch to Chinese", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await test.step("open settings and select Chinese", async () => { + await openSettings(page) + const languageSelector = page.locator('button:has-text("English")') + await languageSelector.first().click() + await page.locator('text="中文"').click() + }) + + await test.step("verify UI is in Chinese", async () => { + await expect(page.locator('button:has-text("发送")')).toBeVisible({ + timeout: 5000, + }) + }) + }) + + test("language persists after reload", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await test.step("switch to Japanese", async () => { + await openSettings(page) + const languageSelector = page.locator('button:has-text("English")') + await languageSelector.first().click() + await page.locator('text="日本語"').click() + await page.keyboard.press("Escape") + await sleep(500) + }) + + await test.step("verify Japanese before reload", async () => { + await expect(page.locator('button:has-text("送信")')).toBeVisible({ + timeout: 10000, + }) + }) + + await test.step("reload and verify Japanese persists", async () => { + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + // Wait for hydration and localStorage to be read + await sleep(1000) + await expect(page.locator('button:has-text("送信")')).toBeVisible({ + timeout: 10000, + }) + }) + }) + + test("Japanese locale URL works", async ({ page }) => { + await page.goto("/ja", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await expect(page.locator('button:has-text("送信")')).toBeVisible({ + timeout: 10000, + }) + }) + + test("Chinese locale URL works", async ({ page }) => { + await page.goto("/zh", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await expect(page.locator('button:has-text("发送")')).toBeVisible({ + timeout: 10000, + }) + }) +}) diff --git a/tests/e2e/lib/fixtures.ts b/tests/e2e/lib/fixtures.ts new file mode 100644 index 0000000..f63f3ac --- /dev/null +++ b/tests/e2e/lib/fixtures.ts @@ -0,0 +1,208 @@ +/** + * Playwright test fixtures for E2E tests + * Uses test.extend to provide common setup and helpers + */ + +import { test as base, expect, type Page, type Route } from "@playwright/test" +import { createMockSSEResponse, createTextOnlyResponse } from "./helpers" + +/** + * Extended test with common fixtures + */ +export const test = base.extend<{ + /** Page with iframe already loaded */ + appPage: Page +}>({ + appPage: async ({ page }, use) => { + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + await use(page) + }, +}) + +export { expect } + +// ============================================ +// Locator helpers +// ============================================ + +/** Get the chat input textarea */ +export function getChatInput(page: Page) { + return page.locator('textarea[aria-label="Chat input"]') +} + +/** Get the draw.io iframe */ +export function getIframe(page: Page) { + return page.locator("iframe") +} + +/** Get the iframe's frame locator for internal queries */ +export function getIframeContent(page: Page) { + return page.frameLocator("iframe") +} + +/** Get the settings button */ +export function getSettingsButton(page: Page) { + return page.locator('[data-testid="settings-button"]') +} + +// ============================================ +// Action helpers +// ============================================ + +/** Send a message in the chat input */ +export async function sendMessage(page: Page, message: string) { + const chatInput = getChatInput(page) + await expect(chatInput).toBeVisible({ timeout: 10000 }) + await chatInput.fill(message) + await chatInput.press("ControlOrMeta+Enter") +} + +/** Wait for diagram generation to complete */ +export async function waitForComplete(page: Page, timeout = 15000) { + await expect(page.locator('text="Complete"')).toBeVisible({ timeout }) +} + +/** Wait for N "Complete" badges */ +export async function waitForCompleteCount( + page: Page, + count: number, + timeout = 15000, +) { + await expect(page.locator('text="Complete"')).toHaveCount(count, { + timeout, + }) +} + +/** Wait for a specific text to appear */ +export async function waitForText(page: Page, text: string, timeout = 15000) { + await expect(page.locator(`text="${text}"`)).toBeVisible({ timeout }) +} + +/** Open settings dialog */ +export async function openSettings(page: Page) { + await getSettingsButton(page).click() + await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 }) +} + +// ============================================ +// Mock helpers +// ============================================ + +interface MockResponse { + xml: string + text: string + toolName?: string +} + +/** + * Create a multi-turn mock handler + * Each request gets the next response in the array + */ +export function createMultiTurnMock(responses: MockResponse[]) { + let requestCount = 0 + return async (route: Route) => { + const response = + responses[requestCount] || responses[responses.length - 1] + requestCount++ + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse( + response.xml, + response.text, + response.toolName, + ), + }) + } +} + +/** + * Create a mock that returns text-only responses + */ +export function createTextOnlyMock(responses: string[]) { + let requestCount = 0 + return async (route: Route) => { + const text = responses[requestCount] || responses[responses.length - 1] + requestCount++ + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createTextOnlyResponse(text), + }) + } +} + +/** + * Create a mock that alternates between text and diagram responses + */ +export function createMixedMock( + responses: Array< + | { type: "text"; text: string } + | { type: "diagram"; xml: string; text: string } + >, +) { + let requestCount = 0 + return async (route: Route) => { + const response = + responses[requestCount] || responses[responses.length - 1] + requestCount++ + if (response.type === "text") { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createTextOnlyResponse(response.text), + }) + } else { + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createMockSSEResponse(response.xml, response.text), + }) + } + } +} + +/** + * Create a mock that returns an error + */ +export function createErrorMock(status: number, error: string) { + return async (route: Route) => { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ error }), + }) + } +} + +// ============================================ +// Persistence helpers +// ============================================ + +/** + * Test that state persists across page reload. + * Runs assertions before reload, reloads page, then runs assertions again. + * Keep assertions narrow and explicit - test one specific thing. + * + * @param page - Playwright page + * @param description - What persistence is being tested (for debugging) + * @param assertion - Async function with expect() calls + */ +export async function expectBeforeAndAfterReload( + page: Page, + description: string, + assertion: () => Promise, +) { + await test.step(`verify ${description} before reload`, assertion) + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + await test.step(`verify ${description} after reload`, assertion) +} + +/** Simple sleep helper */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/tests/e2e/lib/helpers.ts b/tests/e2e/lib/helpers.ts new file mode 100644 index 0000000..70cbaed --- /dev/null +++ b/tests/e2e/lib/helpers.ts @@ -0,0 +1,88 @@ +/** + * Shared test helpers for E2E tests + */ + +/** + * Creates a mock SSE response for the chat API + * Format matches AI SDK UI message stream protocol + */ +export function createMockSSEResponse( + xml: string, + text: string, + toolName = "display_diagram", +) { + const messageId = `msg_${Date.now()}` + const toolCallId = `call_${Date.now()}` + const textId = `text_${Date.now()}` + + const events = [ + { type: "start", messageId }, + { type: "text-start", id: textId }, + { type: "text-delta", id: textId, delta: text }, + { type: "text-end", id: textId }, + { type: "tool-input-start", toolCallId, toolName }, + { type: "tool-input-available", toolCallId, toolName, input: { xml } }, + { + type: "tool-output-available", + toolCallId, + output: "Successfully displayed the diagram", + }, + { type: "finish" }, + ] + + return ( + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + + "data: [DONE]\n\n" + ) +} + +/** + * Creates a text-only SSE response (no tool call) + */ +export function createTextOnlyResponse(text: string) { + const messageId = `msg_${Date.now()}` + const textId = `text_${Date.now()}` + + const events = [ + { type: "start", messageId }, + { type: "text-start", id: textId }, + { type: "text-delta", id: textId, delta: text }, + { type: "text-end", id: textId }, + { type: "finish" }, + ] + + return ( + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + + "data: [DONE]\n\n" + ) +} + +/** + * Creates a mock SSE response with a tool error + */ +export function createToolErrorResponse(text: string, errorMessage: string) { + const messageId = `msg_${Date.now()}` + const toolCallId = `call_${Date.now()}` + const textId = `text_${Date.now()}` + + const events = [ + { type: "start", messageId }, + { type: "text-start", id: textId }, + { type: "text-delta", id: textId, delta: text }, + { type: "text-end", id: textId }, + { type: "tool-input-start", toolCallId, toolName: "display_diagram" }, + { + type: "tool-input-available", + toolCallId, + toolName: "display_diagram", + input: { xml: "" }, + }, + { type: "tool-output-error", toolCallId, error: errorMessage }, + { type: "finish" }, + ] + + return ( + events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + + "data: [DONE]\n\n" + ) +} diff --git a/tests/e2e/model-config.spec.ts b/tests/e2e/model-config.spec.ts new file mode 100644 index 0000000..9b974ec --- /dev/null +++ b/tests/e2e/model-config.spec.ts @@ -0,0 +1,19 @@ +import { expect, getIframe, openSettings, test } from "./lib/fixtures" + +test.describe("Model Configuration", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("settings dialog opens and shows configuration options", async ({ + page, + }) => { + await openSettings(page) + + const dialog = page.locator('[role="dialog"]') + const buttons = dialog.locator("button") + const buttonCount = await buttons.count() + expect(buttonCount).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/multi-turn.spec.ts b/tests/e2e/multi-turn.spec.ts new file mode 100644 index 0000000..a58ba22 --- /dev/null +++ b/tests/e2e/multi-turn.spec.ts @@ -0,0 +1,113 @@ +import { ARCHITECTURE_XML, createBoxXml } from "./fixtures/diagrams" +import { + createMixedMock, + createMultiTurnMock, + expect, + getChatInput, + sendMessage, + test, + waitForComplete, + waitForText, +} from "./lib/fixtures" +import { createTextOnlyResponse } from "./lib/helpers" + +test.describe("Multi-turn Conversation", () => { + test("handles multiple diagram requests in sequence", async ({ page }) => { + await page.route( + "**/api/chat", + createMultiTurnMock([ + { + xml: createBoxXml("box1", "First"), + text: "Creating diagram 1...", + }, + { + xml: createBoxXml("box2", "Second", 200), + text: "Creating diagram 2...", + }, + ]), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + + // First request + await sendMessage(page, "Draw first box") + await waitForText(page, "Creating diagram 1...") + + // Second request + await sendMessage(page, "Draw second box") + await waitForText(page, "Creating diagram 2...") + + // Both messages should be visible + await expect(page.locator('text="Draw first box"')).toBeVisible() + await expect(page.locator('text="Draw second box"')).toBeVisible() + }) + + test("preserves conversation history", async ({ page }) => { + let requestCount = 0 + await page.route("**/api/chat", async (route) => { + requestCount++ + const request = route.request() + const body = JSON.parse(request.postData() || "{}") + + // Verify messages array grows with each request + if (requestCount === 2) { + expect(body.messages?.length).toBeGreaterThan(1) + } + + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: createTextOnlyResponse(`Response ${requestCount}`), + }) + }) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + + // First message + await sendMessage(page, "Hello") + await waitForText(page, "Response 1") + + // Second message (should include history) + await sendMessage(page, "Follow up question") + await waitForText(page, "Response 2") + }) + + test("can continue after a text-only response", async ({ page }) => { + await page.route( + "**/api/chat", + createMixedMock([ + { + type: "text", + text: "I understand. Let me explain the architecture first.", + }, + { + type: "diagram", + xml: ARCHITECTURE_XML, + text: "Here is the diagram:", + }, + ]), + ) + + await page.goto("/", { waitUntil: "networkidle" }) + await page + .locator("iframe") + .waitFor({ state: "visible", timeout: 30000 }) + + // Ask for explanation first + await sendMessage(page, "Explain the architecture") + await waitForText( + page, + "I understand. Let me explain the architecture first.", + ) + + // Then ask for diagram + await sendMessage(page, "Now show it as a diagram") + await waitForComplete(page) + }) +}) diff --git a/tests/e2e/save.spec.ts b/tests/e2e/save.spec.ts new file mode 100644 index 0000000..995f338 --- /dev/null +++ b/tests/e2e/save.spec.ts @@ -0,0 +1,16 @@ +import { expect, getIframe, test } from "./lib/fixtures" + +test.describe("Save Dialog", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("save/download buttons exist", async ({ page }) => { + const buttons = page + .locator("button") + .filter({ has: page.locator("svg") }) + const count = await buttons.count() + expect(count).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts new file mode 100644 index 0000000..41ea092 --- /dev/null +++ b/tests/e2e/settings.spec.ts @@ -0,0 +1,34 @@ +import { + expect, + getIframe, + getSettingsButton, + openSettings, + test, +} from "./lib/fixtures" + +test.describe("Settings", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("settings dialog opens", async ({ page }) => { + await openSettings(page) + // openSettings already verifies dialog is visible + }) + + test("language selection is available", async ({ page }) => { + await openSettings(page) + + const dialog = page.locator('[role="dialog"]') + await expect(dialog.locator('text="English"')).toBeVisible() + }) + + test("draw.io theme toggle exists", async ({ page }) => { + await openSettings(page) + + const dialog = page.locator('[role="dialog"]') + const themeText = dialog.locator("text=/sketch|minimal/i") + await expect(themeText.first()).toBeVisible() + }) +}) diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..ffc4795 --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,36 @@ +import { expect, getIframe, openSettings, test } from "./lib/fixtures" + +test.describe("Smoke Tests", () => { + test("homepage loads without errors", async ({ page }) => { + const errors: string[] = [] + page.on("pageerror", (err) => errors.push(err.message)) + + await page.goto("/", { waitUntil: "networkidle" }) + await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 }) + + const iframe = getIframe(page) + await expect(iframe).toBeVisible({ timeout: 30000 }) + + expect(errors).toEqual([]) + }) + + test("Japanese locale page loads", async ({ page }) => { + const errors: string[] = [] + page.on("pageerror", (err) => errors.push(err.message)) + + await page.goto("/ja", { waitUntil: "networkidle" }) + await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 }) + + const iframe = getIframe(page) + await expect(iframe).toBeVisible({ timeout: 30000 }) + + expect(errors).toEqual([]) + }) + + test("settings dialog opens", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + }) +}) diff --git a/tests/e2e/theme.spec.ts b/tests/e2e/theme.spec.ts new file mode 100644 index 0000000..2c7a8a4 --- /dev/null +++ b/tests/e2e/theme.spec.ts @@ -0,0 +1,88 @@ +import { expect, getIframe, openSettings, sleep, test } from "./lib/fixtures" + +test.describe("Theme Switching", () => { + test("can toggle app dark mode", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + + const html = page.locator("html") + const initialClass = await html.getAttribute("class") + + const themeButton = page.locator( + "button:has(svg.lucide-sun), button:has(svg.lucide-moon)", + ) + + if ((await themeButton.count()) > 0) { + await test.step("toggle theme", async () => { + await themeButton.first().click() + await sleep(500) + }) + + await test.step("verify theme changed", async () => { + const newClass = await html.getAttribute("class") + expect(newClass).not.toBe(initialClass) + }) + } + }) + + test("theme persists after page reload", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + + const themeButton = page.locator( + "button:has(svg.lucide-sun), button:has(svg.lucide-moon)", + ) + + if ((await themeButton.count()) > 0) { + let themeClass: string | null + + await test.step("change theme", async () => { + await themeButton.first().click() + await sleep(300) + themeClass = await page.locator("html").getAttribute("class") + await page.keyboard.press("Escape") + }) + + await test.step("reload page", async () => { + await page.reload({ waitUntil: "networkidle" }) + await getIframe(page).waitFor({ + state: "visible", + timeout: 30000, + }) + }) + + await test.step("verify theme persisted", async () => { + const reloadedClass = await page + .locator("html") + .getAttribute("class") + expect(reloadedClass).toBe(themeClass) + }) + } + }) + + test("draw.io theme toggle exists", async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + await openSettings(page) + + await expect( + page.locator('[role="dialog"], [role="menu"], form').first(), + ).toBeVisible({ timeout: 5000 }) + }) + + test("system theme preference is respected", async ({ page }) => { + await page.emulateMedia({ colorScheme: "dark" }) + + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + + const html = page.locator("html") + const classes = await html.getAttribute("class") + expect(classes).toBeDefined() + }) +}) diff --git a/tests/e2e/upload.spec.ts b/tests/e2e/upload.spec.ts new file mode 100644 index 0000000..ec2a339 --- /dev/null +++ b/tests/e2e/upload.spec.ts @@ -0,0 +1,20 @@ +import { expect, getIframe, test } from "./lib/fixtures" + +test.describe("File Upload Area", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/", { waitUntil: "networkidle" }) + await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) + }) + + test("page loads without console errors", async ({ page }) => { + const errors: string[] = [] + page.on("pageerror", (err) => errors.push(err.message)) + + await page.waitForTimeout(1000) + + const criticalErrors = errors.filter( + (e) => !e.includes("ResizeObserver") && !e.includes("Script error"), + ) + expect(criticalErrors).toEqual([]) + }) +}) diff --git a/tests/unit/ai-providers.test.ts b/tests/unit/ai-providers.test.ts new file mode 100644 index 0000000..46bbf93 --- /dev/null +++ b/tests/unit/ai-providers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers" + +describe("supportsPromptCaching", () => { + it("returns true for Claude models", () => { + expect(supportsPromptCaching("claude-sonnet-4-5")).toBe(true) + expect(supportsPromptCaching("anthropic.claude-3-5-sonnet")).toBe(true) + expect(supportsPromptCaching("us.anthropic.claude-3-5-sonnet")).toBe( + true, + ) + expect(supportsPromptCaching("eu.anthropic.claude-3-5-sonnet")).toBe( + true, + ) + }) + + it("returns false for non-Claude models", () => { + expect(supportsPromptCaching("gpt-4o")).toBe(false) + expect(supportsPromptCaching("gemini-pro")).toBe(false) + expect(supportsPromptCaching("deepseek-chat")).toBe(false) + }) +}) + +describe("supportsImageInput", () => { + it("returns true for models with vision capability", () => { + expect(supportsImageInput("gpt-4-vision")).toBe(true) + expect(supportsImageInput("qwen-vl")).toBe(true) + expect(supportsImageInput("deepseek-vl")).toBe(true) + }) + + it("returns false for Kimi K2 models without vision", () => { + expect(supportsImageInput("kimi-k2")).toBe(false) + expect(supportsImageInput("moonshot/kimi-k2")).toBe(false) + }) + + it("returns false for DeepSeek text models", () => { + expect(supportsImageInput("deepseek-chat")).toBe(false) + expect(supportsImageInput("deepseek-coder")).toBe(false) + }) + + it("returns false for Qwen text models", () => { + expect(supportsImageInput("qwen-turbo")).toBe(false) + expect(supportsImageInput("qwen-plus")).toBe(false) + }) + + it("returns true for Claude and GPT models by default", () => { + expect(supportsImageInput("claude-sonnet-4-5")).toBe(true) + expect(supportsImageInput("gpt-4o")).toBe(true) + expect(supportsImageInput("gemini-pro")).toBe(true) + }) +}) diff --git a/tests/unit/cached-responses.test.ts b/tests/unit/cached-responses.test.ts new file mode 100644 index 0000000..2e0f873 --- /dev/null +++ b/tests/unit/cached-responses.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { + CACHED_EXAMPLE_RESPONSES, + findCachedResponse, +} from "@/lib/cached-responses" + +describe("findCachedResponse", () => { + it("returns cached response for exact match without image", () => { + const result = findCachedResponse( + "Give me a **animated connector** diagram of transformer's architecture", + false, + ) + expect(result).toBeDefined() + expect(result?.xml).toContain("Transformer Architecture") + }) + + it("returns cached response for exact match with image", () => { + const result = findCachedResponse("Replicate this in aws style", true) + expect(result).toBeDefined() + expect(result?.xml).toContain("AWS") + }) + + it("returns undefined for non-matching prompt", () => { + const result = findCachedResponse( + "random prompt that doesn't exist", + false, + ) + expect(result).toBeUndefined() + }) + + it("returns undefined when hasImage doesn't match", () => { + // This prompt exists but requires hasImage=true + const result = findCachedResponse("Replicate this in aws style", false) + expect(result).toBeUndefined() + }) + + it("returns undefined for partial match", () => { + const result = findCachedResponse("Give me a diagram", false) + expect(result).toBeUndefined() + }) + + it("returns response for Draw a cat prompt", () => { + const result = findCachedResponse("Draw a cat for me", false) + expect(result).toBeDefined() + expect(result?.xml).toContain("ellipse") + }) + + it("all cached responses have non-empty xml", () => { + for (const response of CACHED_EXAMPLE_RESPONSES) { + expect(response.xml).not.toBe("") + expect(response.xml.length).toBeGreaterThan(0) + } + }) +}) diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts new file mode 100644 index 0000000..9bf9d47 --- /dev/null +++ b/tests/unit/chat-helpers.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest" +import { + isMinimalDiagram, + replaceHistoricalToolInputs, + validateFileParts, +} from "@/lib/chat-helpers" + +describe("validateFileParts", () => { + it("returns valid for no files", () => { + const messages = [ + { role: "user", parts: [{ type: "text", text: "hello" }] }, + ] + expect(validateFileParts(messages)).toEqual({ valid: true }) + }) + + it("returns valid for files under limit", () => { + const smallBase64 = btoa("x".repeat(100)) + const messages = [ + { + role: "user", + parts: [ + { + type: "file", + url: `data:image/png;base64,${smallBase64}`, + }, + ], + }, + ] + expect(validateFileParts(messages)).toEqual({ valid: true }) + }) + + it("returns error for too many files", () => { + const messages = [ + { + role: "user", + parts: Array(6) + .fill(null) + .map(() => ({ + type: "file", + url: "data:image/png;base64,abc", + })), + }, + ] + const result = validateFileParts(messages) + expect(result.valid).toBe(false) + expect(result.error).toContain("Too many files") + }) + + it("returns error for file exceeding size limit", () => { + // Create base64 that decodes to > 2MB + const largeBase64 = btoa("x".repeat(3 * 1024 * 1024)) + const messages = [ + { + role: "user", + parts: [ + { + type: "file", + url: `data:image/png;base64,${largeBase64}`, + }, + ], + }, + ] + const result = validateFileParts(messages) + expect(result.valid).toBe(false) + expect(result.error).toContain("exceeds") + }) +}) + +describe("isMinimalDiagram", () => { + it("returns true for empty diagram", () => { + const xml = '' + expect(isMinimalDiagram(xml)).toBe(true) + }) + + it("returns false for diagram with content", () => { + const xml = + '' + expect(isMinimalDiagram(xml)).toBe(false) + }) + + it("handles whitespace correctly", () => { + const xml = ' ' + expect(isMinimalDiagram(xml)).toBe(true) + }) +}) + +describe("replaceHistoricalToolInputs", () => { + it("replaces display_diagram tool inputs with placeholder", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolName: "display_diagram", + input: { xml: "" }, + }, + ], + }, + ] + const result = replaceHistoricalToolInputs(messages) + expect(result[0].content[0].input.placeholder).toContain( + "XML content replaced", + ) + }) + + it("replaces edit_diagram tool inputs with placeholder", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolName: "edit_diagram", + input: { operations: [] }, + }, + ], + }, + ] + const result = replaceHistoricalToolInputs(messages) + expect(result[0].content[0].input.placeholder).toContain( + "XML content replaced", + ) + }) + + it("removes tool calls with invalid inputs", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolName: "display_diagram", + input: {}, + }, + { + type: "tool-call", + toolName: "display_diagram", + input: null, + }, + ], + }, + ] + const result = replaceHistoricalToolInputs(messages) + expect(result[0].content).toHaveLength(0) + }) + + it("preserves non-assistant messages", () => { + const messages = [{ role: "user", content: "hello" }] + const result = replaceHistoricalToolInputs(messages) + expect(result).toEqual(messages) + }) + + it("preserves other tool calls", () => { + const messages = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolName: "other_tool", + input: { foo: "bar" }, + }, + ], + }, + ] + const result = replaceHistoricalToolInputs(messages) + expect(result[0].content[0].input).toEqual({ foo: "bar" }) + }) +}) diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts new file mode 100644 index 0000000..1176de3 --- /dev/null +++ b/tests/unit/utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest" +import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" + +describe("isMxCellXmlComplete", () => { + it("returns false for empty/null input", () => { + expect(isMxCellXmlComplete("")).toBe(false) + expect(isMxCellXmlComplete(null)).toBe(false) + expect(isMxCellXmlComplete(undefined)).toBe(false) + }) + + it("returns true for self-closing mxCell", () => { + const xml = + '' + expect(isMxCellXmlComplete(xml)).toBe(true) + }) + + it("returns true for mxCell with closing tag", () => { + const xml = ` + + ` + expect(isMxCellXmlComplete(xml)).toBe(true) + }) + + it("returns false for truncated mxCell", () => { + const xml = + ' { + const xml = ` + { + const xml = ` + ` + expect(isMxCellXmlComplete(xml)).toBe(true) + }) +}) + +describe("wrapWithMxFile", () => { + it("wraps empty string with default structure", () => { + const result = wrapWithMxFile("") + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain('') + expect(result).toContain('') + }) + + it("wraps raw mxCell content", () => { + const xml = '' + const result = wrapWithMxFile(xml) + expect(result).toContain("") + expect(result).toContain(xml) + expect(result).toContain("") + }) + + it("returns full mxfile unchanged", () => { + const fullXml = + '' + const result = wrapWithMxFile(fullXml) + expect(result).toBe(fullXml) + }) + + it("handles whitespace in input", () => { + const result = wrapWithMxFile(" ") + expect(result).toContain("") + }) +}) + +describe("cn (class name utility)", () => { + it("merges class names", () => { + expect(cn("foo", "bar")).toBe("foo bar") + }) + + it("handles conditional classes", () => { + expect(cn("foo", false && "bar", "baz")).toBe("foo baz") + }) + + it("merges tailwind classes correctly", () => { + expect(cn("px-2", "px-4")).toBe("px-4") + expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500") + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..790e1e8 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,17 @@ +import react from "@vitejs/plugin-react" +import tsconfigPaths from "vite-tsconfig-paths" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + environment: "jsdom", + include: ["tests/**/*.test.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"], + exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"], + }, + }, +})