diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c126b6..5bdea7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,11 +56,14 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Install frontend dependencies - run: npm ci - - - name: Type check - run: npm run build + - name: Install frontend dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Type check + run: npm run build - name: Build Tauri app uses: tauri-apps/tauri-action@v0 diff --git a/.sisyphus/evidence/task-1-ci-step.txt b/.sisyphus/evidence/task-1-ci-step.txt new file mode 100644 index 0000000..5fa0826 --- /dev/null +++ b/.sisyphus/evidence/task-1-ci-step.txt @@ -0,0 +1,2 @@ +CI workflow includes a `Run tests` step before `Type check` in `.github/workflows/ci.yml`. +Step order: Checkout -> Setup Node.js -> Install Rust stable -> Rust cache -> Install frontend dependencies -> Run tests -> Type check -> Build Tauri app. diff --git a/.sisyphus/evidence/task-1-npm-test.txt b/.sisyphus/evidence/task-1-npm-test.txt new file mode 100644 index 0000000..ff344e1 Binary files /dev/null and b/.sisyphus/evidence/task-1-npm-test.txt differ diff --git a/.sisyphus/evidence/task-10-cross-platform.txt b/.sisyphus/evidence/task-10-cross-platform.txt new file mode 100644 index 0000000..f7dcc0d --- /dev/null +++ b/.sisyphus/evidence/task-10-cross-platform.txt @@ -0,0 +1,13 @@ +Task 10 cross-platform attachment contract coverage + +Verification: +- Added src/__tests__/cross-platform-contract.test.ts +- Covered desktop/web normalization into shared AttachmentRef shape +- Covered ACP resource_link serialization for multiple attachments +- Covered allowed and disallowed file extensions +- Mocked @tauri-apps/plugin-fs stat to return size 2048 +- Ran npx vitest run: PASS + +Result: +- 9 test files passed +- 39 tests passed diff --git a/.sisyphus/evidence/task-10-wire-format.txt b/.sisyphus/evidence/task-10-wire-format.txt new file mode 100644 index 0000000..c3795c5 --- /dev/null +++ b/.sisyphus/evidence/task-10-wire-format.txt @@ -0,0 +1,10 @@ + + RUN  v3.2.4 D:/agi-project/acp-ui + + 鉁?[39m src/__tests__/cross-platform-contract.test.ts (3 tests) 12ms + + Test Files  1 passed (1) + Tests  3 passed (3) + Start at  18:04:32 + Duration  1.65s (transform 177ms, setup 82ms, collect 138ms, tests 12ms, environment 877ms, prepare 214ms) + diff --git a/.sisyphus/evidence/task-11-picker-cancel.txt b/.sisyphus/evidence/task-11-picker-cancel.txt new file mode 100644 index 0000000..0c36d93 --- /dev/null +++ b/.sisyphus/evidence/task-11-picker-cancel.txt @@ -0,0 +1,22 @@ + + RUN  v3.2.4 D:/agi-project/acp-ui + +stdout | src/__tests__/session-reset-regression.test.ts > session reset regressions > composer attachment lifecycle on send clears pending state but keeps message metadata +Agent initialized: { agentCapabilities: { loadSession: true }, authMethods: [] } + +stdout | src/__tests__/session-reset-regression.test.ts > session reset regressions > composer attachment lifecycle on send clears pending state but keeps message metadata +Prompt completed: end_turn + +stdout | src/__tests__/session-reset-regression.test.ts > session reset regressions > session switch clears pending attachments +Agent initialized: { agentCapabilities: { loadSession: true }, authMethods: [] } + +stdout | src/__tests__/session-reset-regression.test.ts > session reset regressions > canceling picker is a no-op +Agent initialized: { agentCapabilities: { loadSession: true }, authMethods: [] } + + 鉁?[39m src/__tests__/session-reset-regression.test.ts (3 tests) 89ms + + Test Files  1 passed (1) + Tests  3 passed (3) + Start at  18:03:58 + Duration  2.77s (transform 465ms, setup 44ms, collect 658ms, tests 89ms, environment 483ms, prepare 202ms) + diff --git a/.sisyphus/evidence/task-11-session-switch.txt b/.sisyphus/evidence/task-11-session-switch.txt new file mode 100644 index 0000000..abf9bb8 --- /dev/null +++ b/.sisyphus/evidence/task-11-session-switch.txt @@ -0,0 +1,5 @@ +Task 11 regression evidence + +- Added composer reset regression tests for send, session switch, and picker cancel behavior. +- Added minimal ChatView session-change watcher to clear pending composer state only. +- Verification: `npx vitest run` passed (45 tests). diff --git a/.sisyphus/evidence/task-12-command-agnostic.txt b/.sisyphus/evidence/task-12-command-agnostic.txt new file mode 100644 index 0000000..26fc057 --- /dev/null +++ b/.sisyphus/evidence/task-12-command-agnostic.txt @@ -0,0 +1,20 @@ +Task 12 evidence - generic attachment send behavior + +Audit findings: +- ChatView handleAttach/handleSend/template contain no ingest-only branching; pending attachments are selected, validated, rendered, and sent through one generic path. +- sessionStore.sendPrompt(text, attachments) always builds one text block plus optional serialized attachment resource_link blocks with no command-specific checks. +- src/lib/attachments.ts and src/lib/file-picker.ts contain no ingest/command branching. +- Platform-specific picker logic remains isolated to src/lib/file-picker.ts via the mockable _rawPicker seam. +- No scope creep detected: no drag/drop, clipboard paste, preview thumbnails, upload progress, base64 embedding, custom Rust command, ACP extension, or upload service layer. + +Added test: +- src/__tests__/integration-command-agnostic.test.ts +- Verifies /ingest some-text, /query some-text, and plain text all use the same sendPrompt attachment path. +- Asserts identical attachment metadata on stored user messages and identical resource_link block structure apart from the text block content itself. + +Verification: +- npx vitest run -> 12 passed files, 46 passed tests +- npx vue-tsc --noEmit -> passed + +Result: +- Generic attachments are command-agnostic and flow through the existing UI/store/serialization path without special-casing. diff --git a/.sisyphus/evidence/task-12-text-only-regression.txt b/.sisyphus/evidence/task-12-text-only-regression.txt new file mode 100644 index 0000000..8941494 --- /dev/null +++ b/.sisyphus/evidence/task-12-text-only-regression.txt @@ -0,0 +1,76 @@ + + RUN  v3.2.4 D:/agi-project/acp-ui + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Agent initialized: { agentCapabilities: {}, authMethods: [] } + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Prompt completed: end_turn + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Agent initialized: { agentCapabilities: {}, authMethods: [] } + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Prompt completed: end_turn + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Agent initialized: { agentCapabilities: {}, authMethods: [] } + +stdout | src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text +Prompt completed: end_turn + + 鉂?[39m src/__tests__/integration-command-agnostic.test.ts (1 test | 1 failed) 106ms + 脳 attachment flow stays command agnostic > uses the same attachment prompt structure for slash commands and plain text 104ms + 鈫?expected [ { type: 'text', 鈥?1) }, 鈥?2) ] to deeply equal [ { type: 'text', 鈥?1) }, 鈥?2) ] +node.exe : +所在位置 行:1 字符: 1 ++ & "C:\Program Files\nodejs/node.exe" "C:\Users\haxu\AppData\Roaming\n ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + +鈳幆鈳幆鈳幆鈳?[39m Failed Tests 1 鈳幆鈳幆鈳幆鈳?[39m + + FAIL  src/__tests__/integration-command-agnostic.test.ts > attachment flow stays command a +gnostic > uses the same attachment prompt structure for slash commands and plain text +AssertionError: expected [ { type: 'text', 鈥?1) }, 鈥?2) ] to deeply equal [ { type: 'text', 鈥?1) }, 鈥?2) +] + +- Expected ++ Received + +@@ -4,15 +4,17 @@ + "type": "text", + }, + { + "mimeType": "text/markdown", + "name": "notes.md", ++ "size": 12n, + "type": "resource_link", + "uri": "file:///C%3A/docs/notes.md", + }, + { + "mimeType": "text/csv", + "name": "table.csv", ++ "size": 34n, + "type": "resource_link", + "uri": "file:///C%3A/docs/table.csv", + }, + ] + + 鉂?[22m src/__tests__/integration-command-agnostic.test.ts:116:32 + 114|  + 115|  expect(recordedPrompts).toHaveLength(3); + 116|  expect(recordedPrompts[0]).toEqual([ +  |  ^ + 117|  { type: 'text', text: '/ingest some-text' } +[33m, + 118|  { type: 'resource_link', uri: 'file:///C%3A/docs/notes.md', name鈥? +鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆鈳幆[1/1]鈳?[22m + + + Test Files  1 failed (1) + Tests  1 failed (1) + Start at  18:03:59 + Duration  2.05s (transform 134ms, setup 46ms, collect 165ms, tests 106ms, environment 513ms, prepare 233ms) + diff --git a/.sisyphus/evidence/task-2-validation-fail.txt b/.sisyphus/evidence/task-2-validation-fail.txt new file mode 100644 index 0000000..a42eeae --- /dev/null +++ b/.sisyphus/evidence/task-2-validation-fail.txt @@ -0,0 +1,2 @@ +FAIL: npm test +Result: none diff --git a/.sisyphus/evidence/task-2-validation-pass.txt b/.sisyphus/evidence/task-2-validation-pass.txt new file mode 100644 index 0000000..e98a9b7 --- /dev/null +++ b/.sisyphus/evidence/task-2-validation-pass.txt @@ -0,0 +1,3 @@ +PASS: npm test +Result: 2 test files passed, 10 tests passed +Attachment validation tests: passed diff --git a/.sisyphus/evidence/task-4-serializer-edge.txt b/.sisyphus/evidence/task-4-serializer-edge.txt new file mode 100644 index 0000000..ebae296 --- /dev/null +++ b/.sisyphus/evidence/task-4-serializer-edge.txt @@ -0,0 +1,2 @@ +Windows path encoding: file:///C%3A/Users/docs/file.md +Unicode/space encoding: file:///home/user/%E7%9F%A5%E8%AF%86%E5%BA%93%20%E6%96%87%E6%A1%A3.pdf diff --git a/.sisyphus/evidence/task-4-serializer-pass.txt b/.sisyphus/evidence/task-4-serializer-pass.txt new file mode 100644 index 0000000..0de1abc --- /dev/null +++ b/.sisyphus/evidence/task-4-serializer-pass.txt @@ -0,0 +1 @@ +npm test passed: 4 files, 21 tests diff --git a/.sisyphus/evidence/task-5-store-dispatch.txt b/.sisyphus/evidence/task-5-store-dispatch.txt new file mode 100644 index 0000000..479f6f9 --- /dev/null +++ b/.sisyphus/evidence/task-5-store-dispatch.txt @@ -0,0 +1,30 @@ +Task 5 evidence: attachment-aware session dispatch + +Changed files: +- src/stores/session.ts +- src/lib/types.ts +- src/__tests__/session-store.test.ts + +Relevant behavior: +- sendPrompt(text, attachments?) now builds a ContentBlock[] prompt with: + 1. { type: 'text', text } + 2. zero-to-many serialized resource_link blocks from serializeAttachmentsToContentBlocks(attachments) +- user messages persist attachments only when attachments.length > 0 + +Automated proof: +- src/__tests__/session-store.test.ts :: sends text plus attachment resource links and stores attachments metadata +- Assertion verifies prompt payload equals: + [ + { type: 'text', text: 'hello' }, + { type: 'resource_link', uri: 'file:///path1', name: 'file1.md', mimeType: 'text/markdown' }, + { type: 'resource_link', uri: 'file:///path2', name: 'file2.json', mimeType: 'application/json' } + ] +- Assertion verifies store.messages[0].attachments equals the original attachment array + +Command result: +- npm test PASS +- Included: src/__tests__/session-store.test.ts (3 tests) + +Excerpt: +- "✓ src/__tests__/session-store.test.ts (3 tests)" +- "Tests 28 passed (28)" diff --git a/.sisyphus/evidence/task-5-text-only-regression.txt b/.sisyphus/evidence/task-5-text-only-regression.txt new file mode 100644 index 0000000..eb764f5 --- /dev/null +++ b/.sisyphus/evidence/task-5-text-only-regression.txt @@ -0,0 +1,25 @@ +Task 5 evidence: text-only regression coverage + +Regression target: +- sendPrompt('hello') without attachments must keep prior text-only ACP prompt behavior +- sendPrompt('hello', []) must behave the same as text-only + +Automated proof: +- src/__tests__/session-store.test.ts :: sends a text-only prompt and stores no attachments metadata + Expected prompt: + [{ type: 'text', text: 'hello' }] + Expected user message attachments: undefined + +- src/__tests__/session-store.test.ts :: treats an empty attachments array like a text-only prompt + Expected prompt: + [{ type: 'text', text: 'hello' }] + Expected user message attachments: undefined + +Command result: +- npm test PASS +- npm run build PASS + +Excerpts: +- "✓ src/__tests__/session-store.test.ts (3 tests)" +- "Test Files 6 passed (6)" +- "✓ built in 2.09s" diff --git a/.sisyphus/evidence/task-7-invalid-selection.txt b/.sisyphus/evidence/task-7-invalid-selection.txt new file mode 100644 index 0000000..e67ce54 --- /dev/null +++ b/.sisyphus/evidence/task-7-invalid-selection.txt @@ -0,0 +1,10 @@ +Validated composer rejection handling for invalid attachment selections. + +Results: +- Disallowed files are filtered out with visible feedback. +- Rejection messages render in the composer area. +- Valid attachments remain available alongside rejections. + +Verification: +- npm test ✅ +- npm run build ✅ diff --git a/.sisyphus/evidence/task-7-state-preservation.txt b/.sisyphus/evidence/task-7-state-preservation.txt new file mode 100644 index 0000000..1c8d46f --- /dev/null +++ b/.sisyphus/evidence/task-7-state-preservation.txt @@ -0,0 +1,10 @@ +Validated pending attachment state preservation during invalid selections. + +Results: +- Existing valid pending attachments survive later invalid picks. +- Duplicate and over-count cases are rejected without clearing valid chips. +- Composer feedback remains generic and composable. + +Verification: +- npm test ✅ +- npm run build ✅ diff --git a/.sisyphus/evidence/task-8-history-persist.txt b/.sisyphus/evidence/task-8-history-persist.txt new file mode 100644 index 0000000..cd3ab54 --- /dev/null +++ b/.sisyphus/evidence/task-8-history-persist.txt @@ -0,0 +1,13 @@ +Verified with tests that sent user messages retain attachment metadata in store state after composer reset. + +Coverage: +- user messages keep attachments metadata after sendPrompt +- composer pending attachments are cleared before async send +- message history remains metadata-only + +Commands: +- npm test +- npm run build + +Result: +- PASS diff --git a/.sisyphus/evidence/task-8-metadata-only.txt b/.sisyphus/evidence/task-8-metadata-only.txt new file mode 100644 index 0000000..8e9a1c1 --- /dev/null +++ b/.sisyphus/evidence/task-8-metadata-only.txt @@ -0,0 +1,12 @@ +Verified message history stores attachment metadata only. + +Checks: +- no fileContent field on message objects +- no body field on message objects +- no data field on message objects +- no base64 field on message objects +- no blob field on message objects +- attachment entries contain only id, name, path, mimeType, size, source + +Result: +- PASS diff --git a/.sisyphus/evidence/task-9-no-content-preview.txt b/.sisyphus/evidence/task-9-no-content-preview.txt new file mode 100644 index 0000000..879777a --- /dev/null +++ b/.sisyphus/evidence/task-9-no-content-preview.txt @@ -0,0 +1,10 @@ + + RUN  v3.2.4 D:/agi-project/acp-ui + + 鉁?[39m src/__tests__/chatview-history-attachments.test.ts (3 tests) 61ms + + Test Files  1 passed (1) + Tests  3 passed (3) + Start at  18:03:58 + Duration  2.43s (transform 391ms, setup 35ms, collect 676ms, tests 61ms, environment 885ms, prepare 397ms) + diff --git a/.sisyphus/notepads/acp-ui-composer-attachments/learnings.md b/.sisyphus/notepads/acp-ui-composer-attachments/learnings.md new file mode 100644 index 0000000..0ac0ca5 --- /dev/null +++ b/.sisyphus/notepads/acp-ui-composer-attachments/learnings.md @@ -0,0 +1,4 @@ +## 2026-04-12 +- ChatView needs a minimal watcher on `sessionStore.currentSession` to clear only composer-local pending state. +- Pending composer attachments should reset on send and on session transitions, but sent message attachment metadata must remain in the store. +- Canceling the file picker returns an empty selection path and should leave composer state untouched. diff --git a/package-lock.json b/package-lock.json index 9b9f91c..a7caa17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-ui", - "version": "0.1.0", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-ui", - "version": "0.1.0", + "version": "0.1.10", "dependencies": { "@agentclientprotocol/sdk": "^0.13.1", "@microsoft/applicationinsights-web": "^3.3.11", @@ -24,8 +24,11 @@ "@tauri-apps/cli": "^2", "@types/node": "^25.1.0", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^17.6.3", "typescript": "~5.6.2", "vite": "^6.0.3", + "vitest": "^3.2.4", "vue-tsc": "^2.1.10" } }, @@ -542,6 +545,24 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -755,6 +776,24 @@ "integrity": "sha512-JPQZWPKQJjj7kAftdEZL0XDFfbMgXCGiUAZe0d7EhLC3QlXTlZdSckGqqRIQ2QNl0VTEZyZUvRBw6Ednw089Fw==", "license": "MIT" }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", @@ -1368,6 +1407,24 @@ "@tauri-apps/api": "^2.8.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/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", @@ -1381,7 +1438,6 @@ "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1400,6 +1456,131 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1625,6 +1806,27 @@ "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1644,6 +1846,42 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -1702,6 +1940,43 @@ "balanced-match": "^1.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1717,12 +1992,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -1738,6 +2054,21 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1751,6 +2082,67 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1763,6 +2155,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "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/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1811,6 +2210,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "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/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1834,6 +2243,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1849,6 +2275,42 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/happy-dom": { + "version": "17.6.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", + "integrity": "sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1865,6 +2327,23 @@ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "license": "MIT" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -1877,14 +2356,76 @@ "url": "https://github.com/sponsors/mesqueeb" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { "node": ">=6" } @@ -1918,6 +2459,20 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1970,6 +2525,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2005,6 +2570,13 @@ "pathe": "^2.0.1" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -2029,6 +2601,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2036,12 +2631,49 @@ "dev": true, "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -2071,7 +2703,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -2127,6 +2758,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -2213,6 +2851,62 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2231,6 +2925,137 @@ "node": ">=0.10.0" } }, + "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/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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/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/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/superjson": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", @@ -2243,6 +3068,20 @@ "node": ">=16" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2259,6 +3098,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2272,7 +3141,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2331,7 +3199,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2401,6 +3268,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -2413,7 +3376,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -2430,6 +3392,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-router": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.1.tgz", @@ -2531,12 +3500,163 @@ "typescript": ">=5.0.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index 9f5478e..dc5e14b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", + "test": "vitest run", "preview": "vite preview", "tauri": "tauri" }, @@ -26,8 +27,11 @@ "@tauri-apps/cli": "^2", "@types/node": "^25.1.0", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^17.6.3", "typescript": "~5.6.2", "vite": "^6.0.3", + "vitest": "^3.2.4", "vue-tsc": "^2.1.10" } } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6e487c8..e545b41 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,13 +16,17 @@ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "**" }] }, - { - "identifier": "fs:allow-exists", - "allow": [{ "path": "**" }] - }, - { - "identifier": "fs:allow-mkdir", - "allow": [{ "path": "**" }] - } - ] + { + "identifier": "fs:allow-exists", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-stat", + "allow": [{ "path": "**" }] + }, + { + "identifier": "fs:allow-mkdir", + "allow": [{ "path": "**" }] + } + ] } diff --git a/src/__tests__/attachments-serializer.test.ts b/src/__tests__/attachments-serializer.test.ts new file mode 100644 index 0000000..def712b --- /dev/null +++ b/src/__tests__/attachments-serializer.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { serializeAttachmentsToContentBlocks, toFileUri } from '../lib/attachments'; +import type { AttachmentRef } from '../lib/types'; + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + mimeType: overrides.mimeType ?? 'application/octet-stream', + size: overrides.size ?? 1024, + source: overrides.source ?? 'local', + }; +} + +describe('attachment serialization', () => { + it('serializes attachments into resource_link content blocks', () => { + const attachments = [ + makeAttachment({ id: '1', name: 'notes.md', path: '/files/notes.md', mimeType: 'text/markdown' }), + makeAttachment({ id: '2', name: 'data.json', path: '/files/data.json', mimeType: 'application/json' }), + ]; + + const blocks = serializeAttachmentsToContentBlocks(attachments); + + expect(blocks).toEqual([ + { + type: 'resource_link', + uri: 'file:///files/notes.md', + name: 'notes.md', + mimeType: 'text/markdown', + }, + { + type: 'resource_link', + uri: 'file:///files/data.json', + name: 'data.json', + mimeType: 'application/json', + }, + ]); + }); + + it('handles Windows paths', () => { + expect(toFileUri('C:\\Users\\docs\\file.md')).toBe('file:///C%3A/Users/docs/file.md'); + }); + + it('handles Unix paths', () => { + expect(toFileUri('/home/user/docs/file.txt')).toBe('file:///home/user/docs/file.txt'); + }); + + it('encodes spaces and unicode characters', () => { + expect(toFileUri('/home/user/知识库 文档.pdf')).toBe('file:///home/user/%E7%9F%A5%E8%AF%86%E5%BA%93%20%E6%96%87%E6%A1%A3.pdf'); + }); + + it('returns an empty array for no attachments', () => { + expect(serializeAttachmentsToContentBlocks([])).toEqual([]); + }); + + it('serializes Windows file names with encoded uris', () => { + const blocks = serializeAttachmentsToContentBlocks([ + makeAttachment({ + id: '3', + name: '知识库 文档.pdf', + path: 'C:\\Users\\me\\知识库 文档.pdf', + mimeType: 'application/pdf', + }), + ]); + + expect(blocks[0]).toEqual({ + type: 'resource_link', + uri: 'file:///C%3A/Users/me/%E7%9F%A5%E8%AF%86%E5%BA%93%20%E6%96%87%E6%A1%A3.pdf', + name: '知识库 文档.pdf', + mimeType: 'application/pdf', + }); + }); +}); diff --git a/src/__tests__/attachments.test.ts b/src/__tests__/attachments.test.ts new file mode 100644 index 0000000..04afc33 --- /dev/null +++ b/src/__tests__/attachments.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { ALLOWED_EXTENSIONS, MAX_FILE_COUNT, MAX_FILE_SIZE, getMimeType, validateAttachments } from '../lib/attachments'; +import type { AttachmentRef } from '../lib/types'; + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + mimeType: getMimeType(overrides.name), + size: 1024, + source: 'local', + ...overrides, + }; +} + +describe('attachments helpers', () => { + it('allows a valid multi-file set', () => { + const candidates = [ + makeAttachment({ id: '1', name: 'notes.md', path: '/files/notes.md' }), + makeAttachment({ id: '2', name: 'data.json', path: '/files/data.json' }), + makeAttachment({ id: '3', name: 'table.csv', path: '/files/table.csv' }), + ]; + + const result = validateAttachments(candidates, []); + + expect(result.valid).toHaveLength(3); + expect(result.rejected).toEqual([]); + }); + + it('rejects disallowed extensions', () => { + const result = validateAttachments([makeAttachment({ id: '1', name: 'virus.exe', path: '/files/virus.exe' })], []); + + expect(result.valid).toEqual([]); + expect(result.rejected).toEqual([{ name: 'virus.exe', reason: 'disallowed extension' }]); + }); + + it('rejects oversized files', () => { + const result = validateAttachments( + [makeAttachment({ id: '1', name: 'big.pdf', path: '/files/big.pdf', size: MAX_FILE_SIZE + 1 })], + [], + ); + + expect(result.valid).toEqual([]); + expect(result.rejected).toEqual([{ name: 'big.pdf', reason: 'file too large' }]); + }); + + it('rejects duplicate paths', () => { + const existing = [makeAttachment({ id: 'existing', name: 'report.md', path: 'C:/Docs/Report.md' })]; + const result = validateAttachments( + [makeAttachment({ id: '1', name: 'report.md', path: 'c:/docs/report.md' })], + existing, + ); + + expect(result.valid).toEqual([]); + expect(result.rejected).toEqual([{ name: 'report.md', reason: 'duplicate path' }]); + }); + + it('rejects attachments over the max count', () => { + const existing = Array.from({ length: MAX_FILE_COUNT }, (_, index) => + makeAttachment({ id: `existing-${index}`, name: `existing-${index}.txt`, path: `/files/existing-${index}.txt` }), + ); + + const result = validateAttachments([ + makeAttachment({ id: '1', name: 'extra.txt', path: '/files/extra.txt' }), + ], existing); + + expect(result.valid).toEqual([]); + expect(result.rejected).toEqual([{ name: 'extra.txt', reason: 'too many attachments' }]); + }); + + it('maps extensions to mime types', () => { + expect(getMimeType('doc.md')).toBe('text/markdown'); + expect(getMimeType('doc.txt')).toBe('text/plain'); + expect(getMimeType('doc.pdf')).toBe('application/pdf'); + expect(getMimeType('doc.doc')).toBe('application/msword'); + expect(getMimeType('doc.docx')).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(getMimeType('doc.json')).toBe('application/json'); + expect(getMimeType('doc.csv')).toBe('text/csv'); + expect(getMimeType('doc.unknown')).toBe('application/octet-stream'); + }); + + it('returns valid and rejected arrays for mixed input', () => { + const result = validateAttachments( + [ + makeAttachment({ id: '1', name: 'ok.md', path: '/files/ok.md' }), + makeAttachment({ id: '2', name: 'bad.exe', path: '/files/bad.exe' }), + makeAttachment({ id: '3', name: 'large.pdf', path: '/files/large.pdf', size: MAX_FILE_SIZE + 1 }), + ], + [], + ); + + expect(result.valid).toHaveLength(1); + expect(result.valid[0].name).toBe('ok.md'); + expect(result.rejected).toEqual([ + { name: 'bad.exe', reason: 'disallowed extension' }, + { name: 'large.pdf', reason: 'file too large' }, + ]); + }); + + it('returns empty arrays for no candidates', () => { + const result = validateAttachments([], []); + + expect(result.valid).toEqual([]); + expect(result.rejected).toEqual([]); + }); + + it('exports allowed extension list', () => { + expect(ALLOWED_EXTENSIONS).toContain('.md'); + }); +}); diff --git a/src/__tests__/chatview-composer.test.ts b/src/__tests__/chatview-composer.test.ts new file mode 100644 index 0000000..5cc8407 --- /dev/null +++ b/src/__tests__/chatview-composer.test.ts @@ -0,0 +1,85 @@ +import { mount } from '@vue/test-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import ChatView from '../components/ChatView.vue'; +import { useSessionStore } from '../stores/session'; +import { _setRawPicker, _resetRawPicker } from '../lib/file-picker'; + +describe('ChatView Composer Attachments', () => { + beforeEach(() => { + setActivePinia(createPinia()); + _resetRawPicker(); + }); + + it('renders attach button', () => { + const wrapper = mount(ChatView); + expect(wrapper.find('.attach-btn').exists()).toBe(true); + }); + + it('selecting files shows chips', async () => { + const wrapper = mount(ChatView); + + // Mock the file picker to return 2 files + _setRawPicker(async () => ['/path/to/file1.txt', '/path/to/file2.md']); + + // Click attach button + await wrapper.find('.attach-btn').trigger('click'); + + // Wait for reactivity and async picker + await new Promise(r => setTimeout(r, 50)); + + const chips = wrapper.findAll('.attachment-chip'); + expect(chips.length).toBe(2); + expect(chips[0].text()).toContain('file1.txt'); + expect(chips[1].text()).toContain('file2.md'); + }); + + it('remove chip updates list', async () => { + const wrapper = mount(ChatView); + + _setRawPicker(async () => ['/path/to/file1.txt', '/path/to/file2.md']); + await wrapper.find('.attach-btn').trigger('click'); + await new Promise(r => setTimeout(r, 50)); + + let chips = wrapper.findAll('.attachment-chip'); + expect(chips.length).toBe(2); + + // Click remove on the first chip + await chips[0].find('.attachment-chip-remove').trigger('click'); + await new Promise(r => setTimeout(r, 10)); // allow Vue to re-render + + chips = wrapper.findAll('.attachment-chip'); + expect(chips.length).toBe(1); + expect(chips[0].text()).toContain('file2.md'); + }); + + it('send clears pending attachments and passes them to store', async () => { + const wrapper = mount(ChatView); + const store = useSessionStore(); + + // Mock the store sendPrompt + store.sendPrompt = vi.fn().mockResolvedValue(undefined); + + // Set text so send button is enabled + await wrapper.find('textarea').setValue('Hello with files'); + + // Add attachments + _setRawPicker(async () => ['/path/to/file1.txt']); + await wrapper.find('.attach-btn').trigger('click'); + await new Promise(r => setTimeout(r, 50)); + + expect(wrapper.findAll('.attachment-chip').length).toBe(1); + + // Send + await wrapper.find('.send-btn').trigger('click'); + await new Promise(r => setTimeout(r, 50)); + + // Chips should be cleared + expect(wrapper.findAll('.attachment-chip').length).toBe(0); + + // Store should have been called with text and attachments + expect(store.sendPrompt).toHaveBeenCalledWith('Hello with files', expect.arrayContaining([ + expect.objectContaining({ name: 'file1.txt' }) + ])); + }); +}); diff --git a/src/__tests__/chatview-history-attachments.test.ts b/src/__tests__/chatview-history-attachments.test.ts new file mode 100644 index 0000000..74be489 --- /dev/null +++ b/src/__tests__/chatview-history-attachments.test.ts @@ -0,0 +1,87 @@ +import { mount } from '@vue/test-utils'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import ChatView from '../components/ChatView.vue'; +import { useSessionStore } from '../stores/session'; +import type { AttachmentRef } from '../lib/types'; + +describe('ChatView History Attachments', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('renders history attachments for user messages with attachments', async () => { + const store = useSessionStore(); + + // Mock the state to contain a user message with attachments + const mockAttachments: AttachmentRef[] = [ + { id: '1', name: 'document.pdf', path: '/foo/document.pdf', mimeType: 'application/pdf', size: 1024 * 1024 * 2.5, source: 'local' }, + { id: '2', name: 'image.png', path: '/foo/image.png', mimeType: 'image/png', size: 500 * 1024, source: 'local' } + ]; + + store.messages = [ + { + id: 'msg-1', + role: 'user', + content: 'Check out these files', + timestamp: Date.now(), + attachments: mockAttachments + } + ]; + + const wrapper = mount(ChatView); + + // Give time for Vue to render the computed messages + await wrapper.vm.$nextTick(); + + const historyAttachments = wrapper.find('.history-attachments'); + expect(historyAttachments.exists()).toBe(true); + + const chips = historyAttachments.findAll('.history-chip'); + expect(chips.length).toBe(2); + + expect(chips[0].find('.history-chip-name').text()).toBe('document.pdf'); + expect(chips[0].find('.history-chip-meta').text()).toBe('2.5 MB'); + + expect(chips[1].find('.history-chip-name').text()).toBe('image.png'); + expect(chips[1].find('.history-chip-meta').text()).toBe('500 KB'); + }); + + it('does not render an attachment container for messages without attachments', async () => { + const store = useSessionStore(); + + store.messages = [ + { + id: 'msg-1', + role: 'user', + content: 'No files here', + timestamp: Date.now() + } + ]; + + const wrapper = mount(ChatView); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.history-attachments').exists()).toBe(false); + }); + + it('formats zero size file properly', async () => { + const store = useSessionStore(); + + store.messages = [ + { + id: 'msg-1', + role: 'user', + content: 'Empty file', + timestamp: Date.now(), + attachments: [{ id: '1', name: 'empty.txt', path: '/foo/empty.txt', mimeType: 'text/plain', size: 0, source: 'local' }] + } + ]; + + const wrapper = mount(ChatView); + await wrapper.vm.$nextTick(); + + const chip = wrapper.find('.history-chip'); + expect(chip.find('.history-chip-meta').text()).toBe('0 B'); + }); +}); diff --git a/src/__tests__/chatview-validation.test.ts b/src/__tests__/chatview-validation.test.ts new file mode 100644 index 0000000..4542929 --- /dev/null +++ b/src/__tests__/chatview-validation.test.ts @@ -0,0 +1,91 @@ +import { mount } from '@vue/test-utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import ChatView from '../components/ChatView.vue'; +import { _setRawPicker, _resetRawPicker } from '../lib/file-picker'; +import { stat } from '@tauri-apps/plugin-fs'; + +const statMock = vi.mocked(stat); + +function mockStats(entries: Record): void { + statMock.mockImplementation(async (path: string | URL) => ({ + size: entries[typeof path === 'string' ? path : path.toString()] ?? 0, + } as never)); +} + +async function clickAttach(wrapper: ReturnType): Promise { + await wrapper.find('.attach-btn').trigger('click'); + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe('ChatView attachment validation', () => { + beforeEach(() => { + setActivePinia(createPinia()); + _resetRawPicker(); + statMock.mockReset(); + }); + + it('rejects disallowed files and shows feedback', async () => { + const wrapper = mount(ChatView); + _setRawPicker(async () => ['/path/to/file.exe']); + mockStats({ '/path/to/file.exe': 1024 }); + + await clickAttach(wrapper); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(0); + expect(wrapper.text()).toContain('file.exe: disallowed extension'); + }); + + it('keeps valid attachments when an invalid selection is rejected', async () => { + const wrapper = mount(ChatView); + + _setRawPicker(async () => ['/path/to/notes.md']); + mockStats({ '/path/to/notes.md': 1024, '/path/to/bad.exe': 1024 }); + await clickAttach(wrapper); + + expect(wrapper.text()).toContain('notes.md'); + + _setRawPicker(async () => ['/path/to/bad.exe']); + await clickAttach(wrapper); + + expect(wrapper.text()).toContain('notes.md'); + expect(wrapper.text()).toContain('bad.exe: disallowed extension'); + expect(wrapper.findAll('.attachment-chip')).toHaveLength(1); + }); + + it('rejects files when the attachment limit is exceeded', async () => { + const wrapper = mount(ChatView); + const files = Array.from({ length: 10 }, (_, index) => `/path/to/file-${index + 1}.md`); + + _setRawPicker(async () => files); + mockStats(Object.fromEntries(files.map((file) => [file, 1024]))); + await clickAttach(wrapper); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(10); + + _setRawPicker(async () => ['/path/to/extra.md']); + mockStats({ '/path/to/extra.md': 1024 }); + await clickAttach(wrapper); + + expect(wrapper.text()).toContain('extra.md: too many attachments'); + expect(wrapper.findAll('.attachment-chip')).toHaveLength(10); + }); + + it('rejects duplicate files by path', async () => { + const wrapper = mount(ChatView); + + _setRawPicker(async () => ['/path/to/file.md']); + mockStats({ '/path/to/file.md': 1024, '/PATH/TO/FILE.md': 1024 }); + await clickAttach(wrapper); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(1); + + _setRawPicker(async () => ['/PATH/TO/FILE.md']); + await clickAttach(wrapper); + + expect(wrapper.text()).toContain('FILE.md: duplicate path'); + expect(wrapper.findAll('.attachment-chip')).toHaveLength(1); + }); +}); diff --git a/src/__tests__/cross-platform-contract.test.ts b/src/__tests__/cross-platform-contract.test.ts new file mode 100644 index 0000000..68f5994 --- /dev/null +++ b/src/__tests__/cross-platform-contract.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { validateAttachments, serializeAttachmentsToContentBlocks, toFileUri, getMimeType } from '../lib/attachments'; +import { pickFiles, _setRawPicker, _resetRawPicker } from '../lib/file-picker'; +import type { AttachmentRef } from '../lib/types'; + +vi.mock('@tauri-apps/plugin-fs', () => ({ + stat: vi.fn().mockResolvedValue({ size: 2048 }), +})); + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + mimeType: overrides.mimeType ?? getMimeType(overrides.name), + size: overrides.size ?? 2048, + source: overrides.source ?? 'local', + }; +} + +function withoutId(attachment: AttachmentRef): Omit { + const { id: _id, ...rest } = attachment; + return rest; +} + +describe('cross-platform attachment contract', () => { + afterEach(() => { + _resetRawPicker(); + }); + + it('desktop and web inputs normalize to equivalent contract', async () => { + const desktopPaths = ['C:\\Users\\docs\\readme.md', 'C:\\Users\\docs\\data.csv']; + const webPaths = ['/uploads/readme.md', '/uploads/data.csv']; + + _setRawPicker(async () => desktopPaths); + const desktopAttachments = await pickFiles(); + + _setRawPicker(async () => webPaths); + const webAttachments = await pickFiles(); + + expect(desktopAttachments).toHaveLength(2); + expect(webAttachments).toHaveLength(2); + expect(desktopAttachments.every((attachment) => typeof attachment.id === 'string')).toBe(true); + expect(webAttachments.every((attachment) => typeof attachment.id === 'string')).toBe(true); + + expect(desktopAttachments.map(withoutId)).toEqual([ + { + name: 'readme.md', + path: 'C:\\Users\\docs\\readme.md', + mimeType: 'text/markdown', + size: 2048, + source: 'local', + }, + { + name: 'data.csv', + path: 'C:\\Users\\docs\\data.csv', + mimeType: 'text/csv', + size: 2048, + source: 'local', + }, + ]); + + expect(webAttachments.map(withoutId)).toEqual([ + { + name: 'readme.md', + path: '/uploads/readme.md', + mimeType: 'text/markdown', + size: 2048, + source: 'local', + }, + { + name: 'data.csv', + path: '/uploads/data.csv', + mimeType: 'text/csv', + size: 2048, + source: 'local', + }, + ]); + }); + + it('serializes multiple attachments into ACP resource_link blocks', () => { + const attachments = [ + makeAttachment({ id: '1', name: 'notes.md', path: '/files/notes.md' }), + makeAttachment({ id: '2', name: 'paper.pdf', path: '/files/paper.pdf' }), + makeAttachment({ id: '3', name: 'table.csv', path: '/files/table.csv' }), + ]; + + const blocks = serializeAttachmentsToContentBlocks(attachments); + + expect(blocks).toHaveLength(3); + expect(blocks).toEqual([ + { + type: 'resource_link', + uri: toFileUri('/files/notes.md'), + name: 'notes.md', + mimeType: 'text/markdown', + }, + { + type: 'resource_link', + uri: toFileUri('/files/paper.pdf'), + name: 'paper.pdf', + mimeType: 'application/pdf', + }, + { + type: 'resource_link', + uri: toFileUri('/files/table.csv'), + name: 'table.csv', + mimeType: 'text/csv', + }, + ]); + }); + + it('accepts common document types and rejects disallowed types', () => { + const allowedExtensions = ['.md', '.txt', '.pdf', '.doc', '.docx', '.json', '.csv'] as const; + const allowedCandidates = allowedExtensions.map((extension, index) => + makeAttachment({ + id: `allowed-${index}`, + name: `file-${index}${extension}`, + path: `/files/file-${index}${extension}`, + }), + ); + + const allowedResult = validateAttachments(allowedCandidates, []); + + expect(allowedResult.valid).toHaveLength(allowedExtensions.length); + expect(allowedResult.rejected).toEqual([]); + expect(allowedResult.valid.map((attachment) => attachment.name)).toEqual( + allowedExtensions.map((extension, index) => `file-${index}${extension}`), + ); + + const rejectedCandidates = ['.exe', '.zip', '.dll'].map((extension, index) => + makeAttachment({ + id: `rejected-${index}`, + name: `bad-${index}${extension}`, + path: `/files/bad-${index}${extension}`, + }), + ); + + const rejectedResult = validateAttachments(rejectedCandidates, []); + + expect(rejectedResult.valid).toEqual([]); + expect(rejectedResult.rejected).toEqual([ + { name: 'bad-0.exe', reason: 'disallowed extension' }, + { name: 'bad-1.zip', reason: 'disallowed extension' }, + { name: 'bad-2.dll', reason: 'disallowed extension' }, + ]); + }); +}); diff --git a/src/__tests__/file-picker.test.ts b/src/__tests__/file-picker.test.ts new file mode 100644 index 0000000..59793c7 --- /dev/null +++ b/src/__tests__/file-picker.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { stat } from '@tauri-apps/plugin-fs'; +import { _resetRawPicker, _setRawPicker, pickFiles } from '../lib/file-picker'; + +vi.mock('@tauri-apps/plugin-dialog', () => ({ open: vi.fn() })); +vi.mock('@tauri-apps/plugin-fs', () => ({ stat: vi.fn() })); + +beforeEach(() => { + vi.mocked(stat).mockReset(); + _resetRawPicker(); +}); + +describe('pickFiles', () => { + it('normalizes multi-file selections into attachment refs', async () => { + _setRawPicker(async () => ['C:/docs/readme.md', 'C:/docs/data.csv']); + vi.mocked(stat).mockResolvedValueOnce({ size: 11 } as Awaited>); + vi.mocked(stat).mockResolvedValueOnce({ size: 22 } as Awaited>); + + const attachments = await pickFiles(); + + expect(attachments).toHaveLength(2); + expect(attachments[0]).toMatchObject({ + name: 'readme.md', + path: 'C:/docs/readme.md', + mimeType: 'text/markdown', + size: 11, + source: 'local', + }); + expect(attachments[1]).toMatchObject({ + name: 'data.csv', + path: 'C:/docs/data.csv', + mimeType: 'text/csv', + size: 22, + source: 'local', + }); + expect(attachments[0].id).toBeTypeOf('string'); + expect(attachments[1].id).toBeTypeOf('string'); + }); + + it('returns an empty array for cancel or null selection', async () => { + _setRawPicker(async () => null); + + await expect(pickFiles()).resolves.toEqual([]); + }); + + it('returns an empty array for empty selection', async () => { + _setRawPicker(async () => []); + + await expect(pickFiles()).resolves.toEqual([]); + }); + + it('normalizes a single-file selection', async () => { + _setRawPicker(async () => ['D:/file.pdf']); + vi.mocked(stat).mockResolvedValueOnce({ size: 4096 } as Awaited>); + + const attachments = await pickFiles(); + + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + name: 'file.pdf', + path: 'D:/file.pdf', + mimeType: 'application/pdf', + size: 4096, + source: 'local', + }); + }); + + it('keeps attachments when stat fails', async () => { + _setRawPicker(async () => ['D:/file.pdf']); + vi.mocked(stat).mockRejectedValueOnce(new Error('stat failed')); + + const attachments = await pickFiles(); + + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + name: 'file.pdf', + path: 'D:/file.pdf', + mimeType: 'application/pdf', + size: 0, + source: 'local', + }); + }); +}); diff --git a/src/__tests__/integration-command-agnostic.test.ts b/src/__tests__/integration-command-agnostic.test.ts new file mode 100644 index 0000000..1cf1614 --- /dev/null +++ b/src/__tests__/integration-command-agnostic.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { AttachmentRef } from '../lib/types'; + +const mockPrompt = vi.fn(); +const mockInitialize = vi.fn(); +const mockNewSession = vi.fn(); +const mockDisconnect = vi.fn(); +const mockLoad = vi.fn(); +const mockGetVersion = vi.fn(); +const mockSpawnAgent = vi.fn(); +const mockOnAgentStderr = vi.fn(); +const mockTrackEvent = vi.fn(); +const mockTrackError = vi.fn(); + +vi.mock('@tauri-apps/plugin-store', () => ({ + Store: vi.fn(), + load: mockLoad, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mockGetVersion, +})); + +vi.mock('../lib/telemetry', () => ({ + trackEvent: mockTrackEvent, + trackError: mockTrackError, +})); + +vi.mock('../lib/tauri', () => ({ + spawnAgent: mockSpawnAgent, + killAgent: vi.fn(), + onAgentStderr: mockOnAgentStderr, +})); + +vi.mock('../lib/acp-bridge', () => ({ + AcpClientBridge: class {}, + createAcpClient: vi.fn(async () => ({ + pendingPermissionRequest: { value: null }, + onSessionUpdate: null, + initialize: mockInitialize, + newSession: mockNewSession, + prompt: mockPrompt, + disconnect: mockDisconnect, + })), +})); + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + mimeType: overrides.mimeType ?? 'application/octet-stream', + size: overrides.size ?? 1024, + source: overrides.source ?? 'local', + }; +} + +async function createReadyStore() { + setActivePinia(createPinia()); + const { useSessionStore } = await import('../stores/session'); + const store = useSessionStore(); + await store.initStore(); + await store.createSession('Test Agent', 'D:/workspace'); + return store; +} + +describe('attachment flow stays command agnostic', () => { + beforeEach(() => { + vi.resetModules(); + + mockPrompt.mockReset(); + mockInitialize.mockReset(); + mockNewSession.mockReset(); + mockDisconnect.mockReset(); + mockLoad.mockReset(); + mockGetVersion.mockReset(); + mockSpawnAgent.mockReset(); + mockOnAgentStderr.mockReset(); + mockTrackEvent.mockReset(); + mockTrackError.mockReset(); + + mockLoad.mockResolvedValue({ + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + save: vi.fn().mockResolvedValue(undefined), + }); + mockGetVersion.mockResolvedValue('1.0.0-test'); + mockSpawnAgent.mockResolvedValue({ id: 'agent-1', name: 'Test Agent' }); + mockOnAgentStderr.mockResolvedValue(() => undefined); + mockInitialize.mockResolvedValue({ agentCapabilities: {}, authMethods: [] }); + mockNewSession.mockResolvedValue({ sessionId: 'session-1' }); + mockPrompt.mockResolvedValue({ stopReason: 'end_turn' }); + }); + + it('uses the same attachment prompt structure for slash commands and plain text', async () => { + const inputTexts = ['/ingest some-text', '/query some-text', 'plain text']; + const attachments = [ + makeAttachment({ id: '1', name: 'notes.md', path: 'C:/docs/notes.md', mimeType: 'text/markdown', size: 12 }), + makeAttachment({ id: '2', name: 'table.csv', path: 'C:/docs/table.csv', mimeType: 'text/csv', size: 34 }), + ]; + + const recordedPrompts: unknown[] = []; + const recordedMessageAttachments: AttachmentRef[][] = []; + + for (const text of inputTexts) { + const store = await createReadyStore(); + await store.sendPrompt(text, attachments); + + const promptCall = mockPrompt.mock.calls[mockPrompt.mock.calls.length - 1]?.[0]; + recordedPrompts.push(promptCall?.prompt); + recordedMessageAttachments.push(store.messages[0].attachments ?? []); + } + + expect(recordedPrompts).toHaveLength(3); + expect(recordedPrompts[0]).toEqual([ + { type: 'text', text: '/ingest some-text' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/notes.md', name: 'notes.md', mimeType: 'text/markdown' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/table.csv', name: 'table.csv', mimeType: 'text/csv' }, + ]); + expect(recordedPrompts[1]).toEqual([ + { type: 'text', text: '/query some-text' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/notes.md', name: 'notes.md', mimeType: 'text/markdown' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/table.csv', name: 'table.csv', mimeType: 'text/csv' }, + ]); + expect(recordedPrompts[2]).toEqual([ + { type: 'text', text: 'plain text' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/notes.md', name: 'notes.md', mimeType: 'text/markdown' }, + { type: 'resource_link', uri: 'file:///C%3A/docs/table.csv', name: 'table.csv', mimeType: 'text/csv' }, + ]); + + for (const messageAttachments of recordedMessageAttachments) { + expect(messageAttachments).toEqual(attachments); + } + }); +}); diff --git a/src/__tests__/message-attachments.test.ts b/src/__tests__/message-attachments.test.ts new file mode 100644 index 0000000..a6baa55 --- /dev/null +++ b/src/__tests__/message-attachments.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { AttachmentRef } from '../lib/types'; + +const mockPrompt = vi.fn(); +const mockInitialize = vi.fn(); +const mockNewSession = vi.fn(); +const mockDisconnect = vi.fn(); +const mockLoad = vi.fn(); +const mockGetVersion = vi.fn(); +const mockSpawnAgent = vi.fn(); +const mockOnAgentStderr = vi.fn(); +const mockTrackEvent = vi.fn(); +const mockTrackError = vi.fn(); + +vi.mock('@tauri-apps/plugin-store', () => ({ + Store: vi.fn(), + load: mockLoad, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mockGetVersion, +})); + +vi.mock('../lib/telemetry', () => ({ + trackEvent: mockTrackEvent, + trackError: mockTrackError, +})); + +vi.mock('../lib/tauri', () => ({ + spawnAgent: mockSpawnAgent, + killAgent: vi.fn(), + onAgentStderr: mockOnAgentStderr, +})); + +vi.mock('../lib/acp-bridge', () => ({ + AcpClientBridge: class {}, + createAcpClient: vi.fn(async () => ({ + pendingPermissionRequest: { value: null }, + onSessionUpdate: null, + initialize: mockInitialize, + newSession: mockNewSession, + prompt: mockPrompt, + disconnect: mockDisconnect, + })), +})); + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + mimeType: overrides.mimeType ?? 'application/octet-stream', + size: overrides.size ?? 1024, + source: overrides.source ?? 'local', + }; +} + +async function createReadyStore() { + const { useSessionStore } = await import('../stores/session'); + const store = useSessionStore(); + await store.initStore(); + await store.createSession('Test Agent', 'D:/workspace'); + return store; +} + +describe('message attachment persistence', () => { + beforeEach(() => { + vi.resetModules(); + setActivePinia(createPinia()); + + mockPrompt.mockReset(); + mockInitialize.mockReset(); + mockNewSession.mockReset(); + mockDisconnect.mockReset(); + mockLoad.mockReset(); + mockGetVersion.mockReset(); + mockSpawnAgent.mockReset(); + mockOnAgentStderr.mockReset(); + mockTrackEvent.mockReset(); + mockTrackError.mockReset(); + + mockLoad.mockResolvedValue({ + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + save: vi.fn().mockResolvedValue(undefined), + }); + mockGetVersion.mockResolvedValue('1.0.0-test'); + mockSpawnAgent.mockResolvedValue({ id: 'agent-1', name: 'Test Agent' }); + mockOnAgentStderr.mockResolvedValue(() => undefined); + mockInitialize.mockResolvedValue({ agentCapabilities: {}, authMethods: [] }); + mockNewSession.mockResolvedValue({ sessionId: 'session-1' }); + mockPrompt.mockResolvedValue({ stopReason: 'end_turn' }); + }); + + it('retains attachment metadata on sent user messages after composer reset', async () => { + const store = await createReadyStore(); + const attachments = [ + makeAttachment({ id: '1', name: 'file1.md', path: '/path1', mimeType: 'text/markdown', size: 12, source: 'local' }), + makeAttachment({ id: '2', name: 'file2.json', path: '/path2', mimeType: 'application/json', size: 34, source: 'web' }), + ]; + + await store.sendPrompt('hello', attachments); + + const message = store.messages[0]; + + expect(message.role).toBe('user'); + expect(message.attachments).toHaveLength(2); + expect(message.attachments).toEqual([ + { + id: '1', + name: 'file1.md', + path: '/path1', + mimeType: 'text/markdown', + size: 12, + source: 'local', + }, + { + id: '2', + name: 'file2.json', + path: '/path2', + mimeType: 'application/json', + size: 34, + source: 'web', + }, + ]); + }); + + it('stores attachment metadata only and no file body content', async () => { + const store = await createReadyStore(); + const attachments = [ + makeAttachment({ id: '1', name: 'file1.md', path: '/path1' }), + ]; + + await store.sendPrompt('hello', attachments); + + const message = store.messages[0] as Record; + const storedAttachment = message.attachments as Array>; + + expect(message).not.toHaveProperty('fileContent'); + expect(message).not.toHaveProperty('body'); + expect(message).not.toHaveProperty('data'); + expect(message).not.toHaveProperty('base64'); + expect(message).not.toHaveProperty('blob'); + expect(storedAttachment).toHaveLength(1); + expect(storedAttachment[0]).toEqual({ + id: '1', + name: 'file1.md', + path: '/path1', + mimeType: 'application/octet-stream', + size: 1024, + source: 'local', + }); + }); + + it('leaves text-only messages without attachments', async () => { + const store = await createReadyStore(); + + await store.sendPrompt('hello'); + + expect(store.messages[0].attachments).toBeUndefined(); + }); + + it('treats an empty attachment array as no attachments', async () => { + const store = await createReadyStore(); + + await store.sendPrompt('hello', []); + + expect(store.messages[0].attachments).toBeUndefined(); + }); +}); diff --git a/src/__tests__/session-reset-regression.test.ts b/src/__tests__/session-reset-regression.test.ts new file mode 100644 index 0000000..3b4c969 --- /dev/null +++ b/src/__tests__/session-reset-regression.test.ts @@ -0,0 +1,209 @@ +import { mount, flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick } from 'vue'; +import ChatView from '../components/ChatView.vue'; +import { useSessionStore } from '../stores/session'; +import { _resetRawPicker, _setRawPicker } from '../lib/file-picker'; + +const { + mockPrompt, + mockInitialize, + mockNewSession, + mockDisconnect, + mockLoad, + mockGetVersion, + mockSpawnAgent, + mockOnAgentStderr, + mockTrackEvent, + mockTrackError, +} = vi.hoisted(() => ({ + mockPrompt: vi.fn(), + mockInitialize: vi.fn(), + mockNewSession: vi.fn(), + mockDisconnect: vi.fn(), + mockLoad: vi.fn(), + mockGetVersion: vi.fn(), + mockSpawnAgent: vi.fn(), + mockOnAgentStderr: vi.fn(), + mockTrackEvent: vi.fn(), + mockTrackError: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-dialog', () => ({ + open: vi.fn(), + save: vi.fn(), + message: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-fs', () => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + stat: vi.fn(), + exists: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-store', () => ({ + Store: vi.fn(), + load: mockLoad, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mockGetVersion, +})); + +vi.mock('../lib/telemetry', () => ({ + trackEvent: mockTrackEvent, + trackError: mockTrackError, +})); + +vi.mock('../lib/tauri', () => ({ + spawnAgent: mockSpawnAgent, + killAgent: vi.fn(), + onAgentStderr: mockOnAgentStderr, +})); + +vi.mock('../lib/acp-bridge', () => ({ + AcpClientBridge: class {}, + createAcpClient: vi.fn(async () => ({ + pendingPermissionRequest: { value: null }, + onSessionUpdate: null, + initialize: mockInitialize, + newSession: mockNewSession, + prompt: mockPrompt, + disconnect: mockDisconnect, + })), +})); + +function makeAttachmentSession(overrides: Record = {}) { + return { + id: 'session-1', + agentName: 'Test Agent', + sessionId: 'session-1', + title: 'Session 1', + lastUpdated: Date.now(), + cwd: 'D:/workspace', + supportsLoadSession: true, + ...overrides, + }; +} + +async function createReadyStore() { + const store = useSessionStore(); + await store.initStore(); + await store.createSession('Test Agent', 'D:/workspace'); + return store; +} + +async function mountChatViewWithSession() { + const store = await createReadyStore(); + const wrapper = mount(ChatView, { + global: { + stubs: { + ModePicker: true, + ModelPicker: true, + CommandPalette: true, + }, + }, + }); + + await nextTick(); + return { store, wrapper }; +} + +describe('session reset regressions', () => { + beforeEach(() => { + setActivePinia(createPinia()); + _resetRawPicker(); + + mockPrompt.mockReset(); + mockInitialize.mockReset(); + mockNewSession.mockReset(); + mockDisconnect.mockReset(); + mockLoad.mockReset(); + mockGetVersion.mockReset(); + mockSpawnAgent.mockReset(); + mockOnAgentStderr.mockReset(); + mockTrackEvent.mockReset(); + mockTrackError.mockReset(); + + mockLoad.mockResolvedValue({ + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + save: vi.fn().mockResolvedValue(undefined), + }); + mockGetVersion.mockResolvedValue('1.0.0-test'); + mockSpawnAgent.mockResolvedValue({ id: 'agent-1', name: 'Test Agent' }); + mockOnAgentStderr.mockResolvedValue(() => undefined); + mockInitialize.mockResolvedValue({ agentCapabilities: { loadSession: true }, authMethods: [] }); + mockNewSession.mockResolvedValue({ sessionId: 'session-1' }); + mockPrompt.mockResolvedValue({ stopReason: 'end_turn' }); + }); + + it('composer attachment lifecycle on send clears pending state but keeps message metadata', async () => { + const { store, wrapper } = await mountChatViewWithSession(); + + await wrapper.find('textarea').setValue('Hello with files'); + _setRawPicker(async () => ['/path/to/file1.txt']); + + await wrapper.find('.attach-btn').trigger('click'); + await flushPromises(); + + const chipsBeforeSend = wrapper.findAll('.attachment-chip'); + expect(chipsBeforeSend).toHaveLength(1); + expect(chipsBeforeSend[0].text()).toContain('file1.txt'); + + await wrapper.find('.send-btn').trigger('click'); + await flushPromises(); + await nextTick(); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(0); + expect(store.messages).toHaveLength(1); + expect(store.messages[0].attachments).toHaveLength(1); + expect(store.messages[0].attachments?.[0]).toMatchObject({ + name: 'file1.txt', + path: '/path/to/file1.txt', + mimeType: 'text/plain', + source: 'local', + }); + }); + + it('session switch clears pending attachments', async () => { + const { store, wrapper } = await mountChatViewWithSession(); + + _setRawPicker(async () => ['/path/to/file1.txt']); + await wrapper.find('.attach-btn').trigger('click'); + await flushPromises(); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(1); + + store.currentSession = makeAttachmentSession({ + id: 'session-2', + sessionId: 'session-2', + title: 'Session 2', + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(0); + expect(wrapper.find('.attachment-rejections').exists()).toBe(false); + }); + + it('canceling picker is a no-op', async () => { + const { wrapper } = await mountChatViewWithSession(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + _setRawPicker(async () => null); + await wrapper.find('.attach-btn').trigger('click'); + await flushPromises(); + + expect(wrapper.findAll('.attachment-chip')).toHaveLength(0); + expect(wrapper.find('.attachment-rejections').exists()).toBe(false); + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +}); diff --git a/src/__tests__/session-store.test.ts b/src/__tests__/session-store.test.ts new file mode 100644 index 0000000..5dcdb00 --- /dev/null +++ b/src/__tests__/session-store.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { AttachmentRef } from '../lib/types'; +import type { SessionNotification } from '@agentclientprotocol/sdk'; + +const mockPrompt = vi.fn(); +const mockInitialize = vi.fn(); +const mockNewSession = vi.fn(); +const mockDisconnect = vi.fn(); +const mockLoad = vi.fn(); +const mockGetVersion = vi.fn(); +const mockSpawnAgent = vi.fn(); +const mockOnAgentStderr = vi.fn(); +const mockTrackEvent = vi.fn(); +const mockTrackError = vi.fn(); +let lastClient: { + pendingPermissionRequest: { value: null }; + onSessionUpdate: ((notification: SessionNotification) => void) | null; + initialize: typeof mockInitialize; + newSession: typeof mockNewSession; + prompt: typeof mockPrompt; + disconnect: typeof mockDisconnect; +} | null = null; + +vi.mock('@tauri-apps/plugin-store', () => ({ + Store: vi.fn(), + load: mockLoad, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mockGetVersion, +})); + +vi.mock('../lib/telemetry', () => ({ + trackEvent: mockTrackEvent, + trackError: mockTrackError, +})); + +vi.mock('../lib/tauri', () => ({ + spawnAgent: mockSpawnAgent, + killAgent: vi.fn(), + onAgentStderr: mockOnAgentStderr, +})); + +vi.mock('../lib/acp-bridge', () => ({ + AcpClientBridge: class {}, + createAcpClient: vi.fn(async () => { + lastClient = { + pendingPermissionRequest: { value: null }, + onSessionUpdate: null, + initialize: mockInitialize, + newSession: mockNewSession, + prompt: mockPrompt, + disconnect: mockDisconnect, + }; + + return lastClient; + }), +})); + +function makeAttachment(overrides: Partial & Pick): AttachmentRef { + return { + id: overrides.id, + name: overrides.name, + path: overrides.path, + mimeType: overrides.mimeType ?? 'application/octet-stream', + size: overrides.size ?? 1024, + source: overrides.source ?? 'local', + }; +} + +async function createReadyStore() { + const { useSessionStore } = await import('../stores/session'); + const store = useSessionStore(); + await store.initStore(); + await store.createSession('Test Agent', 'D:/workspace'); + return store; +} + +describe('session store sendPrompt', () => { + beforeEach(() => { + vi.resetModules(); + setActivePinia(createPinia()); + + mockPrompt.mockReset(); + mockInitialize.mockReset(); + mockNewSession.mockReset(); + mockDisconnect.mockReset(); + mockLoad.mockReset(); + mockGetVersion.mockReset(); + mockSpawnAgent.mockReset(); + mockOnAgentStderr.mockReset(); + mockTrackEvent.mockReset(); + mockTrackError.mockReset(); + lastClient = null; + + mockLoad.mockResolvedValue({ + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockResolvedValue(undefined), + save: vi.fn().mockResolvedValue(undefined), + }); + mockGetVersion.mockResolvedValue('1.0.0-test'); + mockSpawnAgent.mockResolvedValue({ id: 'agent-1', name: 'Test Agent' }); + mockOnAgentStderr.mockResolvedValue(() => undefined); + mockInitialize.mockResolvedValue({ agentCapabilities: {}, authMethods: [] }); + mockNewSession.mockResolvedValue({ sessionId: 'session-1' }); + mockPrompt.mockResolvedValue({ stopReason: 'end_turn' }); + }); + + it('sends a text-only prompt and stores no attachments metadata', async () => { + const store = await createReadyStore(); + + await store.sendPrompt('hello'); + + expect(mockPrompt).toHaveBeenCalledWith({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'hello' }], + }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0]).toMatchObject({ + role: 'user', + content: 'hello', + }); + expect(store.messages[0].attachments).toBeUndefined(); + }); + + it('sends text plus attachment resource links and stores attachments metadata', async () => { + const store = await createReadyStore(); + const attachments = [ + makeAttachment({ id: '1', name: 'file1.md', path: '/path1', mimeType: 'text/markdown' }), + makeAttachment({ id: '2', name: 'file2.json', path: '/path2', mimeType: 'application/json' }), + ]; + + await store.sendPrompt('hello', attachments); + + expect(mockPrompt).toHaveBeenCalledWith({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'hello' }, + { type: 'resource_link', uri: 'file:///path1', name: 'file1.md', mimeType: 'text/markdown' }, + { type: 'resource_link', uri: 'file:///path2', name: 'file2.json', mimeType: 'application/json' }, + ], + }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0].attachments).toEqual(attachments); + }); + + it('treats an empty attachments array like a text-only prompt', async () => { + const store = await createReadyStore(); + + await store.sendPrompt('hello', []); + + expect(mockPrompt).toHaveBeenCalledWith({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'hello' }], + }); + expect(store.messages).toHaveLength(1); + expect(store.messages[0].attachments).toBeUndefined(); + }); + + it('stores a visible error when the prompt request fails', async () => { + const store = await createReadyStore(); + mockPrompt.mockRejectedValueOnce(new Error('agent unreachable')); + + await expect(store.sendPrompt('hello')).rejects.toThrow('agent unreachable'); + expect(store.error).toBe('Prompt failed: agent unreachable'); + }); + + it('ignores session/prompt timeout when agent activity already streamed', async () => { + const store = await createReadyStore(); + + mockPrompt.mockImplementationOnce(async () => { + lastClient?.onSessionUpdate?.({ + sessionId: 'session-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: 'streamed reply', + }, + }, + } as SessionNotification); + + throw new Error('Request timeout: session/prompt'); + }); + + await expect(store.sendPrompt('hello')).resolves.toBeUndefined(); + expect(store.error).toBeNull(); + expect(store.messages[store.messages.length - 1]).toMatchObject({ + role: 'assistant', + content: 'streamed reply', + }); + }); +}); diff --git a/src/__tests__/smoke.test.ts b/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..cb983c2 --- /dev/null +++ b/src/__tests__/smoke.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import type { ChatMessage } from '../lib/types'; + +describe('smoke test', () => { + it('can import types', () => { + const msg: ChatMessage = { + id: '1', role: 'user', content: 'hello', timestamp: Date.now(), + }; + expect(msg.role).toBe('user'); + }); +}); diff --git a/src/components/ChatView.vue b/src/components/ChatView.vue index 9d005fe..4586be8 100644 --- a/src/components/ChatView.vue +++ b/src/components/ChatView.vue @@ -1,17 +1,56 @@ - + +function formatSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, i); + return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[i]}`; +} + +