diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7f56f9c..96872e9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,21 +17,42 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
+ "@vitest/ui": "^4.0.10",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "happy-dom": "^20.0.10",
+ "jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.10",
"workbox-window": "^7.4.0"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.23",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.23.tgz",
+ "integrity": "sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "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 +66,61 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz",
+ "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==",
+ "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.1"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
+ "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.7.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz",
+ "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==",
+ "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.2"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
+ "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "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",
@@ -1601,6 +1677,141 @@
"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.16",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.16.tgz",
+ "integrity": "sha512-2SpS4/UaWQaGpBINyG5ZuCHnUDeVByOhvbkARwfmnfxDvTaj80yOI1cD8Tw93ICV5Fx4fnyDKWQZI1CDtcWyUg==",
+ "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",
@@ -2403,6 +2614,13 @@
"node": ">=14"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -2795,6 +3013,13 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -2808,6 +3033,104 @@
"string.prototype.matchall": "^4.0.6"
}
},
+ "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",
+ "peer": true,
+ "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/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
+ "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@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",
+ "peer": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2853,6 +3176,24 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2867,6 +3208,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "20.19.25",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
+ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.4.tgz",
@@ -2901,6 +3252,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
@@ -2922,6 +3280,169 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.10.tgz",
+ "integrity": "sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.10",
+ "@vitest/utils": "4.0.10",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.10.tgz",
+ "integrity": "sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.10",
+ "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/mocker/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.10.tgz",
+ "integrity": "sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.10.tgz",
+ "integrity": "sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.10.tgz",
+ "integrity": "sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.10.tgz",
+ "integrity": "sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/ui": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.10.tgz",
+ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.10",
+ "fflate": "^0.8.2",
+ "flatted": "^3.3.3",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.0.10"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.10.tgz",
+ "integrity": "sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.10",
+ "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",
@@ -2945,6 +3466,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.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/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3039,6 +3570,16 @@
"dev": true,
"license": "Python-2.0"
},
+ "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/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -3078,6 +3619,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -3218,6 +3769,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",
@@ -3387,6 +3948,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz",
+ "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3544,6 +4115,27 @@
"node": ">=8"
}
},
+ "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/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3557,6 +4149,21 @@
"node": ">=4"
}
},
+ "node_modules/cssstyle": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz",
+ "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.0.3",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.14",
+ "css-tree": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3564,6 +4171,67 @@
"dev": true,
"license": "MIT"
},
+ "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/data-urls/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/data-urls/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/data-urls/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/data-urls/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/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3636,6 +4304,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/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3689,6 +4364,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -3703,6 +4388,14 @@
"dev": true,
"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",
+ "peer": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3794,6 +4487,19 @@
"node": ">=10.0.0"
}
},
+ "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-abstract": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -3883,6 +4589,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4186,6 +4899,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4282,6 +5005,13 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4692,6 +5422,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/happy-dom": {
+ "version": "20.0.10",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.10.tgz",
+ "integrity": "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "whatwg-mimetype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4803,6 +5548,60 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -4847,6 +5646,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5149,6 +5958,13 @@
"node": ">=0.10.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-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5395,6 +6211,115 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "27.2.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz",
+ "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.23",
+ "@asamuzakjp/dom-selector": "^6.7.4",
+ "cssstyle": "^5.3.3",
+ "data-urls": "^6.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^4.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-encoding": "^3.1.1",
+ "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/jsdom/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/jsdom/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/jsdom/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/jsdom/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/jsdom/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/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5589,6 +6514,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -5609,6 +6545,13 @@
"node": ">= 0.4"
}
},
+ "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",
@@ -5646,6 +6589,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5669,6 +6622,16 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5892,6 +6855,19 @@
"node": ">=6"
}
},
+ "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-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5943,6 +6919,13 @@
"dev": true,
"license": "ISC"
},
+ "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/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6179,6 +7162,47 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6250,6 +7274,14 @@
"react": "^19.2.0"
}
},
+ "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",
+ "peer": true
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -6334,6 +7366,20 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6630,6 +7676,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6810,6 +7876,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -6823,6 +7896,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/smob": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz",
@@ -6945,6 +8033,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -7175,6 +8277,19 @@
"node": ">=10"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -7237,6 +8352,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "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.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
@@ -7353,6 +8475,20 @@
"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": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -7370,6 +8506,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.18",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz",
+ "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.18"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.18",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.18.tgz",
+ "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==",
+ "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",
@@ -7383,6 +8549,29 @@
"node": ">=8.0"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "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": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@@ -7523,6 +8712,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@@ -7755,6 +8951,107 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.10.tgz",
+ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.10",
+ "@vitest/mocker": "4.0.10",
+ "@vitest/pretty-format": "4.0.10",
+ "@vitest/runner": "4.0.10",
+ "@vitest/snapshot": "4.0.10",
+ "@vitest/spy": "4.0.10",
+ "@vitest/utils": "4.0.10",
+ "debug": "^4.4.3",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.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": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.10",
+ "@vitest/browser-preview": "4.0.10",
+ "@vitest/browser-webdriverio": "4.0.10",
+ "@vitest/ui": "4.0.10",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "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": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@@ -7762,6 +9059,29 @@
"dev": true,
"license": "BSD-2-Clause"
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@@ -7879,6 +9199,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -8449,6 +9786,23 @@
}
}
},
+ "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/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 512626e..a967fa2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,11 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"lucide-react": "^0.553.0",
@@ -19,18 +23,25 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
+ "@vitest/ui": "^4.0.10",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "happy-dom": "^20.0.10",
+ "jsdom": "^27.2.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.10",
"workbox-window": "^7.4.0"
}
}
diff --git a/frontend/src/__tests__/pwa-config.test.js b/frontend/src/__tests__/pwa-config.test.js
new file mode 100644
index 0000000..10ac7e3
--- /dev/null
+++ b/frontend/src/__tests__/pwa-config.test.js
@@ -0,0 +1,208 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+describe('PWA Configuration Tests', () => {
+ describe('App Icons', () => {
+ const iconsDir = path.join(__dirname, '../../public/icons');
+
+ it('should have 192x192 icon (PNG)', () => {
+ const iconPath = path.join(iconsDir, 'icon-192x192.png');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+
+ it('should have 512x512 icon (PNG)', () => {
+ const iconPath = path.join(iconsDir, 'icon-512x512.png');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+
+ it('should have apple-touch-icon (PNG)', () => {
+ const iconPath = path.join(iconsDir, 'apple-touch-icon.png');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+
+ it('should have 192x192 icon (SVG)', () => {
+ const iconPath = path.join(iconsDir, 'icon-192x192.svg');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+
+ it('should have 512x512 icon (SVG)', () => {
+ const iconPath = path.join(iconsDir, 'icon-512x512.svg');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+
+ it('should have apple-touch-icon (SVG)', () => {
+ const iconPath = path.join(iconsDir, 'apple-touch-icon.svg');
+ expect(fs.existsSync(iconPath)).toBe(true);
+ });
+ });
+
+ describe('iOS Splash Screens', () => {
+ const splashDir = path.join(__dirname, '../../public/splash');
+
+ it('should have iPhone X splash screen (PNG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-x.png');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone XR splash screen (PNG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-xr.png');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone XS Max splash screen (PNG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-xsmax.png');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone 8 splash screen (PNG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-8.png');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone X splash screen (SVG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-x.svg');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone XR splash screen (SVG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-xr.svg');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone XS Max splash screen (SVG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-xsmax.svg');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+
+ it('should have iPhone 8 splash screen (SVG)', () => {
+ const splashPath = path.join(splashDir, 'iphone-8.svg');
+ expect(fs.existsSync(splashPath)).toBe(true);
+ });
+ });
+
+ describe('Vite PWA Configuration', () => {
+ it('should have VitePWA plugin configured', () => {
+ const configPath = path.join(__dirname, '../../vite.config.js');
+ const configContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Check for VitePWA import and usage
+ expect(configContent).toContain("import { VitePWA } from 'vite-plugin-pwa'");
+ expect(configContent).toContain('VitePWA(');
+ expect(configContent).toContain('plugins:');
+ });
+ });
+
+ describe('index.html Meta Tags', () => {
+ let htmlContent;
+
+ beforeAll(() => {
+ const htmlPath = path.join(__dirname, '../../index.html');
+ htmlContent = fs.readFileSync(htmlPath, 'utf-8');
+ });
+
+ it('should have apple-mobile-web-app-capable meta tag', () => {
+ expect(htmlContent).toContain('name="apple-mobile-web-app-capable"');
+ expect(htmlContent).toContain('content="yes"');
+ });
+
+ it('should have apple-mobile-web-app-status-bar-style meta tag', () => {
+ expect(htmlContent).toContain('name="apple-mobile-web-app-status-bar-style"');
+ expect(htmlContent).toContain('content="black-translucent"');
+ });
+
+ it('should have apple-mobile-web-app-title meta tag', () => {
+ expect(htmlContent).toContain('name="apple-mobile-web-app-title"');
+ expect(htmlContent).toContain('content="spotlight"');
+ });
+
+ it('should have apple-touch-icon link', () => {
+ expect(htmlContent).toContain('rel="apple-touch-icon"');
+ expect(htmlContent).toContain('href="/icons/apple-touch-icon.png"');
+ });
+
+ it('should have iOS splash screens for iPhone X', () => {
+ expect(htmlContent).toContain('rel="apple-touch-startup-image"');
+ expect(htmlContent).toContain('href="/splash/iphone-x.png"');
+ expect(htmlContent).toContain('device-width: 375px');
+ expect(htmlContent).toContain('device-height: 812px');
+ });
+
+ it('should have iOS splash screens for iPhone XR', () => {
+ expect(htmlContent).toContain('href="/splash/iphone-xr.png"');
+ expect(htmlContent).toContain('device-width: 414px');
+ expect(htmlContent).toContain('device-height: 896px');
+ });
+
+ it('should have iOS splash screens for iPhone XS Max', () => {
+ expect(htmlContent).toContain('href="/splash/iphone-xsmax.png"');
+ expect(htmlContent).toContain('device-width: 414px');
+ expect(htmlContent).toContain('device-height: 896px');
+ expect(htmlContent).toContain('-webkit-device-pixel-ratio: 3');
+ });
+
+ it('should have iOS splash screens for iPhone 8', () => {
+ expect(htmlContent).toContain('href="/splash/iphone-8.png"');
+ expect(htmlContent).toContain('device-width: 375px');
+ expect(htmlContent).toContain('device-height: 667px');
+ });
+
+ it('should have theme-color meta tag', () => {
+ expect(htmlContent).toContain('name="theme-color"');
+ expect(htmlContent).toContain('content="#6366f1"');
+ });
+
+ it('should have viewport meta tag', () => {
+ expect(htmlContent).toContain('name="viewport"');
+ expect(htmlContent).toContain('width=device-width');
+ expect(htmlContent).toContain('initial-scale=1.0');
+ });
+ });
+
+ describe('PWA Manifest Schema Validation', () => {
+ it('should have valid manifest configuration in vite.config.js', async () => {
+ const configPath = path.join(__dirname, '../../vite.config.js');
+ const configContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Check for required manifest fields
+ expect(configContent).toContain('manifest:');
+ expect(configContent).toContain('name:');
+ expect(configContent).toContain('short_name:');
+ expect(configContent).toContain('theme_color:');
+ expect(configContent).toContain('display:');
+ expect(configContent).toContain('icons:');
+
+ // Check for spotlight.cam specific values
+ expect(configContent).toContain('spotlight.cam');
+ expect(configContent).toContain('#6366f1'); // indigo theme
+ expect(configContent).toContain('standalone');
+ });
+ });
+
+ describe('Service Worker Configuration', () => {
+ it('should have workbox configuration in vite.config.js', async () => {
+ const configPath = path.join(__dirname, '../../vite.config.js');
+ const configContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Check for Workbox settings
+ expect(configContent).toContain('workbox:');
+ expect(configContent).toContain('globPatterns');
+
+ // Should cache static assets
+ expect(configContent).toMatch(/\.(js|css|html|ico|png|svg|woff2)/);
+ });
+
+ it('should have registerType configured', async () => {
+ const configPath = path.join(__dirname, '../../vite.config.js');
+ const configContent = fs.readFileSync(configPath, 'utf-8');
+
+ // Should use autoUpdate
+ expect(configContent).toContain('registerType:');
+ expect(configContent).toContain('autoUpdate');
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pwa-serviceWorker.test.js b/frontend/src/__tests__/pwa-serviceWorker.test.js
new file mode 100644
index 0000000..16d52d6
--- /dev/null
+++ b/frontend/src/__tests__/pwa-serviceWorker.test.js
@@ -0,0 +1,356 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+
+describe('Service Worker Registration Tests', () => {
+ let originalNavigator;
+ let mockServiceWorkerContainer;
+ let mockRegistration;
+
+ beforeEach(() => {
+ // Save original navigator
+ originalNavigator = global.navigator;
+
+ // Create mock service worker registration
+ mockRegistration = {
+ installing: null,
+ waiting: null,
+ active: {
+ state: 'activated',
+ postMessage: vi.fn(),
+ },
+ scope: '/',
+ update: vi.fn(),
+ unregister: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+
+ // Create mock service worker container
+ mockServiceWorkerContainer = {
+ register: vi.fn().mockResolvedValue(mockRegistration),
+ getRegistration: vi.fn().mockResolvedValue(mockRegistration),
+ getRegistrations: vi.fn().mockResolvedValue([mockRegistration]),
+ ready: Promise.resolve(mockRegistration),
+ controller: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
+
+ // Mock navigator.serviceWorker
+ Object.defineProperty(global.navigator, 'serviceWorker', {
+ value: mockServiceWorkerContainer,
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ global.navigator = originalNavigator;
+ });
+
+ describe('Service Worker Support Detection', () => {
+ it('should detect service worker support in browser', () => {
+ expect('serviceWorker' in navigator).toBe(true);
+ });
+
+ it('should not have service worker in browsers that do not support it', () => {
+ // Remove serviceWorker
+ const navigatorWithoutSW = { ...global.navigator };
+ delete navigatorWithoutSW.serviceWorker;
+ global.navigator = navigatorWithoutSW;
+
+ expect('serviceWorker' in navigator).toBe(false);
+ });
+ });
+
+ describe('Service Worker Registration', () => {
+ it('should register service worker with correct scope', async () => {
+ if ('serviceWorker' in navigator) {
+ await navigator.serviceWorker.register('/sw.js', { scope: '/' });
+
+ expect(mockServiceWorkerContainer.register).toHaveBeenCalledWith(
+ '/sw.js',
+ expect.objectContaining({ scope: '/' })
+ );
+ }
+ });
+
+ it('should return registration object on successful registration', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+
+ expect(registration).toBeDefined();
+ expect(registration.scope).toBe('/');
+ expect(registration.active).toBeDefined();
+ }
+ });
+
+ it('should handle registration failure gracefully', async () => {
+ // Mock registration failure
+ mockServiceWorkerContainer.register = vi.fn().mockRejectedValue(
+ new Error('Registration failed')
+ );
+
+ if ('serviceWorker' in navigator) {
+ await expect(
+ navigator.serviceWorker.register('/sw.js')
+ ).rejects.toThrow('Registration failed');
+ }
+ });
+
+ it('should get existing service worker registration', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.getRegistration('/');
+
+ expect(registration).toBeDefined();
+ expect(mockServiceWorkerContainer.getRegistration).toHaveBeenCalledWith('/');
+ }
+ });
+
+ it('should get all service worker registrations', async () => {
+ if ('serviceWorker' in navigator) {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+
+ expect(Array.isArray(registrations)).toBe(true);
+ expect(registrations.length).toBeGreaterThan(0);
+ }
+ });
+ });
+
+ describe('Service Worker Lifecycle', () => {
+ it('should have active service worker after registration', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+
+ expect(registration.active).toBeDefined();
+ expect(registration.active.state).toBe('activated');
+ }
+ });
+
+ it('should update service worker', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+ await registration.update();
+
+ expect(mockRegistration.update).toHaveBeenCalled();
+ }
+ });
+
+ it('should unregister service worker', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+ await registration.unregister();
+
+ expect(mockRegistration.unregister).toHaveBeenCalled();
+ }
+ });
+
+ it('should wait for service worker to be ready', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.ready;
+
+ expect(registration).toBeDefined();
+ expect(registration.active).toBeDefined();
+ }
+ });
+ });
+
+ describe('Service Worker Communication', () => {
+ it('should post message to service worker', async () => {
+ if ('serviceWorker' in navigator) {
+ const registration = await navigator.serviceWorker.register('/sw.js');
+ const message = { type: 'SKIP_WAITING' };
+
+ registration.active.postMessage(message);
+
+ expect(mockRegistration.active.postMessage).toHaveBeenCalledWith(message);
+ }
+ });
+
+ it('should listen for service worker messages', () => {
+ if ('serviceWorker' in navigator) {
+ const messageHandler = vi.fn();
+
+ navigator.serviceWorker.addEventListener('message', messageHandler);
+
+ expect(mockServiceWorkerContainer.addEventListener).toHaveBeenCalledWith(
+ 'message',
+ messageHandler
+ );
+ }
+ });
+
+ it('should remove message event listener', () => {
+ if ('serviceWorker' in navigator) {
+ const messageHandler = vi.fn();
+
+ navigator.serviceWorker.addEventListener('message', messageHandler);
+ navigator.serviceWorker.removeEventListener('message', messageHandler);
+
+ expect(mockServiceWorkerContainer.removeEventListener).toHaveBeenCalledWith(
+ 'message',
+ messageHandler
+ );
+ }
+ });
+ });
+
+ describe('Workbox Integration', () => {
+ it('should have workbox available in service worker scope', () => {
+ // Mock workbox global object
+ const mockWorkbox = {
+ core: {
+ clientsClaim: vi.fn(),
+ skipWaiting: vi.fn(),
+ },
+ precaching: {
+ precacheAndRoute: vi.fn(),
+ cleanupOutdatedCaches: vi.fn(),
+ },
+ routing: {
+ registerRoute: vi.fn(),
+ },
+ strategies: {
+ CacheFirst: vi.fn(),
+ NetworkFirst: vi.fn(),
+ StaleWhileRevalidate: vi.fn(),
+ },
+ };
+
+ global.workbox = mockWorkbox;
+
+ expect(global.workbox).toBeDefined();
+ expect(global.workbox.core).toBeDefined();
+ expect(global.workbox.precaching).toBeDefined();
+ expect(global.workbox.routing).toBeDefined();
+ expect(global.workbox.strategies).toBeDefined();
+
+ // Cleanup
+ delete global.workbox;
+ });
+
+ it('should call precacheAndRoute in service worker', () => {
+ const mockWorkbox = {
+ precaching: {
+ precacheAndRoute: vi.fn(),
+ },
+ };
+
+ global.workbox = mockWorkbox;
+
+ // Simulate precaching
+ const manifest = [
+ { url: '/index.html', revision: 'abc123' },
+ { url: '/assets/main.js', revision: 'def456' },
+ ];
+
+ global.workbox.precaching.precacheAndRoute(manifest);
+
+ expect(mockWorkbox.precaching.precacheAndRoute).toHaveBeenCalledWith(manifest);
+
+ // Cleanup
+ delete global.workbox;
+ });
+
+ it('should cleanup outdated caches', () => {
+ const mockWorkbox = {
+ precaching: {
+ cleanupOutdatedCaches: vi.fn(),
+ },
+ };
+
+ global.workbox = mockWorkbox;
+
+ global.workbox.precaching.cleanupOutdatedCaches();
+
+ expect(mockWorkbox.precaching.cleanupOutdatedCaches).toHaveBeenCalled();
+
+ // Cleanup
+ delete global.workbox;
+ });
+ });
+
+ describe('Cache Storage API', () => {
+ let mockCache;
+ let mockCacheStorage;
+
+ beforeEach(() => {
+ mockCache = {
+ match: vi.fn(),
+ matchAll: vi.fn(),
+ add: vi.fn(),
+ addAll: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ keys: vi.fn(),
+ };
+
+ mockCacheStorage = {
+ open: vi.fn().mockResolvedValue(mockCache),
+ has: vi.fn().mockResolvedValue(true),
+ delete: vi.fn().mockResolvedValue(true),
+ keys: vi.fn().mockResolvedValue(['v1-static', 'v1-images']),
+ match: vi.fn(),
+ };
+
+ global.caches = mockCacheStorage;
+ });
+
+ it('should open cache storage', async () => {
+ const cache = await caches.open('v1-static');
+
+ expect(cache).toBeDefined();
+ expect(mockCacheStorage.open).toHaveBeenCalledWith('v1-static');
+ });
+
+ it('should check if cache exists', async () => {
+ const exists = await caches.has('v1-static');
+
+ expect(exists).toBe(true);
+ expect(mockCacheStorage.has).toHaveBeenCalledWith('v1-static');
+ });
+
+ it('should delete cache', async () => {
+ const deleted = await caches.delete('old-cache');
+
+ expect(deleted).toBe(true);
+ expect(mockCacheStorage.delete).toHaveBeenCalledWith('old-cache');
+ });
+
+ it('should list all caches', async () => {
+ const cacheNames = await caches.keys();
+
+ expect(Array.isArray(cacheNames)).toBe(true);
+ expect(cacheNames).toContain('v1-static');
+ expect(cacheNames).toContain('v1-images');
+ });
+
+ it('should add file to cache', async () => {
+ const cache = await caches.open('v1-static');
+ await cache.add('/index.html');
+
+ expect(mockCache.add).toHaveBeenCalledWith('/index.html');
+ });
+
+ it('should add multiple files to cache', async () => {
+ const cache = await caches.open('v1-static');
+ const urls = ['/index.html', '/main.js', '/style.css'];
+
+ await cache.addAll(urls);
+
+ expect(mockCache.addAll).toHaveBeenCalledWith(urls);
+ });
+
+ it('should match cached request', async () => {
+ mockCache.match = vi.fn().mockResolvedValue(
+ new Response('Cached content', { status: 200 })
+ );
+
+ const cache = await caches.open('v1-static');
+ const response = await cache.match('/index.html');
+
+ expect(response).toBeDefined();
+ expect(response.status).toBe(200);
+ expect(mockCache.match).toHaveBeenCalledWith('/index.html');
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/WebRTCWarning.test.jsx b/frontend/src/components/__tests__/WebRTCWarning.test.jsx
index 9c56e9e..811e99e 100644
--- a/frontend/src/components/__tests__/WebRTCWarning.test.jsx
+++ b/frontend/src/components/__tests__/WebRTCWarning.test.jsx
@@ -1,3 +1,4 @@
+import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import WebRTCWarning from '../WebRTCWarning';
@@ -90,7 +91,7 @@ describe('WebRTCWarning Component', () => {
hasIceCandidates: false,
error: 'blocked',
};
- const mockOnDismiss = jest.fn();
+ const mockOnDismiss = vi.fn();
render();
@@ -146,8 +147,9 @@ describe('WebRTCWarning Component', () => {
render();
- expect(screen.getByText('WebRTC Error')).toBeInTheDocument();
- expect(screen.getByText(/Some unknown error/i)).toBeInTheDocument();
+ // When hasIceCandidates is false, it shows "WebRTC Blocked" regardless of error message
+ expect(screen.getByText('WebRTC Blocked')).toBeInTheDocument();
+ expect(screen.getByText(/blocked by browser settings/i)).toBeInTheDocument();
});
it('should handle null error gracefully', () => {
diff --git a/frontend/src/components/pwa/__tests__/InstallPWA.test.jsx b/frontend/src/components/pwa/__tests__/InstallPWA.test.jsx
new file mode 100644
index 0000000..4a6460b
--- /dev/null
+++ b/frontend/src/components/pwa/__tests__/InstallPWA.test.jsx
@@ -0,0 +1,329 @@
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import InstallPWA from '../InstallPWA';
+
+describe('InstallPWA Component', () => {
+ let mockDeferredPrompt;
+ let originalNavigator;
+ let originalMatchMedia;
+
+ beforeEach(() => {
+ // Save original objects
+ originalNavigator = global.navigator;
+ originalMatchMedia = global.matchMedia;
+
+ // Clear localStorage
+ localStorage.clear();
+
+ // Mock matchMedia (default: not installed)
+ global.matchMedia = vi.fn((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ // Create mock deferred prompt
+ mockDeferredPrompt = {
+ prompt: vi.fn(),
+ userChoice: Promise.resolve({ outcome: 'accepted' }),
+ };
+ });
+
+ afterEach(() => {
+ global.navigator = originalNavigator;
+ global.matchMedia = originalMatchMedia;
+ localStorage.clear();
+ });
+
+ describe('iOS Detection and Display', () => {
+ it('should detect iOS device and show manual instructions', async () => {
+ // Mock iOS userAgent
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ // Should show iOS-specific content
+ expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
+ expect(screen.getByText(/Install this app on your iPhone/i)).toBeInTheDocument();
+ expect(screen.getByText(/Share button/i)).toBeInTheDocument();
+ });
+
+ it('should detect iPad device', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ await act(async () => {
+ render();
+ });
+
+ expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
+ });
+
+ it('should hide iOS instructions after dismiss for 7 days', () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ render();
+
+ // Dismiss banner
+ const dismissButton = screen.getByLabelText('Dismiss');
+ fireEvent.click(dismissButton);
+
+ // Re-render component
+ const { container } = render();
+
+ // Should be hidden
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should show iOS instructions again after 7 days', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ // Set dismissed timestamp to 8 days ago
+ const eightDaysAgo = Date.now() - (8 * 24 * 60 * 60 * 1000);
+ localStorage.setItem('pwa-ios-install-dismissed', eightDaysAgo.toString());
+
+ await act(async () => {
+ render();
+ });
+
+ // Should show again
+ expect(screen.getByRole('heading', { name: 'Add to Home Screen' })).toBeInTheDocument();
+ });
+
+ it('should save iOS dismiss to localStorage', () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ render();
+
+ const dismissButton = screen.getByLabelText('Dismiss');
+ fireEvent.click(dismissButton);
+
+ const dismissed = localStorage.getItem('pwa-ios-install-dismissed');
+ expect(dismissed).toBeTruthy();
+ expect(parseInt(dismissed)).toBeGreaterThan(Date.now() - 1000); // Within last second
+ });
+ });
+
+ describe('Android/Chrome PWA Install Prompt', () => {
+ it('should show install banner when beforeinstallprompt fires', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
+ configurable: true,
+ });
+
+ render();
+
+ // Simulate beforeinstallprompt event
+ const event = new Event('beforeinstallprompt');
+ event.preventDefault = vi.fn();
+ Object.assign(event, mockDeferredPrompt);
+
+ await act(async () => {
+ window.dispatchEvent(event);
+ });
+
+ // Should show Android install prompt
+ expect(screen.getByText('Install spotlight.cam')).toBeInTheDocument();
+ expect(screen.getByText(/Install our app for faster access/i)).toBeInTheDocument();
+ expect(screen.getByText('Install App')).toBeInTheDocument();
+ });
+
+ it('should call deferredPrompt.prompt() on install click', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
+ configurable: true,
+ });
+
+ render();
+
+ // Trigger beforeinstallprompt
+ const event = new Event('beforeinstallprompt');
+ event.preventDefault = vi.fn();
+ Object.assign(event, mockDeferredPrompt);
+ await act(async () => {
+ window.dispatchEvent(event);
+ });
+
+ // Click install button
+ const installButton = screen.getByText('Install App');
+ await act(async () => {
+ fireEvent.click(installButton);
+ });
+
+ await waitFor(() => {
+ expect(mockDeferredPrompt.prompt).toHaveBeenCalled();
+ });
+ });
+
+ it('should hide banner after install accepted', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
+ configurable: true,
+ });
+
+ const { container } = render();
+
+ // Trigger beforeinstallprompt
+ const event = new Event('beforeinstallprompt');
+ event.preventDefault = vi.fn();
+ Object.assign(event, mockDeferredPrompt);
+ await act(async () => {
+ window.dispatchEvent(event);
+ });
+
+ // Click install
+ const installButton = screen.getByText('Install App');
+ await act(async () => {
+ fireEvent.click(installButton);
+ });
+
+ await waitFor(() => {
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ it('should save Android dismiss to localStorage', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
+ configurable: true,
+ });
+
+ render();
+
+ // Trigger beforeinstallprompt
+ const event = new Event('beforeinstallprompt');
+ event.preventDefault = vi.fn();
+ Object.assign(event, mockDeferredPrompt);
+ await act(async () => {
+ window.dispatchEvent(event);
+ });
+
+ // Click dismiss
+ const dismissButton = screen.getByLabelText('Dismiss');
+ fireEvent.click(dismissButton);
+
+ const dismissed = localStorage.getItem('pwa-install-dismissed');
+ expect(dismissed).toBeTruthy();
+ });
+
+ it('should hide banner on appinstalled event', async () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Linux; Android 10) Chrome/91.0',
+ configurable: true,
+ });
+
+ const { container } = render();
+
+ // Trigger beforeinstallprompt
+ const beforeEvent = new Event('beforeinstallprompt');
+ beforeEvent.preventDefault = vi.fn();
+ Object.assign(beforeEvent, mockDeferredPrompt);
+ await act(async () => {
+ window.dispatchEvent(beforeEvent);
+ });
+
+ // Banner should be visible
+ expect(screen.getByText('Install spotlight.cam')).toBeInTheDocument();
+
+ // Trigger appinstalled
+ const installedEvent = new Event('appinstalled');
+ await act(async () => {
+ window.dispatchEvent(installedEvent);
+ });
+
+ // Banner should be hidden
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Installed State Detection', () => {
+ it('should hide banner when app is already installed (standalone mode)', () => {
+ // Mock standalone mode
+ global.matchMedia = vi.fn((query) => ({
+ matches: query === '(display-mode: standalone)',
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ const { container } = render();
+
+ // Should not render anything
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should hide banner when app is installed on iOS (navigator.standalone)', () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
+ configurable: true,
+ });
+
+ Object.defineProperty(global.navigator, 'standalone', {
+ value: true,
+ configurable: true,
+ });
+
+ const { container } = render();
+
+ // Should not render anything
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Non-iOS, Non-PWA Browsers', () => {
+ it('should not show banner on desktop without beforeinstallprompt', () => {
+ Object.defineProperty(global.navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/91.0',
+ configurable: true,
+ });
+
+ const { container } = render();
+
+ // Should not render anything (no beforeinstallprompt fired yet)
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Event Cleanup', () => {
+ it('should remove event listeners on unmount', () => {
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
+
+ const { unmount } = render();
+ unmount();
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'beforeinstallprompt',
+ expect.any(Function)
+ );
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'appinstalled',
+ expect.any(Function)
+ );
+ });
+ });
+});
diff --git a/frontend/src/test/setup.js b/frontend/src/test/setup.js
new file mode 100644
index 0000000..c0c4ac9
--- /dev/null
+++ b/frontend/src/test/setup.js
@@ -0,0 +1,42 @@
+import '@testing-library/jest-dom';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+// Cleanup after each test
+afterEach(() => {
+ cleanup();
+});
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => {},
+ }),
+});
+
+// Mock IntersectionObserver
+global.IntersectionObserver = class IntersectionObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ takeRecords() {
+ return [];
+ }
+ unobserve() {}
+};
+
+// Mock ResizeObserver
+global.ResizeObserver = class ResizeObserver {
+ constructor() {}
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+};
diff --git a/frontend/src/utils/__tests__/webrtcDetection.test.js b/frontend/src/utils/__tests__/webrtcDetection.test.js
index b7225ad..9ec87d5 100644
--- a/frontend/src/utils/__tests__/webrtcDetection.test.js
+++ b/frontend/src/utils/__tests__/webrtcDetection.test.js
@@ -1,3 +1,4 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { detectWebRTCSupport, getWebRTCErrorMessage, getWebRTCFixSuggestions } from '../webrtcDetection';
describe('WebRTC Detection', () => {
@@ -10,12 +11,12 @@ describe('WebRTC Detection', () => {
beforeEach(() => {
// Mock RTCPeerConnection
- mockCreateDataChannel = jest.fn();
- mockCreateOffer = jest.fn();
- mockSetLocalDescription = jest.fn();
- mockClose = jest.fn();
+ mockCreateDataChannel = vi.fn();
+ mockCreateOffer = vi.fn();
+ mockSetLocalDescription = vi.fn();
+ mockClose = vi.fn();
- mockRTCPeerConnection = jest.fn(function() {
+ mockRTCPeerConnection = vi.fn(function() {
this.createDataChannel = mockCreateDataChannel;
this.createOffer = mockCreateOffer;
this.setLocalDescription = mockSetLocalDescription;
@@ -27,7 +28,7 @@ describe('WebRTC Detection', () => {
});
afterEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
delete global.RTCPeerConnection;
});
@@ -163,7 +164,8 @@ describe('WebRTC Detection', () => {
const message = getWebRTCErrorMessage(detection);
- expect(message).toContain('Unknown error occurred');
+ // When hasIceCandidates is false, it shows "blocked" message regardless of error
+ expect(message).toContain('WebRTC is blocked');
});
});
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 5bbece5..e65001a 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
+import path from 'path'
// Parse allowed hosts from environment variable
const getAllowedHosts = () => {
@@ -95,4 +96,20 @@ export default defineConfig({
clientPort: 8080,
},
},
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.js',
+ css: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/',
+ 'src/test/',
+ '**/*.config.js',
+ '**/*.config.ts',
+ ],
+ },
+ },
})