diff --git a/package-lock.json b/package-lock.json index de098d6..c01fad7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.0.0", "sharp": "^0.34.5", "typescript": "^5.3.3", @@ -247,9 +248,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -257,9 +258,9 @@ } }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -291,13 +292,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -383,19 +384,29 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -677,9 +688,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -2683,6 +2694,37 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -2841,6 +2883,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/aws4fetch": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", @@ -3239,6 +3300,16 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hono": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.1.tgz", @@ -3262,6 +3333,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3307,6 +3385,58 @@ "dev": true, "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "5.9.6", "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", @@ -3455,6 +3585,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -3511,9 +3682,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3537,15 +3708,18 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/opencode-antigravity-auth": { "version": "1.6.0", @@ -3601,9 +3775,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3614,9 +3788,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3634,7 +3808,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3968,9 +4142,9 @@ "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==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -3978,14 +4152,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3995,9 +4169,9 @@ } }, "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==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4268,9 +4442,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -4285,9 +4459,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -4302,9 +4476,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -4319,9 +4493,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -4336,9 +4510,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -4353,9 +4527,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -4370,9 +4544,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -4387,9 +4561,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -4404,9 +4578,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -4421,9 +4595,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -4438,9 +4612,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -4455,9 +4629,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -4472,9 +4646,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -4489,9 +4663,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -4506,9 +4680,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -4523,9 +4697,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -4540,9 +4714,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -4556,10 +4730,27 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -4573,10 +4764,27 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -4590,10 +4798,27 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -4608,9 +4833,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -4625,9 +4850,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -4642,9 +4867,9 @@ } }, "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -4686,9 +4911,9 @@ } }, "node_modules/vitest/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4699,38 +4924,38 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/vitest/node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 211ca8a..26e3887 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@types/react": "^18.2.48", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", + "@vitest/coverage-v8": "^4.0.18", "jsdom": "^28.0.0", "sharp": "^0.34.5", "typescript": "^5.3.3", diff --git a/src/__tests__/ai-client.test.ts b/src/__tests__/ai-client.test.ts new file mode 100644 index 0000000..fc7bc81 --- /dev/null +++ b/src/__tests__/ai-client.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import type { AIAnalysisSnapshot } from '../types'; + +let analyzeSnapshot: typeof import('../services/ai-client')['analyzeSnapshot']; +let AIAnalysisError: typeof import('../services/ai-client')['AIAnalysisError']; + +const VALID_AI_BODY = JSON.stringify({ + summary: 'App looks healthy.', + security: [], + crashRisks: [], + performance: [ + { + title: 'Re-renders', + severity: 'warning', + description: 'Too many renders', + suggestion: 'Memoize', + }, + ], + rootCauses: [], + suggestions: [], +}); + +function makeSnapshot(overrides: Partial = {}): AIAnalysisSnapshot { + return { + issues: [ + { type: 'MISSING_KEY', severity: 'warning', component: 'List', message: 'Missing key', suggestion: 'Add key' }, + ], + components: [{ name: 'App', renderCount: 5, avgDuration: 10 }], + crashes: [], + memory: null, + pageMetrics: null, + reactVersion: '18.2.0', + reactMode: 'development', + totalRenders: 5, + totalTimelineEvents: 0, + ...overrides, + }; +} + +function makeOpenAIResponse(content: string) { + return { + choices: [{ message: { content } }], + usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, + }; +} + +beforeEach(async () => { + vi.resetModules(); + const optimizerMod = await import('../services/token-optimizer'); + optimizerMod.tokenOptimizer.clearCache(); + + const mod = await import('../services/ai-client'); + analyzeSnapshot = mod.analyzeSnapshot; + AIAnalysisError = mod.AIAnalysisError; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('analyzeSnapshot empty snapshot guard', () => { + it('throws EMPTY_SNAPSHOT when no analyzable data is present', async () => { + await expect(analyzeSnapshot(makeSnapshot({ + issues: [], + components: [], + crashes: [], + memory: null, + pageMetrics: null, + totalRenders: 0, + }))).rejects.toMatchObject({ + name: 'AIAnalysisError', + code: 'EMPTY_SNAPSHOT', + }); + }); + + it('does not throw EMPTY_SNAPSHOT when only memory is populated', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse(VALID_AI_BODY), + })); + + const result = await analyzeSnapshot(makeSnapshot({ + issues: [], + components: [], + crashes: [], + memory: { usedMB: 50, totalMB: 100, limitMB: 2048, growthRateKBs: 0, warnings: [] }, + totalRenders: 0, + })); + + expect(result.summary).toBe('App looks healthy.'); + }); +}); + +describe('analyzeSnapshot happy path', () => { + it('returns a structured AIAnalysisResult on a valid fetch response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse(VALID_AI_BODY), + })); + + const result = await analyzeSnapshot(makeSnapshot()); + + expect(result.summary).toBe('App looks healthy.'); + expect(result.performance).toHaveLength(1); + expect(result.performance[0].title).toBe('Re-renders'); + expect(result.tokenUsage).toEqual({ prompt: 100, completion: 50, total: 150 }); + expect(result.model).toBe('gemini-2.5-flash-lite'); + expect(result.id).toMatch(/^ai-/); + expect(result.snapshotHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('strips markdown JSON code fences before parsing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse(`\`\`\`json\n${VALID_AI_BODY}\n\`\`\``), + })); + + await expect(analyzeSnapshot(makeSnapshot())).resolves.toMatchObject({ summary: 'App looks healthy.' }); + }); + + it('returns a cached result on the second call with the same snapshot', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse(VALID_AI_BODY), + }); + vi.stubGlobal('fetch', fetchMock); + + const snapshot = makeSnapshot(); + const first = await analyzeSnapshot(snapshot); + const second = await analyzeSnapshot(snapshot); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second.snapshotHash).toBe(first.snapshotHash); + expect(second.id).not.toBe(first.id); + }); +}); + +describe('analyzeSnapshot error paths', () => { + it('throws NETWORK_ERROR when fetch rejects', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Connection refused'))); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ code: 'NETWORK_ERROR' }); + }); + + it('throws AUTH_ERROR on 401', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ code: 'AUTH_ERROR' }); + }); + + it('throws RATE_LIMITED with retry metadata on 429', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: 'Too Many Requests' })); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ + code: 'RATE_LIMITED', + retryAfterMs: 60000, + }); + }); + + it('throws PROXY_ERROR on generic HTTP failures', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable' })); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ code: 'PROXY_ERROR' }); + }); + + it('throws ABORTED when the fetch rejects with AbortError', async () => { + const controller = new AbortController(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('The operation was aborted.', 'AbortError'))); + + await expect(analyzeSnapshot(makeSnapshot(), { signal: controller.signal })).rejects.toMatchObject({ code: 'ABORTED' }); + }); + + it('throws PARSE_ERROR when response JSON parsing fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => { + throw new SyntaxError('Bad JSON'); + }, + })); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ code: 'PARSE_ERROR' }); + }); + + it('throws PARSE_ERROR when model content is missing', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [], usage: {} }), + })); + + await expect(analyzeSnapshot(makeSnapshot())).rejects.toMatchObject({ code: 'PARSE_ERROR' }); + }); + + it('falls back to raw text as summary when model content is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => makeOpenAIResponse('This is just plain text, not JSON at all.'), + })); + + const result = await analyzeSnapshot(makeSnapshot()); + + expect(result.summary).toContain('This is just plain text'); + expect(result.suggestions).toEqual([ + expect.objectContaining({ title: 'Raw Analysis', severity: 'info' }), + ]); + }); +}); + +describe('AIAnalysisError', () => { + it('sets name, message, and code correctly', () => { + const error = new AIAnalysisError('Something went wrong', 'NETWORK_ERROR'); + + expect(error.name).toBe('AIAnalysisError'); + expect(error.message).toBe('Something went wrong'); + expect(error.code).toBe('NETWORK_ERROR'); + expect(error).toBeInstanceOf(Error); + }); + + it('stores optional retryAfterMs when provided', () => { + const error = new AIAnalysisError('Rate limited', 'RATE_LIMITED', 30_000); + + expect(error.retryAfterMs).toBe(30_000); + }); +}); diff --git a/src/__tests__/snapshot-builder.test.ts b/src/__tests__/snapshot-builder.test.ts new file mode 100644 index 0000000..69cd7c4 --- /dev/null +++ b/src/__tests__/snapshot-builder.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect } from 'vitest'; +import { buildSnapshot, hashSnapshot, snapshotToPromptText } from '../services/snapshot-builder'; +import type { AIAnalysisSnapshot, CrashEntry, Issue, RenderInfo, TabState } from '../types'; + +function makeEmptyTabState(): TabState { + return { + reactDetected: false, + reactVersion: null, + reactMode: null, + reduxDetected: false, + issues: [], + components: [], + renders: new Map(), + clsReport: null, + reduxState: null, + reduxActions: [], + memoryReport: null, + pageLoadMetrics: null, + timelineEvents: [], + }; +} + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: 'issue-1', + type: 'MISSING_KEY', + severity: 'warning', + component: 'MyList', + message: 'Missing key prop', + suggestion: 'Add a unique key', + timestamp: Date.now(), + ...overrides, + }; +} + +function makeRenderInfo(overrides: Partial = {}): RenderInfo { + return { + componentId: 'comp-1', + componentName: 'MyComponent', + renderCount: 5, + lastRenderTime: Date.now(), + renderDurations: [10, 20, 30], + selfDurations: [5, 10, 15], + triggerReasons: [], + ...overrides, + }; +} + +function makeCrash(overrides: Partial = {}): CrashEntry { + return { + id: 'crash-1', + timestamp: Date.now(), + type: 'js-error', + message: 'Cannot read property', + stack: 'line1\nline2', + analysisHints: ['Check null refs'], + ...overrides, + }; +} + +function makeSnapshot(overrides: Partial = {}): AIAnalysisSnapshot { + return { + issues: [], + components: [], + crashes: [], + memory: null, + pageMetrics: null, + reactVersion: '18.2.0', + reactMode: 'development', + totalRenders: 0, + totalTimelineEvents: 0, + ...overrides, + }; +} + +describe('buildSnapshot', () => { + it('returns empty arrays and null sections for an empty TabState', () => { + const snapshot = buildSnapshot(makeEmptyTabState()); + + expect(snapshot.issues).toEqual([]); + expect(snapshot.components).toEqual([]); + expect(snapshot.crashes).toEqual([]); + expect(snapshot.memory).toBeNull(); + expect(snapshot.pageMetrics).toBeNull(); + expect(snapshot.totalRenders).toBe(0); + expect(snapshot.totalTimelineEvents).toBe(0); + }); + + it('limits issues to the first 50 entries', () => { + const state = makeEmptyTabState(); + state.issues = Array.from({ length: 51 }, (_, index) => + makeIssue({ id: `issue-${index}`, component: `Comp${index}` }) + ); + + const snapshot = buildSnapshot(state); + + expect(snapshot.issues).toHaveLength(50); + expect(snapshot.issues[0].component).toBe('Comp0'); + expect(snapshot.issues[49].component).toBe('Comp49'); + }); + + it('maps issues to the prompt-safe shape', () => { + const state = makeEmptyTabState(); + state.issues = [ + makeIssue({ + id: 'private-id', + type: 'EXCESSIVE_RERENDERS', + severity: 'error', + component: 'Header', + message: 'Too many renders', + suggestion: 'Use memo', + code: '
', + fiberId: 'fiber-1', + }), + ]; + + const issue = buildSnapshot(state).issues[0]; + + expect(issue).toEqual({ + type: 'EXCESSIVE_RERENDERS', + severity: 'error', + component: 'Header', + message: 'Too many renders', + suggestion: 'Use memo', + }); + expect((issue as Record).id).toBeUndefined(); + expect((issue as Record).code).toBeUndefined(); + expect((issue as Record).timestamp).toBeUndefined(); + expect((issue as Record).fiberId).toBeUndefined(); + }); + + it('sorts components by render count descending and limits to 30', () => { + const state = makeEmptyTabState(); + for (let index = 1; index <= 31; index += 1) { + state.renders.set( + `comp-${index}`, + makeRenderInfo({ + componentId: `comp-${index}`, + componentName: `Comp${index}`, + renderCount: index, + }), + ); + } + + const snapshot = buildSnapshot(state); + + expect(snapshot.components).toHaveLength(30); + expect(snapshot.components[0]).toMatchObject({ name: 'Comp31', renderCount: 31 }); + expect(snapshot.components[29]).toMatchObject({ name: 'Comp2', renderCount: 2 }); + }); + + it('rounds average render duration to two decimals', () => { + const state = makeEmptyTabState(); + state.renders.set('comp-1', makeRenderInfo({ renderDurations: [1, 2, 2] })); + + expect(buildSnapshot(state).components[0].avgDuration).toBe(1.67); + }); + + it('uses 0 average duration when no render durations exist', () => { + const state = makeEmptyTabState(); + state.renders.set('comp-1', makeRenderInfo({ renderDurations: [] })); + + expect(buildSnapshot(state).components[0].avgDuration).toBe(0); + }); + + it('sums total renders across all render entries', () => { + const state = makeEmptyTabState(); + state.renders.set('a', makeRenderInfo({ componentId: 'a', renderCount: 10 })); + state.renders.set('b', makeRenderInfo({ componentId: 'b', renderCount: 25 })); + + expect(buildSnapshot(state).totalRenders).toBe(35); + }); + + it('includes rounded memory values when current memory exists', () => { + const state = makeEmptyTabState(); + state.memoryReport = { + current: { + timestamp: Date.now(), + usedJSHeapSize: 50.25 * 1024 * 1024, + totalJSHeapSize: 100 * 1024 * 1024, + jsHeapSizeLimit: 2048 * 1024 * 1024, + }, + history: [], + growthRate: 512.25 * 1024, + peakUsage: 0, + warnings: ['High memory usage'], + crashes: [], + }; + + expect(buildSnapshot(state).memory).toEqual({ + usedMB: 50.3, + totalMB: 100, + limitMB: 2048, + growthRateKBs: 512.3, + warnings: ['High memory usage'], + }); + }); + + it('includes page metrics subset when page load metrics exist', () => { + const state = makeEmptyTabState(); + state.pageLoadMetrics = { + fcp: 800, + lcp: 1500, + ttfb: 100, + domContentLoaded: 900, + loadComplete: 2000, + timestamp: Date.now(), + }; + + expect(buildSnapshot(state).pageMetrics).toEqual({ fcp: 800, lcp: 1500, ttfb: 100 }); + }); + + it('carries React metadata and timeline event count through', () => { + const state = makeEmptyTabState(); + state.reactVersion = '18.3.0'; + state.reactMode = 'production'; + state.timelineEvents = [ + { id: 'event-1', timestamp: 1, type: 'render', payload: { componentName: 'A', componentId: 'a', trigger: 'mount' } }, + { id: 'event-2', timestamp: 2, type: 'error', payload: { errorType: 'js-error', message: 'boom' } }, + ]; + + const snapshot = buildSnapshot(state); + + expect(snapshot.reactVersion).toBe('18.3.0'); + expect(snapshot.reactMode).toBe('production'); + expect(snapshot.totalTimelineEvents).toBe(2); + }); + + it('keeps the last 10 crashes from the memory report', () => { + const state = makeEmptyTabState(); + state.memoryReport = { + current: null, + history: [], + growthRate: 0, + peakUsage: 0, + warnings: [], + crashes: Array.from({ length: 12 }, (_, index) => + makeCrash({ id: `crash-${index}`, message: `Crash ${index}` }) + ), + }; + + const snapshot = buildSnapshot(state); + + expect(snapshot.crashes).toHaveLength(10); + expect(snapshot.crashes[0].message).toBe('Crash 2'); + expect(snapshot.crashes[9].message).toBe('Crash 11'); + }); + + it('truncates crash stacks to 10 lines', () => { + const state = makeEmptyTabState(); + state.memoryReport = { + current: null, + history: [], + growthRate: 0, + peakUsage: 0, + warnings: [], + crashes: [makeCrash({ stack: Array.from({ length: 12 }, (_, index) => `line${index + 1}`).join('\n') })], + }; + + const stack = buildSnapshot(state).crashes[0].stack; + + expect(stack?.split('\n')).toHaveLength(10); + expect(stack).not.toContain('line11'); + }); +}); + +describe('hashSnapshot', () => { + it('returns a 64-character lowercase hex string', async () => { + const hash = await hashSnapshot(makeSnapshot()); + + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic for the same input', async () => { + const snapshot = makeSnapshot({ + issues: [{ type: 'MISSING_KEY', severity: 'warning', component: 'List', message: 'Missing key', suggestion: 'Add key' }], + totalRenders: 42, + }); + + await expect(hashSnapshot(snapshot)).resolves.toBe(await hashSnapshot(snapshot)); + }); + + it('changes when render totals change', async () => { + await expect(hashSnapshot(makeSnapshot({ totalRenders: 10 }))).resolves.not.toBe( + await hashSnapshot(makeSnapshot({ totalRenders: 99 })), + ); + }); + + it('changes when memory summary changes', async () => { + const withMemory = makeSnapshot({ + memory: { usedMB: 50, totalMB: 100, limitMB: 2048, growthRateKBs: 0, warnings: [] }, + }); + + await expect(hashSnapshot(withMemory)).resolves.not.toBe(await hashSnapshot(makeSnapshot({ memory: null }))); + }); +}); + +describe('snapshotToPromptText', () => { + it('includes React version, mode, renders, and timeline totals', () => { + const text = snapshotToPromptText(makeSnapshot({ totalRenders: 12, totalTimelineEvents: 3 })); + + expect(text).toContain('React 18.2.0 (development mode)'); + expect(text).toContain('Total renders: 12 | Timeline events: 3'); + }); + + it('falls back to unknown React metadata', () => { + const text = snapshotToPromptText(makeSnapshot({ reactVersion: null, reactMode: null })); + + expect(text).toContain('React unknown (unknown mode)'); + }); + + it('includes detected issues when present', () => { + const text = snapshotToPromptText(makeSnapshot({ + issues: [{ type: 'MISSING_KEY', severity: 'warning', component: 'List', message: 'Missing key', suggestion: 'Add key' }], + })); + + expect(text).toContain('## Detected Issues'); + expect(text).toContain('[WARNING] MISSING_KEY in : Missing key'); + }); + + it('omits detected issues section when no issues exist', () => { + expect(snapshotToPromptText(makeSnapshot({ issues: [] }))).not.toContain('## Detected Issues'); + }); + + it('includes only the top 15 components in prompt text', () => { + const text = snapshotToPromptText(makeSnapshot({ + components: Array.from({ length: 16 }, (_, index) => ({ + name: `Comp${index + 1}`, + renderCount: index + 1, + avgDuration: 5, + })), + })); + + expect(text).toContain('## Top Components by Render Count'); + expect(text).toContain('Comp15'); + expect(text).not.toContain('Comp16'); + }); + + it('includes crash stacks and analysis hints', () => { + const text = snapshotToPromptText(makeSnapshot({ + crashes: [{ type: 'js-error', message: 'Cannot read property', stack: 'at App.js:10\nat index.js:5', analysisHints: ['Check null ref'] }], + })); + + expect(text).toContain('## Crashes/Errors'); + expect(text).toContain('[js-error] Cannot read property'); + expect(text).toContain('Stack: at App.js:10 | at index.js:5'); + expect(text).toContain('Hints: Check null ref'); + }); + + it('includes memory usage and warnings', () => { + const text = snapshotToPromptText(makeSnapshot({ + memory: { usedMB: 75, totalMB: 150, limitMB: 2048, growthRateKBs: 10, warnings: ['leak detected'] }, + })); + + expect(text).toContain('## Memory'); + expect(text).toContain('Used: 75MB / 2048MB'); + expect(text).toContain('Growth: 10KB/s'); + expect(text).toContain('Warnings: leak detected'); + }); + + it('includes page load metrics and N/A fallbacks', () => { + const text = snapshotToPromptText(makeSnapshot({ + pageMetrics: { fcp: null, lcp: 1500, ttfb: null }, + })); + + expect(text).toContain('## Page Load Metrics'); + expect(text).toContain('FCP: N/Ams | LCP: 1500ms | TTFB: N/Ams'); + }); +}); diff --git a/src/__tests__/token-optimizer.test.ts b/src/__tests__/token-optimizer.test.ts new file mode 100644 index 0000000..46e401e --- /dev/null +++ b/src/__tests__/token-optimizer.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import type { AIAnalysisResult } from '../types'; + +let tokenOptimizer: typeof import('../services/token-optimizer')['tokenOptimizer']; + +function makeResult(overrides: Partial = {}): AIAnalysisResult { + return { + id: 'ai-1', + timestamp: Date.now(), + snapshotHash: 'abc123', + model: 'gemini-flash', + summary: 'All good', + security: [], + crashRisks: [], + performance: [], + rootCauses: [], + suggestions: [], + tokenUsage: { prompt: 100, completion: 50, total: 150 }, + latencyMs: 200, + ...overrides, + }; +} + +beforeEach(async () => { + vi.resetModules(); + const mod = await import('../services/token-optimizer'); + tokenOptimizer = mod.tokenOptimizer; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('checkRateLimit', () => { + it('allows calls and shows 3 remaining before any calls are recorded', () => { + expect(tokenOptimizer.checkRateLimit()).toEqual({ + allowed: true, + remainingCalls: 3, + resetInMs: 0, + unlimited: false, + }); + }); + + it('decrements remaining calls after each recorded call', () => { + tokenOptimizer.recordCall(); + expect(tokenOptimizer.checkRateLimit()).toMatchObject({ allowed: true, remainingCalls: 2 }); + + tokenOptimizer.recordCall(); + expect(tokenOptimizer.checkRateLimit()).toMatchObject({ allowed: true, remainingCalls: 1 }); + }); + + it('blocks the fourth call inside the free window', () => { + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + + expect(tokenOptimizer.checkRateLimit()).toMatchObject({ + allowed: false, + remainingCalls: 0, + unlimited: false, + }); + }); + + it('reports reset time while rate limited', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + vi.advanceTimersByTime(60_000); + + const result = tokenOptimizer.checkRateLimit(); + + expect(result.allowed).toBe(false); + expect(result.resetInMs).toBe(4 * 60 * 1000); + }); + + it('expires calls outside the 5-minute sliding window', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + expect(tokenOptimizer.checkRateLimit()).toMatchObject({ allowed: true, remainingCalls: 3 }); + }); + +}); + +describe('getCachedResult', () => { + it('returns null for a key that was never set', () => { + expect(tokenOptimizer.getCachedResult('missing')).toBeNull(); + }); + + it('returns the result immediately after setting it', () => { + const result = makeResult(); + tokenOptimizer.setCachedResult('hash-1', result); + + expect(tokenOptimizer.getCachedResult('hash-1')).toEqual(result); + }); + + it('returns null for an expired entry after the 10-minute TTL', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + tokenOptimizer.setCachedResult('hash-ttl', makeResult()); + vi.advanceTimersByTime(10 * 60 * 1000 + 1); + + expect(tokenOptimizer.getCachedResult('hash-ttl')).toBeNull(); + }); + + it('keeps an entry within the 10-minute TTL', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const result = makeResult(); + + tokenOptimizer.setCachedResult('hash-fresh', result); + vi.advanceTimersByTime(9 * 60 * 1000); + + expect(tokenOptimizer.getCachedResult('hash-fresh')).toEqual(result); + }); +}); + +describe('setCachedResult', () => { + it('evicts the oldest entry when the cache reaches 20 entries', () => { + for (let index = 0; index < 20; index += 1) { + tokenOptimizer.setCachedResult(`hash-${index}`, makeResult({ id: `ai-${index}` })); + } + + tokenOptimizer.setCachedResult('hash-overflow', makeResult({ id: 'ai-overflow' })); + + expect(tokenOptimizer.getCacheSize()).toBe(20); + expect(tokenOptimizer.getCachedResult('hash-0')).toBeNull(); + expect(tokenOptimizer.getCachedResult('hash-overflow')).not.toBeNull(); + }); + +}); + +describe('clearCache', () => { + it('removes all cached entries', () => { + tokenOptimizer.setCachedResult('a', makeResult()); + tokenOptimizer.setCachedResult('b', makeResult()); + + tokenOptimizer.clearCache(); + + expect(tokenOptimizer.getCacheSize()).toBe(0); + expect(tokenOptimizer.getCachedResult('a')).toBeNull(); + }); +}); + +describe('getCacheSize', () => { + it('returns 0 on a fresh instance', () => { + expect(tokenOptimizer.getCacheSize()).toBe(0); + }); + + it('increments after each new cached result', () => { + tokenOptimizer.setCachedResult('x', makeResult()); + tokenOptimizer.setCachedResult('y', makeResult()); + + expect(tokenOptimizer.getCacheSize()).toBe(2); + }); +}); + +describe('getTotalTokensUsed', () => { + it('returns 0 when cache is empty', () => { + expect(tokenOptimizer.getTotalTokensUsed()).toBe(0); + }); + + it('sums total tokens across cached entries', () => { + tokenOptimizer.setCachedResult('h1', makeResult({ tokenUsage: { prompt: 100, completion: 50, total: 150 } })); + tokenOptimizer.setCachedResult('h2', makeResult({ tokenUsage: { prompt: 200, completion: 100, total: 300 } })); + + expect(tokenOptimizer.getTotalTokensUsed()).toBe(450); + }); +}); + +describe('validateSubscriptionKey', () => { + it('returns false and stays unsubscribed for an empty key', async () => { + await expect(tokenOptimizer.validateSubscriptionKey('')).resolves.toBe(false); + expect(tokenOptimizer.isSubscribed).toBe(false); + expect(tokenOptimizer.subscriptionKey).toBe(''); + }); + + it('returns false and stays unsubscribed for whitespace-only keys', async () => { + await expect(tokenOptimizer.validateSubscriptionKey(' ')).resolves.toBe(false); + expect(tokenOptimizer.isSubscribed).toBe(false); + }); + + it('returns false when the validation endpoint returns non-ok status', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json: async () => ({ valid: false }) })); + + await expect(tokenOptimizer.validateSubscriptionKey('some-key')).resolves.toBe(false); + expect(tokenOptimizer.isSubscribed).toBe(false); + }); + + it('returns false when the validation request throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure'))); + + await expect(tokenOptimizer.validateSubscriptionKey('some-key')).resolves.toBe(false); + expect(tokenOptimizer.isSubscribed).toBe(false); + }); + + it('returns true and stores trimmed key when endpoint returns valid', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ valid: true }) })); + + await expect(tokenOptimizer.validateSubscriptionKey(' good-key ')).resolves.toBe(true); + expect(tokenOptimizer.isSubscribed).toBe(true); + expect(tokenOptimizer.subscriptionKey).toBe('good-key'); + }); + + it('allows unlimited calls when subscribed', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ valid: true }) })); + + await tokenOptimizer.validateSubscriptionKey('good-key'); + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + tokenOptimizer.recordCall(); + + expect(tokenOptimizer.checkRateLimit()).toMatchObject({ allowed: true, unlimited: true }); + }); +}); diff --git a/src/panel/Panel.tsx b/src/panel/Panel.tsx index 7ff0700..28f9921 100644 --- a/src/panel/Panel.tsx +++ b/src/panel/Panel.tsx @@ -150,6 +150,28 @@ export function Panel() { } }, [tabId, state.reduxDetected]); + // WAI-ARIA Tabs — arrow-key roving focus between tabs + const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => { + const tabCount = TABS.length; + let nextIndex: number | null = null; + if (e.key === 'ArrowRight') { + nextIndex = (currentIndex + 1) % tabCount; + } else if (e.key === 'ArrowLeft') { + nextIndex = (currentIndex - 1 + tabCount) % tabCount; + } else if (e.key === 'Home') { + nextIndex = 0; + } else if (e.key === 'End') { + nextIndex = tabCount - 1; + } + if (nextIndex !== null) { + e.preventDefault(); + const nextTab = TABS[nextIndex]; + handleTabChange(nextTab.id); + const nextBtn = document.getElementById(`tab-${nextTab.id}`); + nextBtn?.focus(); + } + }, [handleTabChange]); + useEffect(() => { fetchState(); @@ -427,25 +449,38 @@ export function Panel() {
-