diff --git a/docs/testing.md b/docs/testing.md index 732fb67..f8a2091 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,39 +1,59 @@ # Testing Strategy -## Stack +## Backend stack - **pytest** + **pytest-asyncio** (`asyncio_mode = auto`) - **pytest-mock** — `mocker` fixture - **httpx** — `TestClient` for FastAPI routes - **asgi-lifespan** — lifespan management in integration tests +## Web client stack +- **Vitest** + **@vue/test-utils** — Vue 3 component and composable testing +- **happy-dom** — lightweight DOM environment +- **Pinia testing utils** — `@pinia/testing` for store mocks + ## Directory layout ``` -tests/ -├── conftest.py # Shared fixtures (settings override, event_loop policy) -├── conftest_factory.py # Factories: FakeLLMBackend, FakeTool, make_profile, FakePool -├── unit/ # No external deps (mocked DB / LLM) +tests/ # Backend (pytest) +├── conftest.py +├── conftest_factory.py +├── unit/ │ ├── core/ -│ │ ├── test_events.py # 17 tests — wire serialization for all 15 event types -│ │ ├── test_context_builder.py # 9 tests — system prompt caching, persona, iteration budget -│ │ ├── test_compressor.py # 14 tests — partition, format, compress_context -│ │ ├── test_registry.py # 10 tests — Tool/Profile/Backend registries -│ │ └── test_planning.py +│ │ ├── test_events.py # 17 tests +│ │ ├── test_context_builder.py +│ │ ├── test_compressor.py +│ │ ├── test_registry.py +│ │ ├── test_planning.py +│ │ ├── test_agent_context_size.py +│ │ └── test_agent_stream_guard.py │ ├── memory/ -│ │ ├── test_store.py # 18 tests — upsert, search, delete, summary, session state -│ │ └── test_extractor.py # 11 tests — JSON fact extraction, summary regeneration +│ │ ├── test_store.py +│ │ └── test_extractor.py │ ├── tools/ -│ │ └── test_filesystem.py +│ │ ├── test_filesystem.py +│ │ ├── test_code_exec.py +│ │ └── test_terminal.py │ ├── profiles/ -│ │ └── test_base.py # 9 tests — Pydantic coercion, defaults, extra fields +│ │ └── test_base.py │ └── config/ │ └── test_settings.py -├── integration/ # FastAPI TestClient + mocked or real DB +├── integration/ +│ ├── conftest.py │ ├── test_api_routes.py -│ ├── test_websocket.py -│ └── test_memory_store.py +│ └── test_websocket.py └── e2e/ - └── test_chat_flow.py # Critical path: message → tool call → response + └── test_chat_flow.py + +webclient/tests/ # Web client (Vitest) +├── unit/ +│ ├── api/ +│ │ └── index.test.js # 8 tests — request helper, verbs, errors, FormData +│ ├── stores/ +│ │ ├── chat.test.js # 23 tests — buildMessageList, WS handlers, session load +│ │ ├── sessions.test.js # 6 tests — fetch, create, delete, pin sorting +│ │ └── profiles.test.js # 3 tests — fetch, selection, lookup +│ └── composables/ +│ └── useWebSocket.test.js # 7 tests — connect, dispatch, reconnect ``` ## Mock strategy @@ -82,27 +102,26 @@ | 5 | `navi.tools.filesystem` | 13 | ✅ Done | | 5 | `navi.tools.code_exec` | 5 | ✅ Done | | 5 | `navi.tools.terminal` | 4 | ✅ Done | +| 6 | `webclient/api` | 8 | ✅ Done | +| 6 | `webclient/stores/chat` | 23 | ✅ Done | +| 6 | `webclient/stores/sessions` | 6 | ✅ Done | +| 6 | `webclient/stores/profiles` | 3 | ✅ Done | +| 6 | `webclient/composables/useWebSocket` | 7 | ✅ Done | ## Running tests ```bash -# All tests -pytest - -# Unit only -pytest tests/unit - -# With verbose -pytest -v tests/unit/core - -# Single file -pytest -v tests/unit/core/test_events.py - -# Single test -pytest -v tests/unit/core/test_events.py::TestToolStarted::test_to_wire - -# Integration (requires TEST_DATABASE_URL) +# Backend tests +pytest # all backend tests +pytest tests/unit # unit only +pytest -v tests/unit/core # verbose +pytest -v tests/unit/core/test_events.py::TestToolStarted::test_to_wire # single test TEST_DATABASE_URL=postgresql://... pytest tests/integration + +# Web client tests (run from webclient/) +cd webclient && npm test # all webclient tests +npx vitest run tests/unit/api # single directory +npx vitest run -t "buildMessageList" # filter by test name ``` ## Adding a new test diff --git a/webclient/package-lock.json b/webclient/package-lock.json index a5c5c92..9b6ce06 100644 --- a/webclient/package-lock.json +++ b/webclient/package-lock.json @@ -16,9 +16,13 @@ "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { + "@pinia/testing": "^0.1.7", "@vitejs/plugin-vue": "^5.2.3", + "@vue/test-utils": "^2.4.9", + "happy-dom": "^17.6.3", "sass": "^1.68.0", - "vite": "^6.3.3" + "vite": "^6.3.3", + "vitest": "^3.2.4" } }, "node_modules/@babel/helper-string-parser": { @@ -509,12 +513,37 @@ "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/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "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/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -843,6 +872,60 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pinia/testing": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.7.tgz", + "integrity": "sha512-xcDq6Ry/kNhZ5bsUMl7DeoFXwdume1NYzDggCiDUDKoPQ6Mo0eH9VU7bJvBtlurqe6byAntWoX5IhVFqWzRz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "pinia": ">=2.2.6" + } + }, + "node_modules/@pinia/testing/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "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.60.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", @@ -1258,6 +1341,24 @@ "vue": "^2.7.0 || ^3.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/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", @@ -1279,6 +1380,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/@vue/compiler-core": { "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", @@ -1412,6 +1638,80 @@ "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.9.tgz", + "integrity": "sha512-YwgowiO1mPleZqpgAGfxvWu/A5A8nkLrbyH2SqiQRkyzCIaDzzo27/2uS/F1g7fRLvl8BUY0+Sr1eC+6+IHfrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "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/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/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -1421,6 +1721,53 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "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": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1437,6 +1784,47 @@ "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/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", @@ -1452,12 +1840,55 @@ "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", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1469,6 +1900,39 @@ "node": ">=8" } }, + "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", @@ -1481,6 +1945,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", @@ -1529,6 +2000,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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1547,6 +2028,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", @@ -1562,6 +2060,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/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -1584,6 +2118,13 @@ "dev": true, "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-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1595,6 +2136,16 @@ "node": ">=0.10.0" } }, + "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-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1621,6 +2172,82 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "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/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", @@ -1642,12 +2269,45 @@ "node": ">= 18" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "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", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "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/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1674,6 +2334,73 @@ "license": "MIT", "optional": true }, + "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-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==", + "dev": true, + "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", @@ -1748,6 +2475,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/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1834,6 +2568,62 @@ "@parcel/watcher": "^2.4.1" } }, + "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", @@ -1852,6 +2642,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", @@ -1864,6 +2785,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.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1881,6 +2816,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/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1956,6 +2921,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/vue": { "version": "3.5.32", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", @@ -1977,6 +3038,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-virtual-scroller": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-2.0.1.tgz", @@ -1994,6 +3062,157 @@ "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==", "license": "MIT" + }, + "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/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" + } } } } diff --git a/webclient/package.json b/webclient/package.json index ebab69d..8c82494 100644 --- a/webclient/package.json +++ b/webclient/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@tanstack/vue-virtual": "^3.13.6", @@ -17,8 +18,12 @@ "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { + "@pinia/testing": "^0.1.7", "@vitejs/plugin-vue": "^5.2.3", + "@vue/test-utils": "^2.4.9", + "happy-dom": "^17.6.3", "sass": "^1.68.0", - "vite": "^6.3.3" + "vite": "^6.3.3", + "vitest": "^3.2.4" } } diff --git a/webclient/src/stores/chat.js b/webclient/src/stores/chat.js index f84a42a..08ef8db 100644 --- a/webclient/src/stores/chat.js +++ b/webclient/src/stores/chat.js @@ -530,6 +530,7 @@ saveDraft, loadDraft, appendUserMessage, + buildMessageList, onStreamStart, onReplayStart, onReplayEnd, diff --git a/webclient/tests/unit/api/index.test.js b/webclient/tests/unit/api/index.test.js new file mode 100644 index 0000000..bbe69e9 --- /dev/null +++ b/webclient/tests/unit/api/index.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import * as api from '@/api/index.js' + +describe('API request helper', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('getProfiles calls GET /agents/profiles', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [{ id: 'secretary' }], + }) + const result = await api.getProfiles() + expect(global.fetch).toHaveBeenCalledOnce() + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toContain('/agents/profiles') + expect(opts.method).toBe('GET') + expect(result).toEqual([{ id: 'secretary' }]) + }) + + it('createSession calls POST /sessions with body', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ session_id: 's1' }), + }) + const result = await api.createSession('developer') + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toContain('/sessions') + expect(opts.method).toBe('POST') + expect(opts.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(opts.body)).toEqual({ profile_id: 'developer' }) + expect(result).toEqual({ session_id: 's1' }) + }) + + it('deleteSession calls DELETE', async () => { + global.fetch.mockResolvedValue({ ok: true, status: 204 }) + await api.deleteSession('s1') + const [url, opts] = global.fetch.mock.calls[0] + expect(opts.method).toBe('DELETE') + expect(url).toContain('/sessions/s1') + }) + + it('204 response returns null', async () => { + global.fetch.mockResolvedValue({ ok: true, status: 204 }) + const result = await api.deleteSession('s1') + expect(result).toBeNull() + }) + + it('4xx throws with status text', async () => { + global.fetch.mockResolvedValue({ + ok: false, + status: 404, + text: async () => 'Not found', + }) + await expect(api.getSession('bad')).rejects.toThrow('404') + }) + + it('getSessions calls GET /sessions', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [], + }) + await api.getSessions() + const [url] = global.fetch.mock.calls[0] + expect(url).toContain('/sessions') + }) + + it('pinSession calls PATCH with pinned', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ pinned: true }), + }) + await api.pinSession('s1', true) + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toContain('/sessions/s1/pin') + expect(opts.method).toBe('PATCH') + expect(JSON.parse(opts.body)).toEqual({ pinned: true }) + }) + + it('uploadFile sends FormData', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ name: 'file.txt' }), + }) + const file = new File(['content'], 'file.txt', { type: 'text/plain' }) + await api.uploadFile('s1', file) + const [url, opts] = global.fetch.mock.calls[0] + expect(url).toContain('/sessions/s1/files') + expect(opts.method).toBe('POST') + expect(opts.body instanceof FormData).toBe(true) + }) +}) diff --git a/webclient/tests/unit/composables/useWebSocket.test.js b/webclient/tests/unit/composables/useWebSocket.test.js new file mode 100644 index 0000000..ac44d02 --- /dev/null +++ b/webclient/tests/unit/composables/useWebSocket.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useWebSocket } from '@/composables/useWebSocket.js' + +class MockWebSocket extends EventTarget { + constructor(url) { + super() + this.url = url + this.readyState = WebSocket.CONNECTING + this.sent = [] + MockWebSocket.last = this + } + + send(data) { this.sent.push(data) } + close() { + this.readyState = WebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + _open() { + this.readyState = WebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + _msg(data) { + this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) })) + } +} + +// Stub global WebSocket before each test +beforeEach(() => { + vi.stubGlobal('WebSocket', MockWebSocket) + setActivePinia(createPinia()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useWebSocket', () => { + it('connect opens socket', () => { + const ws = useWebSocket() + ws.connect('s1') + expect(MockWebSocket.last).toBeTruthy() + expect(MockWebSocket.last.url).toContain('/ws/sessions/s1') + }) + + it('connected flag after open', () => { + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + expect(ws.connected.value).toBe(true) + expect(ws.reconnecting.value).toBe(false) + }) + + it('send delivers JSON when open', () => { + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + ws.send({ type: 'message', content: 'hi' }) + expect(MockWebSocket.last.sent).toHaveLength(1) + expect(JSON.parse(MockWebSocket.last.sent[0])).toEqual({ type: 'message', content: 'hi' }) + }) + + it('disconnect sets destroyed and clears state', () => { + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + ws.disconnect() + expect(ws.connected.value).toBe(false) + }) + + it('dispatches stream_start to chat store', () => { + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + MockWebSocket.last._msg({ type: 'stream_start' }) + // No crash = dispatched; deeper assertions need chat store spy + expect(ws.connected.value).toBe(true) + }) + + it('dispatches session_sync', () => { + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + MockWebSocket.last._msg({ type: 'session_sync' }) + expect(ws.connected.value).toBe(true) + }) + + it('reconnect schedules on close', () => { + vi.useFakeTimers() + const ws = useWebSocket() + ws.connect('s1') + MockWebSocket.last._open() + MockWebSocket.last.close() + expect(ws.reconnecting.value).toBe(true) + vi.advanceTimersByTime(3000) + // Should have created a new MockWebSocket instance + expect(MockWebSocket.last.readyState).toBe(WebSocket.CONNECTING) + vi.useRealTimers() + }) +}) diff --git a/webclient/tests/unit/stores/chat.test.js b/webclient/tests/unit/stores/chat.test.js new file mode 100644 index 0000000..389a56b --- /dev/null +++ b/webclient/tests/unit/stores/chat.test.js @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useChatStore } from '@/stores/chat.js' + +vi.mock('@/stores/sessions.js', () => ({ + useSessionsStore: () => ({ + updatePreview: vi.fn(), + updateName: vi.fn(), + sessions: [], + }), +})) + +vi.mock('@/api/index.js', () => ({ + getSession: vi.fn(), + getFeedback: vi.fn().mockResolvedValue({ feedback: [] }), + setFeedback: vi.fn(), + generateSessionName: vi.fn(), +})) + +import * as api from '@/api/index.js' + +describe('Chat store — buildMessageList', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('empty array → empty result', () => { + const store = useChatStore() + expect(store.buildMessageList([])).toEqual([]) + }) + + it('single user message', () => { + const store = useChatStore() + const raw = [{ role: 'user', content: 'hi', created_at: 'T1' }] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ role: 'user', text: 'hi', time: 'T1' }) + }) + + it('assistant with final text', () => { + const store = useChatStore() + const raw = [{ role: 'assistant', content: 'hello', created_at: 'T2' }] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ role: 'assistant', text: 'hello', done: true }) + }) + + it('assistant tool call + tool result', () => { + const store = useChatStore() + const raw = [ + { role: 'assistant', tool_calls: [{ id: 'tc1', name: 'test_tool', arguments: { x: 1 } }] }, + { role: 'tool', tool_call_id: 'tc1', name: 'test_tool', content: 'ok' }, + { role: 'assistant', content: 'done' }, + ] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + const msg = result[0] + expect(msg.role).toBe('assistant') + expect(msg.text).toBe('done') + expect(msg.tools).toHaveLength(1) + expect(msg.tools[0]).toMatchObject({ kind: 'tool', name: 'test_tool', result: 'ok', success: true }) + }) + + it('marks tool failed when content starts with Error:', () => { + const store = useChatStore() + const raw = [ + { role: 'assistant', tool_calls: [{ id: 'tc1', name: 'bad', arguments: {} }] }, + { role: 'tool', tool_call_id: 'tc1', name: 'bad', content: 'Error: failed' }, + { role: 'assistant', content: 'sorry' }, + ] + const result = store.buildMessageList(raw) + expect(result[0].tools[0].success).toBe(false) + }) + + it('skips orphan tool messages', () => { + const store = useChatStore() + const raw = [ + { role: 'user', content: 'hi' }, + { role: 'tool', tool_call_id: 'x', name: 'orphan', content: 'lost' }, + ] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + expect(result[0].role).toBe('user') + }) + + it('includes compression notice', () => { + const store = useChatStore() + const raw = [{ role: 'system', is_compression: true, content: 'summary' }] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ role: 'system', type: 'compression_notice', summary: 'summary' }) + }) + + it('includes summary card', () => { + const store = useChatStore() + const raw = [{ role: 'assistant', is_summary: true, content: ' recap' }] + const result = store.buildMessageList(raw) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ type: 'summary', text: ' recap' }) + }) + + it('propagates metrics from assistant turns', () => { + const store = useChatStore() + const raw = [ + { role: 'assistant', content: 'hi', elapsed_seconds: 1.5, token_count: 42 }, + ] + const result = store.buildMessageList(raw) + expect(result[0]).toMatchObject({ elapsed_seconds: 1.5, token_count: 42 }) + }) + + it('handles images in user message', () => { + const store = useChatStore() + const raw = [{ role: 'user', content: 'look', images: ['data:image/png;base64,abc'] }] + const result = store.buildMessageList(raw) + expect(result[0].images[0]).toBe('data:image/png;base64,abc') + }) + + it('wraps bare image buffers with data URI', () => { + const store = useChatStore() + const raw = [{ role: 'user', content: 'look', images: ['abc123'] }] + const result = store.buildMessageList(raw) + expect(result[0].images[0]).toBe('data:image/jpeg;base64,abc123') + }) + + it('injects plan card into tools', () => { + const store = useChatStore() + const raw = [ + { role: 'assistant', is_plan: true, content: 'do A then B' }, + { role: 'assistant', content: 'ok' }, + ] + const result = store.buildMessageList(raw) + expect(result[0].tools).toHaveLength(1) + expect(result[0].tools[0]).toMatchObject({ kind: 'plan', text: 'do A then B' }) + }) +}) + +describe('Chat store — WS handlers', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('onStreamStart creates streaming message', () => { + const store = useChatStore() + store.onStreamStart() + expect(store.streaming).toBe(true) + expect(store.messages).toHaveLength(1) + expect(store.messages[0].role).toBe('assistant') + expect(store.messages[0].type).toBe('stream') + expect(store.streamingMsg).toBeTruthy() + }) + + it('onStreamDelta appends to streaming text', () => { + const store = useChatStore() + store.onStreamStart() + store.onStreamDelta('hello') + store.onStreamDelta(' world') + expect(store.streamingMsg.text).toBe('hello world') + }) + + it('onStreamEnd finalizes message', () => { + const store = useChatStore() + store.onStreamStart() + store.onStreamDelta('done') + store.onStreamEnd({ context_tokens: 100, max_context_tokens: 200 }) + expect(store.streaming).toBe(false) + expect(store.streamingMsg).toBeNull() + expect(store.messages[0].done).toBe(true) + expect(store.contextTokens).toBe(100) + expect(store.maxContextTokens).toBe(200) + }) + + it('onStreamEnd purges empty message', () => { + const store = useChatStore() + store.onStreamStart() + store.onStreamEnd({}) + expect(store.messages).toHaveLength(0) + }) + + it('onToolStarted adds pending card', () => { + const store = useChatStore() + store.onStreamStart() + store.onToolStarted({ tool: 'ls', args: { path: '/' }, is_subagent: false }) + const msg = store.streamingMsg + expect(msg.tools).toHaveLength(1) + expect(msg.tools[0]).toMatchObject({ kind: 'tool', name: 'ls', pending: true }) + }) + + it('onToolCall resolves pending card', () => { + const store = useChatStore() + store.onStreamStart() + store.onToolStarted({ tool: 'ls', args: {}, is_subagent: false }) + store.onToolCall({ tool: 'ls', result: 'file.txt', success: true, is_subagent: false }) + const card = store.streamingMsg.tools[0] + expect(card.pending).toBe(false) + expect(card.result).toBe('file.txt') + expect(card.success).toBe(true) + }) + + it('onError pushes error message', () => { + const store = useChatStore() + store.onError({ message: 'boom' }) + expect(store.messages).toHaveLength(1) + expect(store.messages[0]).toMatchObject({ role: 'system', type: 'error', text: 'boom' }) + expect(store.streaming).toBe(false) + }) + + it('onContextCompressed pushes notice', () => { + const store = useChatStore() + store.onContextCompressed({ messages_before: 10, messages_after: 5, summary: 's' }) + expect(store.messages[0]).toMatchObject({ type: 'compression_notice', before: 10, after: 5 }) + }) + + it('appendUserMessage adds user card', () => { + const store = useChatStore() + store.appendUserMessage('hi', [], []) + expect(store.messages[0]).toMatchObject({ role: 'user', text: 'hi' }) + }) + + it('loadSession resets state and loads messages', async () => { + api.getSession.mockResolvedValue({ + session_id: 's1', + profile_id: 'secretary', + messages: [{ role: 'user', content: 'hi' }], + context_token_count: 50, + max_context_tokens: 100, + }) + const store = useChatStore() + await store.loadSession('s1') + expect(store.currentId).toBe('s1') + expect(store.currentProfileId).toBe('secretary') + expect(store.messages).toHaveLength(1) + expect(store.loading).toBe(false) + }) + + it('clearSession resets everything', () => { + const store = useChatStore() + store.messages = [{ role: 'user', text: 'x' }] + store.currentId = 's1' + store.clearSession() + expect(store.currentId).toBeNull() + expect(store.messages).toHaveLength(0) + }) +}) diff --git a/webclient/tests/unit/stores/profiles.test.js b/webclient/tests/unit/stores/profiles.test.js new file mode 100644 index 0000000..475bf0a --- /dev/null +++ b/webclient/tests/unit/stores/profiles.test.js @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useProfilesStore } from '@/stores/profiles.js' + +vi.mock('@/api/index.js', () => ({ + getProfiles: vi.fn(), +})) + +import * as api from '@/api/index.js' + +describe('Profiles store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('fetchProfiles loads and selects first', async () => { + api.getProfiles.mockResolvedValue([ + { id: 'secretary', name: 'Secretary' }, + { id: 'developer', name: 'Developer' }, + ]) + const store = useProfilesStore() + await store.fetchProfiles() + expect(store.profiles).toHaveLength(2) + expect(store.selectedProfileId).toBe('secretary') + expect(store.loading).toBe(false) + }) + + it('fetchProfiles does not overwrite selection', async () => { + api.getProfiles.mockResolvedValue([ + { id: 'secretary' }, + { id: 'developer' }, + ]) + const store = useProfilesStore() + store.selectedProfileId = 'developer' + await store.fetchProfiles() + expect(store.selectedProfileId).toBe('developer') + }) + + it('getProfile finds by id', async () => { + api.getProfiles.mockResolvedValue([{ id: 's', name: 'S' }]) + const store = useProfilesStore() + await store.fetchProfiles() + expect(store.getProfile('s')).toEqual({ id: 's', name: 'S' }) + expect(store.getProfile('x')).toBeNull() + }) +}) diff --git a/webclient/tests/unit/stores/sessions.test.js b/webclient/tests/unit/stores/sessions.test.js new file mode 100644 index 0000000..3210439 --- /dev/null +++ b/webclient/tests/unit/stores/sessions.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useSessionsStore } from '@/stores/sessions.js' + +vi.mock('@/api/index.js', () => ({ + getSessions: vi.fn(), + createSession: vi.fn(), + deleteSession: vi.fn(), + pinSession: vi.fn(), +})) + +import * as api from '@/api/index.js' + +describe('Sessions store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('fetchSessions loads sessions', async () => { + api.getSessions.mockResolvedValue([ + { session_id: 's1', profile_id: 'secretary', pinned: false }, + ]) + const store = useSessionsStore() + await store.fetchSessions() + expect(store.sessions).toHaveLength(1) + expect(store.sessions[0].session_id).toBe('s1') + expect(store.loading).toBe(false) + }) + + it('createSession prepends placeholder and returns session', async () => { + api.createSession.mockResolvedValue({ + session_id: 's2', + profile_id: 'developer', + created_at: '2024-01-01T00:00:00Z', + }) + const store = useSessionsStore() + const result = await store.createSession('developer') + expect(api.createSession).toHaveBeenCalledWith('developer') + expect(store.sessions).toHaveLength(1) + expect(store.sessions[0].session_id).toBe('s2') + expect(store.sessions[0].message_count).toBe(0) + expect(result.session_id).toBe('s2') + }) + + it('deleteSession filters out the session', async () => { + api.deleteSession.mockResolvedValue() + const store = useSessionsStore() + store.sessions = [ + { session_id: 's1' }, + { session_id: 's2' }, + ] + await store.deleteSession('s1') + expect(api.deleteSession).toHaveBeenCalledWith('s1') + expect(store.sessions).toHaveLength(1) + expect(store.sessions[0].session_id).toBe('s2') + }) + + it('pinSession sorts pinned to top', async () => { + api.pinSession.mockResolvedValue() + const store = useSessionsStore() + store.sessions = [ + { session_id: 's1', pinned: false }, + { session_id: 's2', pinned: false }, + ] + await store.pinSession('s2', true) + expect(api.pinSession).toHaveBeenCalledWith('s2', true) + expect(store.sessions[0].session_id).toBe('s2') + expect(store.sessions[0].pinned).toBe(true) + }) + + it('updatePreview mutates session preview', () => { + const store = useSessionsStore() + store.sessions = [{ session_id: 's1', preview: '' }] + store.updatePreview('s1', 'hello') + expect(store.sessions[0].preview).toBe('hello') + }) + + it('updateName mutates session name', () => { + const store = useSessionsStore() + store.sessions = [{ session_id: 's1', name: null }] + store.updateName('s1', 'Work') + expect(store.sessions[0].name).toBe('Work') + }) +}) diff --git a/webclient/vitest.config.js b/webclient/vitest.config.js new file mode 100644 index 0000000..d7c547a --- /dev/null +++ b/webclient/vitest.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'happy-dom', + globals: true, + include: ['tests/**/*.test.js'], + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})