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" },