diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..2cd0445
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,4 @@
+# Code owners: these owners will be requested for review when someone opens a PR.
+# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/codeowners
+
+* @ManliestBen
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..73c05ea
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+# Run build and tests on push and pull requests.
+# Branch protection can require this check before merging into dev or main.
+# Flow: feature branches → PR into dev → PR from dev into main (deploy).
+#
+# No secrets required: all HA and Google Calendar API calls are mocked in tests.
+# Tests never connect to your Home Assistant or Google—they cannot control real devices.
+
+name: CI
+
+on:
+ push:
+ branches: [dev, main, master]
+ pull_request:
+ branches: [dev, main, master]
+
+jobs:
+ build-and-test:
+ name: Build and test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install root dependencies
+ run: npm ci
+
+ - name: Install dashboard dependencies
+ run: npm run install:dashboard
+
+ - name: Build
+ run: npm run build
+
+ - name: Test
+ run: npm test
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 12ade30..2d61d35 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -17,15 +17,83 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
+ "happy-dom": "^20.6.2",
+ "jsdom": "^28.1.0",
"nodemon": "^3.1.11",
"typescript": "^5.3.0",
- "vite": "^5.0.0"
+ "vite": "^5.0.0",
+ "vitest": "^4.0.18"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true
+ },
+ "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
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "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.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "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
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -298,6 +366,144 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
+ "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
+ "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.1",
+ "@csstools/css-calc": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.27",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
+ "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ]
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -570,6 +776,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -586,6 +808,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -602,6 +840,22 @@
"node": ">=12"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -666,6 +920,23 @@
"node": ">=12"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
+ "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
+ "dev": true,
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1042,6 +1313,104 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true
+ },
+ "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,
+ "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,
+ "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
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "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,
+ "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,
+ "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",
@@ -1083,6 +1452,22 @@
"@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,
+ "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
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1130,6 +1515,21 @@
"@types/react": "^18.0.0"
}
},
+ "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
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1150,6 +1550,84 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1207,11 +1685,29 @@
"node": ">= 8"
}
},
+ "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,
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
+ "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,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1259,8 +1755,17 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
- "node_modules/bignumber.js": {
- "version": "9.3.1",
+ "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,
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"engines": {
@@ -1470,6 +1975,15 @@
}
]
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1630,12 +2144,103 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
+ "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,
+ "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
+ },
+ "node_modules/cssstyle": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
+ "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
+ "dev": true,
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.26",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "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,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@@ -1668,6 +2273,12 @@
}
}
},
+ "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
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1698,6 +2309,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -1715,6 +2335,13 @@
"node": ">=8"
}
},
+ "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,
+ "peer": true
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1780,6 +2407,18 @@
"once": "^1.4.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,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1796,6 +2435,12 @@
"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
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -1859,6 +2504,15 @@
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
+ "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,
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1880,6 +2534,15 @@
"node": ">=6"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -2224,6 +2887,44 @@
"node": ">=14.0.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.6.2",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.2.tgz",
+ "integrity": "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.18.3"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/happy-dom/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/happy-dom/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,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2255,6 +2956,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2287,6 +3000,19 @@
"node": ">=8.0.0"
}
},
+ "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,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
@@ -2358,6 +3084,15 @@
"integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
"dev": true
},
+ "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,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2435,6 +3170,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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
+ },
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -2451,6 +3192,81 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
+ "node_modules/jsdom": {
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
+ "dev": true,
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "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,
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2528,6 +3344,25 @@
"yallist": "^3.0.2"
}
},
+ "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,
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -2536,6 +3371,12 @@
"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
+ },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -2613,6 +3454,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "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,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -2802,6 +3652,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ]
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2821,6 +3681,18 @@
"wrappy": "1"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2834,6 +3706,12 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
+ "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
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2904,6 +3782,34 @@
"node": ">=10"
}
},
+ "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,
+ "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-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,
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -2931,6 +3837,15 @@
"once": "^1.3.1"
}
},
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -3004,6 +3919,13 @@
"react": "^18.3.1"
}
},
+ "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,
+ "peer": true
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3038,6 +3960,19 @@
"node": ">=8.10.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3047,6 +3982,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -3129,6 +4073,18 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -3281,6 +4237,12 @@
"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
+ },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -3363,6 +4325,12 @@
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
+ "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
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -3371,6 +4339,12 @@
"node": ">= 0.8"
}
},
+ "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
+ },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -3405,6 +4379,18 @@
"node": ">=8"
}
},
+ "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,
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -3428,6 +4414,12 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "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
+ },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -3454,13 +4446,100 @@
"node": ">=6"
}
},
- "node_modules/to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dependencies": {
- "is-number": "^7.0.0"
- },
+ "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
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "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,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
+ "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
+ "dev": true,
+ "dependencies": {
+ "tldts-core": "^7.0.23"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
+ "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
+ "dev": true
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
"engines": {
"node": ">=8.0"
}
@@ -3482,6 +4561,18 @@
"nodetouch": "bin/nodetouch.js"
}
},
+ "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,
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -3544,6 +4635,15 @@
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
"dev": true
},
+ "node_modules/undici": {
+ "version": "7.22.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -3684,11 +4784,647 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/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==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "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/node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "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"
+ }
+ },
+ "node_modules/vitest/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "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==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -3698,6 +5434,22 @@
"webidl-conversions": "^3.0.0"
}
},
+ "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,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -3720,6 +5472,42 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "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
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index a7e5fe8..7da1ad1 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -9,7 +9,9 @@
"dev:calendar": "nodemon calendar-server.js",
"build": "vite build",
"preview": "vite preview",
- "serve": "nodemon server.js"
+ "serve": "nodemon server.js",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^12.6.2",
@@ -21,12 +23,18 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^8.2.2",
+ "happy-dom": "^20.6.2",
+ "jsdom": "^28.1.0",
"nodemon": "^3.1.11",
"typescript": "^5.3.0",
- "vite": "^5.0.0"
+ "vite": "^5.0.0",
+ "vitest": "^4.0.18"
}
}
diff --git a/dashboard/src/App.test.tsx b/dashboard/src/App.test.tsx
new file mode 100644
index 0000000..fe730b3
--- /dev/null
+++ b/dashboard/src/App.test.tsx
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import App from './App';
+import * as api from './api';
+
+vi.mock('./api', async (importOriginal) => {
+ const actual = (await importOriginal()) as typeof api;
+ return { ...actual, getStates: vi.fn() };
+});
+
+describe('App', () => {
+ beforeEach(() => {
+ vi.mocked(api.getStates).mockResolvedValue([]);
+ });
+
+ it('renders header with title and theme selector', async () => {
+ render();
+ await screen.findByRole('heading', { name: /canopy/i });
+ expect(screen.getByLabelText(/theme/i)).toBeInTheDocument();
+ });
+
+ it('renders all nav tabs', async () => {
+ render();
+ await screen.findByRole('heading', { name: /canopy/i });
+ expect(screen.getByRole('button', { name: /lighting/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /climate/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /switches/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /covers/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /locks & security/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /sensors/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /calendar/i })).toBeInTheDocument();
+ });
+
+ it('shows loading then main content when getStates resolves', async () => {
+ render();
+ expect(screen.getByText(/loading/i)).toBeInTheDocument();
+ await screen.findByRole('button', { name: /lighting/i });
+ expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
+ });
+
+ it('switches tab when clicking a tab button', async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByRole('button', { name: /lighting/i });
+ await user.click(screen.getByRole('button', { name: /calendar/i }));
+ expect(screen.getByRole('button', { name: /calendar/i })).toHaveClass('active');
+ });
+
+ it('only the active tab button has active class', async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByRole('button', { name: /lighting/i });
+ const lighting = screen.getByRole('button', { name: /lighting/i });
+ const calendar = screen.getByRole('button', { name: /calendar/i });
+ expect(lighting).toHaveClass('active');
+ expect(calendar).not.toHaveClass('active');
+ await user.click(calendar);
+ expect(calendar).toHaveClass('active');
+ expect(lighting).not.toHaveClass('active');
+ });
+
+ it('allows changing theme via select', async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByLabelText(/theme/i);
+ const select = screen.getByLabelText(/theme/i);
+ await user.selectOptions(select, 'dark');
+ expect(select).toHaveValue('dark');
+ });
+
+ it('theme select has all six options: System, Light, Dark, Bold (Light), Bold (Dark), Pride', async () => {
+ render();
+ await screen.findByLabelText(/theme/i);
+ const select = screen.getByLabelText(/theme/i) as HTMLSelectElement;
+ const options = Array.from(select.options).map((o) => ({ value: o.value, label: o.label || o.text }));
+ expect(options).toEqual(
+ expect.arrayContaining([
+ { value: 'system', label: 'System' },
+ { value: 'light', label: 'Light' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'bold-light', label: 'Bold (Light)' },
+ { value: 'bold-dark', label: 'Bold (Dark)' },
+ { value: 'pride', label: 'Pride' },
+ ])
+ );
+ expect(options).toHaveLength(6);
+ });
+
+ it('applies selected theme to document (data-theme and select value)', async () => {
+ const user = userEvent.setup();
+ render();
+ await screen.findByLabelText(/theme/i);
+ const select = screen.getByLabelText(/theme/i);
+ await user.selectOptions(select, 'pride');
+ expect(select).toHaveValue('pride');
+ await waitFor(() => {
+ expect(document.documentElement.getAttribute('data-theme')).toBe('pride');
+ });
+ });
+
+ it('shows error banner when getStates rejects', async () => {
+ vi.mocked(api.getStates).mockRejectedValueOnce(new Error('Network error'));
+ render();
+ await screen.findByText(/network error/i);
+ expect(screen.getByText(/network error/i)).toBeInTheDocument();
+ expect(screen.getByText(/check that the ha proxy/i)).toBeInTheDocument();
+ });
+
+ it('passes only light entities to Lighting tab', async () => {
+ vi.mocked(api.getStates).mockResolvedValue([
+ { entity_id: 'light.living', state: 'on', attributes: {} },
+ { entity_id: 'switch.fan', state: 'off', attributes: {} },
+ { entity_id: 'climate.thermostat', state: 'heat', attributes: {} },
+ ] as never[]);
+ render();
+ await screen.findByRole('button', { name: /lighting/i });
+ const main = screen.getByRole('main');
+ expect(main).toHaveTextContent('light.living');
+ expect(main).not.toHaveTextContent('switch.fan');
+ expect(main).not.toHaveTextContent('climate.thermostat');
+ });
+
+ it('passes switch and fan entities to Switches tab', async () => {
+ vi.mocked(api.getStates).mockResolvedValue([
+ { entity_id: 'switch.fan', state: 'off', attributes: {} },
+ { entity_id: 'fan.ceiling', state: 'on', attributes: {} },
+ { entity_id: 'light.desk', state: 'on', attributes: {} },
+ ] as never[]);
+ render();
+ await screen.findByRole('button', { name: /lighting/i });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: /switches/i }));
+ const main = screen.getByRole('main');
+ expect(main).toHaveTextContent('switch.fan');
+ expect(main).toHaveTextContent('fan.ceiling');
+ expect(main).not.toHaveTextContent('light.desk');
+ });
+});
diff --git a/dashboard/src/api.test.ts b/dashboard/src/api.test.ts
new file mode 100644
index 0000000..b6e1220
--- /dev/null
+++ b/dashboard/src/api.test.ts
@@ -0,0 +1,397 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ domainFromEntityId,
+ getStates,
+ getState,
+ getSavedCalendars,
+ addSavedCalendar,
+ updateSavedCalendar,
+ deleteSavedCalendar,
+ getCalendarEvents,
+ getCalendarList,
+ createCalendarEvent,
+ getCalendarEvent,
+ deleteCalendarEvent,
+ updateCalendarEvent,
+ getCalendarDiagnose,
+ callService,
+} from './api';
+
+const mockFetch = () => vi.stubGlobal('fetch', vi.fn());
+const unmockFetch = () => vi.unstubAllGlobals();
+
+describe('domainFromEntityId', () => {
+ it('returns the domain part before the first dot', () => {
+ expect(domainFromEntityId('light.living_room')).toBe('light');
+ expect(domainFromEntityId('climate.thermostat')).toBe('climate');
+ expect(domainFromEntityId('switch.fan')).toBe('switch');
+ expect(domainFromEntityId('lock.front_door')).toBe('lock');
+ expect(domainFromEntityId('alarm_control_panel.house')).toBe('alarm_control_panel');
+ });
+
+ it('returns the full string when there is no dot', () => {
+ expect(domainFromEntityId('light')).toBe('light');
+ });
+});
+
+describe('getStates', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns parsed JSON when response is ok', async () => {
+ const mockStates = [{ entity_id: 'light.a', state: 'on' }];
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockStates),
+ });
+ const result = await getStates();
+ expect(result).toEqual(mockStates);
+ expect(fetch).toHaveBeenCalledWith('/api/states');
+ });
+
+ it('throws when response is not ok', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ });
+ await expect(getStates()).rejects.toThrow('States: 500 Internal Server Error');
+ });
+});
+
+describe('getSavedCalendars', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns array when response is ok', async () => {
+ const mockCalendars = [{ id: 1, title: 'Home', calendarId: 'x@group', colorIndex: 0 }];
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(mockCalendars),
+ });
+ const result = await getSavedCalendars();
+ expect(result).toEqual(mockCalendars);
+ expect(fetch).toHaveBeenCalledWith('/calendar-api/saved-calendars');
+ });
+
+ it('throws with message when response is not ok', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ json: () => Promise.resolve({ error: 'Unauthorized' }),
+ });
+ await expect(getSavedCalendars()).rejects.toThrow('Unauthorized');
+ });
+});
+
+describe('callService', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('POSTs to the correct URL with JSON body', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+ await callService('light', 'turn_on', { entity_id: 'light.a' });
+ expect(fetch).toHaveBeenCalledWith('/api/services/light/turn_on', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ entity_id: 'light.a' }),
+ });
+ });
+
+ it('throws with domain.service in message when not ok', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 403,
+ });
+ await expect(callService('climate', 'set_temperature', { entity_id: 'climate.a', temperature: 20 }))
+ .rejects.toThrow('climate.set_temperature');
+ });
+});
+
+describe('getState', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('fetches single entity and returns parsed state', async () => {
+ const state = { entity_id: 'light.living', state: 'on', attributes: {} };
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(state),
+ });
+ const result = await getState('light.living');
+ expect(result).toEqual(state);
+ expect(fetch).toHaveBeenCalledWith('/api/states/light.living');
+ });
+
+ it('encodes entity_id in URL', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({}),
+ });
+ await getState('light.my room');
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining(encodeURIComponent('light.my room')));
+ });
+});
+
+describe('getCalendarEvents', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('builds URL with days and maxResults', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ events: [] }),
+ });
+ await getCalendarEvents({ days: 42, maxResults: 100 });
+ expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/calendar-api\/events\?.*days=42.*maxResults=100/));
+ });
+
+ it('returns event list from response.events', async () => {
+ const events = [{ id: '1', summary: 'Meeting', start: '2025-06-15T10:00:00', end: '2025-06-15T11:00:00' }];
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ events, calendarSummaries: {} }),
+ });
+ const result = await getCalendarEvents({ days: 7 });
+ expect(result).toHaveProperty('events', events);
+ });
+
+ it('accepts array response (legacy)', async () => {
+ const events = [{ id: '1', summary: 'E', start: '2025-06-15T10:00:00' }];
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(events),
+ });
+ const result = await getCalendarEvents();
+ expect(Array.isArray(result)).toBe(true);
+ expect(result).toEqual(events);
+ });
+
+ it('throws with serviceAccountEmail when API returns it', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: false,
+ status: 403,
+ json: () => Promise.resolve({
+ error: 'Calendar API disabled',
+ serviceAccountEmail: 'canopy@project.iam.gserviceaccount.com',
+ }),
+ });
+ const err = await getCalendarEvents().then(
+ () => null,
+ (e: Error & { serviceAccountEmail?: string }) => e
+ );
+ expect(err).not.toBeNull();
+ expect((err as Error).message).toContain('Calendar');
+ expect((err as Error & { serviceAccountEmail?: string }).serviceAccountEmail).toBe('canopy@project.iam.gserviceaccount.com');
+ });
+});
+
+describe('getCalendarList', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('returns array of calendars', async () => {
+ const list = [{ id: 'a@group', summary: 'Home' }];
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(list),
+ });
+ const result = await getCalendarList();
+ expect(result).toEqual(list);
+ });
+
+ it('handles response.calendars shape', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ calendars: [{ id: 'x', summary: 'Y' }] }),
+ });
+ const result = await getCalendarList();
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({ id: 'x', summary: 'Y' });
+ });
+});
+
+describe('addSavedCalendar', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('POSTs with title, calendarId, colorIndex', async () => {
+ const saved = { id: 1, title: 'Work', calendarId: 'work@group', colorIndex: 2 };
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(saved),
+ });
+ const result = await addSavedCalendar({ title: 'Work', calendarId: 'work@group', colorIndex: 2 });
+ expect(result).toEqual(saved);
+ expect(fetch).toHaveBeenCalledWith(
+ '/calendar-api/saved-calendars',
+ expect.objectContaining({
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: 'Work', calendarId: 'work@group', colorIndex: 2 }),
+ })
+ );
+ });
+
+ it('can include colorHex', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 1, title: 'C', calendarId: 'c@group', colorIndex: 0, colorHex: '#ff0000' }),
+ });
+ await addSavedCalendar({ title: 'C', calendarId: 'c@group', colorHex: '#ff0000' });
+ expect(fetch).toHaveBeenCalledWith(
+ '/calendar-api/saved-calendars',
+ expect.objectContaining({
+ body: expect.stringContaining('"colorHex":"#ff0000"'),
+ })
+ );
+ });
+});
+
+describe('updateSavedCalendar', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('PATCHes to saved-calendars/:id', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 1, title: 'Updated', calendarId: 'x@group', colorIndex: 1 }),
+ });
+ await updateSavedCalendar(1, { title: 'Updated' });
+ expect(fetch).toHaveBeenCalledWith(
+ '/calendar-api/saved-calendars/1',
+ expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'Updated' }) })
+ );
+ });
+});
+
+describe('deleteSavedCalendar', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('DELETEs saved-calendars/:id', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({ ok: true });
+ await deleteSavedCalendar(1);
+ expect(fetch).toHaveBeenCalledWith('/calendar-api/saved-calendars/1', { method: 'DELETE' });
+ });
+});
+
+describe('createCalendarEvent', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('POSTs payload with calendarId, summary, date, allDay', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'ev1', htmlLink: 'https://calendar.google.com/event/ev1' }),
+ });
+ const payload = {
+ calendarId: 'home@group',
+ summary: 'Team standup',
+ date: '2025-06-20',
+ allDay: false,
+ startTime: '09:00',
+ endTime: '09:30',
+ timeZone: 'America/Chicago',
+ };
+ const result = await createCalendarEvent(payload);
+ expect(result).toMatchObject({ id: 'ev1' });
+ expect(fetch).toHaveBeenCalledWith(
+ '/calendar-api/events',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify(payload),
+ })
+ );
+ });
+});
+
+describe('getCalendarEvent', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('GETs calendar event by calendarId and eventId', async () => {
+ const ev = { id: 'ev1', summary: 'Meeting', start: '2025-06-15T10:00:00', end: '2025-06-15T11:00:00' };
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve(ev),
+ });
+ const result = await getCalendarEvent('cal@group', 'ev1');
+ expect(result).toEqual(ev);
+ expect(fetch).toHaveBeenCalledWith('/calendar-api/calendars/cal%40group/events/ev1');
+ });
+});
+
+describe('deleteCalendarEvent', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('DELETEs event', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({ ok: true });
+ await deleteCalendarEvent('cal@group', 'ev1');
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining('/calendar-api/calendars/'),
+ expect.objectContaining({ method: 'DELETE' })
+ );
+ });
+});
+
+describe('updateCalendarEvent', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('PATCHes event with payload', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ id: 'ev1' }),
+ });
+ await updateCalendarEvent('cal@group', 'ev1', {
+ summary: 'Updated title',
+ date: '2025-06-16',
+ allDay: true,
+ });
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining('events/ev1'),
+ expect.objectContaining({
+ method: 'PATCH',
+ body: expect.stringContaining('Updated title'),
+ })
+ );
+ });
+});
+
+describe('getCalendarDiagnose', () => {
+ beforeEach(mockFetch);
+ afterEach(unmockFetch);
+
+ it('GETs diagnose without calendarId', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ list: { ok: true } }),
+ });
+ const result = await getCalendarDiagnose();
+ expect(result).toMatchObject({ list: { ok: true } });
+ expect(fetch).toHaveBeenCalledWith('/calendar-api/diagnose');
+ });
+
+ it('GETs diagnose with calendarId query', async () => {
+ (fetch as ReturnType).mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({ direct: { ok: true } }),
+ });
+ await getCalendarDiagnose('my@group');
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining('calendarId=my%40group'));
+ });
+});
diff --git a/dashboard/src/components/AddEventModal.test.tsx b/dashboard/src/components/AddEventModal.test.tsx
new file mode 100644
index 0000000..f85f21d
--- /dev/null
+++ b/dashboard/src/components/AddEventModal.test.tsx
@@ -0,0 +1,197 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { AddEventModal, RECURRENCE_OPTIONS, REMINDER_PRESETS } from './AddEventModal';
+import * as api from '../api';
+
+vi.mock('../api', () => ({
+ createCalendarEvent: vi.fn(),
+}));
+
+const mockSavedCalendars = [
+ { id: 1, title: 'Home', calendarId: 'home@group', colorIndex: 0 },
+];
+
+const defaultProps = {
+ savedCalendars: mockSavedCalendars,
+ onClose: vi.fn(),
+ onSuccess: vi.fn(),
+};
+
+describe('AddEventModal', () => {
+ beforeEach(() => {
+ vi.mocked(api.createCalendarEvent).mockReset();
+ defaultProps.onClose.mockClear();
+ defaultProps.onSuccess.mockClear();
+ });
+
+ it('renders modal with title and form', () => {
+ render();
+ expect(screen.getByRole('heading', { name: /add event/i })).toBeInTheDocument();
+ expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
+ });
+
+ it('pre-fills date when defaultDate is provided', () => {
+ render();
+ const dateInput = screen.getByLabelText(/date/i) as HTMLInputElement;
+ expect(dateInput.value).toBe('2025-06-15');
+ });
+
+ it('pre-fills start and end time when defaultStartTime and defaultEndTime are provided', () => {
+ render(
+
+ );
+ const startInput = screen.getByLabelText(/start time/i) as HTMLInputElement;
+ const endInput = screen.getByLabelText(/end time/i) as HTMLInputElement;
+ expect(startInput.value).toBe('14:00');
+ expect(endInput.value).toBe('15:00');
+ });
+
+ it('shows All day checkbox', () => {
+ render();
+ expect(screen.getByRole('checkbox', { name: /all day/i })).toBeInTheDocument();
+ });
+
+ it('has Save and Cancel buttons', () => {
+ render();
+ expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+
+ it('shows Repeat and Calendar dropdowns', () => {
+ render();
+ expect(screen.getByLabelText(/repeat/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/calendar/i)).toBeInTheDocument();
+ });
+
+ it('shows validation error when no calendar selected and user submits', async () => {
+ render();
+ const form = screen.getByRole('dialog').querySelector('form');
+ expect(form).toBeTruthy();
+ fireEvent.submit(form!);
+ await waitFor(() => {
+ expect(screen.getByText(/please select a calendar/i)).toBeInTheDocument();
+ });
+ expect(api.createCalendarEvent).not.toHaveBeenCalled();
+ });
+
+ it('calls createCalendarEvent with correct payload and then onSuccess and onClose on success', async () => {
+ vi.mocked(api.createCalendarEvent).mockResolvedValue({ id: 'ev1' });
+ render(
+
+ );
+ const titleInput = screen.getByPlaceholderText(/add title/i);
+ fireEvent.change(titleInput, { target: { value: 'Team standup' } });
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(api.createCalendarEvent).toHaveBeenCalledTimes(1);
+ });
+ const payload = vi.mocked(api.createCalendarEvent).mock.calls[0][0];
+ expect(payload.calendarId).toBe('home@group');
+ expect(payload.summary).toBe('Team standup');
+ expect(payload.date).toBe('2025-06-20');
+ expect(payload.allDay).toBe(false);
+ expect(payload.startTime).toBe('09:00');
+ expect(payload.endTime).toBe('10:00');
+ expect(payload.timeZone).toBeDefined();
+ expect(defaultProps.onSuccess).toHaveBeenCalled();
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+
+ it('sends allDay event without startTime/endTime/timeZone', async () => {
+ vi.mocked(api.createCalendarEvent).mockResolvedValue({ id: 'ev1' });
+ render();
+ fireEvent.click(screen.getByRole('checkbox', { name: /all day/i }));
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(api.createCalendarEvent).toHaveBeenCalledTimes(1);
+ });
+ const payload = vi.mocked(api.createCalendarEvent).mock.calls[0][0];
+ expect(payload.allDay).toBe(true);
+ expect(payload.startTime).toBeUndefined();
+ expect(payload.endTime).toBeUndefined();
+ expect(payload.timeZone).toBeUndefined();
+ });
+
+ it('sends location and description when filled', async () => {
+ vi.mocked(api.createCalendarEvent).mockResolvedValue({ id: 'ev1' });
+ render();
+ fireEvent.change(screen.getByLabelText(/title/i), { target: { value: 'Meet' } });
+ fireEvent.change(screen.getByLabelText(/location/i), { target: { value: 'Office' } });
+ fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Notes' } });
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(api.createCalendarEvent).toHaveBeenCalledTimes(1);
+ });
+ const payload = vi.mocked(api.createCalendarEvent).mock.calls[0][0];
+ expect(payload.location).toBe('Office');
+ expect(payload.description).toBe('Notes');
+ });
+
+ it('shows API error in banner when createCalendarEvent rejects', async () => {
+ vi.mocked(api.createCalendarEvent).mockRejectedValue(new Error('Calendar API disabled'));
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/calendar api disabled/i)).toBeInTheDocument();
+ });
+ expect(defaultProps.onClose).not.toHaveBeenCalled();
+ });
+
+ it('uses (No title) when title is empty', async () => {
+ vi.mocked(api.createCalendarEvent).mockResolvedValue({ id: 'ev1' });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /save/i }));
+ await waitFor(() => {
+ expect(api.createCalendarEvent).toHaveBeenCalledTimes(1);
+ });
+ expect(vi.mocked(api.createCalendarEvent).mock.calls[0][0].summary).toBe('(No title)');
+ });
+
+ it('calls onClose when Cancel is clicked', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when backdrop is clicked', () => {
+ render();
+ const backdrop = screen.getByRole('dialog');
+ fireEvent.click(backdrop);
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose on Escape key', () => {
+ render();
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('RECURRENCE_OPTIONS', () => {
+ it('includes none and common presets', () => {
+ const values = RECURRENCE_OPTIONS.map((o) => o.value);
+ expect(values).toContain('none');
+ expect(values).toContain('daily');
+ expect(values).toContain('weekdays');
+ expect(values).toContain('custom');
+ });
+});
+
+describe('REMINDER_PRESETS', () => {
+ it('includes popup and email options', () => {
+ const methods = [...new Set(REMINDER_PRESETS.map((p) => p.method))];
+ expect(methods).toContain('popup');
+ expect(methods).toContain('email');
+ });
+});
diff --git a/dashboard/src/components/CalendarTab.test.tsx b/dashboard/src/components/CalendarTab.test.tsx
new file mode 100644
index 0000000..506fdfe
--- /dev/null
+++ b/dashboard/src/components/CalendarTab.test.tsx
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { CalendarTab } from './CalendarTab';
+import * as api from '../api';
+
+vi.mock('../api', () => ({
+ getCalendarEvents: vi.fn().mockResolvedValue({ events: [] }),
+ getSavedCalendars: vi.fn().mockResolvedValue([]),
+ getCalendarList: vi.fn().mockResolvedValue([]),
+ addSavedCalendar: vi.fn(),
+ updateSavedCalendar: vi.fn(),
+ deleteSavedCalendar: vi.fn(),
+ getCalendarEvent: vi.fn(),
+ deleteCalendarEvent: vi.fn(),
+}));
+
+describe('CalendarTab', () => {
+ beforeEach(() => {
+ vi.mocked(api.getCalendarEvents).mockResolvedValue({ events: [] });
+ vi.mocked(api.getSavedCalendars).mockResolvedValue([]);
+ vi.mocked(api.getCalendarList).mockResolvedValue([]);
+ });
+
+ it('renders without crashing', async () => {
+ render();
+ await screen.findByRole('button', { name: /calendars/i });
+ });
+
+ it('shows Calendars and Add Event buttons', async () => {
+ render();
+ await screen.findByRole('button', { name: /calendars/i });
+ expect(screen.getByRole('button', { name: /add event/i })).toBeInTheDocument();
+ });
+
+ it('shows view mode selector with Daily, Weekly, Biweekly, Monthly', async () => {
+ render();
+ await screen.findByRole('button', { name: /calendars/i });
+ const select = screen.getByRole('combobox');
+ expect(select).toBeInTheDocument();
+ expect(select).toHaveDisplayValue('Biweekly');
+ expect(screen.getByRole('option', { name: 'Daily' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Weekly' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Biweekly' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Monthly' })).toBeInTheDocument();
+ });
+
+ it('switches view mode when selecting Daily', async () => {
+ render();
+ await screen.findByRole('button', { name: /calendars/i });
+ const select = screen.getByRole('combobox');
+ fireEvent.change(select, { target: { value: 'daily' } });
+ expect(select).toHaveValue('daily');
+ });
+
+ it('when no saved calendars, Add Event opens Calendars modal', async () => {
+ vi.mocked(api.getSavedCalendars).mockResolvedValue([]);
+ render();
+ await screen.findByRole('button', { name: /calendars/i });
+ fireEvent.click(screen.getByRole('button', { name: /add event/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /^calendars$/i })).toBeInTheDocument();
+ });
+ });
+
+ it('when saved calendars exist, Add Event opens Add Event modal', async () => {
+ vi.mocked(api.getSavedCalendars).mockResolvedValue([
+ { id: 1, title: 'Home', calendarId: 'home@group', colorIndex: 0 },
+ ]);
+ render();
+ await screen.findByRole('button', { name: /add event/i });
+ fireEvent.click(screen.getByRole('button', { name: /add event/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /add event/i })).toBeInTheDocument();
+ });
+ });
+
+ it('shows error banner when getCalendarEvents rejects', async () => {
+ vi.mocked(api.getCalendarEvents).mockRejectedValue(new Error('Calendar API disabled'));
+ render();
+ await waitFor(() => {
+ expect(screen.getByText(/calendar api disabled|failed to load calendar/i)).toBeInTheDocument();
+ });
+ });
+
+ it('renders events when getCalendarEvents returns events', async () => {
+ const start = new Date();
+ start.setDate(start.getDate() + 1);
+ const dateKey = start.getFullYear() + '-' + String(start.getMonth() + 1).padStart(2, '0') + '-' + String(start.getDate()).padStart(2, '0');
+ vi.mocked(api.getCalendarEvents).mockResolvedValue({
+ events: [
+ {
+ id: 'ev1',
+ summary: 'Team standup',
+ start: `${dateKey}T09:00:00`,
+ end: `${dateKey}T09:30:00`,
+ calendarId: 'home@group',
+ },
+ ],
+ calendarSummaries: {},
+ });
+ vi.mocked(api.getSavedCalendars).mockResolvedValue([
+ { id: 1, title: 'Home', calendarId: 'home@group', colorIndex: 0 },
+ ]);
+ render();
+ await waitFor(() => {
+ expect(screen.getByText(/team standup/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/dashboard/src/components/ClimateTab.test.tsx b/dashboard/src/components/ClimateTab.test.tsx
new file mode 100644
index 0000000..ee4c2eb
--- /dev/null
+++ b/dashboard/src/components/ClimateTab.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ClimateTab } from './ClimateTab';
+import * as api from '../api';
+
+vi.mock('../api', () => ({ callService: vi.fn().mockResolvedValue(undefined) }));
+
+const mockEntities = [
+ {
+ entity_id: 'climate.thermostat',
+ state: 'heat',
+ attributes: {
+ friendly_name: 'Living room',
+ current_temperature: 20,
+ temperature: 21,
+ hvac_modes: ['heat', 'cool', 'off'],
+ hvac_mode: 'heat',
+ min_temp: 5,
+ max_temp: 35,
+ target_temp_step: 1,
+ },
+ },
+];
+
+describe('ClimateTab', () => {
+ beforeEach(() => vi.mocked(api.callService).mockClear());
+
+ it('shows entity count', () => {
+ render();
+ expect(screen.getByText(/1 climate/)).toBeInTheDocument();
+ });
+
+ it('shows current and target temperature', () => {
+ render();
+ expect(screen.getByText(/current: 20°/i)).toBeInTheDocument();
+ expect(screen.getByText(/target: 21°/i)).toBeInTheDocument();
+ });
+
+ it('shows HVAC mode buttons', () => {
+ render();
+ expect(screen.getByRole('button', { name: 'heat' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'cool' })).toBeInTheDocument();
+ });
+
+ it('calls set_temperature when minus clicked', async () => {
+ const onAction = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: '−' }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('climate', 'set_temperature', {
+ entity_id: 'climate.thermostat',
+ temperature: 20,
+ });
+ });
+ expect(onAction).toHaveBeenCalled();
+ });
+
+ it('calls set_hvac_mode when HVAC button clicked', async () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'cool' }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('climate', 'set_hvac_mode', {
+ entity_id: 'climate.thermostat',
+ hvac_mode: 'cool',
+ });
+ });
+ });
+
+ it('shows no climate when empty', () => {
+ render();
+ expect(screen.getByText(/no climate/i)).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/CoversTab.test.tsx b/dashboard/src/components/CoversTab.test.tsx
new file mode 100644
index 0000000..d2d416c
--- /dev/null
+++ b/dashboard/src/components/CoversTab.test.tsx
@@ -0,0 +1,45 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { CoversTab } from './CoversTab';
+import * as api from '../api';
+
+vi.mock('../api', () => ({ callService: vi.fn().mockResolvedValue(undefined) }));
+
+const closedCover = [{ entity_id: 'cover.blind', state: 'closed', attributes: {} }];
+const openCover = [{ entity_id: 'cover.blind', state: 'open', attributes: {} }];
+
+describe('CoversTab', () => {
+ beforeEach(() => vi.mocked(api.callService).mockClear());
+
+ it('shows entity count', () => {
+ render();
+ expect(screen.getByText(/1 cover$/)).toBeInTheDocument();
+ });
+
+ it('shows Open, Close, Stop buttons', () => {
+ render();
+ expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument();
+ });
+
+ it('calls open_cover when Open clicked', async () => {
+ const onAction = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /open/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('cover', 'open_cover', { entity_id: 'cover.blind' });
+ });
+ expect(onAction).toHaveBeenCalled();
+ });
+
+ it('Open disabled when cover is open', () => {
+ render();
+ expect(screen.getByRole('button', { name: /open/i })).toBeDisabled();
+ });
+
+ it('shows no covers when empty', () => {
+ render();
+ expect(screen.getByText(/no covers/i)).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/EditEventModal.test.tsx b/dashboard/src/components/EditEventModal.test.tsx
new file mode 100644
index 0000000..d575c09
--- /dev/null
+++ b/dashboard/src/components/EditEventModal.test.tsx
@@ -0,0 +1,49 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EditEventModal } from './EditEventModal';
+
+vi.mock('../api', () => ({
+ getCalendarEvent: vi.fn().mockResolvedValue(null),
+ updateCalendarEvent: vi.fn().mockResolvedValue({ id: 'ev1' }),
+}));
+
+const savedCalendars = [{ id: 1, title: 'Home', calendarId: 'home@group', colorIndex: 0 }];
+const initialEvent = {
+ id: 'ev1',
+ summary: 'Team standup',
+ start: '2025-06-15T09:00:00',
+ end: '2025-06-15T09:30:00',
+ calendarId: 'home@group',
+};
+
+describe('EditEventModal', () => {
+ it('renders with initialEvent and shows Edit event title', () => {
+ render(
+
+ );
+ expect(screen.getByRole('heading', { name: /edit event/i })).toBeInTheDocument();
+ expect(screen.getByDisplayValue('Team standup')).toBeInTheDocument();
+ });
+
+ it('has Save and Cancel buttons', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/LightingTab.test.tsx b/dashboard/src/components/LightingTab.test.tsx
new file mode 100644
index 0000000..b0e1811
--- /dev/null
+++ b/dashboard/src/components/LightingTab.test.tsx
@@ -0,0 +1,42 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { LightingTab } from './LightingTab';
+import * as api from '../api';
+
+vi.mock('../api', () => ({
+ callService: vi.fn().mockResolvedValue({}),
+}));
+
+const mockEntity = (overrides: Partial<{ entity_id: string; state: string; attributes: object }> = {}) => ({
+ entity_id: 'light.living_room',
+ state: 'on',
+ attributes: { friendly_name: 'Living room', brightness: 255 },
+ ...overrides,
+});
+
+describe('LightingTab', () => {
+ it('renders entity count and cards', () => {
+ const entities = [mockEntity()];
+ render();
+ expect(screen.getByText(/1 light/)).toBeInTheDocument();
+ expect(screen.getByText(/Living room/)).toBeInTheDocument();
+ expect(screen.getByText('on')).toBeInTheDocument();
+ });
+
+ it('shows On/Off button and toggles via callService', async () => {
+ const onAction = vi.fn();
+ const entities = [mockEntity()];
+ render();
+ const toggle = screen.getByRole('button', { name: /on/i });
+ fireEvent.click(toggle);
+ await waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('light', 'turn_off', { entity_id: 'light.living_room' });
+ });
+ });
+
+ it('shows plural when multiple entities', () => {
+ const entities = [mockEntity(), mockEntity({ entity_id: 'light.bedroom', attributes: { friendly_name: 'Bedroom' } })];
+ render();
+ expect(screen.getByText(/2 lights/)).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/SecurityTab.test.tsx b/dashboard/src/components/SecurityTab.test.tsx
new file mode 100644
index 0000000..afba0ee
--- /dev/null
+++ b/dashboard/src/components/SecurityTab.test.tsx
@@ -0,0 +1,56 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SecurityTab } from './SecurityTab';
+import * as api from '../api';
+
+vi.mock('../api', () => ({ callService: vi.fn().mockResolvedValue(undefined) }));
+
+describe('SecurityTab', () => {
+ beforeEach(() => vi.mocked(api.callService).mockClear());
+
+ it('shows lock count and Unlock button', () => {
+ const entities = [{ entity_id: 'lock.front_door', state: 'locked', attributes: { friendly_name: 'Front door' } }];
+ render();
+ expect(screen.getByText(/1 lock/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument();
+ });
+
+ it('calls lock service when Lock clicked', async () => {
+ const entities = [{ entity_id: 'lock.front_door', state: 'unlocked', attributes: {} }];
+ const onAction = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /lock/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('lock', 'lock', { entity_id: 'lock.front_door' });
+ });
+ expect(onAction).toHaveBeenCalled();
+ });
+
+ it('shows alarm and Disarm / Arm Home / Arm Away', () => {
+ const entities = [{ entity_id: 'alarm_control_panel.house', state: 'armed_home', attributes: {} }];
+ render();
+ expect(screen.getByText(/1 alarm/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /disarm/i })).toBeInTheDocument();
+ });
+
+ it('when disarmed shows Arm Home and Arm Away', () => {
+ const entities = [{ entity_id: 'alarm_control_panel.house', state: 'disarmed', attributes: {} }];
+ render();
+ expect(screen.getByRole('button', { name: /arm home/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /arm away/i })).toBeInTheDocument();
+ });
+
+ it('calls alarm_disarm when Disarm clicked', async () => {
+ const entities = [{ entity_id: 'alarm_control_panel.house', state: 'armed_away', attributes: {} }];
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /disarm/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('alarm_control_panel', 'alarm_disarm', expect.objectContaining({ entity_id: 'alarm_control_panel.house' }));
+ });
+ });
+
+ it('shows no locks or alarm panels when empty', () => {
+ render();
+ expect(screen.getByText(/no locks or alarm panels/i)).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/SensorsTab.test.tsx b/dashboard/src/components/SensorsTab.test.tsx
new file mode 100644
index 0000000..247069d
--- /dev/null
+++ b/dashboard/src/components/SensorsTab.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SensorsTab } from './SensorsTab';
+
+const mockSensor = (entityId: string, state: string, attrs: Record = {}) => ({
+ entity_id: entityId,
+ state,
+ attributes: { friendly_name: entityId.replace('.', ' '), ...attrs },
+});
+
+describe('SensorsTab', () => {
+ it('renders binary and sensor counts', () => {
+ const entities = [
+ mockSensor('binary_sensor.door', 'on'),
+ mockSensor('sensor.temperature', '72', { unit_of_measurement: '°F' }),
+ ];
+ render();
+ expect(screen.getByText(/1 binary, 1 sensor/)).toBeInTheDocument();
+ });
+
+ it('shows no sensors message when empty', () => {
+ render();
+ expect(screen.getByText(/no sensors/i)).toBeInTheDocument();
+ });
+
+ it('renders entity cards with state', () => {
+ const entities = [mockSensor('sensor.temp', '21.5', { unit_of_measurement: '°C' })];
+ render();
+ expect(screen.getByText(/sensor temp/i)).toBeInTheDocument();
+ expect(screen.getByText(/21.5 °C/)).toBeInTheDocument();
+ });
+
+ it('formats timestamp device_class state as locale date string', () => {
+ const iso = '2025-06-15T14:30:00';
+ const entities = [mockSensor('sensor.next_ring', iso, { device_class: 'timestamp' })];
+ render();
+ const formatted = new Date(iso).toLocaleString();
+ expect(screen.getByText(formatted)).toBeInTheDocument();
+ });
+
+ it('shows binary sensor state without unit', () => {
+ const entities = [mockSensor('binary_sensor.door', 'on')];
+ render();
+ expect(screen.getByText(/binary_sensor door/i)).toBeInTheDocument();
+ expect(screen.getByText('on')).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/components/SwitchesTab.test.tsx b/dashboard/src/components/SwitchesTab.test.tsx
new file mode 100644
index 0000000..9aac285
--- /dev/null
+++ b/dashboard/src/components/SwitchesTab.test.tsx
@@ -0,0 +1,103 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { SwitchesTab } from './SwitchesTab';
+import * as api from '../api';
+
+vi.mock('../api', async (importOriginal) => {
+ const actual = (await importOriginal()) as typeof api;
+ return { ...actual, callService: vi.fn().mockResolvedValue(undefined) };
+});
+
+describe('SwitchesTab', () => {
+ beforeEach(() => {
+ vi.mocked(api.callService).mockClear();
+ });
+
+ it('shows entity count', () => {
+ const entities = [
+ { entity_id: 'switch.lamp', state: 'off', attributes: { friendly_name: 'Lamp' } },
+ ];
+ render();
+ expect(screen.getByText(/1 switch\/fan/)).toBeInTheDocument();
+ });
+
+ it('calls switch turn_on when Off switch is clicked', async () => {
+ const entities = [
+ { entity_id: 'switch.lamp', state: 'off', attributes: {} },
+ ];
+ const onAction = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /off/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('switch', 'turn_on', {
+ entity_id: 'switch.lamp',
+ });
+ });
+ expect(onAction).toHaveBeenCalled();
+ });
+
+ it('calls switch turn_off when On switch is clicked', async () => {
+ const entities = [
+ { entity_id: 'switch.lamp', state: 'on', attributes: {} },
+ ];
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /on/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('switch', 'turn_off', {
+ entity_id: 'switch.lamp',
+ });
+ });
+ });
+
+ it('calls fan turn_on for fan entity', async () => {
+ const entities = [
+ { entity_id: 'fan.ceiling', state: 'off', attributes: {} },
+ ];
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /off/i }));
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('fan', 'turn_on', {
+ entity_id: 'fan.ceiling',
+ });
+ });
+ });
+
+ it('shows fan percentage and speed slider when fan has percentage', () => {
+ const entities = [
+ {
+ entity_id: 'fan.ceiling',
+ state: 'on',
+ attributes: { percentage: 50 },
+ },
+ ];
+ render();
+ expect(screen.getByText(/speed/i)).toBeInTheDocument();
+ const slider = screen.getByRole('slider');
+ expect(slider).toHaveValue('50');
+ });
+
+ it('calls fan set_percentage when slider is changed', async () => {
+ const entities = [
+ {
+ entity_id: 'fan.ceiling',
+ state: 'on',
+ attributes: { percentage: 50 },
+ },
+ ];
+ render();
+ const slider = screen.getByRole('slider');
+ fireEvent.change(slider, { target: { value: '75' } });
+ await vi.waitFor(() => {
+ expect(api.callService).toHaveBeenCalledWith('fan', 'set_percentage', {
+ entity_id: 'fan.ceiling',
+ percentage: 75,
+ });
+ });
+ });
+
+ it('shows plural and no switches message when empty', () => {
+ render();
+ expect(screen.getByText(/0 switch\/fans/)).toBeInTheDocument();
+ expect(screen.getByText(/no switches or fans/i)).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/config.test.ts b/dashboard/src/config.test.ts
new file mode 100644
index 0000000..4dc7153
--- /dev/null
+++ b/dashboard/src/config.test.ts
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { TARGET_WIDTH, TARGET_HEIGHT } from './config';
+
+describe('config', () => {
+ it('defines target display resolution for 1920x1080', () => {
+ expect(TARGET_WIDTH).toBe(1920);
+ expect(TARGET_HEIGHT).toBe(1080);
+ });
+});
diff --git a/dashboard/src/test/setup.ts b/dashboard/src/test/setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/dashboard/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 3934b8f..8ccea41 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -2,7 +2,11 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
@@ -14,8 +18,18 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+ "types": [
+ "vitest/globals",
+ "@testing-library/jest-dom"
+ ]
},
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js
index 1e65717..db36989 100644
--- a/dashboard/vite.config.js
+++ b/dashboard/vite.config.js
@@ -1,5 +1,6 @@
import react from '@vitejs/plugin-react';
import { loadEnv } from 'vite';
+import { defineConfig } from 'vitest/config';
export default ({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
@@ -7,8 +8,15 @@ export default ({ mode }) => {
const haToken = env.VITE_HA_TOKEN || '';
const calendarTarget = 'http://localhost:3001';
- return {
+ return defineConfig({
plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'happy-dom',
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ pool: 'threads',
+ },
server: {
host: true,
port: 5173,
@@ -31,5 +39,5 @@ export default ({ mode }) => {
},
},
},
- };
+ });
};
diff --git a/package.json b/package.json
index 77ad8e4..79c0e00 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"install:dashboard": "npm install --prefix dashboard",
"dev": "npm run dev --prefix dashboard",
"build": "npm run build --prefix dashboard",
+ "test": "npm run test --prefix dashboard",
"serve": "npm run serve --prefix dashboard",
"preview": "npm run preview --prefix dashboard"
},