From 4717f3e6690bc68324d171972f90d6d8fd92fb19 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 1 Jan 2026 02:19:11 +0100 Subject: [PATCH] feat: add keyboard shortcut tests and fix browser conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing: - Add vitest with jsdom environment for unit testing - Create 61 comprehensive tests for keyboard shortcuts - Add test helpers for platform switching and key simulation - Mock SvelteKit $app modules for testing Fix: - Change Windows/Linux shortcuts from Ctrl to Alt to avoid browser shortcut conflicts (Ctrl+N opens new browser window) - Mac shortcuts remain Cmd+N/K/B (unaffected) New shortcuts on Windows/Linux: - Alt+N: New chat - Alt+K: Search conversations - Alt+B: Toggle sidebar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/package-lock.json | 1095 +++++++++++++++++++- frontend/package.json | 13 +- frontend/src/lib/utils/keyboard.test.ts | 807 +++++++++++++++ frontend/src/lib/utils/keyboard.ts | 20 +- frontend/src/tests/mocks/app/navigation.ts | 14 + frontend/src/tests/mocks/app/stores.ts | 18 + frontend/src/tests/setup.ts | 133 +++ frontend/vitest.config.ts | 25 + 8 files changed, 2115 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/utils/keyboard.test.ts create mode 100644 frontend/src/tests/mocks/app/navigation.ts create mode 100644 frontend/src/tests/mocks/app/stores.ts create mode 100644 frontend/src/tests/setup.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a29be72..dc27305 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,18 +21,29 @@ "devDependencies": { "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/kit": "^2.16.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/svelte": "^5.3.1", "@types/node": "^22.10.0", "autoprefixer": "^10.4.20", + "jsdom": "^27.4.0", "postcss": "^8.4.49", "svelte": "^5.16.0", "svelte-check": "^4.1.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -45,6 +56,211 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -461,6 +677,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz", + "integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1412,12 +1646,107 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "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/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -1485,6 +1814,127 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/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": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1497,6 +1947,39 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1543,6 +2026,16 @@ "node": ">= 0.4" } }, + "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/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -1599,6 +2092,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1697,6 +2200,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -1776,6 +2289,20 @@ "node": ">= 0.6" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1788,6 +2315,36 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", + "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1805,6 +2362,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1860,6 +2424,13 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", @@ -1882,6 +2453,26 @@ "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "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", @@ -1954,6 +2545,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/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2117,6 +2718,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -2127,6 +2741,34 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2201,6 +2843,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2219,6 +2868,53 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2252,6 +2948,26 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2294,6 +3010,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2504,6 +3227,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-to-es": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", @@ -2515,12 +3249,32 @@ "regex-recursion": "^5.1.1" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "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/pdfjs-dist": { "version": "5.4.530", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", @@ -2739,6 +3493,21 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -2749,6 +3518,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2769,6 +3548,13 @@ ], "license": "MIT" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2817,6 +3603,16 @@ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2923,6 +3719,19 @@ "node": ">=6" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2945,6 +3754,13 @@ "@types/hast": "^3.0.4" } }, + "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/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -2978,6 +3794,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -3082,6 +3912,13 @@ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3213,6 +4050,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3229,6 +4083,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3250,6 +4134,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3513,6 +4423,187 @@ } } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "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/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9d6a60..c6cb700 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,21 +8,28 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "devDependencies": { "@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/kit": "^2.16.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/typography": "^0.5.16", + "@testing-library/dom": "^10.4.1", + "@testing-library/svelte": "^5.3.1", "@types/node": "^22.10.0", "autoprefixer": "^10.4.20", + "jsdom": "^27.4.0", "postcss": "^8.4.49", "svelte": "^5.16.0", "svelte-check": "^4.1.0", "tailwindcss": "^3.4.17", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.0.16" }, "dependencies": { "@skeletonlabs/skeleton": "^2.10.0", diff --git a/frontend/src/lib/utils/keyboard.test.ts b/frontend/src/lib/utils/keyboard.test.ts new file mode 100644 index 0000000..827f89a --- /dev/null +++ b/frontend/src/lib/utils/keyboard.test.ts @@ -0,0 +1,807 @@ +/** + * Keyboard shortcuts tests + * + * Tests the keyboard shortcuts management system including: + * - Platform detection + * - Modifier key handling + * - Shortcut registration and triggering + * - Input field detection + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + keyboardShortcuts, + isPrimaryModifier, + getPrimaryModifierDisplay, + formatShortcut, + getShortcuts, + _resetPlatformCache, + type Shortcut +} from './keyboard'; +import { setPlatform, pressShortcut, pressKeyOn } from '../../tests/setup'; + +// Helper to properly switch platforms (resets cache + sets navigator.platform) +function switchPlatform(platform: 'mac' | 'windows' | 'linux'): void { + _resetPlatformCache(); + setPlatform(platform); +} + +describe('keyboard.ts', () => { + describe('Platform Detection', () => { + it('detects Mac platform correctly', () => { + switchPlatform('mac'); + const event = new KeyboardEvent('keydown', { metaKey: true }); + expect(isPrimaryModifier(event)).toBe(true); + }); + + it('detects Windows platform correctly (uses Alt)', () => { + switchPlatform('windows'); + const event = new KeyboardEvent('keydown', { altKey: true }); + expect(isPrimaryModifier(event)).toBe(true); + }); + + it('detects Linux platform correctly (uses Alt)', () => { + switchPlatform('linux'); + const event = new KeyboardEvent('keydown', { altKey: true }); + expect(isPrimaryModifier(event)).toBe(true); + }); + }); + + describe('isPrimaryModifier', () => { + beforeEach(() => { + switchPlatform('mac'); + }); + + it('returns true for metaKey on Mac', () => { + const event = new KeyboardEvent('keydown', { metaKey: true }); + expect(isPrimaryModifier(event)).toBe(true); + }); + + it('returns false for ctrlKey on Mac', () => { + const event = new KeyboardEvent('keydown', { ctrlKey: true }); + expect(isPrimaryModifier(event)).toBe(false); + }); + + it('returns true for altKey on Windows', () => { + switchPlatform('windows'); + const event = new KeyboardEvent('keydown', { altKey: true }); + expect(isPrimaryModifier(event)).toBe(true); + }); + + it('returns false for metaKey on Windows', () => { + switchPlatform('windows'); + const event = new KeyboardEvent('keydown', { metaKey: true }); + expect(isPrimaryModifier(event)).toBe(false); + }); + + it('returns false for ctrlKey on Windows (browser shortcut conflict)', () => { + switchPlatform('windows'); + const event = new KeyboardEvent('keydown', { ctrlKey: true }); + expect(isPrimaryModifier(event)).toBe(false); + }); + }); + + describe('getPrimaryModifierDisplay', () => { + it('returns ⌘ on Mac', () => { + switchPlatform('mac'); + expect(getPrimaryModifierDisplay()).toBe('⌘'); + }); + + it('returns Alt on Windows', () => { + switchPlatform('windows'); + expect(getPrimaryModifierDisplay()).toBe('Alt'); + }); + + it('returns Alt on Linux', () => { + switchPlatform('linux'); + expect(getPrimaryModifierDisplay()).toBe('Alt'); + }); + }); + + describe('formatShortcut', () => { + beforeEach(() => { + switchPlatform('mac'); + }); + + it('formats single key without modifiers', () => { + expect(formatShortcut('Escape')).toBe('Escape'); + }); + + it('formats key with meta modifier on Mac', () => { + expect(formatShortcut('k', { meta: true })).toBe('⌘K'); + }); + + it('formats key with ctrl modifier', () => { + expect(formatShortcut('s', { ctrl: true })).toBe('CtrlS'); + }); + + it('formats key with shift modifier on Mac', () => { + expect(formatShortcut('n', { shift: true })).toBe('⇧N'); + }); + + it('formats key with alt modifier on Mac', () => { + expect(formatShortcut('p', { alt: true })).toBe('⌥P'); + }); + + it('formats multiple modifiers', () => { + expect(formatShortcut('z', { ctrl: true, shift: true })).toBe('Ctrl⇧Z'); + }); + + it('formats with Windows-style on non-Mac', () => { + switchPlatform('windows'); + expect(formatShortcut('k', { meta: true })).toBe('Win+K'); + expect(formatShortcut('s', { ctrl: true })).toBe('Ctrl+S'); + expect(formatShortcut('n', { shift: true })).toBe('Shift+N'); + }); + + it('uppercases single character keys', () => { + expect(formatShortcut('a')).toBe('A'); + expect(formatShortcut('z')).toBe('Z'); + }); + }); + + describe('getShortcuts', () => { + beforeEach(() => { + switchPlatform('mac'); + }); + + it('returns all predefined shortcuts', () => { + const shortcuts = getShortcuts(); + expect(shortcuts).toHaveProperty('NEW_CHAT'); + expect(shortcuts).toHaveProperty('SEARCH'); + expect(shortcuts).toHaveProperty('TOGGLE_SIDENAV'); + expect(shortcuts).toHaveProperty('CLOSE_MODAL'); + expect(shortcuts).toHaveProperty('SEND_MESSAGE'); + expect(shortcuts).toHaveProperty('STOP_GENERATION'); + }); + + it('NEW_CHAT has correct configuration', () => { + const shortcuts = getShortcuts(); + expect(shortcuts.NEW_CHAT.id).toBe('new-chat'); + expect(shortcuts.NEW_CHAT.key).toBe('n'); + expect(shortcuts.NEW_CHAT.description).toBe('New chat'); + }); + + it('uses meta modifier on Mac', () => { + const shortcuts = getShortcuts(); + expect(shortcuts.NEW_CHAT.modifiers).toEqual({ meta: true }); + expect(shortcuts.SEARCH.modifiers).toEqual({ meta: true }); + }); + + it('uses alt modifier on Windows (avoids browser shortcut conflicts)', () => { + switchPlatform('windows'); + const shortcuts = getShortcuts(); + expect(shortcuts.NEW_CHAT.modifiers).toEqual({ alt: true }); + expect(shortcuts.SEARCH.modifiers).toEqual({ alt: true }); + }); + + it('CLOSE_MODAL has no modifiers', () => { + const shortcuts = getShortcuts(); + expect('modifiers' in shortcuts.CLOSE_MODAL).toBe(false); + expect(shortcuts.CLOSE_MODAL.key).toBe('Escape'); + }); + }); +}); + +describe('KeyboardShortcutsManager', () => { + beforeEach(() => { + switchPlatform('mac'); + keyboardShortcuts.destroy(); // Clean state + keyboardShortcuts.initialize(); + }); + + afterEach(() => { + keyboardShortcuts.destroy(); + }); + + describe('initialize and destroy', () => { + it('initializes and attaches event listener', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-init', + key: 'a', + description: 'Test init', + handler + }); + + pressShortcut('a'); + expect(handler).toHaveBeenCalled(); + }); + + it('destroy removes event listener', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-destroy', + key: 'b', + description: 'Test destroy', + handler + }); + + keyboardShortcuts.destroy(); + pressShortcut('b'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('clears shortcuts on destroy', () => { + keyboardShortcuts.register({ + id: 'test-clear', + key: 'c', + description: 'Test clear', + handler: vi.fn() + }); + + expect(keyboardShortcuts.getShortcuts()).toHaveLength(1); + keyboardShortcuts.destroy(); + expect(keyboardShortcuts.getShortcuts()).toHaveLength(0); + }); + }); + + describe('register and unregister', () => { + it('registers a shortcut', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-register', + key: 'r', + description: 'Test register', + handler + }); + + const shortcuts = keyboardShortcuts.getShortcuts(); + expect(shortcuts).toHaveLength(1); + expect(shortcuts[0].id).toBe('test-register'); + }); + + it('unregisters a shortcut', () => { + keyboardShortcuts.register({ + id: 'test-unregister', + key: 'u', + description: 'Test unregister', + handler: vi.fn() + }); + + expect(keyboardShortcuts.getShortcuts()).toHaveLength(1); + keyboardShortcuts.unregister('test-unregister'); + expect(keyboardShortcuts.getShortcuts()).toHaveLength(0); + }); + + it('shortcut is enabled by default', () => { + keyboardShortcuts.register({ + id: 'test-enabled-default', + key: 'e', + description: 'Test enabled', + handler: vi.fn() + }); + + const shortcut = keyboardShortcuts.getShortcuts()[0]; + expect(shortcut.enabled).toBe(true); + }); + + it('respects explicit enabled: false', () => { + keyboardShortcuts.register({ + id: 'test-disabled', + key: 'd', + description: 'Test disabled', + handler: vi.fn(), + enabled: false + }); + + const shortcut = keyboardShortcuts.getShortcuts()[0]; + expect(shortcut.enabled).toBe(false); + }); + }); + + describe('shortcut triggering', () => { + it('triggers handler on key press', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-trigger', + key: 't', + description: 'Test trigger', + handler + }); + + pressShortcut('t'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('passes event to handler', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-event', + key: 'e', + description: 'Test event', + handler + }); + + pressShortcut('e'); + expect(handler).toHaveBeenCalledWith(expect.any(KeyboardEvent)); + }); + + it('triggers with correct modifier on Mac', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-mac-mod', + key: 'm', + modifiers: { meta: true }, + description: 'Test Mac modifier', + handler + }); + + // Without modifier - should NOT trigger + pressShortcut('m'); + expect(handler).not.toHaveBeenCalled(); + + // With meta (Cmd) - should trigger + pressShortcut('m', { meta: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('triggers with correct modifier on Windows (Alt)', () => { + switchPlatform('windows'); + keyboardShortcuts.destroy(); + keyboardShortcuts.initialize(); + + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-win-mod', + key: 'w', + modifiers: { alt: true }, + description: 'Test Windows modifier', + handler + }); + + // Without modifier - should NOT trigger + pressShortcut('w'); + expect(handler).not.toHaveBeenCalled(); + + // With alt - should trigger + pressShortcut('w', { alt: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('is case insensitive for keys', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-case', + key: 'K', + description: 'Test case', + handler + }); + + pressShortcut('k'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('does not trigger for wrong key', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-wrong-key', + key: 'x', + description: 'Test wrong key', + handler + }); + + pressShortcut('y'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not trigger for wrong modifiers', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-wrong-mod', + key: 'z', + modifiers: { meta: true, shift: true }, + description: 'Test wrong modifiers', + handler + }); + + // Only meta, missing shift + pressShortcut('z', { meta: true }); + expect(handler).not.toHaveBeenCalled(); + + // Correct modifiers + pressShortcut('z', { meta: true, shift: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('preventDefault behavior', () => { + it('prevents default by default', () => { + keyboardShortcuts.register({ + id: 'test-prevent-default', + key: 'p', + description: 'Test prevent default', + handler: vi.fn() + }); + + const event = pressShortcut('p'); + expect(event.defaultPrevented).toBe(true); + }); + + it('does not prevent default when preventDefault: false', () => { + keyboardShortcuts.register({ + id: 'test-no-prevent', + key: 'n', + description: 'Test no prevent', + handler: vi.fn(), + preventDefault: false + }); + + const event = pressShortcut('n'); + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('setEnabled', () => { + it('disables a specific shortcut', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-disable', + key: 'd', + description: 'Test disable', + handler + }); + + keyboardShortcuts.setEnabled('test-disable', false); + + pressShortcut('d'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('re-enables a disabled shortcut', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-reenable', + key: 'r', + description: 'Test reenable', + handler + }); + + keyboardShortcuts.setEnabled('test-reenable', false); + keyboardShortcuts.setEnabled('test-reenable', true); + + pressShortcut('r'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('handles non-existent shortcut gracefully', () => { + // Should not throw + expect(() => { + keyboardShortcuts.setEnabled('non-existent', false); + }).not.toThrow(); + }); + }); + + describe('setGlobalEnabled', () => { + it('disables all shortcuts when global disabled', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + keyboardShortcuts.register({ + id: 'test-global-1', + key: 'a', + description: 'Test global 1', + handler: handler1 + }); + keyboardShortcuts.register({ + id: 'test-global-2', + key: 'b', + description: 'Test global 2', + handler: handler2 + }); + + keyboardShortcuts.setGlobalEnabled(false); + + pressShortcut('a'); + pressShortcut('b'); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).not.toHaveBeenCalled(); + }); + + it('re-enables all shortcuts when global enabled', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-global-reenable', + key: 'g', + description: 'Test global reenable', + handler + }); + + keyboardShortcuts.setGlobalEnabled(false); + keyboardShortcuts.setGlobalEnabled(true); + + pressShortcut('g'); + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('input field detection', () => { + it('does not trigger shortcuts when focused on input', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-input', + key: 'i', + description: 'Test input', + handler + }); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + pressKeyOn(input, 'i'); + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(input); + }); + + it('does not trigger shortcuts when focused on textarea', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-textarea', + key: 't', + description: 'Test textarea', + handler + }); + + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + + pressKeyOn(textarea, 't'); + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(textarea); + }); + + it('does not trigger shortcuts when focused on contenteditable', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-contenteditable', + key: 'c', + description: 'Test contenteditable', + handler + }); + + const div = document.createElement('div'); + div.contentEditable = 'true'; + // jsdom doesn't implement isContentEditable, so we need to mock it + Object.defineProperty(div, 'isContentEditable', { value: true }); + document.body.appendChild(div); + div.focus(); + + pressKeyOn(div, 'c'); + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(div); + }); + + it('DOES trigger Escape even when focused on input', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-escape-input', + key: 'Escape', + description: 'Test Escape in input', + handler + }); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + + pressKeyOn(input, 'Escape'); + expect(handler).toHaveBeenCalledTimes(1); + + document.body.removeChild(input); + }); + + it('DOES trigger Escape even when focused on textarea', () => { + const handler = vi.fn(); + keyboardShortcuts.register({ + id: 'test-escape-textarea', + key: 'Escape', + description: 'Test Escape in textarea', + handler + }); + + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + + pressKeyOn(textarea, 'Escape'); + expect(handler).toHaveBeenCalledTimes(1); + + document.body.removeChild(textarea); + }); + }); + + describe('multiple shortcuts', () => { + it('only triggers matching shortcut', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + const handler3 = vi.fn(); + + keyboardShortcuts.register({ + id: 'multi-1', + key: 'a', + description: 'Multi 1', + handler: handler1 + }); + keyboardShortcuts.register({ + id: 'multi-2', + key: 'b', + description: 'Multi 2', + handler: handler2 + }); + keyboardShortcuts.register({ + id: 'multi-3', + key: 'c', + description: 'Multi 3', + handler: handler3 + }); + + pressShortcut('b'); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledTimes(1); + expect(handler3).not.toHaveBeenCalled(); + }); + + it('first registered shortcut wins on conflict', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + keyboardShortcuts.register({ + id: 'conflict-1', + key: 'x', + description: 'Conflict 1', + handler: handler1 + }); + keyboardShortcuts.register({ + id: 'conflict-2', + key: 'x', + description: 'Conflict 2', + handler: handler2 + }); + + pressShortcut('x'); + + // First registered should trigger (Map iteration order) + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).not.toHaveBeenCalled(); + }); + }); + + describe('getShortcuts', () => { + it('returns all registered shortcuts', () => { + keyboardShortcuts.register({ + id: 'get-1', + key: 'a', + description: 'Get 1', + handler: vi.fn() + }); + keyboardShortcuts.register({ + id: 'get-2', + key: 'b', + description: 'Get 2', + handler: vi.fn() + }); + + const shortcuts = keyboardShortcuts.getShortcuts(); + expect(shortcuts).toHaveLength(2); + expect(shortcuts.map(s => s.id)).toContain('get-1'); + expect(shortcuts.map(s => s.id)).toContain('get-2'); + }); + + it('returns empty array when no shortcuts registered', () => { + expect(keyboardShortcuts.getShortcuts()).toEqual([]); + }); + }); +}); + +describe('Real-world shortcut scenarios', () => { + beforeEach(() => { + switchPlatform('mac'); + keyboardShortcuts.destroy(); + keyboardShortcuts.initialize(); + }); + + afterEach(() => { + keyboardShortcuts.destroy(); + }); + + it('Cmd+N creates new chat', () => { + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.NEW_CHAT, + handler + }); + + pressShortcut('n', { meta: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('Cmd+K opens search', () => { + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.SEARCH, + handler + }); + + pressShortcut('k', { meta: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('Cmd+B toggles sidenav', () => { + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.TOGGLE_SIDENAV, + handler + }); + + pressShortcut('b', { meta: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('Escape closes modal', () => { + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.CLOSE_MODAL, + handler + }); + + pressShortcut('Escape'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('Alt+N works on Windows (avoids browser Ctrl+N conflict)', () => { + switchPlatform('windows'); + keyboardShortcuts.destroy(); + keyboardShortcuts.initialize(); + + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.NEW_CHAT, + handler + }); + + // Alt+N should trigger (our shortcut) + pressShortcut('n', { alt: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('Ctrl+N does NOT trigger on Windows (reserved by browser)', () => { + switchPlatform('windows'); + keyboardShortcuts.destroy(); + keyboardShortcuts.initialize(); + + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.NEW_CHAT, + handler + }); + + // Ctrl+N should NOT trigger (browser opens new window) + pressShortcut('n', { ctrl: true }); + expect(handler).not.toHaveBeenCalled(); + }); + + it('prevents browser default for Cmd+K (spotlight search)', () => { + const handler = vi.fn(); + const shortcuts = getShortcuts(); + keyboardShortcuts.register({ + ...shortcuts.SEARCH, + handler + }); + + const event = pressShortcut('k', { meta: true }); + expect(event.defaultPrevented).toBe(true); + }); +}); diff --git a/frontend/src/lib/utils/keyboard.ts b/frontend/src/lib/utils/keyboard.ts index c2e44f9..2612486 100644 --- a/frontend/src/lib/utils/keyboard.ts +++ b/frontend/src/lib/utils/keyboard.ts @@ -46,17 +46,25 @@ const isMac = (): boolean => { }; /** - * Check if the primary modifier is pressed (Cmd on Mac, Ctrl on others) + * Reset platform cache (for testing only) + * @internal + */ +export function _resetPlatformCache(): void { + _isMac = null; +} + +/** + * Check if the primary modifier is pressed (Cmd on Mac, Alt on others) */ export function isPrimaryModifier(event: KeyboardEvent): boolean { - return isMac() ? event.metaKey : event.ctrlKey; + return isMac() ? event.metaKey : event.altKey; } /** * Get the display string for the primary modifier */ export function getPrimaryModifierDisplay(): string { - return isMac() ? '⌘' : 'Ctrl'; + return isMac() ? '⌘' : 'Alt'; } /** @@ -198,10 +206,12 @@ class KeyboardShortcutsManager { export const keyboardShortcuts = new KeyboardShortcutsManager(); /** - * Get platform-aware primary modifier (Cmd on Mac, Ctrl on others) + * Get platform-aware primary modifier + * - Mac: Cmd (meta) + * - Windows/Linux: Alt (because Ctrl+N/K/etc are browser shortcuts that can't be overridden) */ function getPrimaryModifiers(): Modifiers { - return isMac() ? { meta: true } : { ctrl: true }; + return isMac() ? { meta: true } : { alt: true }; } /** diff --git a/frontend/src/tests/mocks/app/navigation.ts b/frontend/src/tests/mocks/app/navigation.ts new file mode 100644 index 0000000..74c613d --- /dev/null +++ b/frontend/src/tests/mocks/app/navigation.ts @@ -0,0 +1,14 @@ +/** + * Mock for $app/navigation + */ + +import { vi } from 'vitest'; + +export const goto = vi.fn().mockResolvedValue(undefined); +export const invalidate = vi.fn().mockResolvedValue(undefined); +export const invalidateAll = vi.fn().mockResolvedValue(undefined); +export const preloadData = vi.fn().mockResolvedValue(undefined); +export const preloadCode = vi.fn().mockResolvedValue(undefined); +export const beforeNavigate = vi.fn(); +export const afterNavigate = vi.fn(); +export const onNavigate = vi.fn(); diff --git a/frontend/src/tests/mocks/app/stores.ts b/frontend/src/tests/mocks/app/stores.ts new file mode 100644 index 0000000..85b210e --- /dev/null +++ b/frontend/src/tests/mocks/app/stores.ts @@ -0,0 +1,18 @@ +/** + * Mock for $app/stores + */ + +import { readable, writable } from 'svelte/store'; + +export const page = readable({ + url: new URL('http://localhost'), + params: {}, + route: { id: '/' }, + status: 200, + error: null, + data: {}, + form: null +}); + +export const navigating = readable(null); +export const updated = { check: async () => false, subscribe: readable(false).subscribe }; diff --git a/frontend/src/tests/setup.ts b/frontend/src/tests/setup.ts new file mode 100644 index 0000000..cdf4a66 --- /dev/null +++ b/frontend/src/tests/setup.ts @@ -0,0 +1,133 @@ +/** + * Vitest test setup + * Configures the testing environment with necessary mocks and utilities + */ + +import { vi, beforeEach, afterEach } from 'vitest'; + +// Mock navigator for platform detection +Object.defineProperty(globalThis, 'navigator', { + value: { + platform: 'MacIntel', // Default to Mac for consistent tests + userAgent: 'Mozilla/5.0 (Macintosh)' + }, + writable: true, + configurable: true +}); + +// Mock window if not present +if (typeof window === 'undefined') { + // @ts-expect-error - Minimal window mock for Node environment + globalThis.window = globalThis; +} + +// Track event listeners for cleanup +const eventListeners: Map> = new Map(); + +// Store original methods +const originalAddEventListener = window.addEventListener.bind(window); +const originalRemoveEventListener = window.removeEventListener.bind(window); + +// Override addEventListener to track listeners (use type assertion for simplified signature) +(window as Window).addEventListener = function( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions +): void { + if (listener) { + if (!eventListeners.has(type)) { + eventListeners.set(type, new Set()); + } + eventListeners.get(type)!.add(listener); + } + originalAddEventListener(type, listener as EventListener, options); +}; + +(window as Window).removeEventListener = function( + type: string, + listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions +): void { + if (listener) { + eventListeners.get(type)?.delete(listener); + } + originalRemoveEventListener(type, listener as EventListener, options); +}; + +// Helper to dispatch keyboard events +export function dispatchKeyboardEvent( + key: string, + options: Partial = {} +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options + }); + window.dispatchEvent(event); + return event; +} + +// Helper to simulate keyboard shortcut +export function pressShortcut( + key: string, + modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } = {} +): KeyboardEvent { + return dispatchKeyboardEvent(key, { + ctrlKey: modifiers.ctrl ?? false, + altKey: modifiers.alt ?? false, + shiftKey: modifiers.shift ?? false, + metaKey: modifiers.meta ?? false + }); +} + +// Helper to simulate keyboard event on specific element +export function pressKeyOn( + element: HTMLElement, + key: string, + modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean } = {} +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ctrlKey: modifiers.ctrl ?? false, + altKey: modifiers.alt ?? false, + shiftKey: modifiers.shift ?? false, + metaKey: modifiers.meta ?? false + }); + element.dispatchEvent(event); + return event; +} + +// Helper to set platform +export function setPlatform(platform: 'mac' | 'windows' | 'linux'): void { + const platforms: Record = { + mac: 'MacIntel', + windows: 'Win32', + linux: 'Linux x86_64' + }; + Object.defineProperty(navigator, 'platform', { + value: platforms[platform], + writable: true, + configurable: true + }); +} + +// Reset keyboard manager state between tests +beforeEach(() => { + // Reset platform to Mac by default + setPlatform('mac'); +}); + +afterEach(() => { + // Clear all event listeners + eventListeners.forEach((listeners, type) => { + listeners.forEach(listener => { + originalRemoveEventListener(type, listener); + }); + listeners.clear(); + }); + vi.clearAllMocks(); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..590342a --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [svelte({ hot: !process.env.VITEST })], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/lib/**/*.ts'], + exclude: ['src/lib/**/*.svelte', 'src/tests/**'] + } + }, + resolve: { + alias: { + $lib: resolve('./src/lib'), + $app: resolve('./src/tests/mocks/app') + } + } +});