diff --git a/.github/workflows/react.yml b/.github/workflows/react.yml index 1fc14dd..796f49d 100644 --- a/.github/workflows/react.yml +++ b/.github/workflows/react.yml @@ -26,17 +26,6 @@ jobs: - name: Run tests run: npm test - coverage: - name: Jest Coverage Report - runs-on: ubuntu-latest - needs: [ unitTests ] - steps: - - uses: actions/checkout@v4 - - uses: ArtiomTr/jest-coverage-report-action@v2 - with: - custom-title: 'Jest Coverage Report' - package-manager: 'npm' - integration_tests: name: Run integration tests needs: [ unitTests ] diff --git a/package-lock.json b/package-lock.json index 7519b46..5704c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,11 +24,13 @@ "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", + "happy-dom": "^20.4.0", "husky": "^9.0.0", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", @@ -38,7 +40,8 @@ "react-dom": "^18.3.0", "rollup": "^4.55.1", "ts-jest": "^29.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "engines": { "node": ">=18.0.0" @@ -750,6 +753,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -2043,6 +2488,13 @@ "@sinonjs/commons": "^3.0.1" } }, + "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, + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -2182,6 +2634,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2346,6 +2816,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@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, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -2903,10 +3390,172 @@ ], "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "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/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "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/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/acorn": { "version": "8.15.0", @@ -3195,6 +3844,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3516,6 +4204,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4292,6 +4990,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4352,6 +5057,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4744,6 +5491,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5236,6 +5993,47 @@ "uglify-js": "^3.1.4" } }, + "node_modules/happy-dom": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.4.0.tgz", + "integrity": "sha512-RDeQm3dT9n0A5f/TszjUmNCLEuPnMGv3Tv4BmNINebz/h17PA6LMBcxJ5FrcqltNBMh9jA/8ufgDdBYUdBt+eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^4.5.0", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "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, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7724,6 +8522,28 @@ "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, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7892,6 +8712,25 @@ "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -8091,6 +8930,17 @@ "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" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8308,6 +9158,13 @@ "dev": true, "license": "ISC" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8430,6 +9287,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9172,6 +10058,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9242,6 +10135,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -9283,6 +10186,20 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9702,6 +10619,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -9719,6 +10653,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -10142,6 +11086,159 @@ "node": ">=10.12.0" } }, + "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, + "license": "MIT", + "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/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "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/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10317,6 +11414,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 6d884b8..a8af4e8 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "build": "tsc && rollup -c", "build:win": "npm run clean:win && tsc && rollup -c", "lint": "tsc --noEmit && eslint 'src/**/*.{js,ts,tsx}' --fix", - "test": "jest --silent", - "test-coverage": "jest --coverage --coverageReporters=\"text-summary\" --silent", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:legacy": "jest --silent", "prepublishOnly": "npm run test && npm run build", "prepare": "npm run build && husky" }, @@ -60,11 +62,13 @@ "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.35.0", "eslint-plugin-react-hooks": "^4.6.2", + "happy-dom": "^20.4.0", "husky": "^9.0.0", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", @@ -74,6 +78,7 @@ "react-dom": "^18.3.0", "rollup": "^4.55.1", "ts-jest": "^29.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } } diff --git a/src/Provider.tsx b/src/Provider.tsx index 5d4a134..083214c 100644 --- a/src/Provider.tsx +++ b/src/Provider.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// @ts-nocheck import * as React from 'react'; import { UserAttributes } from '@optimizely/optimizely-sdk'; import { getLogger } from '@optimizely/optimizely-sdk'; diff --git a/src/autoUpdate.ts b/src/autoUpdate.ts index 3df8897..a1f6a47 100644 --- a/src/autoUpdate.ts +++ b/src/autoUpdate.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +// @ts-nocheck import { enums } from '@optimizely/optimizely-sdk'; import { ReactSDKClient } from './client'; import { LoggerFacade } from '@optimizely/optimizely-sdk/dist/modules/logging'; diff --git a/src/client.ts b/src/client.ts index 700b9c9..481f147 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +// @ts-nocheck import * as optimizely from '@optimizely/optimizely-sdk'; import { OptimizelyDecision, UserInfo, createFailedDecision, areUsersEqual } from './utils'; diff --git a/src/client/client.spec.ts b/src/client/client.spec.ts new file mode 100644 index 0000000..e6dd303 --- /dev/null +++ b/src/client/client.spec.ts @@ -0,0 +1,54 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi, describe, it, expect, beforeEach, type MockedFunction } from 'vitest'; +import { createInstance as jsCreateInstance } from '@optimizely/optimizely-sdk'; +import type { Config } from '@optimizely/optimizely-sdk'; +import { createInstance, CLIENT_ENGINE, CLIENT_VERSION } from './createInstance'; + +vi.mock('@optimizely/optimizely-sdk', () => ({ + createInstance: vi.fn().mockReturnValue({ + onReady: vi.fn().mockResolvedValue(undefined), + createUserContext: vi.fn(), + close: vi.fn(), + }), +})); + +const mockedJsCreateInstance = jsCreateInstance as MockedFunction; + +// Minimal valid config for testing +const createTestConfig = (overrides: Partial = {}): Config => ({ + projectConfigManager: {} as Config['projectConfigManager'], + ...overrides, +}); + +describe('createInstance', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('client engine and version', () => { + it('should set clientEngine and clientVersion to "react-sdk"', () => { + createInstance(createTestConfig()); + expect(mockedJsCreateInstance).toHaveBeenCalledWith( + expect.objectContaining({ + clientEngine: CLIENT_ENGINE, + clientVersion: CLIENT_VERSION, + }) + ); + }); + }); +}); diff --git a/src/client/createInstance.ts b/src/client/createInstance.ts index ff9a15c..93a2849 100644 --- a/src/client/createInstance.ts +++ b/src/client/createInstance.ts @@ -17,8 +17,8 @@ import { createInstance as jsCreateInstance } from '@optimizely/optimizely-sdk'; import type { Config, Client } from '@optimizely/optimizely-sdk'; -const CLIENT_ENGINE = 'react-sdk'; -const CLIENT_VERSION = '4.0.0'; +export const CLIENT_ENGINE = 'react-sdk'; +export const CLIENT_VERSION = '4.0.0'; /** * Creates an Optimizely client instance for use with React SDK. diff --git a/src/hooks.ts b/src/hooks.ts index a84b266..e462db5 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +// @ts-nocheck import { useCallback, useContext, useEffect, useState, useRef, useMemo } from 'react'; import { UserAttributes, OptimizelyDecideOption, getLogger } from '@optimizely/optimizely-sdk'; diff --git a/src/index.ts b/src/index.ts index 63634d9..615c288 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2018-2019, 2023, 2024 Optimizely + * Copyright 2018-2019, 2023, 2024, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,29 +14,9 @@ * limitations under the License. */ -export { OptimizelyContext, OptimizelyContextConsumer, OptimizelyContextProvider } from './Context'; -export { OptimizelyProvider } from './Provider'; -export { OptimizelyFeature } from './Feature'; -export { useFeature, useExperiment, useDecision, useTrackEvent } from './hooks'; -export { withOptimizely, WithOptimizelyProps, WithoutOptimizelyProps } from './withOptimizely'; -export { OptimizelyExperiment } from './Experiment'; -export { OptimizelyVariation } from './Variation'; -export { OptimizelyDecision } from './utils'; +// Client - re-export everything +export * from './client'; -export { - logging, - errorHandler, - setLogger, - setLogLevel, - enums, - eventDispatcher, - OptimizelyDecideOption, - ActivateListenerPayload, - TrackListenerPayload, - ListenerPayload, - OptimizelySegmentOption, -} from '@optimizely/optimizely-sdk'; - -export { createInstance, ReactSDKClient } from './client'; - -export { default as logOnlyEventDispatcher } from './logOnlyEventDispatcher'; +// Provider +export { OptimizelyProvider } from './provider/index'; +export type { UserInfo, OptimizelyProviderProps } from './provider/index'; diff --git a/src/logOnlyEventDispatcher.ts b/src/logOnlyEventDispatcher.ts index 89c8e90..65ae01b 100644 --- a/src/logOnlyEventDispatcher.ts +++ b/src/logOnlyEventDispatcher.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// @ts-nocheck import * as optimizely from '@optimizely/optimizely-sdk'; const logger = optimizely.getLogger('ReactSDK'); diff --git a/src/logger.tsx b/src/logger.tsx index 7703155..04acbed 100644 --- a/src/logger.tsx +++ b/src/logger.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +// @ts-nocheck import * as optimizely from '@optimizely/optimizely-sdk'; import { sprintf } from './utils'; diff --git a/src/provider/OptimizelyProvider.spec.tsx b/src/provider/OptimizelyProvider.spec.tsx new file mode 100644 index 0000000..65333e0 --- /dev/null +++ b/src/provider/OptimizelyProvider.spec.tsx @@ -0,0 +1,266 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi, describe, it, expect } from 'vitest'; +import React, { useContext } from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import type { Client as OptimizelyClient, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +import { OptimizelyProvider, OptimizelyContext } from './OptimizelyProvider'; +import type { OptimizelyContextValue } from './types'; + +/** + * Create a mock Optimizely client for testing. + */ +function createMockClient(overrides: Partial = {}): OptimizelyClient { + const mockUserContext: OptimizelyUserContext = { + getUserId: vi.fn().mockReturnValue('test-user'), + getAttributes: vi.fn().mockReturnValue({}), + fetchQualifiedSegments: vi.fn().mockResolvedValue(true), + decide: vi.fn(), + decideAll: vi.fn(), + decideForKeys: vi.fn(), + setForcedDecision: vi.fn(), + getForcedDecision: vi.fn(), + removeForcedDecision: vi.fn(), + removeAllForcedDecisions: vi.fn(), + trackEvent: vi.fn(), + getOptimizely: vi.fn(), + setQualifiedSegments: vi.fn(), + getQualifiedSegments: vi.fn().mockReturnValue([]), + } as unknown as OptimizelyUserContext; + + return { + // onReady() resolves when client is ready, rejects on timeout/error + onReady: vi.fn().mockResolvedValue(undefined), + createUserContext: vi.fn().mockReturnValue(mockUserContext), + close: vi.fn(), + getOptimizelyConfig: vi.fn(), + notificationCenter: {} as OptimizelyClient['notificationCenter'], + sendOdpEvent: vi.fn(), + isOdpIntegrated: vi.fn().mockReturnValue(false), + ...overrides, + } as unknown as OptimizelyClient; +} + +/** + * Test component that consumes and exposes context value. + */ +function ContextConsumer({ onContext }: { onContext: (ctx: OptimizelyContextValue | null) => void }) { + const context = useContext(OptimizelyContext); + React.useEffect(() => { + onContext(context); + }, [context, onContext]); + return
Consumer
; +} + +describe('OptimizelyProvider', () => { + describe('context value', () => { + it('should provide context with store and client', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + expect(capturedContext!.client).toBe(mockClient); + expect(capturedContext!.store).toBeDefined(); + expect(typeof capturedContext!.store.getState).toBe('function'); + expect(typeof capturedContext!.store.subscribe).toBe('function'); + }); + + it('should provide stable context value across re-renders', async () => { + const mockClient = createMockClient(); + const capturedContexts: (OptimizelyContextValue | null)[] = []; + + const { rerender } = render( + + capturedContexts.push(ctx)} /> + + ); + + // Re-render with same client but different user + rerender( + + capturedContexts.push(ctx)} /> + + ); + + // Context value should be stable (same reference) when client doesn't change + const firstContext = capturedContexts[0]; + const lastContext = capturedContexts[capturedContexts.length - 1]; + + expect(firstContext).toBe(lastContext); + }); + }); + + describe('client.onReady()', () => { + it('should call onReady with undefined timeout when not provided', async () => { + const mockClient = createMockClient(); + + render( + +
Child
+
+ ); + + expect(mockClient.onReady).toHaveBeenCalledWith({ timeout: undefined }); + }); + + it('should call onReady with custom timeout', async () => { + const mockClient = createMockClient(); + + render( + +
Child
+
+ ); + + expect(mockClient.onReady).toHaveBeenCalledWith({ timeout: 5000 }); + }); + + it('should set isClientReady to true when onReady succeeds', async () => { + const mockClient = createMockClient({ + onReady: vi.fn().mockResolvedValue(undefined), + }); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().isClientReady).toBe(true); + }); + + expect(capturedContext!.store.getState().error).toBeNull(); + }); + + it('should set isClientReady to false and set error when onReady rejects', async () => { + const testError = new Error('Client initialization failed'); + const mockClient = createMockClient({ + onReady: vi.fn().mockRejectedValue(testError), + }); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().error).toBe(testError); + }); + + // Client is NOT ready when onReady rejects + expect(capturedContext!.store.getState().isClientReady).toBe(false); + }); + + it('should set error when onReady times out (rejects)', async () => { + const timeoutError = new Error('onReady timeout after 100ms'); + const mockClient = createMockClient({ + onReady: vi.fn().mockRejectedValue(timeoutError), + }); + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().error).toBe(timeoutError); + }); + + expect(capturedContext!.store.getState().isClientReady).toBe(false); + }); + }); + + describe('error handling', () => { + it('should set error when client is not provided', async () => { + let capturedContext: OptimizelyContextValue | null = null; + + render( + + (capturedContext = ctx)} /> + + ); + + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().error?.message).toBe('Optimizely client is required'); + }); + }); + + describe('cleanup', () => { + it('should reset store on unmount', async () => { + const mockClient = createMockClient(); + let capturedContext: OptimizelyContextValue | null = null; + + const { unmount } = render( + + (capturedContext = ctx)} /> + + ); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + expect(capturedContext!.store.getState().isClientReady).toBe(true); + }); + + const store = capturedContext!.store; + + unmount(); + + // Store should be reset + expect(store.getState().isClientReady).toBe(false); + expect(store.getState().userContext).toBeNull(); + expect(store.getState().error).toBeNull(); + }); + }); + + describe('rendering', () => { + it('should render children', () => { + const mockClient = createMockClient(); + + render( + +
Hello World
+
+ ); + + expect(screen.getByTestId('child')).toBeInTheDocument(); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('should render without children', () => { + const mockClient = createMockClient(); + + expect(() => { + render(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/provider/OptimizelyProvider.tsx b/src/provider/OptimizelyProvider.tsx new file mode 100644 index 0000000..8bce82d --- /dev/null +++ b/src/provider/OptimizelyProvider.tsx @@ -0,0 +1,104 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { createContext, useRef, useMemo, useEffect } from 'react'; + +import { ProviderStateStore } from './ProviderStateStore'; +import type { OptimizelyProviderProps, OptimizelyContextValue } from './types'; + +// TODO: Replace with proper logger when implemented +const logger = { + info: (msg: string) => console.info(`[OptimizelyProvider] ${msg}`), + warn: (msg: string) => console.warn(`[OptimizelyProvider] ${msg}`), + error: (msg: string) => console.error(`[OptimizelyProvider] ${msg}`), +}; + +/** + * React Context for Optimizely. + */ +export const OptimizelyContext = createContext(null); + +export function OptimizelyProvider({ + client, + user, + timeout, + skipSegments = false, + children, +}: OptimizelyProviderProps): React.ReactElement { + const storeRef = useRef(null); + // Todo: const prevUserRef = useRef(undefined); + + if (storeRef.current === null) { + storeRef.current = new ProviderStateStore(); + } + + const store = storeRef.current; + + const contextValue = useMemo( + () => ({ + store, + client, + }), + [store, client] + ); + + useEffect(() => { + if (!client) { + logger?.error('OptimizelyProvider must be passed an Optimizely client instance'); + store.setError(new Error('Optimizely client is required')); + return; + } + + let isMounted = true; + + const waitForClientReady = async (): Promise => { + try { + await client.onReady({ timeout }); + + if (!isMounted) return; + + store.setClientReady(true); + } catch (error) { + if (!isMounted) return; + const err = error instanceof Error ? error : new Error(String(error)); + store.setState({ + isClientReady: false, + error: err, + }); + } + }; + + waitForClientReady(); + + return () => { + isMounted = false; + }; + }, [client, timeout, store]); + + // Handle user changes + useEffect(() => { + // TODO: UserContextManager implementation + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + store.reset(); + }; + }, [store]); + + return {children}; +} diff --git a/src/provider/ProviderStateStore.spec.ts b/src/provider/ProviderStateStore.spec.ts new file mode 100644 index 0000000..8bfd902 --- /dev/null +++ b/src/provider/ProviderStateStore.spec.ts @@ -0,0 +1,269 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { ProviderStateStore } from './ProviderStateStore'; + +describe('ProviderStateStore', () => { + let store: ProviderStateStore; + + beforeEach(() => { + store = new ProviderStateStore(); + }); + + describe('initial state', () => { + it('should have correct initial state', () => { + const state = store.getState(); + + expect(state.isClientReady).toBe(false); + expect(state.userContext).toBeNull(); + expect(state.error).toBeNull(); + }); + }); + + describe('subscribe', () => { + it('should add listener and return unsubscribe function', () => { + const listener = vi.fn(); + + const unsubscribe = store.subscribe(listener); + + expect(typeof unsubscribe).toBe('function'); + }); + + it('should notify listener when state changes', () => { + const listener = vi.fn(); + store.subscribe(listener); + + store.setClientReady(true); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + isClientReady: true, + }) + ); + }); + + it('should notify multiple listeners', () => { + const listener1 = vi.fn(); + const listener2 = vi.fn(); + store.subscribe(listener1); + store.subscribe(listener2); + + store.setClientReady(true); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + }); + + it('should not notify after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + unsubscribe(); + store.setClientReady(true); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should handle multiple unsubscribes gracefully', () => { + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + unsubscribe(); + unsubscribe(); // Second call should not throw + + store.setClientReady(true); + expect(listener).not.toHaveBeenCalled(); + }); + + it('should allow re-subscribing after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe1 = store.subscribe(listener); + + unsubscribe1(); + + const unsubscribe2 = store.subscribe(listener); + store.setClientReady(true); + + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe2(); + }); + }); + + describe('setClientReady', () => { + it('should update isClientReady state', () => { + store.setClientReady(true); + + expect(store.getState().isClientReady).toBe(true); + }); + + it('should not notify if value has not changed', () => { + const listener = vi.fn(); + store.subscribe(listener); + + store.setClientReady(false); // Same as initial value + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should preserve other state properties', () => { + const mockUserContext = { userId: 'test-user' } as any; + const mockError = new Error('test'); + + store.setUserContext(mockUserContext); + store.setError(mockError); + store.setClientReady(true); + + const state = store.getState(); + expect(state.userContext).toBe(mockUserContext); + expect(state.error).toBe(mockError); + expect(state.isClientReady).toBe(true); + }); + }); + + describe('setUserContext', () => { + it('should update userContext state', () => { + const mockUserContext = { userId: 'test-user' } as any; + + store.setUserContext(mockUserContext); + + expect(store.getState().userContext).toBe(mockUserContext); + }); + + it('should allow setting userContext to null', () => { + const mockUserContext = { userId: 'test-user' } as any; + store.setUserContext(mockUserContext); + + store.setUserContext(null); + + expect(store.getState().userContext).toBeNull(); + }); + + it('should preserve other state properties', () => { + const mockError = new Error('test'); + store.setClientReady(true); + store.setError(mockError); + + const mockUserContext = { userId: 'test-user' } as any; + store.setUserContext(mockUserContext); + + const state = store.getState(); + expect(state.isClientReady).toBe(true); + expect(state.error).toBe(mockError); + }); + }); + + describe('setError', () => { + it('should update error state', () => { + const error = new Error('Test error'); + + store.setError(error); + + expect(store.getState().error).toBe(error); + }); + + it('should allow clearing error', () => { + const error = new Error('Test error'); + store.setError(error); + + store.setError(null); + + expect(store.getState().error).toBeNull(); + }); + + it('should not notify if same error reference', () => { + const error = new Error('Test error'); + store.setError(error); + + const listener = vi.fn(); + store.subscribe(listener); + + store.setError(error); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should not clear other state when error is set', () => { + const mockUserContext = { userId: 'test-user' } as any; + store.setClientReady(true); + store.setUserContext(mockUserContext); + + store.setError(new Error('test')); + + const state = store.getState(); + expect(state.isClientReady).toBe(true); + expect(state.userContext).toBe(mockUserContext); + }); + }); + + describe('setState', () => { + it('should batch update multiple properties', () => { + const listener = vi.fn(); + store.subscribe(listener); + + const mockUserContext = { userId: 'test-user' } as any; + store.setState({ + isClientReady: true, + userContext: mockUserContext, + }); + + // Should only notify once for batch update + expect(listener).toHaveBeenCalledTimes(1); + + const state = store.getState(); + expect(state.isClientReady).toBe(true); + expect(state.userContext).toBe(mockUserContext); + }); + + it('should allow partial updates', () => { + store.setClientReady(true); + + store.setState({ error: new Error('test') }); + + const state = store.getState(); + expect(state.isClientReady).toBe(true); + expect(state.error).not.toBeNull(); + }); + }); + + describe('reset', () => { + it('should reset to initial state', () => { + const mockUserContext = { userId: 'test-user' } as any; + store.setClientReady(true); + store.setUserContext(mockUserContext); + store.setError(new Error('test')); + + store.reset(); + + const state = store.getState(); + expect(state.isClientReady).toBe(false); + expect(state.userContext).toBeNull(); + expect(state.error).toBeNull(); + }); + + it('should notify listeners on reset', () => { + const listener = vi.fn(); + store.setClientReady(true); + store.subscribe(listener); + + store.reset(); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/provider/ProviderStateStore.ts b/src/provider/ProviderStateStore.ts new file mode 100644 index 0000000..2a96699 --- /dev/null +++ b/src/provider/ProviderStateStore.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; +import type { ProviderState } from './types'; + +/** + * Listener function type for state subscriptions. + */ +export type StateListener = (state: ProviderState) => void; + +/** + * Initial state for the provider store. + */ +const initialState: ProviderState = { + isClientReady: false, + userContext: null, + error: null, +}; + +/** + * Observable store holding shared Provider state. + * + * The store follows a simple observable pattern: + * - Hooks subscribe via `subscribe()` and receive state updates + * - Provider updates state via setter methods + * - Listeners are notified on state changes via callbacks + */ +export class ProviderStateStore { + private state: ProviderState; + private listeners: Set; + + constructor() { + this.state = { ...initialState }; + this.listeners = new Set(); + } + + /** + * Get the current state snapshot. + * Returns a reference to the current state object. + */ + getState(): ProviderState { + return this.state; + } + + /** + * Subscribe to state changes. + * + * @param listener - Callback invoked with new state on each change + * @returns Unsubscribe function to remove the listener + */ + subscribe(listener: StateListener): () => void { + this.listeners.add(listener); + + // Return unsubscribe function + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Set whether the client is ready. + * e.g: Called by Provider after client.onReady() resolves. + */ + setClientReady(ready: boolean): void { + if (this.state.isClientReady === ready) { + return; + } + + this.state.isClientReady = ready; + this.notifyListeners(); + } + + /** + * Set the current user context. + * e.g: Called by UserContextManager when user context is created. + */ + setUserContext(ctx: OptimizelyUserContext | null): void { + // Always update userContext even if same reference - + // user attributes may have changed + this.state.userContext = ctx; + this.notifyListeners(); + } + + /** + * Set an error that occurred during initialization. + * Setting an error does NOT clear userContext or isClientReady. + */ + setError(error: Error | null): void { + if (this.state.error === error) { + return; // No change, skip notification + } + + this.state.error = error; + this.notifyListeners(); + } + + /** + * Batch update multiple state properties at once. + * Useful when multiple state changes should trigger a single notification. + */ + setState(partialState: Partial): void { + this.state = { + ...this.state, + ...partialState, + }; + this.notifyListeners(); + } + + /** + * Reset store to initial state. + * Useful for testing or when Provider unmounts. + */ + reset(): void { + this.state = { ...initialState }; + this.notifyListeners(); + } + + /** + * Notify all listeners of state change. + */ + private notifyListeners(): void { + this.listeners.forEach((listener) => { + listener(this.state); + }); + } +} diff --git a/src/provider/index.ts b/src/provider/index.ts new file mode 100644 index 0000000..d795289 --- /dev/null +++ b/src/provider/index.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Types +export type { UserInfo, OptimizelyProviderProps, ProviderState, OptimizelyContextValue } from './types'; + +// Store +export { ProviderStateStore } from './ProviderStateStore'; +export type { StateListener } from './ProviderStateStore'; + +// Provider and Context +export { OptimizelyProvider, OptimizelyContext } from './OptimizelyProvider'; diff --git a/src/provider/types.ts b/src/provider/types.ts new file mode 100644 index 0000000..6d07d76 --- /dev/null +++ b/src/provider/types.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2026, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ReactNode } from 'react'; +import type { Client as OptimizelyClient, OptimizelyUserContext, UserAttributes } from '@optimizely/optimizely-sdk'; +import { ProviderStateStore } from './ProviderStateStore'; + +/** + * User information passed to the Provider or hooks for user override. + */ +export interface UserInfo { + id?: string; + attributes?: UserAttributes; +} + +/** + * Props for the OptimizelyProvider component. + */ +export interface OptimizelyProviderProps { + /** + * The Optimizely client instance. + */ + client: OptimizelyClient; + + /** + * User information for decisions. + */ + user?: UserInfo; + + /** + * Timeout in milliseconds to wait for the client to become ready. + * @default 30000 - 30 seconds + */ + timeout?: number; + + /** + * Skip fetching ODP segments for the user context. + * @default false + */ + skipSegments?: boolean; + + /** + * React children to render. + */ + children?: ReactNode; +} + +/** + * Internal state managed by the ProviderStateStore. + * This is the reactive state that hooks subscribe to. + */ +export interface ProviderState { + /** + * Whether js onReady() is resolved. + */ + isClientReady: boolean; + + /** + * The current user context for making decisions. + * null while initializing or if user creation failed. + */ + userContext: OptimizelyUserContext | null; + + /** + * Error that occurred during initialization or user context creation. + */ + error: Error | null; +} + +/** + * The value provided via React Context. + * Contains stable references to the store and client. + * Hooks subscribe directly to the store for state changes. + */ +export interface OptimizelyContextValue { + /** + * The state store - hooks subscribe to this for reactive updates. + */ + store: ProviderStateStore; + + /** + * The Optimizely client instance. + */ + client: OptimizelyClient; +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..97aaec4 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + setupFiles: ['./vitest.setup.ts'], + include: [ + 'src/client/**/*.spec.{ts,tsx}', + 'src/provider/**/*.spec.{ts,tsx}', + // Add more paths as migration progresses + ], + coverage: { + provider: 'v8', + reporter: ['text-summary', 'lcov'], + reportsDirectory: './coverage', + include: ['src/client/**', 'src/provider/**'], + }, + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest';