diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9447997..d5d79a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,8 +24,8 @@ jobs: - name: Type check run: npm run typecheck - - name: Run frontend tests - run: npm test + - name: Run frontend tests with coverage + run: npm run test:coverage - name: Build SDK run: npm run build @@ -37,3 +37,26 @@ jobs: - name: Build demo working-directory: demo run: npm run build + + integration-test: + name: Integration Tests (Live Backend) + runs-on: ubuntu-latest + if: vars.SIMFACE_TEST_API_URL != '' + needs: frontend-test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install dependencies + run: npm install + + - name: Run integration tests + run: npm run test:integration + env: + SIMFACE_TEST_API_URL: ${{ vars.SIMFACE_TEST_API_URL }} + SIMFACE_TEST_PROJECT_ID: ${{ secrets.SIMFACE_TEST_PROJECT_ID }} + SIMFACE_TEST_API_KEY: ${{ secrets.SIMFACE_TEST_API_KEY }} diff --git a/package-lock.json b/package-lock.json index 3134313..61e5872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.1.0", "eslint": "^9.0.0", "globals": "^17.4.0", "jsdom": "^25.0.0", @@ -38,6 +39,66 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -804,6 +865,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -811,6 +882,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", @@ -1519,18 +1601,49 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.0", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1538,13 +1651,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1553,7 +1666,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -1565,9 +1678,9 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1578,13 +1691,13 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -1592,13 +1705,14 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1607,9 +1721,9 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -1617,13 +1731,14 @@ } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, "funding": { @@ -1713,6 +1828,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1829,6 +1956,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1970,9 +2104,9 @@ } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -2509,6 +2643,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2624,6 +2765,52 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2794,6 +2981,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3206,9 +3421,9 @@ "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==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -3492,31 +3707,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "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", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -3532,12 +3747,13 @@ "@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", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -3566,6 +3782,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index 5ed7439..fe60e3f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "demo:dev": "node ./scripts/demo-dev.mjs", "test": "vitest run", "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:coverage": "vitest run --coverage", "lint": "eslint src vite.config.ts", "typecheck": "tsc --noEmit" }, @@ -51,6 +53,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^4.1.0", "eslint": "^9.0.0", "globals": "^17.4.0", "jsdom": "^25.0.0", diff --git a/src/index.test.ts b/src/index.test.ts index e914e8a..ec41686 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { SimFaceCaptureElement } from './types/index.js'; +import type { SimFaceCaptureElement } from './types'; const captureMocks = vi.hoisted(() => ({ captureFromCamera: vi.fn(), @@ -85,4 +85,58 @@ describe('SDK entrypoints', () => { message: 'Capture cancelled by user', }); }); + + it('returns cancelled result when enroll capture returns null', async () => { + captureMocks.captureFromCamera.mockResolvedValue(null); + + const result = await enroll(config, 'user-1', { capturePreference: 'auto-preferred' }); + + expect(apiClientMethodMocks.enroll).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + clientId: 'user-1', + message: 'Capture cancelled by user', + }); + }); + + it('returns alreadyEnrolled result from API', async () => { + const blob = new Blob(['capture'], { type: 'image/jpeg' }); + captureMocks.captureFromCamera.mockResolvedValue(blob); + apiClientMethodMocks.enroll.mockResolvedValue({ + success: false, + clientId: 'user-1', + alreadyEnrolled: true, + }); + + const result = await enroll(config, 'user-1', { capturePreference: 'auto-preferred' }); + + expect(result).toEqual(expect.objectContaining({ alreadyEnrolled: true })); + }); + + it('verify() happy path returns match result from API', async () => { + const blob = new Blob(['capture'], { type: 'image/jpeg' }); + captureMocks.captureFromCamera.mockResolvedValue(blob); + apiClientMethodMocks.verify.mockResolvedValue({ + match: true, + score: 0.95, + threshold: 0.5, + }); + + const result = await verify(config, 'user-1', { capturePreference: 'auto-preferred' }); + + expect(apiClientMethodMocks.verify).toHaveBeenCalledWith('user-1', blob); + expect(result).toEqual({ match: true, score: 0.95, threshold: 0.5 }); + }); + + it('enroll() propagates API key validation failure', async () => { + apiClientMethodMocks.validateAPIKey.mockRejectedValue(new Error('Invalid API key')); + + await expect(enroll(config, 'user-1')).rejects.toThrow('Invalid API key'); + }); + + it('verify() propagates API key validation failure', async () => { + apiClientMethodMocks.validateAPIKey.mockRejectedValue(new Error('Invalid API key')); + + await expect(verify(config, 'user-1')).rejects.toThrow('Invalid API key'); + }); }); diff --git a/src/integration/fixtures/README.md b/src/integration/fixtures/README.md new file mode 100644 index 0000000..a49a69c --- /dev/null +++ b/src/integration/fixtures/README.md @@ -0,0 +1,29 @@ +# Integration Test Fixtures + +This directory contains face images used by the E2E integration tests that run +against the real SimFace backend. + +## Current images + +| File | Purpose | +|------|---------| +| `face-a.jpg` | **Placeholder** — replace with a real face photo (Person A, enrollment image). | +| `face-b.jpg` | **Placeholder** — replace with a real face photo (Person A, verification image — should match face-a). | +| `face-c.jpg` | **Placeholder** — replace with a real face photo (Person B — should NOT match face-a/b). | + +## Replacing with real face images + +For full face-matching verification, replace these placeholders with real face +photographs. Requirements: + +- JPEG format, ≥ 200×200 px +- Single face clearly visible, frontal pose +- `face-a.jpg` and `face-b.jpg` should be the **same person** (to test match) +- `face-c.jpg` should be a **different person** (to test no-match) +- Use images you have permission to use (e.g. your own photos or an open dataset + like [LFW](http://vis-www.cs.umass.edu/lfw/) or + [Generated Photos](https://generated.photos)) + +> **Note:** The backend may reject non-face images. If the integration tests +> fail with enrollment errors, that's the most likely cause — replace the +> placeholders with real face photos. diff --git a/src/integration/fixtures/face-a.jpg b/src/integration/fixtures/face-a.jpg new file mode 100644 index 0000000..b04ae8e Binary files /dev/null and b/src/integration/fixtures/face-a.jpg differ diff --git a/src/integration/fixtures/face-b.jpg b/src/integration/fixtures/face-b.jpg new file mode 100644 index 0000000..1b392ea Binary files /dev/null and b/src/integration/fixtures/face-b.jpg differ diff --git a/src/integration/fixtures/face-c.jpg b/src/integration/fixtures/face-c.jpg new file mode 100644 index 0000000..c79fe7e Binary files /dev/null and b/src/integration/fixtures/face-c.jpg differ diff --git a/src/integration/sdk-workflow.integration.test.ts b/src/integration/sdk-workflow.integration.test.ts new file mode 100644 index 0000000..97f7869 --- /dev/null +++ b/src/integration/sdk-workflow.integration.test.ts @@ -0,0 +1,96 @@ +/** + * E2E integration tests — exercises the real SimFace backend. + * + * These tests make actual HTTP calls and depend on the following env vars: + * SIMFACE_TEST_API_URL — Backend base URL + * SIMFACE_TEST_PROJECT_ID — Project to enroll/verify against + * SIMFACE_TEST_API_KEY — Valid API key for that project + * + * They are skipped gracefully when the env vars are absent (local dev, forks). + * + * Run manually: + * SIMFACE_TEST_API_URL=https://... \ + * SIMFACE_TEST_PROJECT_ID=... \ + * SIMFACE_TEST_API_KEY=... \ + * npx vitest run --config vitest.integration.config.ts + */ + +import { describe, expect, it, beforeAll } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { SimFaceAPIClient } from '../services/api-client.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const API_URL = process.env.SIMFACE_TEST_API_URL; +const PROJECT_ID = process.env.SIMFACE_TEST_PROJECT_ID; +const API_KEY = process.env.SIMFACE_TEST_API_KEY; + +const hasCredentials = !!(API_URL && PROJECT_ID && API_KEY); + +// Unique client ID per run to avoid collisions across parallel CI jobs +const CLIENT_ID = `ci-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +function loadFixture(name: string): Blob { + const buffer = readFileSync(resolve(__dirname, 'fixtures', name)); + return new Blob([buffer], { type: 'image/jpeg' }); +} + +describe.sequential.skipIf(!hasCredentials)('SDK integration (live backend)', () => { + let client: SimFaceAPIClient; + let faceA: Blob; + let faceB: Blob; + let faceC: Blob; + + beforeAll(() => { + client = new SimFaceAPIClient({ + projectId: PROJECT_ID!, + apiKey: API_KEY!, + apiUrl: API_URL!, + }); + + faceA = loadFixture('face-a.jpg'); + faceB = loadFixture('face-b.jpg'); + faceC = loadFixture('face-c.jpg'); + }); + + it('validates the API key', async () => { + const result = await client.validateAPIKey(); + expect(result.valid).toBe(true); + expect(result.projectId).toBe(PROJECT_ID); + }, 30_000); + + it('enrolls a new user with face-a', async () => { + const result = await client.enroll(CLIENT_ID, faceA); + expect(result.success).toBe(true); + expect(result.clientId).toBe(CLIENT_ID); + expect(result.alreadyEnrolled).toBeFalsy(); + }, 30_000); + + it('verifies the enrolled user with face-b (same person, match)', async () => { + const result = await client.verify(CLIENT_ID, faceB); + expect(result.match).toBe(true); + expect(result.score).toBeGreaterThan(0); + expect(result.notEnrolled).toBeFalsy(); + }, 30_000); + + it('verifies the enrolled user with face-c (different person, no match)', async () => { + const result = await client.verify(CLIENT_ID, faceC); + expect(result.match).toBe(false); + expect(result.notEnrolled).toBeFalsy(); + }, 30_000); + + it('returns alreadyEnrolled when enrolling the same user again', async () => { + const result = await client.enroll(CLIENT_ID, faceA); + expect(result.alreadyEnrolled).toBe(true); + }, 30_000); + + it('returns notEnrolled when verifying an unknown user', async () => { + const unknownId = `unknown-${Date.now()}`; + const result = await client.verify(unknownId, faceA); + expect(result.notEnrolled).toBe(true); + expect(result.match).toBe(false); + }, 30_000); +}); diff --git a/src/services/face-detection.test.ts b/src/services/face-detection.test.ts index 0da8697..b776ab6 100644 --- a/src/services/face-detection.test.ts +++ b/src/services/face-detection.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { FaceQualityResult } from '../types/index.js'; +import type { FaceQualityResult } from '../types'; const mediaPipeMocks = vi.hoisted(() => ({ createFromOptions: vi.fn(), @@ -26,17 +26,18 @@ vi.mock('@mediapipe/tasks-vision', () => ({ vi.mock('./face-quality.js', () => faceQualityMocks); vi.mock('./sharpness.js', () => sharpnessMocks); -import { assessFaceQualityForVideo } from './face-detection.js'; +import { assessFaceQuality, assessFaceQualityForVideo } from './face-detection.js'; const MOCK_SHARPNESS_SCORE = 0.73; -describe('assessFaceQualityForVideo', () => { - const detectForVideo = vi.fn(); +const detect = vi.fn(); +const detectForVideo = vi.fn(); +describe('assessFaceQualityForVideo', () => { beforeEach(() => { vi.clearAllMocks(); mediaPipeMocks.forVisionTasks.mockResolvedValue({}); - mediaPipeMocks.createFromOptions.mockResolvedValue({ detectForVideo }); + mediaPipeMocks.createFromOptions.mockResolvedValue({ detect, detectForVideo }); sharpnessMocks.computeSharpnessScore.mockReturnValue(MOCK_SHARPNESS_SCORE); }); @@ -108,6 +109,59 @@ function createMediaPipeDetection(overrides: Partial<{ width: number; height: nu }; } +describe('assessFaceQuality', () => { + beforeEach(() => { + vi.clearAllMocks(); + mediaPipeMocks.forVisionTasks.mockResolvedValue({}); + mediaPipeMocks.createFromOptions.mockResolvedValue({ detect, detectForVideo }); + }); + + it('calls image detector with the image element', async () => { + detect.mockReturnValue({ + detections: [createMediaPipeDetection()], + }); + faceQualityMocks.evaluateFaceQuality.mockReturnValue(createQualityResult()); + + const img = createImageElement(); + await assessFaceQuality(img); + + expect(detect).toHaveBeenCalledWith(img); + }); + + it('does not pass resolveSharpnessScore for image mode', async () => { + detect.mockReturnValue({ + detections: [createMediaPipeDetection()], + }); + faceQualityMocks.evaluateFaceQuality.mockReturnValue(createQualityResult()); + + await assessFaceQuality(createImageElement()); + + expect(faceQualityMocks.evaluateFaceQuality).toHaveBeenCalledWith( + expect.not.objectContaining({ resolveSharpnessScore: expect.anything() }), + ); + }); + + it('uses naturalWidth and naturalHeight from the image element', async () => { + detect.mockReturnValue({ + detections: [createMediaPipeDetection()], + }); + faceQualityMocks.evaluateFaceQuality.mockReturnValue(createQualityResult()); + + await assessFaceQuality(createImageElement(1920, 1080)); + + expect(faceQualityMocks.evaluateFaceQuality).toHaveBeenCalledWith( + expect.objectContaining({ width: 1920, height: 1080 }), + ); + }); +}); + +function createImageElement(naturalWidth = 640, naturalHeight = 480) { + const img = new Image(); + Object.defineProperty(img, 'naturalWidth', { value: naturalWidth }); + Object.defineProperty(img, 'naturalHeight', { value: naturalHeight }); + return img; +} + function createVideoElement() { return { videoWidth: 640, diff --git a/src/services/sharpness.test.ts b/src/services/sharpness.test.ts index a7a23a0..8537ac6 100644 --- a/src/services/sharpness.test.ts +++ b/src/services/sharpness.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { laplacianVariance, MIN_SHARPNESS_SCORE } from './sharpness.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { computeSharpnessScore, laplacianVariance, MIN_SHARPNESS_SCORE } from './sharpness.js'; +import { REFERENCE_VARIANCE } from '../shared/capture-config.js'; /** * Helper to create synthetic ImageData with a flat RGBA pixel array. @@ -120,3 +121,94 @@ describe('MIN_SHARPNESS_SCORE', () => { expect(MIN_SHARPNESS_SCORE).toBeLessThan(1); }); }); + +// --------------------------------------------------------------------------- +// computeSharpnessScore +// --------------------------------------------------------------------------- + +function createMockVideo(width = 640, height = 480): HTMLVideoElement { + const video = document.createElement('video'); + Object.defineProperty(video, 'videoWidth', { value: width, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: height, configurable: true }); + return video; +} + +describe('computeSharpnessScore', () => { + let mockCtx: { drawImage: ReturnType; getImageData: ReturnType }; + let getContextSpy: ReturnType; + + beforeEach(() => { + mockCtx = { + drawImage: vi.fn(), + getImageData: vi.fn(), + }; + getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(mockCtx as unknown as CanvasRenderingContext2D); + }); + + afterEach(() => { + getContextSpy.mockRestore(); + }); + + it('returns 0 when canvas context is unavailable', () => { + getContextSpy.mockReturnValue(null); + const video = createMockVideo(); + const region = { x: 0, y: 0, width: 50, height: 50 }; + expect(computeSharpnessScore(video, region)).toBe(0); + }); + + it('returns a normalised score for a video frame', () => { + const region = { x: 0, y: 0, width: 50, height: 50 }; + mockCtx.getImageData.mockReturnValue(createEdgeImageData(50, 50, 0, 255)); + + const score = computeSharpnessScore(createMockVideo(), region); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(1); + }); + + it('clamps score to 1 when variance exceeds REFERENCE_VARIANCE', () => { + const region = { x: 0, y: 0, width: 50, height: 50 }; + // Checkerboard with cell size 1 produces very high Laplacian variance + mockCtx.getImageData.mockReturnValue(createCheckerboardImageData(50, 50, 0, 255, 1)); + + // Verify the underlying variance actually exceeds REFERENCE_VARIANCE + const varianceCheck = laplacianVariance(createCheckerboardImageData(50, 50, 0, 255, 1)); + expect(varianceCheck).toBeGreaterThan(REFERENCE_VARIANCE); + + const score = computeSharpnessScore(createMockVideo(), region); + expect(score).toBe(1); + }); + + it('returns 0 for uniform (blurry) regions', () => { + const region = { x: 0, y: 0, width: 50, height: 50 }; + mockCtx.getImageData.mockReturnValue(createUniformImageData(50, 50, 128)); + + const score = computeSharpnessScore(createMockVideo(), region); + expect(score).toBe(0); + }); + + it('reuses provided canvas instead of creating a new one', () => { + const region = { x: 0, y: 0, width: 60, height: 40 }; + mockCtx.getImageData.mockReturnValue(createEdgeImageData(60, 40, 0, 255)); + + const canvas = document.createElement('canvas'); + computeSharpnessScore(createMockVideo(), region, canvas); + + expect(canvas.width).toBe(region.width); + expect(canvas.height).toBe(region.height); + }); + + it('gray buffer reuse grows when region size increases', () => { + const canvas = document.createElement('canvas'); + + // First call – small region + const smallRegion = { x: 0, y: 0, width: 10, height: 10 }; + mockCtx.getImageData.mockReturnValue(createEdgeImageData(10, 10, 0, 255)); + expect(() => computeSharpnessScore(createMockVideo(), smallRegion, canvas)).not.toThrow(); + + // Second call – larger region forces buffer growth + const largeRegion = { x: 0, y: 0, width: 50, height: 50 }; + mockCtx.getImageData.mockReturnValue(createEdgeImageData(50, 50, 0, 255)); + expect(() => computeSharpnessScore(createMockVideo(), largeRegion, canvas)).not.toThrow(); + }); +}); diff --git a/src/shared/auto-capture.test.ts b/src/shared/auto-capture.test.ts new file mode 100644 index 0000000..de3155a --- /dev/null +++ b/src/shared/auto-capture.test.ts @@ -0,0 +1,90 @@ +import {describe, expect, it} from 'vitest'; +import type {FaceQualityResult} from '../types'; +import {AUTO_CAPTURE_COUNTDOWN_MS, autoCaptureCompleteMessage, autoCaptureCountdownMessage,} from './auto-capture.js'; + +function createQualityResult(overrides: Partial = {}): FaceQualityResult { + return { + hasFace: true, + faceCount: 1, + confidence: 0.95, + captureScore: 0.9, + sharpnessScore: 0.73, + isCentered: true, + passesQualityChecks: true, + feedback: 'good', + message: 'Hold still. Capturing automatically...', + ...overrides, + }; +} + +describe('autoCaptureCountdownMessage', () => { + it('returns idle message when countdownStartedAt is null', () => { + const result = autoCaptureCountdownMessage(1000, null, createQualityResult()); + expect(result).toBe( + 'Center your face in the oval. We will capture automatically when framing looks good.', + ); + }); + + it('returns "Hold steady..." with correct seconds when quality passes', () => { + const startedAt = 0; + const timestamp = 2500; + const result = autoCaptureCountdownMessage(timestamp, startedAt, createQualityResult()); + // remaining = 5000 - 2500 = 2500ms → ceil(2.5) = 3s + expect(result).toBe('Hold steady. Capturing the best frame in 3s.'); + }); + + it('uses qualityResult.message prefix when quality does not pass', () => { + const startedAt = 0; + const timestamp = 2500; + const qr = createQualityResult({ + passesQualityChecks: false, + message: 'Move closer.', + }); + const result = autoCaptureCountdownMessage(timestamp, startedAt, qr); + expect(result).toBe('Move closer. Best frame selection finishes in 3s.'); + }); + + it('shows 0s when remaining time is exactly 0', () => { + const startedAt = 0; + const result = autoCaptureCountdownMessage(AUTO_CAPTURE_COUNTDOWN_MS, startedAt, createQualityResult()); + expect(result).toBe('Hold steady. Capturing the best frame in 0s.'); + }); + + it('shows 0s when timestamp exceeds countdown duration', () => { + const startedAt = 0; + const timestamp = AUTO_CAPTURE_COUNTDOWN_MS + 5000; + const qr = createQualityResult({ passesQualityChecks: false, message: 'Too far.' }); + const result = autoCaptureCountdownMessage(timestamp, startedAt, qr); + expect(result).toBe('Too far. Best frame selection finishes in 0s.'); + }); + + it('rounds up remaining seconds mid-countdown', () => { + const startedAt = 1000; + const timestamp = 3500; + // remaining = 5000 - 2500 = 2500ms → ceil(2.5) = 3s + const result = autoCaptureCountdownMessage(timestamp, startedAt, createQualityResult()); + expect(result).toBe('Hold steady. Capturing the best frame in 3s.'); + }); +}); + +describe('autoCaptureCompleteMessage', () => { + it('returns generic message when bestQualityResult is null', () => { + expect(autoCaptureCompleteMessage(null)).toBe( + 'Capture complete. Review and confirm this photo.', + ); + }); + + it('returns "Best frame captured..." when captureScore > 0', () => { + const qr = createQualityResult({ captureScore: 0.85 }); + expect(autoCaptureCompleteMessage(qr)).toBe( + 'Best frame captured. Review and confirm this photo.', + ); + }); + + it('returns generic message when captureScore is 0', () => { + const qr = createQualityResult({ captureScore: 0 }); + expect(autoCaptureCompleteMessage(qr)).toBe( + 'Capture complete. Review and confirm this photo.', + ); + }); +}); diff --git a/src/shared/capture-runtime.test.ts b/src/shared/capture-runtime.test.ts new file mode 100644 index 0000000..4edd824 --- /dev/null +++ b/src/shared/capture-runtime.test.ts @@ -0,0 +1,430 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CameraAccessError, + blobToDataURL, + blobToImage, + createReusableFrameCapture, + describeCameraError, + openUserFacingCameraStream, + resumeVideoPlayback, + waitForVideoReady, +} from './capture-runtime.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeVideo(overrides: { videoWidth?: number; videoHeight?: number; readyState?: number } = {}) { + const video = document.createElement('video'); + Object.defineProperty(video, 'videoWidth', { value: overrides.videoWidth ?? 640, configurable: true }); + Object.defineProperty(video, 'videoHeight', { value: overrides.videoHeight ?? 480, configurable: true }); + if (overrides.readyState !== undefined) { + Object.defineProperty(video, 'readyState', { value: overrides.readyState, configurable: true }); + } + vi.spyOn(video, 'play').mockResolvedValue(); + return video; +} + +function mockCanvasContext() { + const ctx = { drawImage: vi.fn() } as unknown as CanvasRenderingContext2D; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function ( + this: HTMLCanvasElement, + cb: BlobCallback, + ) { + cb(new Blob(['test'], { type: 'image/jpeg' })); + }); + return ctx; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('CameraAccessError', () => { + it('is an instance of Error', () => { + const err = new CameraAccessError('boom'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(CameraAccessError); + }); + + it('has name "CameraAccessError"', () => { + expect(new CameraAccessError('x').name).toBe('CameraAccessError'); + }); + + it('stores the message', () => { + expect(new CameraAccessError('oops').message).toBe('oops'); + }); + + it('attaches cause when provided', () => { + const cause = new TypeError('inner'); + const err = new CameraAccessError('wrap', { cause }); + expect(err.cause).toBe(cause); + }); + + it('has no cause property when not provided', () => { + const err = new CameraAccessError('plain'); + expect('cause' in err).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +describe('describeCameraError', () => { + it('handles NotAllowedError', () => { + const err = new DOMException('denied', 'NotAllowedError'); + expect(describeCameraError(err)).toBe('Camera access was denied. Allow camera access and try again.'); + }); + + it('handles SecurityError', () => { + const err = new DOMException('sec', 'SecurityError'); + expect(describeCameraError(err)).toBe('Camera access was denied. Allow camera access and try again.'); + }); + + it('handles NotFoundError', () => { + const err = new DOMException('nf', 'NotFoundError'); + expect(describeCameraError(err)).toBe('No camera was found on this device.'); + }); + + it('handles NotReadableError', () => { + const err = new DOMException('busy', 'NotReadableError'); + expect(describeCameraError(err)).toBe('The camera is already in use by another application.'); + }); + + it('handles unknown DOMException with message', () => { + const err = new DOMException('something weird', 'AbortError'); + expect(describeCameraError(err)).toBe('something weird'); + }); + + it('handles unknown DOMException with empty message', () => { + const err = new DOMException('', 'UnknownError'); + expect(describeCameraError(err)).toBe('Failed to access the camera.'); + }); + + it('handles generic Error', () => { + expect(describeCameraError(new Error('generic'))).toBe('generic'); + }); + + it('handles non-Error value', () => { + expect(describeCameraError('string')).toBe('Failed to access the camera.'); + expect(describeCameraError(42)).toBe('Failed to access the camera.'); + expect(describeCameraError(null)).toBe('Failed to access the camera.'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('openUserFacingCameraStream', () => { + it('returns a MediaStream on success', async () => { + const fakeStream = {} as MediaStream; + Object.defineProperty(navigator, 'mediaDevices', { + value: { getUserMedia: vi.fn().mockResolvedValue(fakeStream) }, + configurable: true, + }); + + const stream = await openUserFacingCameraStream(); + expect(stream).toBe(fakeStream); + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({ + video: { facingMode: { ideal: 'user' } }, + audio: false, + }); + }); + + it('throws CameraAccessError when getUserMedia is missing', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + value: {}, + configurable: true, + }); + + await expect(openUserFacingCameraStream()).rejects.toThrow(CameraAccessError); + await expect(openUserFacingCameraStream()).rejects.toThrow( + 'In-browser camera capture is not supported in this browser.', + ); + }); + + it('throws CameraAccessError when mediaDevices is undefined', async () => { + Object.defineProperty(navigator, 'mediaDevices', { + value: undefined, + configurable: true, + }); + + await expect(openUserFacingCameraStream()).rejects.toThrow(CameraAccessError); + }); + + it('wraps DOMException from getUserMedia in CameraAccessError', async () => { + const domErr = new DOMException('denied', 'NotAllowedError'); + Object.defineProperty(navigator, 'mediaDevices', { + value: { getUserMedia: vi.fn().mockRejectedValue(domErr) }, + configurable: true, + }); + + try { + await openUserFacingCameraStream(); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CameraAccessError); + expect((err as CameraAccessError).cause).toBe(domErr); + expect((err as CameraAccessError).message).toBe( + 'Camera access was denied. Allow camera access and try again.', + ); + } + }); +}); + +// --------------------------------------------------------------------------- + +describe('waitForVideoReady', () => { + it('resolves immediately when video is already ready', async () => { + const video = makeVideo({ readyState: HTMLMediaElement.HAVE_CURRENT_DATA }); + + await waitForVideoReady(video); + expect(video.play).toHaveBeenCalled(); + }); + + it('resolves when loadedmetadata fires', async () => { + const video = makeVideo({ readyState: 0 }); + + const promise = waitForVideoReady(video); + video.dispatchEvent(new Event('loadedmetadata')); + await promise; + + expect(video.play).toHaveBeenCalled(); + }); + + it('rejects with default message when error fires', async () => { + const video = makeVideo({ readyState: 0 }); + + const promise = waitForVideoReady(video); + video.dispatchEvent(new Event('error')); + + await expect(promise).rejects.toThrow('Failed to start the camera preview.'); + }); + + it('rejects with custom error message', async () => { + const video = makeVideo({ readyState: 0 }); + + const promise = waitForVideoReady(video, 'Custom error'); + video.dispatchEvent(new Event('error')); + + await expect(promise).rejects.toThrow('Custom error'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('resumeVideoPlayback', () => { + it('calls video.play()', () => { + const video = makeVideo(); + resumeVideoPlayback(video); + expect(video.play).toHaveBeenCalled(); + }); + + it('silently catches play rejection', () => { + const video = makeVideo(); + (video.play as ReturnType).mockRejectedValue(new Error('AbortError')); + + // Should not throw + expect(() => resumeVideoPlayback(video)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- + +describe('blobToDataURL', () => { + const OriginalFileReader = globalThis.FileReader; + + afterEach(() => { + globalThis.FileReader = OriginalFileReader; + }); + + it('resolves with a data URL', async () => { + const blob = new Blob(['hello'], { type: 'text/plain' }); + const result = await blobToDataURL(blob); + expect(result).toMatch(/^data:/); + }); + + it('rejects when FileReader errors', async () => { + globalThis.FileReader = class MockFileReader { + onload: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null; + onerror: ((this: FileReader, ev: ProgressEvent) => unknown) | null = null; + result: string | ArrayBuffer | null = null; + + readAsDataURL() { + // Simulate async error + queueMicrotask(() => this.onerror?.(new ProgressEvent('error') as ProgressEvent)); + } + } as unknown as typeof FileReader; + + await expect(blobToDataURL(new Blob(['x']))).rejects.toThrow('Failed to read image'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('blobToImage', () => { + const OriginalImage = globalThis.Image; + const OriginalCreateObjectURL = globalThis.URL.createObjectURL; + const OriginalRevokeObjectURL = globalThis.URL.revokeObjectURL; + + afterEach(() => { + globalThis.Image = OriginalImage; + globalThis.URL.createObjectURL = OriginalCreateObjectURL; + globalThis.URL.revokeObjectURL = OriginalRevokeObjectURL; + }); + + it('resolves with an HTMLImageElement on success', async () => { + const revokeStub = vi.fn(); + globalThis.URL.createObjectURL = vi.fn().mockReturnValue('blob:fake-url'); + globalThis.URL.revokeObjectURL = revokeStub; + + // Mock Image so that setting `src` triggers `onload` asynchronously + globalThis.Image = class extends OriginalImage { + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instance = this; + Object.defineProperty(this, 'src', { + set() { + queueMicrotask(() => instance.onload?.(new Event('load') as Event)); + }, + configurable: true, + }); + } + } as typeof Image; + + const blob = new Blob(['img'], { type: 'image/png' }); + const result = await blobToImage(blob); + + expect(result).toBeInstanceOf(HTMLImageElement); + expect(revokeStub).toHaveBeenCalledWith('blob:fake-url'); + }); + + it('rejects on image error', async () => { + const revokeStub = vi.fn(); + globalThis.URL.createObjectURL = vi.fn().mockReturnValue('blob:fake-url'); + globalThis.URL.revokeObjectURL = revokeStub; + + globalThis.Image = class extends OriginalImage { + constructor() { + super(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instance = this; + Object.defineProperty(this, 'src', { + set() { + queueMicrotask(() => instance.onerror?.(new Event('error') as Event)); + }, + configurable: true, + }); + } + } as typeof Image; + + const blob = new Blob(['bad'], { type: 'image/png' }); + await expect(blobToImage(blob)).rejects.toThrow('Failed to load captured image'); + expect(revokeStub).toHaveBeenCalledWith('blob:fake-url'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('createReusableFrameCapture', () => { + let ctx: CanvasRenderingContext2D; + + beforeEach(() => { + ctx = mockCanvasContext(); + }); + + it('captureWorkingFrame draws video to working canvas', () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + capture.captureWorkingFrame(video); + expect(ctx.drawImage).toHaveBeenCalled(); + }); + + it('captureWorkingFrame throws when video has no dimensions', () => { + const capture = createReusableFrameCapture(); + const video = makeVideo({ videoWidth: 0, videoHeight: 0 }); + + expect(() => capture.captureWorkingFrame(video)).toThrow('Camera preview is not ready yet.'); + }); + + it('captureBlob draws and returns a blob', async () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + const blob = await capture.captureBlob(video); + expect(blob).toBeInstanceOf(Blob); + }); + + it('promoteWorkingToBest copies working canvas to best canvas', () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + capture.captureWorkingFrame(video); + capture.promoteWorkingToBest(); + expect(capture.hasStoredBestFrame()).toBe(true); + }); + + it('promoteWorkingToBest throws when working canvas is empty', () => { + // jsdom defaults canvas to 300×150; intercept createElement to produce 0×0 canvases + const origCreateElement = document.createElement.bind(document); + const createSpy = vi + .spyOn(document, 'createElement') + .mockImplementation(((tag: string, options?: ElementCreationOptions) => { + const el = origCreateElement(tag, options); + if (tag === 'canvas') { + (el as HTMLCanvasElement).width = 0; + (el as HTMLCanvasElement).height = 0; + } + return el; + }) as typeof document.createElement); + + const capture = createReusableFrameCapture(); + createSpy.mockRestore(); + + expect(() => capture.promoteWorkingToBest()).toThrow('No working frame to promote.'); + }); + + it('storeBestFrame draws video directly to best canvas', () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + capture.storeBestFrame(video); + expect(capture.hasStoredBestFrame()).toBe(true); + }); + + it('hasStoredBestFrame returns false initially', () => { + const capture = createReusableFrameCapture(); + expect(capture.hasStoredBestFrame()).toBe(false); + }); + + it('storedBestFrameToBlob throws when no best frame stored', async () => { + const capture = createReusableFrameCapture(); + await expect(capture.storedBestFrameToBlob()).rejects.toThrow('No best frame is available.'); + }); + + it('storedBestFrameToBlob returns blob when best frame exists', async () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + capture.storeBestFrame(video); + const blob = await capture.storedBestFrameToBlob(); + expect(blob).toBeInstanceOf(Blob); + }); + + it('resetStoredBestFrame clears the stored best frame', () => { + const capture = createReusableFrameCapture(); + const video = makeVideo(); + + capture.storeBestFrame(video); + expect(capture.hasStoredBestFrame()).toBe(true); + + capture.resetStoredBestFrame(); + expect(capture.hasStoredBestFrame()).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 6d0c3e3..0ae0b3a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,5 +24,17 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + exclude: ['src/integration/**', 'node_modules/**'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + exclude: ['src/integration/**', 'src/**/*.test.ts'], + thresholds: { + statements: 85, + branches: 72, + functions: 85, + lines: 85, + }, + }, }, }); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..b6fc3a7 --- /dev/null +++ b/vitest.integration.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@simprints/simface-sdk': resolve(__dirname, 'src/index.ts'), + }, + }, + test: { + include: ['src/integration/**/*.test.ts'], + environment: 'node', + globals: true, + testTimeout: 30_000, + sequence: { + // Integration tests are order-dependent (enroll before verify) + concurrent: false, + }, + }, +});