diff --git a/.changeset/expo-verify-e2e-small-fixes.md b/.changeset/expo-verify-e2e-small-fixes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-e2e-small-fixes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/expo-verify-rename-e2e-tests.md b/.changeset/expo-verify-rename-e2e-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-rename-e2e-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/expo-verify-skill.md b/.changeset/expo-verify-skill.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-skill.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee010ea28b4..6a47ac61ac6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,64 @@ jobs: pnpm changeset status --since=origin/main; fi + expo-native-runner-tests: + needs: [check-permissions] + name: Expo Native Runner Tests + runs-on: 'blacksmith-8vcpu-ubuntu-2204' + defaults: + run: + shell: bash + timeout-minutes: ${{ vars.TIMEOUT_MINUTES_NORMAL && fromJSON(vars.TIMEOUT_MINUTES_NORMAL) || 5 }} + + env: + PACKAGE: integration/expo-native + WATCHED: >- + integration/expo-native + .github/workflows/ci.yml + .github/workflows/verify-remote.yml + .github/actionlint.yaml + integration/templates/expo-native + packages/expo + packages/expo-biometrics + packages/expo-google-signin + packages/expo-native-components + + steps: + - name: Checkout Repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + fetch-depth: 2 # The pull request merge commit and its parents, to see what the PR changes + fetch-tags: false + show-progress: false + + - name: Check whether the package changed + id: changed + env: + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" != "pull_request" ] || ! git diff --quiet HEAD^1 HEAD -- $WATCHED; then + echo "package=true" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node + if: steps.changed.outputs.package == 'true' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.15.0 + + - name: Install the package's own dependencies + if: steps.changed.outputs.package == 'true' + run: npm ci --prefix "$PACKAGE" + + - name: Run the package's unit tests, which include the check of the copied core against its manifest + if: steps.changed.outputs.package == 'true' + run: npm test --prefix "$PACKAGE" + + - name: Typecheck the package + if: steps.changed.outputs.package == 'true' + run: npm run typecheck --prefix "$PACKAGE" + build-packages: needs: [check-permissions] name: Build Packages diff --git a/.prettierignore b/.prettierignore index fc97a58a963..bc6c89c0c5c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -29,3 +29,18 @@ CLAUDE.md # Written by `eslint --suppress-rule` / `--prune-suppressions`; ESLint owns the formatting. /**/eslint-suppressions.json + +# Copied byte for byte from another repo; do not reformat. +integration/expo-native/src/core/ +integration/expo-native/src/platform/android/ +integration/expo-native/src/platform/ios/ +integration/expo-native/specs/fixtures.ts +integration/expo-native/specs/support/ +integration/expo-native/testing/ +integration/expo-native/e2e.config.ts +integration/expo-native/test/*.ts +!integration/expo-native/test/freshness.test.ts +!integration/expo-native/test/host.test.ts +!integration/expo-native/test/remote-host.test.ts +integration/expo-native/.verify/ +integration/expo-native/specs/explored/ diff --git a/eslint.config.mjs b/eslint.config.mjs index 5f5fe54fd43..d9c2a450cbe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -332,6 +332,7 @@ export default tseslint.config([ '**/build/*', '**/coverage/*', '**/dist/*', + '**/integration/expo-native/**/*', '**/integration/templates/**/*', '**/node_modules/**', '*.snap', diff --git a/integration/expo-native/.gitignore b/integration/expo-native/.gitignore new file mode 100644 index 00000000000..603ba938f6c --- /dev/null +++ b/integration/expo-native/.gitignore @@ -0,0 +1,3 @@ +.e2e/ +.verify/ +specs/explored/ diff --git a/integration/expo-native/e2e.config.ts b/integration/expo-native/e2e.config.ts new file mode 100644 index 00000000000..a2cc581db48 --- /dev/null +++ b/integration/expo-native/e2e.config.ts @@ -0,0 +1,5 @@ +import { app } from './specs/app.ts'; +import { composeE2EConfig } from './specs/support/config.ts'; +import { readTarget } from './specs/support/inputs.ts'; + +export default composeE2EConfig(app, readTarget(app, process.env), process.env); diff --git a/integration/expo-native/package-lock.json b/integration/expo-native/package-lock.json new file mode 100644 index 00000000000..b3679103700 --- /dev/null +++ b/integration/expo-native/package-lock.json @@ -0,0 +1,1783 @@ +{ + "name": "verify-clerk-expo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "verify-clerk-expo", + "devDependencies": { + "@e2e-dev/github": "0.4.0", + "@e2e-dev/mobile": "0.10.0", + "@types/node": "24.19.1", + "ai": "7.0.128", + "e2e": "0.18.0", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24.8.0 <25" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.104", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.104.tgz", + "integrity": "sha512-4lTyt8a4BMfVz4QGo+VO5odakmYMX5kq+xiRgdQGGY7o/jP2WSasp6LvIvYvt/lHzz1MfE+xKTOJhz7mMwjynA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.22", + "@ai-sdk/provider-utils": "5.0.54", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": { + "version": "4.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.22.tgz", + "integrity": "sha512-1Jtmn36VNMOBo9CMUQrPqsR744qtqRloEiXpWCYS8ffPlhS2CrSNmoho5lPjp3R7EnkzBsGI6p51TA7fQJQZdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.21.tgz", + "integrity": "sha512-UpbC9C1oht8dhfKPbVXSLRZS3DI8uk8n5v2uMjBPNIYGsC2kL045ywq7D8Hh9KgnH9rP5W/GxQdYtWScRouqlA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.54", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.54.tgz", + "integrity": "sha512-amqDxFnw9+dpVrDACRDGmExUUNOH9C1D2bhg/DqWJnamEOieDTXBBXLHujSby/0MqHdAFksnpE8Pzfqx6ABMBw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.22", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.29.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider-utils/node_modules/@ai-sdk/provider": { + "version": "4.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.22.tgz", + "integrity": "sha512-1Jtmn36VNMOBo9CMUQrPqsR744qtqRloEiXpWCYS8ffPlhS2CrSNmoho5lPjp3R7EnkzBsGI6p51TA7fQJQZdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@e2e-dev/github": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@e2e-dev/github/-/github-0.4.0.tgz", + "integrity": "sha512-h4F51Ncwk9VWgdPgAJCjXvqQrBd6sQSt3yvpl9XNJkLTOccndr3A6eAfw6iY49KvNOpZicdQ/glDHxs1c4Lm7Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^22.22.3 || >=24.8.0" + }, + "peerDependencies": { + "e2e": ">=0.15.0 <1" + } + }, + "node_modules/@e2e-dev/mobile": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@e2e-dev/mobile/-/mobile-0.10.0.tgz", + "integrity": "sha512-Oz6ePagl8S/WnCQlfw0WuABpGAgMKK0mTshzuO/p8XEdttwBfO9sJsAWG7PiRF9dwEyU1IkCu8eNdtv/pk3akA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "agent-device": "0.21.22", + "pngjs": "7.0.0", + "zod": "4.6.1" + }, + "engines": { + "node": "^22.22.3 || >=24.8.0" + }, + "peerDependencies": { + "ai": "^7.0.0", + "e2e": ">=0.15.0 <1" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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/@modelcontextprotocol/core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.0.tgz", + "integrity": "sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.0.tgz", + "integrity": "sha512-+6b0LdsQLmHsvS7J6sQJ4weTpzUn61fU1JKS+321/+IyDYoHW5CR2Tgqcs6GgAAQvlesCo6yX3xttgmx+XGtJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.152.0.tgz", + "integrity": "sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.152.0.tgz", + "integrity": "sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.152.0.tgz", + "integrity": "sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.152.0.tgz", + "integrity": "sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.152.0.tgz", + "integrity": "sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.152.0.tgz", + "integrity": "sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.152.0.tgz", + "integrity": "sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.152.0.tgz", + "integrity": "sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.152.0.tgz", + "integrity": "sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.152.0.tgz", + "integrity": "sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.152.0.tgz", + "integrity": "sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.152.0.tgz", + "integrity": "sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.152.0.tgz", + "integrity": "sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.152.0.tgz", + "integrity": "sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.152.0.tgz", + "integrity": "sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.152.0.tgz", + "integrity": "sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.152.0.tgz", + "integrity": "sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.152.0.tgz", + "integrity": "sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.152.0.tgz", + "integrity": "sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.152.0.tgz", + "integrity": "sha512-fsq/7JVJo8YAmMPnt3Z7akMdMoCokRND5jjawa5DxuKJBEaI0Dl3ZqEXFlLabKoZhlApIexZ7aycXFcjcTcWvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxc-transform/binding-android-arm-eabi": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm-eabi/-/binding-android-arm-eabi-0.152.0.tgz", + "integrity": "sha512-GBVYZdUggANq7ndUuE9HBi1jO6d2+oud3cevfQkpqrV7JsDNwQU5UCGa8rZCvzjIvyn6ikQuJu+sj4RvLyN6Uw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-android-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-android-arm64/-/binding-android-arm64-0.152.0.tgz", + "integrity": "sha512-pqeCLGUnozzyJE9Ed9js2w+V1lHQ43qPImMfgTh7it+a3LJqUjAt+OesJ6pqkPycRFV6b0pUWZm6EkpM8mk7ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-arm64/-/binding-darwin-arm64-0.152.0.tgz", + "integrity": "sha512-0d68wdepj2bjnDijPdvC/3MnrZcs8hoCp19qnGX2tyxnSIPevgqa9RX5i31ex5JMczht/VGp4aKeV1p12dwzQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-darwin-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-darwin-x64/-/binding-darwin-x64-0.152.0.tgz", + "integrity": "sha512-jFTRTyZzYhqSrxkaI9hTwClPTXtdqRyPSXUFGLJmStYS+b6qnhbI1ottivWdAg0HJXytyg8YeqgUJWSu2lU/jA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-freebsd-x64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-freebsd-x64/-/binding-freebsd-x64-0.152.0.tgz", + "integrity": "sha512-L4Y8IfrcaxjK9kK3ddRAyZPa6nzJHn6fgkVIKoBlP53vmCobPL1mtuIdXQThD0VmV6e4aSAJg2An6qkdjeZC8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-gnueabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.152.0.tgz", + "integrity": "sha512-GrohqmIKEK/YXCzAGP8XGQrPVBdUHPpvqdhvw+VutpWZofxDOq39Zed+Gqlea2mycXo+/UNeq1194eaAYkqKQQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm-musleabihf": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.152.0.tgz", + "integrity": "sha512-wUI+JoZEs4+GntxjK2YOKtQDG822wv+aGYm1OUZ2+NAgi8ghOTUP3RH/GX8Vq0qJ2Yw3OwKLUIZzufXklQ7pCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.152.0.tgz", + "integrity": "sha512-EcY52e0BOfynOA/brCInvQO3ZSukbAEc+BsbpRoRdJ4qV10NK0aS/FheBvkj/V3cFidee6QlrUpqgpN4WKV+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-arm64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.152.0.tgz", + "integrity": "sha512-L4OGw0UuuyYKAPnUvvW+tvq6nS/tBoolVuPekNJqYnDYfe5Nh1619ImfhP0o0Wz+SG4OQW35ByRWjVf/oK3x0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-ppc64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.152.0.tgz", + "integrity": "sha512-5K9qNjNKlyhBdGkkIhYFonO/H3Yc37miy5cXefTB71aPOv9jv6HQrDuj6AJcBkggbmkeyEJhlFamUbGMsMGBiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-riscv64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.152.0.tgz", + "integrity": "sha512-WlGvnZhmRItWqjsGd2OBKPan75FbqpF4HPuGL7P6f7Oz1VHm6IatnwCK2MI16QXH7BEs6GRohZHpmgThc0V/uw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-riscv64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.152.0.tgz", + "integrity": "sha512-TWZX7QGkU4LF9NS+HBUVWsbQXWDuWt58r+L/31gSJ/iHCJw/3jM2yD408IRVjXYX5jFgmsJbxrUlW3xlVeAL3Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-s390x-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.152.0.tgz", + "integrity": "sha512-VIP7463d0nIAGFv2VXb4mRf20qrpZg0kNHBw8E3fLuHj0bt4HVHDgsqAgeFWOonWwSwsM415TFjNmHhlaStCLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-gnu": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.152.0.tgz", + "integrity": "sha512-hwnHMVCnGIoS/eMh7T836mJpWim2iCSWGlCS+aZ+SjhEEUvSSUE54ljua7bk25vaj4L84cFnruTCa7HqyaQ2YQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-linux-x64-musl": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-linux-x64-musl/-/binding-linux-x64-musl-0.152.0.tgz", + "integrity": "sha512-5NPItMgM9GfIW0SbpOXvEFIijin5VcNEParmBEm9j+RXr8Ko7pZ3a1ydk/Sy+EOvaSTkXMY6TkVyLfczwdudSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-openharmony-arm64": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-openharmony-arm64/-/binding-openharmony-arm64-0.152.0.tgz", + "integrity": "sha512-pNzMq4AhBUV8E/cc41SgYTjzW/AWq1DtEKz1l56Mn1TKfidJwP8sGnIw0EXhD6zKFUlPoqkWfvoe5TiDAYeT+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-win32-arm64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.152.0.tgz", + "integrity": "sha512-PU6cGmVDYo2HHG9ul6vKj189YJPZrcscdWzQakJD6NU4CqvqISbZLUHwz6DVe0v8CbOe2d24zUdipILaRofZ5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-win32-ia32-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.152.0.tgz", + "integrity": "sha512-Sdcoky580dPgj9Qp+i4zrCV7NpIYZW6bBrcQvOwyDmNlJQ2iYK/V3Q3FGNHLulrxgXrpzqQwMcLs8KRIg0u0yw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-transform/binding-win32-x64-msvc": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-transform/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.152.0.tgz", + "integrity": "sha512-QnN+/Hsig/vI9zoCNVbbqv0GmZWUGqlPg2ZTEyVw/PvgAXd8pRRfN6CxdKEtJTnaVwJFVYaw/5LR6ZWCVBwDeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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/@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/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitest/expect": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-5.0.3.tgz", + "integrity": "sha512-N/VGY122AqBx25OB4EatqlVu8PBayvm9dQzpJ8kVq/pOvqAhXTE5MAmoSMzdB8klDU9wcgSEUliwgMNMtxPa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "5.0.3", + "@vitest/utils": "5.0.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.3.tgz", + "integrity": "sha512-3hr5eV5z2ff0l2Q1vaKQbB4j3M3tElw8stDokEOTqXJB3Es5VrT8gBRIMY59gGuFp8Y61sWlWFK1aNUH5dHDYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.3.tgz", + "integrity": "sha512-77fDTZv8eu1CzlIfgPU2yrl5MXSUaUAOvgdZEeVm3X3WIxUubIX2SforqS6176lT32HCF6uBVMYVqfHxw7o6eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/agent-device": { + "version": "0.21.22", + "resolved": "https://registry.npmjs.org/agent-device/-/agent-device-0.21.22.tgz", + "integrity": "sha512-ndUh5b+lZ2fRzzY80TTl/KmxR4bh+3FezSH0UGCjPCpN//1trSqtzzNUWunc4woY/vW3DEiPx5xYIwG+fBOXWA==", + "dev": true, + "license": "MIT", + "bin": { + "agent-device": "bin/agent-device.mjs" + }, + "engines": { + "node": ">=22.12" + }, + "peerDependencies": { + "ai": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } + } + }, + "node_modules/ai": { + "version": "7.0.128", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.128.tgz", + "integrity": "sha512-WMKN83DodYtH52ETPHq0jrSwj3wU0aHkhLHXMrwMRabC/aMpIqeWsXJgjkUwrOqkaKHWONdupk/2ttPRvAyFcg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.104", + "@ai-sdk/provider": "4.0.22", + "@ai-sdk/provider-utils": "5.0.54" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "4.0.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.22.tgz", + "integrity": "sha512-1Jtmn36VNMOBo9CMUQrPqsR744qtqRloEiXpWCYS8ffPlhS2CrSNmoho5lPjp3R7EnkzBsGI6p51TA7fQJQZdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "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/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "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/e2e": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/e2e/-/e2e-0.18.0.tgz", + "integrity": "sha512-mVIJtGXYBMLRK12CESeOowuh86nDDBhjfHIuyELuSFbaEmzl4EC4tVhm3T4Y6jidEGnGexZOATzlQ7tM/xw8JA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.21", + "@clack/prompts": "1.8.1", + "@jridgewell/remapping": "2.3.5", + "@modelcontextprotocol/server": "2.3.0", + "@oxc-project/runtime": "0.152.0", + "@vitest/expect": "5.0.3", + "commander": "15.0.0", + "get-tsconfig": "4.14.3", + "oxc-parser": "0.152.0", + "oxc-transform": "0.152.0", + "picocolors": "1.1.1", + "pngjs": "7.0.0", + "zod": "4.6.1" + }, + "bin": { + "e2e": "dist/cli/bin.js" + }, + "engines": { + "node": "^22.22.3 || >=24.8.0" + }, + "peerDependencies": { + "@ai-sdk/anthropic": "^4.0.0", + "@ai-sdk/google": "^4.0.0", + "@ai-sdk/openai": "^4.0.0", + "@ai-sdk/openai-compatible": "^3.0.0", + "@ai-sdk/xai": "^5.0.0", + "ai": "^7.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/anthropic": { + "optional": true + }, + "@ai-sdk/google": { + "optional": true + }, + "@ai-sdk/openai": { + "optional": true + }, + "@ai-sdk/openai-compatible": { + "optional": true + }, + "@ai-sdk/xai": { + "optional": true + }, + "ai": { + "optional": true + } + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/oxc-parser": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.152.0.tgz", + "integrity": "sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.152.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.152.0", + "@oxc-parser/binding-android-arm64": "0.152.0", + "@oxc-parser/binding-darwin-arm64": "0.152.0", + "@oxc-parser/binding-darwin-x64": "0.152.0", + "@oxc-parser/binding-freebsd-x64": "0.152.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.152.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.152.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.152.0", + "@oxc-parser/binding-linux-arm64-musl": "0.152.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.152.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.152.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.152.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.152.0", + "@oxc-parser/binding-linux-x64-gnu": "0.152.0", + "@oxc-parser/binding-linux-x64-musl": "0.152.0", + "@oxc-parser/binding-openharmony-arm64": "0.152.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.152.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.152.0", + "@oxc-parser/binding-win32-x64-msvc": "0.152.0" + } + }, + "node_modules/oxc-transform": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/oxc-transform/-/oxc-transform-0.152.0.tgz", + "integrity": "sha512-9rs8qImolzWswXGlhmye9Yz9Mce9hOe4Pivb7rSEhF/6VKn1Q122BUe9Mr1O9xQAoBX+SrF6xXfdgxKHeQf+uQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-transform/binding-android-arm-eabi": "0.152.0", + "@oxc-transform/binding-android-arm64": "0.152.0", + "@oxc-transform/binding-darwin-arm64": "0.152.0", + "@oxc-transform/binding-darwin-x64": "0.152.0", + "@oxc-transform/binding-freebsd-x64": "0.152.0", + "@oxc-transform/binding-linux-arm-gnueabihf": "0.152.0", + "@oxc-transform/binding-linux-arm-musleabihf": "0.152.0", + "@oxc-transform/binding-linux-arm64-gnu": "0.152.0", + "@oxc-transform/binding-linux-arm64-musl": "0.152.0", + "@oxc-transform/binding-linux-ppc64-gnu": "0.152.0", + "@oxc-transform/binding-linux-riscv64-gnu": "0.152.0", + "@oxc-transform/binding-linux-riscv64-musl": "0.152.0", + "@oxc-transform/binding-linux-s390x-gnu": "0.152.0", + "@oxc-transform/binding-linux-x64-gnu": "0.152.0", + "@oxc-transform/binding-linux-x64-musl": "0.152.0", + "@oxc-transform/binding-openharmony-arm64": "0.152.0", + "@oxc-transform/binding-win32-arm64-msvc": "0.152.0", + "@oxc-transform/binding-win32-ia32-msvc": "0.152.0", + "@oxc-transform/binding-win32-x64-msvc": "0.152.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/integration/expo-native/package.json b/integration/expo-native/package.json new file mode 100644 index 00000000000..72189b7f02e --- /dev/null +++ b/integration/expo-native/package.json @@ -0,0 +1,20 @@ +{ + "name": "verify-clerk-expo", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.ts", + "typecheck": "tsc -p ." + }, + "devDependencies": { + "@e2e-dev/github": "0.4.0", + "@e2e-dev/mobile": "0.10.0", + "@types/node": "24.19.1", + "ai": "7.0.128", + "e2e": "0.18.0", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24.8.0 <25" + } +} diff --git a/integration/expo-native/specs/app.ts b/integration/expo-native/specs/app.ts new file mode 100644 index 00000000000..570c569cb8d --- /dev/null +++ b/integration/expo-native/specs/app.ts @@ -0,0 +1,39 @@ +import type { TestApp } from './support/inputs.ts'; +import type { AppEntry, Platform } from './support/types.ts'; + +export const APP_ID = 'com.clerk.exponativebuildfixture'; + +const ANDROID_ACTIVITY = '.MainActivity'; +const DEV_CLIENT_SCHEME = 'exp+clerk-expo-native-build-fixture'; + +export function devClientEntry(platform: Platform, devServer: string): AppEntry { + return platform === 'ios' + ? { + kind: 'dev-client', + launchArguments: [ + '--initialUrl', + devServer, + '-EXDevMenuShowsAtLaunch', + 'NO', + '-EXDevMenuIsOnboardingFinished', + 'YES', + '-EXDevMenuShowFloatingActionButton', + 'NO', + ], + openLink: null, + androidActivity: null, + } + : { + kind: 'dev-client', + launchArguments: [], + openLink: `${DEV_CLIENT_SCHEME}://expo-development-client/?url=${encodeURIComponent(devServer)}`, + androidActivity: ANDROID_ACTIVITY, + }; +} + +export const app: TestApp = { + platforms: ['ios', 'android'], + id: (): string => APP_ID, + entry: (platform: Platform, devServer: string | null): AppEntry => + devServer === null ? { kind: 'binary' } : devClientEntry(platform, devServer), +}; diff --git a/integration/expo-native/specs/features/custom-flow-sign-in/complete.e2e.ts b/integration/expo-native/specs/features/custom-flow-sign-in/complete.e2e.ts new file mode 100644 index 00000000000..40bc8cb12fb --- /dev/null +++ b/integration/expo-native/specs/features/custom-flow-sign-in/complete.e2e.ts @@ -0,0 +1,17 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; +import { homeLinks } from '../../native.ts'; + +test('useSignIn completes with the email code', async ({ host, screen }) => { + const user = await host.seedUser(); + await host.launch(); + await host.tap(homeLinks(screen).customSignIn); + await host.fill(screen.getByTestId('verify.customSignIn.emailAddress'), user.email); + await host.tap(screen.getByTestId('verify.customSignIn.sendCode')); + await expect(screen.getByTestId('verify.customSignIn.code')).toBeVisible({ timeout: 20_000 }); + await expect(screen.getByTestId('verify.customSignIn.error')).toHaveCount(0); + await host.screenshot('custom-code'); + await host.fill(screen.getByTestId('verify.customSignIn.code'), CLERK_TEST_CODE); + await host.tap(screen.getByTestId('verify.customSignIn.verifyCode')); + await host.expectSignedInAs(user, 30_000); + await host.screenshot('custom-signed-in'); +}); diff --git a/integration/expo-native/specs/features/custom-flow-sign-up/complete.e2e.ts b/integration/expo-native/specs/features/custom-flow-sign-up/complete.e2e.ts new file mode 100644 index 00000000000..4a08e720716 --- /dev/null +++ b/integration/expo-native/specs/features/custom-flow-sign-up/complete.e2e.ts @@ -0,0 +1,18 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; +import { homeLinks } from '../../native.ts'; + +test('useSignUp completes with the email code', async ({ host, screen }) => { + const email = await host.newEmail(); + await host.launch(); + await host.tap(homeLinks(screen).customSignUp); + await host.fill(screen.getByTestId('verify.customSignUp.emailAddress'), email); + await host.fill(screen.getByTestId('verify.customSignUp.password'), `Verify-${host.runId}-Pw1!`); + await host.tap(screen.getByTestId('verify.customSignUp.sendCode')); + await expect(screen.getByTestId('verify.customSignUp.code')).toBeVisible({ timeout: 20_000 }); + await expect(screen.getByTestId('verify.customSignUp.error')).toHaveCount(0); + await host.screenshot('custom-signup-code'); + await host.fill(screen.getByTestId('verify.customSignUp.code'), CLERK_TEST_CODE); + await host.tap(screen.getByTestId('verify.customSignUp.verifyCode')); + await host.expectSignedInAs(email, 30_000); + await host.screenshot('custom-signed-up'); +}); diff --git a/integration/expo-native/specs/features/native-auth-view/complete.e2e.ts b/integration/expo-native/specs/features/native-auth-view/complete.e2e.ts new file mode 100644 index 00000000000..bead392fe36 --- /dev/null +++ b/integration/expo-native/specs/features/native-auth-view/complete.e2e.ts @@ -0,0 +1,15 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; + +test('signs in through AuthView with the email code', { platforms: ['ios'] }, async ({ host, screen }) => { + const user = await host.seedUser(); + await host.launch({ authMode: 'signIn' }); + await host.tap(host.app.signInFullScreen); + await host.fill(screen.getByTestId('clerk.auth.start.identifier'), user.email); + await host.tap(screen.getByTestId('clerk.auth.start.continue')); + await expect(screen.getByTestId('clerk.auth.signIn.code')).toBeVisible({ timeout: 20_000 }); + await expect(screen.getByText(user.email)).toBeVisible(); + await host.screenshot('code-screen'); + await host.fill(screen.getByTestId('clerk.auth.signIn.code'), CLERK_TEST_CODE); + await host.expectSignedInAs(user, 30_000); + await host.screenshot('signed-in'); +}); diff --git a/integration/expo-native/specs/features/native-auth-view/logo.e2e.ts b/integration/expo-native/specs/features/native-auth-view/logo.e2e.ts new file mode 100644 index 00000000000..8d591a2fc55 --- /dev/null +++ b/integration/expo-native/specs/features/native-auth-view/logo.e2e.ts @@ -0,0 +1,28 @@ +import { test, expect } from '../../fixtures.ts'; +import { closeNativeAuth, homeLinks, nativeAuth, signInWithPassword } from '../../native.ts'; + +test('signs in with a password through the AuthView that has a logo, after it closed and reopened', async ({ + host, + screen, + device, + platform, +}) => { + const logo = screen.getByText('E2E Custom Logo'); + const signInWithLogo = homeLinks(screen).authLogo; + const user = await host.seedUser({ password: true }); + await host.launch(); + await host.tap(signInWithLogo); + await expect(logo).toBeVisible({ timeout: 20_000 }); + await expect(nativeAuth(screen, platform).identifier).toBeVisible(); + await host.screenshot('logo'); + await closeNativeAuth(screen, device, platform); + await host.expectSignedOut(15_000); + await expect(logo).toHaveCount(0); + + await host.tap(signInWithLogo); + await expect(logo).toBeVisible({ timeout: 20_000 }); + await host.screenshot('logo-reopened'); + await signInWithPassword(host, screen, platform, user); + await host.expectSignedInAs(user, 45_000); + await host.screenshot('signed-in-with-logo'); +}); diff --git a/integration/expo-native/specs/features/native-auth-view/opens.e2e.ts b/integration/expo-native/specs/features/native-auth-view/opens.e2e.ts new file mode 100644 index 00000000000..5ed07149879 --- /dev/null +++ b/integration/expo-native/specs/features/native-auth-view/opens.e2e.ts @@ -0,0 +1,37 @@ +import { test, expect } from '../../fixtures.ts'; +import { closeNativeAuth, homeLinks, nativeAuth } from '../../native.ts'; + +test('the home full-screen sign-in button shows AuthView with no close button', async ({ host, screen, platform }) => { + await host.launch(); + await host.tap(host.app.signInFullScreen); + await expect(nativeAuth(screen, platform).identifier).toBeVisible({ timeout: 20_000 }); + if (platform === 'ios') await expect(screen.getByTestId('clerk.dismissButton')).toHaveCount(0); + await host.screenshot('auth-full-screen'); +}); + +test('closing the AuthView that the home opened returns to the home', async ({ host, screen, device, platform }) => { + const identifier = nativeAuth(screen, platform).identifier; + await host.launch(); + await host.expectSignedOut(); + await host.tap(host.app.signIn); + await expect(identifier).toBeVisible({ timeout: 20_000 }); + await host.screenshot('auth-from-home'); + await closeNativeAuth(screen, device, platform); + await host.expectSignedOut(15_000); + await expect(host.app.signIn).toBeVisible(); + await expect(identifier).toHaveCount(0); + await host.screenshot('closed-modal'); +}); + +test( + 'the close button of a full-screen AuthView returns to the home', + { platforms: ['ios'] }, + async ({ host, screen }) => { + await host.launch(); + await host.tap(homeLinks(screen).nativeAuth); + await host.tap(screen.getByTestId('clerk.dismissButton').last()); + await host.expectSignedOut(15_000); + await expect(host.app.signIn).toBeVisible(); + await host.screenshot('dismissed-home'); + }, +); diff --git a/integration/expo-native/specs/features/native-js-sync/sign-in-from-native.e2e.ts b/integration/expo-native/specs/features/native-js-sync/sign-in-from-native.e2e.ts new file mode 100644 index 00000000000..3912cbf6c8a --- /dev/null +++ b/integration/expo-native/specs/features/native-js-sync/sign-in-from-native.e2e.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile, nativeUserButton, signInWithPassword } from '../../native.ts'; + +test('a native password sign-in reaches the JS hooks and survives a relaunch', async ({ host, screen, platform }) => { + const user = await host.seedUser({ password: true }); + await host.launch(); + await host.tap(host.app.signIn); + await signInWithPassword(host, screen, platform, user); + await host.expectSignedInAs(user, 45_000); + const session = (await host.app.sessionId.textContent()) ?? ''; + await host.screenshot('signed-in-from-native'); + + await host.launch({ keepStorage: true, landsOn: host.app.signedIn }); + await host.expectSignedInAs(user); + await expect(host.app.sessionId).toHaveText(session); + await host.screenshot('restored-home'); +}); + +test('a second native sign-in in the same process reaches the JS hooks', async ({ host, screen, platform }) => { + const user = await host.seedUser({ password: true }); + await host.launch(); + await host.tap(host.app.signIn); + await signInWithPassword(host, screen, platform, user); + await host.expectSignedInAs(user, 45_000); + + await host.tap(nativeUserButton(screen, platform)); + const profile = nativeProfile(screen); + await expect(profile.signOut).toBeVisible({ timeout: 30_000 }); + await host.tap(profile.signOut); + await host.expectSignedOut(20_000); + + await host.tap(host.app.signIn); + await signInWithPassword(host, screen, platform, user); + await host.expectSignedInAs(user, 45_000); + await host.screenshot('signed-in-again'); +}); diff --git a/integration/expo-native/specs/features/native-js-sync/sign-out-from-native.e2e.ts b/integration/expo-native/specs/features/native-js-sync/sign-out-from-native.e2e.ts new file mode 100644 index 00000000000..7c85b175502 --- /dev/null +++ b/integration/expo-native/specs/features/native-js-sync/sign-out-from-native.e2e.ts @@ -0,0 +1,14 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile, nativeUserButton } from '../../native.ts'; + +test('signing out in the native profile signs out the JS hooks', async ({ host, screen, platform }) => { + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + await host.tap(nativeUserButton(screen, platform)); + const profile = nativeProfile(screen); + await expect(profile.signOut).toBeVisible({ timeout: 30_000 }); + await host.screenshot('before-sign-out'); + await host.tap(profile.signOut); + await host.expectSignedOut(20_000); + await host.screenshot('signed-out-from-native'); +}); diff --git a/integration/expo-native/specs/features/native-modules/biometric-availability.e2e.ts b/integration/expo-native/specs/features/native-modules/biometric-availability.e2e.ts new file mode 100644 index 00000000000..a8af2723009 --- /dev/null +++ b/integration/expo-native/specs/features/native-modules/biometric-availability.e2e.ts @@ -0,0 +1,18 @@ +import { test, expect } from '../../fixtures.ts'; +import { homeLinks } from '../../native.ts'; + +test('useBiometricCredentials reports what the expo-biometrics native module found on the device', async ({ + host, + screen, + platform, +}) => { + const nativeAnswer = platform === 'ios' ? 'biometric_authentication_unavailable' : 'no_local_credential'; + await host.launch(); + await host.tap(homeLinks(screen).nativeModules); + await host.tap(screen.getByTestId('biometric-availability-button')); + await expect(screen.getByTestId('biometric-availability-result')).toHaveText( + `biometric availability: ${nativeAnswer}`, + { timeout: 15_000 }, + ); + await host.screenshot('biometric-availability'); +}); diff --git a/integration/expo-native/specs/features/native-modules/biometric-availability.settings.json b/integration/expo-native/specs/features/native-modules/biometric-availability.settings.json new file mode 100644 index 00000000000..1934771b5b1 --- /dev/null +++ b/integration/expo-native/specs/features/native-modules/biometric-availability.settings.json @@ -0,0 +1,4 @@ +{ + "config": { "auth_biometric": { "used_for_sign_in": true } }, + "environment": { "auth_config.native_settings.trusted_device_sign_in_enabled": true } +} diff --git a/integration/expo-native/specs/features/native-modules/google-sign-in.e2e.ts b/integration/expo-native/specs/features/native-modules/google-sign-in.e2e.ts new file mode 100644 index 00000000000..dd949c8fab7 --- /dev/null +++ b/integration/expo-native/specs/features/native-modules/google-sign-in.e2e.ts @@ -0,0 +1,33 @@ +import { test, expect } from '../../fixtures.ts'; +import { homeLinks } from '../../native.ts'; + +const GOOGLE_UI_TIMEOUT_MS = 30_000; + +test('useSignInWithGoogle opens the native Google sign-in, and cancelling it on iOS ends the flow with no session', async ({ + host, + screen, + device, + platform, +}) => { + await host.launch(); + await host.tap(homeLinks(screen).nativeModules); + await host.tap(screen.getByTestId('google-sign-in-button')); + if (platform === 'android') { + const googlePage = screen.getByTestId(/^com\.google\.android\.gms:id\//); + let opened = false; + for (const until = Date.now() + GOOGLE_UI_TIMEOUT_MS; !opened && Date.now() < until; ) { + opened = await googlePage.count().then( + count => count > 0, + () => false, + ); + if (!opened) await new Promise(resolve => setTimeout(resolve, 1_000)); + } + expect(opened, 'a page of Google Play services is on screen').toBe(true); + return; + } + await expect(screen.getByText(/accounts\.google\.com/)).toBeVisible({ timeout: GOOGLE_UI_TIMEOUT_MS }); + await host.screenshot('google-system-prompt'); + await device.alert('dismiss'); + await expect(screen.getByTestId('google-result')).toHaveText('Google sign-in was cancelled', { timeout: 15_000 }); + await host.screenshot('google-cancelled'); +}); diff --git a/integration/expo-native/specs/features/token-cache-persistence/relaunch.e2e.ts b/integration/expo-native/specs/features/token-cache-persistence/relaunch.e2e.ts new file mode 100644 index 00000000000..f0b28390ed5 --- /dev/null +++ b/integration/expo-native/specs/features/token-cache-persistence/relaunch.e2e.ts @@ -0,0 +1,30 @@ +import { test, expect } from '../../fixtures.ts'; +import { homeLinks } from '../../native.ts'; + +test('the token cache keeps the session across a relaunch, and a new scope starts signed out', async ({ + host, + screen, +}) => { + const clientToken = screen.getByTestId('verify.tokenCache.clientToken'); + const cachedUser = screen.getByTestId('verify.tokenCache.user'); + const tokenCache = homeLinks(screen).tokenCache; + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + const session = (await host.app.sessionId.textContent()) ?? ''; + + await host.launch({ keepStorage: true, landsOn: host.app.signedIn }); + await host.expectSignedInAs(user); + await expect(host.app.sessionId).toHaveText(session); + await host.screenshot('restored-home-after-relaunch'); + + await host.tap(tokenCache); + await expect(clientToken).toHaveText('client token kept from the last launch: yes', { timeout: 15_000 }); + await expect(cachedUser).toHaveText(`user: ${user.id}`, { timeout: 15_000 }); + await host.screenshot('token-cache'); + + await host.launch(); + await host.tap(tokenCache); + await expect(clientToken).toHaveText('client token kept from the last launch: no', { timeout: 15_000 }); + await expect(cachedUser).toHaveText('user: none', { timeout: 15_000 }); + await host.screenshot('new-scope'); +}); diff --git a/integration/expo-native/specs/features/user-button-and-profile/embedded-profile.e2e.ts b/integration/expo-native/specs/features/user-button-and-profile/embedded-profile.e2e.ts new file mode 100644 index 00000000000..4c089cadc11 --- /dev/null +++ b/integration/expo-native/specs/features/user-button-and-profile/embedded-profile.e2e.ts @@ -0,0 +1,45 @@ +import { test, expect } from '../../fixtures.ts'; +import { embeddedProfile, homeLinks, leaveCustomPage, nativeProfile, tapProfileBack } from '../../native.ts'; + +test('onHostBack closes the embedded profile after native navigation', async ({ host, screen }) => { + const user = await host.seedUser(); + const profile = nativeProfile(screen); + const embedded = embeddedProfile(screen); + await host.launch({ signedInAs: user }); + await host.tap(homeLinks(screen).embeddedProfile); + await expect(profile.manageAccount).toBeVisible({ timeout: 20_000 }); + await host.screenshot('embedded-profile'); + + await host.tap(profile.manageAccount); + await expect(screen.getByText(user.email)).toBeVisible({ timeout: 20_000 }); + await tapProfileBack(screen); + await expect(embedded.customPageRow).toBeVisible({ timeout: 15_000 }); + + await tapProfileBack(screen); + await host.expectSignedInAs(user, 15_000); + await expect(embedded.customPageRow).toHaveCount(0); + await host.screenshot('closed-home'); +}); + +test('a custom page renders React Native content inside the embedded profile', async ({ + host, + screen, + device, + platform, +}) => { + const user = await host.seedUser(); + const profile = nativeProfile(screen); + const embedded = embeddedProfile(screen); + await host.launch({ signedInAs: user }); + await host.tap(homeLinks(screen).embeddedProfile); + await host.tap(embedded.customPageRow); + await expect(embedded.customPageBody).toBeVisible({ timeout: 15_000 }); + + await leaveCustomPage(screen, device, platform); + await expect(profile.manageAccount).toBeVisible({ timeout: 15_000 }); + await expect(embedded.customPageBody).toHaveCount(0); + + await tapProfileBack(screen); + await host.expectSignedInAs(user, 15_000); + await expect(profile.manageAccount).toHaveCount(0); +}); diff --git a/integration/expo-native/specs/features/user-button-and-profile/profile.e2e.ts b/integration/expo-native/specs/features/user-button-and-profile/profile.e2e.ts new file mode 100644 index 00000000000..a71f7bff979 --- /dev/null +++ b/integration/expo-native/specs/features/user-button-and-profile/profile.e2e.ts @@ -0,0 +1,23 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile, nativeUserButton } from '../../native.ts'; + +test('the home UserButton opens the profile of the signed-in user', async ({ host, screen, platform }) => { + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + await host.tap(nativeUserButton(screen, platform)); + const profile = nativeProfile(screen); + await expect(profile.manageAccount).toBeVisible({ timeout: 20_000 }); + await host.screenshot('user-button-profile'); + await host.tap(profile.manageAccount); + await expect(screen.getByText(user.email)).toBeVisible({ timeout: 20_000 }); + await host.screenshot('profile'); +}); + +test('the home sign-out button ends the session', async ({ host }) => { + const user = await host.seedUser(); + await host.launch({ signedInAs: user }); + await host.tap(host.app.signOut); + await host.expectSignedOut(); + await expect(host.app.signIn).toBeVisible(); + await host.screenshot('signed-out'); +}); diff --git a/integration/expo-native/specs/fixtures.ts b/integration/expo-native/specs/fixtures.ts new file mode 100644 index 00000000000..df19bb4f538 --- /dev/null +++ b/integration/expo-native/specs/fixtures.ts @@ -0,0 +1,157 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { join } from 'node:path'; +import { test as base } from '@e2e-dev/mobile'; +import { expect, type Locator } from 'e2e'; +import { app as testApp } from './app.ts'; +import { busyWaits, onceTheRunnerIsFree, type BusyWait } from './support/busy-runner.ts'; +import { aboutToLaunch, testUsers } from './support/clerk.ts'; +import { ASSERTION_TIMEOUT_MS, PACKAGE_DIR } from './support/config.ts'; +import { agentDevice, deviceCommand } from './support/device.ts'; +import { EVIDENCE_PACE, evidenceBlock, tapMarker } from './support/evidence.ts'; +import { fillField } from './support/fill.ts'; +import { readClerk, readEvidenceMarks, readRun, readTarget, type Target } from './support/inputs.ts'; +import { appLauncher } from './support/launch.ts'; +import { redact } from './support/secret.ts'; +import { tapMarkedOnceUncovered } from './support/tapping.ts'; +import { + APP_ELEMENT_IDS, + CLERK_TEST_CODE, + signedInText, + type AppLocators, + type HostFixture, + type LaunchOptions, + type SeededUser, + type TestEmail, +} from './support/types.ts'; +import { errorScreenElseSavePasswordPrompt, onScreen, signedInAs, signedOut, until, untilVisible, type Sight, type WaitPace, type WaitScreen } from './support/waiting.ts'; + +export { expect, CLERK_TEST_CODE }; + +const LAUNCH_TIMEOUT_MS = 60_000; +const POLL_MS = 400; +const TYPING_DELAY_MS = 40; +const CONFIRM_READS = 5; +const ONLY_E2E_WORKER_SLOT = 0; + +const RUN = readRun(process.env); +const EVIDENCE_MARKS = readEvidenceMarks(process.env); + +const AGENT_DEVICE = join(PACKAGE_DIR, 'node_modules', '.bin', 'agent-device'); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const runnerBusy = (): BusyWait => busyWaits(6, () => sleep(5_000)); + +const randomId = (): string => randomBytes(8).toString('hex'); + +const BUILDS_THIS_WORKER_INSTALLED = new Set(); + +async function adb(target: Target, args: readonly string[]): Promise { + const result = await deviceCommand(target.device, target.platform, args); + if (result.code !== 0) throw new Error(`adb ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`); +} + +function typeOnDevice(target: Target, [command, ...operands]: readonly [string, ...string[]]): Promise { + const device = agentDevice(target.device, target.platform); + return new Promise((resolve, reject) => { + const env = { ...process.env, ...device.env }; + execFile(AGENT_DEVICE, [command, '--delay-ms', String(TYPING_DELAY_MS), '--session', `${target.session}-${ONLY_E2E_WORKER_SLOT}`, ...device.selector, '--', ...operands], { env }, (error, _stdout, stderr) => { + if (error === null) resolve(); + else reject(new Error(redact(`agent-device ${command} failed: ${stderr.trim() || error.message}`))); + }); + }); +} + +export const test = base.extend<{ host: HostFixture }>({ + host: async ({ app: e2eApp, device, screen, platform }, use) => { + const target = readTarget(testApp, process.env); + if (target.platform !== platform) throw new Error(`e2e runs the ${platform} target and the inputs name ${target.platform}; pass --target ${target.platform}`); + const clerk = readClerk(process.env); + const users = testUsers(clerk, RUN); + const appId = testApp.id(platform); + const app = Object.fromEntries(Object.entries(APP_ELEMENT_IDS).map(([name, id]) => [name, screen.getByTestId(id)])) as AppLocators; + const launchApp = appLauncher( + { platform, id: appId, entry: testApp.entry(platform, target.build.devServer), buildPath: target.build.path, publishableKey: clerk.publishableKey, run: RUN }, + { + installApp: (path) => device.installApp(path), + signInTicket: users.signInTicket, + aboutToLaunch: () => aboutToLaunch(clerk.access), + openApp: (id, options) => (options === undefined ? device.openApp(id) : device.openApp(id, { relaunch: true, launchArguments: [...options.launchArguments] })), + adb: (args) => adb(target, args), + }, + BUILDS_THIS_WORKER_INSTALLED, + randomId, + ); + + const waitScreen: WaitScreen = { + home: app, + errorScreenElsePrompt: device.locator(errorScreenElseSavePasswordPrompt(APP_ELEMENT_IDS.error)), + dismissPrompt: () => screen.getByRole('button', { name: 'Not Now' }).tap({ timeout: ASSERTION_TIMEOUT_MS }), + }; + const pace = (timeoutMs: number): WaitPace => ({ timeoutMs, now: Date.now, wait: () => sleep(POLL_MS) }); + const waitFor = (sight: Sight, what: string, timeoutMs: number): Promise => until(sight, what, waitScreen, pace(timeoutMs)); + + const describeSignedIn = (who: SeededUser | TestEmail): string => + typeof who === 'string' ? `"${signedInText(who)}" with a user ID and a session ID on the home` : `"${signedInText(who.email)}" with user ID ${who.id} and a session ID on the home`; + + function landing(options: LaunchOptions): { readonly sight: Sight; readonly what: string } { + const on = (locator: Locator, what: string) => ({ sight: onScreen(locator, what), what }); + if (options.landsOn !== undefined) return on(options.landsOn, 'the screen the spec names in landsOn'); + const user = options.signedInAs ?? null; + if (user !== null) return { sight: signedInAs(app, user), what: describeSignedIn(user) }; + if (options.keepStorage === true) { + throw new Error('a launch with keepStorage and no signedInAs has no landing the fixture can know; pass landsOn with the locator the launch should show'); + } + return on(app.signIn, 'the "Sign in" button on the home'); + } + + const beat = async (control: Locator): Promise => { + if (EVIDENCE_MARKS === null) return; + await control.waitFor({ timeout: ASSERTION_TIMEOUT_MS }).catch(() => undefined); + await sleep(EVIDENCE_PACE.beatMs); + }; + + const marker = { marksFile: EVIDENCE_MARKS, now: Date.now, hold: sleep }; + const markTap = tapMarker(marker); + + const tap = (control: Locator, busy: BusyWait): Promise => + tapMarkedOnceUncovered(control, markTap, { timeoutMs: ASSERTION_TIMEOUT_MS, now: Date.now, wait: () => sleep(POLL_MS) }, busy); + + const host: HostFixture = { + runId: RUN, + app, + newEmail: async (options) => users.newEmail(options), + newPhone: users.newPhone, + seedUser: users.seed, + seedOrganization: users.seedOrganization, + async launch(options = {}) { + const lands = landing(options); + const launchId = await launchApp(options); + await waitFor(lands.sight, `${lands.what} after launch ${launchId}`, LAUNCH_TIMEOUT_MS); + }, + expectSignedInAs: (who, timeoutMs = ASSERTION_TIMEOUT_MS) => waitFor(signedInAs(app, who), describeSignedIn(who), timeoutMs), + expectSignedOut: (timeoutMs = ASSERTION_TIMEOUT_MS) => waitFor(signedOut(app), '"Signed out" and no user ID or session ID on the home', timeoutMs), + expectVisible: (target, what, timeoutMs = ASSERTION_TIMEOUT_MS) => untilVisible(target, what, waitScreen, pace(timeoutMs)), + async screenshot(label) { + await onceTheRunnerIsFree(() => e2eApp.screenshot(label), runnerBusy()); + }, + async tap(control) { + await beat(control); + await tap(control, runnerBusy()); + }, + async fill(field, text) { + await beat(field); + await fillField( + { tap: (options) => markTap(field, () => field.tap(options)), all: () => field.all() }, + typeof text === 'string' ? text : text.use('device-input', (value) => value), + { platform, focused: device.locator('role=textbox focused'), texts: device.locator('role=text'), type: (command) => typeOnDevice(target, command) }, + { tapTimeoutMs: ASSERTION_TIMEOUT_MS, reads: CONFIRM_READS, now: Date.now, wait: () => sleep(POLL_MS) }, + runnerBusy(), + ); + }, + evidence: evidenceBlock(marker), + }; + await use(host); + }, +}); diff --git a/integration/expo-native/specs/native.ts b/integration/expo-native/specs/native.ts new file mode 100644 index 00000000000..0d8ebee4f79 --- /dev/null +++ b/integration/expo-native/specs/native.ts @@ -0,0 +1,137 @@ +import { expect, type Locator } from 'e2e'; +import type { SecretLike, SeededUser } from './support/types.ts'; + +interface Screen { + getByTestId(id: string): Locator; + getByText(text: string | RegExp): Locator; + getByLabel(text: string): Locator; + getByRole(role: 'textbox'): Locator; + getByDisplayValue(value: string): Locator; + tapAt(point: { x: number; y: number }): Promise; +} + +interface Device { + back(): Promise; +} + +interface Host { + readonly app: { readonly signedIn: Locator }; + tap(target: Locator): Promise; + fill(target: Locator, text: string | SecretLike): Promise; +} + +const CLOSE_TAKES_EFFECT_MS = 3_000; +const PASSWORD_SCREEN_TIMEOUT_MS = 20_000; +const SIGNED_IN_TIMEOUT_MS = 45_000; + +export function nativeAuth(screen: Screen, platform: string) { + const ios = platform === 'ios'; + return { + identifier: ios + ? screen.getByTestId('clerk.auth.start.identifier') + : screen.getByText('Enter your email or username'), + continue: ios ? screen.getByTestId('clerk.auth.start.continue') : screen.getByText('Continue'), + passwordLabel: screen.getByText('Enter your password'), + passwordField: screen.getByRole('textbox').last(), + passwordContinue: ios ? screen.getByTestId('clerk.auth.signIn.continue') : screen.getByText('Continue'), + }; +} + +async function gone(locator: Locator, withinMs: number): Promise { + const deadline = Date.now() + withinMs; + while ((await locator.count()) > 0) { + if (Date.now() >= deadline) return false; + await new Promise(resolve => setTimeout(resolve, 300)); + } + return true; +} + +export async function closeNativeAuth(screen: Screen, device: Device, platform: string): Promise { + if (platform !== 'ios') { + await device.back(); + return; + } + const close = screen.getByTestId('clerk.dismissButton').last(); + await close.tap(); + if (!(await gone(close, CLOSE_TAKES_EFFECT_MS))) await close.tap(); +} + +async function reachPasswordScreen(host: Host, screen: Screen, platform: string, user: SeededUser): Promise { + const auth = nativeAuth(screen, platform); + await expect(auth.continue).toBeVisible({ timeout: PASSWORD_SCREEN_TIMEOUT_MS }); + const remembered = screen.getByDisplayValue(user.email); + if ((await remembered.count()) === 0) await host.fill(auth.identifier, user.email); + await host.tap(auth.continue); + await expect(auth.passwordLabel.first()).toBeVisible({ timeout: PASSWORD_SCREEN_TIMEOUT_MS }); + await expect(auth.passwordField).toBeVisible(); + return auth.passwordField; +} + +async function declineAndroidPasswordManager(screen: Screen, signedIn: Locator): Promise { + const prompt = screen.getByText(/Google Password Manager/); + await expect + .poll(async () => (await prompt.count()) > 0 || (await signedIn.count()) > 0, { timeout: SIGNED_IN_TIMEOUT_MS }) + .toBe(true); + if ((await prompt.count()) > 0) { + await screen + .getByText(/^(Not now|Never)$/i) + .first() + .tap(); + } +} + +export async function signInWithPassword( + host: Host, + screen: Screen, + platform: string, + user: SeededUser, +): Promise { + if (user.password === null) throw new Error('seed the user with host.seedUser({ password: true })'); + const field = await reachPasswordScreen(host, screen, platform, user); + await host.fill(field, user.password); + await host.tap(nativeAuth(screen, platform).passwordContinue); + if (platform === 'android') await declineAndroidPasswordManager(screen, host.app.signedIn); +} + +export function homeLinks(screen: Screen) { + return { + nativeAuth: screen.getByTestId('e2e.home.nativeAuth'), + authLogo: screen.getByTestId('e2e.home.authLogo'), + customSignIn: screen.getByTestId('e2e.home.customSignIn'), + customSignUp: screen.getByTestId('e2e.home.customSignUp'), + tokenCache: screen.getByTestId('e2e.home.tokenCache'), + embeddedProfile: screen.getByTestId('e2e.home.embeddedProfile'), + nativeModules: screen.getByTestId('e2e.home.nativeModules'), + }; +} + +export function nativeUserButton(screen: Screen, platform: string): Locator { + return platform === 'ios' ? screen.getByTestId('clerk.userButton.profile') : screen.getByLabel('Open user profile'); +} + +export function nativeProfile(screen: Screen) { + return { + manageAccount: screen.getByText('Manage account'), + signOut: screen.getByText('Sign out'), + }; +} + +export function embeddedProfile(screen: Screen) { + return { + customPageRow: screen.getByText('E2E Custom Page'), + customPageBody: screen.getByText('Rehosted RN body'), + }; +} + +export async function tapProfileBack(screen: Screen): Promise { + const back = screen.getByLabel('Back').last(); + await expect(back).toBeVisible(); + const frame = await back.boundingBox(); + if (frame === null) throw new Error('the Back button of the profile has no frame'); + await screen.tapAt({ x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 }); +} + +export async function leaveCustomPage(screen: Screen, device: Device, platform: string): Promise { + if (platform === 'ios') await tapProfileBack(screen); + else await device.back(); +} diff --git a/integration/expo-native/specs/support/agent.ts b/integration/expo-native/specs/support/agent.ts new file mode 100644 index 00000000000..9ee11d184ad --- /dev/null +++ b/integration/expo-native/specs/support/agent.ts @@ -0,0 +1,63 @@ +import { readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Secret } from './secret.ts'; + +export const DEFAULT_AGENT_MODEL = 'anthropic/claude-haiku-5.5'; +export const BACKUP_AGENT_MODEL = 'openai/gpt-6-luna-fast'; + +type Env = Readonly>; +type GatewayKey = Secret<'ai-gateway-key'>; + +export type AgentCredential = + | { readonly variable: 'AI_GATEWAY_API_KEY'; readonly key: GatewayKey } + | { readonly variable: 'AI_GATEWAY_API_KEY_FILE'; readonly file: string; readonly key: GatewayKey }; + +export interface Agent { + readonly model: string; + readonly backup: string; + readonly credential: AgentCredential; +} + +export class UnusableAgentKey extends Error { + readonly what: string; + readonly fix: string; + constructor(what: string, fix: string) { + super(`${what}; ${fix}`); + this.what = what; + this.fix = fix; + } +} + +export const isSet = (value: string | undefined): value is string => value !== undefined && value.trim() !== ''; + +function keyFromFile(file: string): GatewayKey { + const unusable = (why: string) => new UnusableAgentKey(`AI_GATEWAY_API_KEY_FILE names ${file}, which ${why}`, 'unset AI_GATEWAY_API_KEY_FILE, or point it at a file that holds the Vercel AI Gateway key'); + let mode: number; + try { + mode = statSync(file).mode; + } catch { + throw unusable('does not exist'); + } + if ((mode & 0o077) !== 0) throw new UnusableAgentKey(`${file} can be read by other users of this machine`, `chmod 600 ${file}`); + let value: string; + try { + value = readFileSync(file, 'utf8').trim(); + } catch (error) { + throw unusable(`cannot be read (${(error as NodeJS.ErrnoException).code ?? (error as Error).message})`); + } + if (value === '') throw new UnusableAgentKey(`${file} holds no key`, `put the Vercel AI Gateway key in ${file}, or unset AI_GATEWAY_API_KEY_FILE`); + return new Secret('ai-gateway-key', value); +} + +function credential(env: Env): AgentCredential | null { + if (isSet(env.AI_GATEWAY_API_KEY)) return { variable: 'AI_GATEWAY_API_KEY', key: new Secret('ai-gateway-key', env.AI_GATEWAY_API_KEY.trim()) }; + if (!isSet(env.AI_GATEWAY_API_KEY_FILE)) return null; + const file = resolve(env.AI_GATEWAY_API_KEY_FILE); + return { variable: 'AI_GATEWAY_API_KEY_FILE', file, key: keyFromFile(file) }; +} + +export function readAgent(env: Env): Agent | null { + const found = credential(env); + if (found === null) return null; + return { model: DEFAULT_AGENT_MODEL, backup: BACKUP_AGENT_MODEL, credential: found }; +} diff --git a/integration/expo-native/specs/support/busy-runner.ts b/integration/expo-native/specs/support/busy-runner.ts new file mode 100644 index 00000000000..cd93d1a4a84 --- /dev/null +++ b/integration/expo-native/specs/support/busy-runner.ts @@ -0,0 +1,34 @@ +const STILL_FINISHING = /iOS (?:automation )?runner is still finishing a (?:previous )?command/; + +export const isRunnerBusy = (error: unknown): boolean => error instanceof Error && STILL_FINISHING.test(error.message); + +export interface BusyWait { + waited(): Promise; +} + +export function busyWaits(most: number, wait: () => Promise): BusyWait { + let left = most; + return { + async waited() { + if (left <= 0) return false; + left -= 1; + await wait(); + return true; + }, + }; +} + +export const NO_BUSY_WAIT: BusyWait = busyWaits(0, async () => undefined); + +export async function onceTheRunnerIsFree(step: () => Promise, busy: BusyWait): Promise { + let refusal: unknown; + for (;;) { + try { + return await step(); + } catch (error) { + if (!isRunnerBusy(error)) throw error; + refusal ??= error; + if (!(await busy.waited())) throw refusal; + } + } +} diff --git a/integration/expo-native/specs/support/clerk.ts b/integration/expo-native/specs/support/clerk.ts new file mode 100644 index 00000000000..b3201ccf081 --- /dev/null +++ b/integration/expo-native/specs/support/clerk.ts @@ -0,0 +1,263 @@ +import { randomBytes, randomInt } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import net from 'node:net'; +import { runIdParts, type ClerkAccess, type ClerkInstance } from './inputs.ts'; +import { SHORTEST_SECRET, Secret, protect } from './secret.ts'; +import { commandsFor } from './typing.ts'; +import type { EmailOptions, OrganizationSeed, RunId, SecretLike, SeedOptions, SeededOrganization, SeededUser, TestEmail, TestPhone } from './types.ts'; + +export const BACKEND_API_URL = 'https://api.clerk.com/v1'; + +export const BACKEND_CALLS = { + createUser: { method: 'POST', path: '/users' }, + signInToken: { method: 'POST', path: '/sign_in_tokens' }, + usersByPhone: { method: 'GET', path: '/users' }, + createOrganization: { method: 'POST', path: '/organizations' }, + createMembership: { method: 'POST', path: '/organizations/{organization_id}/memberships' }, + createDomain: { method: 'POST', path: '/organizations/{organization_id}/domains' }, + listPermissions: { method: 'GET', path: '/organization_permissions' }, + listRoles: { method: 'GET', path: '/organization_roles' }, + createRole: { method: 'POST', path: '/organization_roles' }, +} as const; + +export type BackendCall = keyof typeof BACKEND_CALLS; + +export const PATH_PARAM = /\{([a-z_]+)\}/g; + +export const backendPath = (call: BackendCall, params: Readonly> = {}): string => + BACKEND_CALLS[call].path.replace(PATH_PARAM, (_, name: string) => encodeURIComponent(params[name] ?? '')); + +export const HARNESS_ROLES = { + 'org:admin': null, + 'org:member': null, + 'org:e2e_domains_reader': { name: 'Domains reader', permissions: ['org:sys_domains:read', 'org:sys_memberships:read'] }, +} as const satisfies Readonly>; + +export type HarnessRole = keyof typeof HARNESS_ROLES; + +export const isHarnessRole = (value: unknown): value is HarnessRole => typeof value === 'string' && Object.hasOwn(HARNESS_ROLES, value); + +export const SYSTEM_PERMISSIONS = { query: 'org:sys_', limit: '100' } as const; + +export const ABOUT_TO_LAUNCH = { method: 'POST', path: '/about_to_launch' } as const; + +export const TICKET_SECONDS = 120; + +// Node gives each address of a host 250 ms to connect before it tries the next, and fails the request when +// all of them miss it. On a network with no IPv6 route that fails about one request in five to api.clerk.com. +export const CONNECT_ATTEMPT_MS = 2_500; +net.setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_MS); + +const notTestIdentity = (value: string, kind: string): Error => new Error(`${value} is not a ${kind}; use an address with +clerk_test or a 555-0100..0199 phone that this run created`); + +export function runEmailPrefix(run: RunId): string { + const { time, tag } = runIdParts(run); + return `${time}${tag}`; +} + +const fourCharacters = (): string => randomInt(36 ** 4).toString(36).padStart(4, '0'); + +export function newTestEmail(run: RunId, domain = 'example.com'): TestEmail { + return parseTestEmail(`${runEmailPrefix(run)}${fourCharacters()}+clerk_test@${domain}`); +} + +export const newTestDomain = (run: RunId): string => `${runEmailPrefix(run)}${fourCharacters()}.clerk.test`; + +export const isTestDomainOf = (run: RunId, domain: string): boolean => new RegExp(`^${runEmailPrefix(run)}[0-9a-z]{4}\\.clerk\\.test$`).test(domain); + +export const organizationNamePrefix = (run: RunId): string => `${runEmailPrefix(run)} `; + +const TEST_EMAIL = /^[a-z0-9._-]+\+clerk_test@[a-z0-9.-]+\.[a-z]+$/; +export function parseTestEmail(value: string): TestEmail { + if (!TEST_EMAIL.test(value)) throw notTestIdentity(value, '+clerk_test email'); + return value as TestEmail; +} + +export function parseTestPhone(value: string): TestPhone { + const digits = value.replace(/[^0-9]/g, ''); + const national = digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits; + if (!/^[2-9]\d{2}55501\d{2}$/.test(national)) throw notTestIdentity(value, '555-0100..0199 test phone'); + return `+1${national}` as TestPhone; +} + +export const TEST_PHONES: readonly TestPhone[] = Array.from({ length: 100 }, (_, n) => parseTestPhone(`+1201555${String(100 + n).padStart(4, '0')}`)); + +export function typedPassword(value: string): Secret<'password'> { + const password = new Secret('password', value); + for (const typedAtOnce of commandsFor(value)) if (typedAtOnce.length >= SHORTEST_SECRET) protect(typedAtOnce); + return password; +} + +export function newTestPassword(): Secret<'password'> { + return typedPassword(`${randomBytes(12).toString('hex')}Aa1!`); +} + +export const runPassword = (run: RunId): Secret<'password'> => typedPassword(`Verify-${run}-Pw1!`); + +export interface TestUsers { + newEmail(options?: EmailOptions): TestEmail; + newPhone(): Promise; + seed(options?: SeedOptions): Promise; + seedOrganization(seed: Seed): Promise>; + signInTicket(user: SeededUser): Promise>; +} + +interface Reply { + readonly status: number; + readonly json: unknown; +} + +const ok = (reply: Reply): boolean => reply.status >= 200 && reply.status < 300; + +const errorsOf = (reply: Reply): readonly { readonly code?: string; readonly message?: string; readonly long_message?: string }[] => (reply.json as { errors?: { code?: string; message?: string; long_message?: string }[] } | null)?.errors ?? []; + +const identifierIsTaken = (reply: Reply): boolean => reply.status === 422 && errorsOf(reply).some((error) => error.code === 'form_identifier_exists'); + +interface Listed { + readonly id: string; + readonly key: string; + readonly permissions?: readonly { readonly key: string }[]; +} + +const listedOf = (reply: Reply): readonly Listed[] => (reply.json as { data?: Listed[] } | null)?.data ?? []; + +function refusal(call: BackendCall, reply: Reply): Error { + const said = errorsOf(reply).map((error) => error.long_message ?? error.message ?? error.code ?? 'no reason given').join('; '); + const { method, path } = BACKEND_CALLS[call]; + return new Error(`Clerk ${method} ${path} answered ${reply.status}${said === '' ? '' : `: ${said}`}`); +} + +function endpoint(access: ClerkAccess): { readonly baseUrl: string; readonly bearer: SecretLike } { + if (access.kind === 'secret-key') return { baseUrl: BACKEND_API_URL, bearer: access.key }; + return { baseUrl: access.url, bearer: new Secret('stand-in-token', readFileSync(access.tokenFile, 'utf8')) }; +} + +export async function aboutToLaunch(access: ClerkAccess, fetchImpl: typeof fetch = fetch): Promise { + if (access.kind !== 'stand-in') return; + const { baseUrl, bearer } = endpoint(access); + const response = await bearer.use('bapi-authorization', (plain) => + fetchImpl(`${baseUrl}${ABOUT_TO_LAUNCH.path}`, { method: ABOUT_TO_LAUNCH.method, headers: { Authorization: `Bearer ${plain}` }, signal: AbortSignal.timeout(60_000) }), + ); + if (response.ok) return; + const said = errorsOf({ status: response.status, json: await response.json().catch(() => null) }).map((error) => error.long_message ?? error.message ?? error.code); + throw new Error(`the verify CLI was not ready for the app to launch: ${said.join('; ') || `it answered ${response.status}`}`); +} + +export function testUsers(clerk: ClerkInstance, run: RunId, fetchImpl: typeof fetch = fetch): TestUsers { + runPassword(run); + const handedOut = new Set(); + + async function ask(call: BackendCall, sent: { readonly params?: Readonly>; readonly query?: string; readonly body?: unknown }): Promise { + const { method } = BACKEND_CALLS[call]; + const { baseUrl, bearer } = endpoint(clerk.access); + const response = await bearer.use('bapi-authorization', (plain) => + fetchImpl(`${baseUrl}${backendPath(call, sent.params)}${sent.query === undefined ? '' : `?${sent.query}`}`, { + method, + headers: { Authorization: `Bearer ${plain}`, 'Content-Type': 'application/json' }, + ...(sent.body === undefined ? {} : { body: JSON.stringify(sent.body) }), + signal: AbortSignal.timeout(30_000), + }), + ); + const text = await response.text(); + try { + return { status: response.status, json: text.length > 0 ? JSON.parse(text) : null }; + } catch { + return { status: response.status, json: null }; + } + } + + function phonesNotHandedOut(): readonly TestPhone[] { + const first = Math.floor(Math.random() * TEST_PHONES.length); + return TEST_PHONES.map((_, i) => TEST_PHONES[(first + i) % TEST_PHONES.length]!).filter((phone) => !handedOut.has(phone)); + } + + async function answered(call: BackendCall, sent: Parameters[1]): Promise { + const reply = await ask(call, sent); + if (!ok(reply)) throw refusal(call, reply); + return reply; + } + + const roleOnInstance = async (role: HarnessRole): Promise => + listedOf(await answered('listRoles', { query: `query=${encodeURIComponent(role)}` })).find((listed) => listed.key === role); + + function asTheTableSays(role: HarnessRole, listed: Listed): void { + const wanted = [...(HARNESS_ROLES[role]?.permissions ?? [])].sort(); + const has = (listed.permissions ?? []).map((permission) => permission.key).sort(); + if (has.join() !== wanted.join()) throw new Error(`role ${role} on this instance has the permissions ${has.join(', ')}, and the tests give it ${wanted.join(', ')}; run down so the next run creates a new application`); + } + + async function roleReady(role: HarnessRole): Promise { + const wanted = HARNESS_ROLES[role]; + if (wanted === null) return; + const existing = await roleOnInstance(role); + if (existing !== undefined) return asTheTableSays(role, existing); + const permissions = listedOf(await answered('listPermissions', { query: new URLSearchParams(SYSTEM_PERMISSIONS).toString() })); + const ids = wanted.permissions.map((key) => { + const id = permissions.find((permission) => permission.key === key)?.id; + if (id === undefined) throw new Error(`the instance has no permission ${key}, which role ${role} needs`); + return id; + }); + const made = await ask('createRole', { body: { name: wanted.name, key: role, permissions: ids } }); + if (ok(made)) return; + if (!identifierIsTaken(made)) throw refusal('createRole', made); + const raced = await roleOnInstance(role); + if (raced === undefined) throw refusal('createRole', made); + asTheTableSays(role, raced); + } + + const everyPhoneTaken = (otherwise: string): Error => new Error(`every 555-0100..0199 test phone is taken on this instance; delete its test users${otherwise}`); + + return { + newEmail: (options = {}) => newTestEmail(run, options.domain), + async newPhone() { + for (const phone of phonesNotHandedOut()) { + const holders = await ask('usersByPhone', { query: `phone_number=${encodeURIComponent(phone)}` }); + if (!ok(holders)) throw refusal('usersByPhone', holders); + if (Array.isArray(holders.json) && holders.json.length > 0) continue; + handedOut.add(phone); + return phone; + } + throw everyPhoneTaken(''); + }, + async seed(options = {}) { + const email = newTestEmail(run); + const password = options.password === true ? newTestPassword() : null; + for (const phone of options.phone === true ? phonesNotHandedOut() : [null]) { + const created = await ask('createUser', { + body: { + email_address: [email], + ...(phone === null ? {} : { phone_number: [phone] }), + ...(password === null ? { skip_password_requirement: true } : { password: password.use('bapi-user-password', (plain) => plain), bypass_client_trust: true }), + }, + }); + if (phone !== null && identifierIsTaken(created)) continue; + if (!ok(created)) throw refusal('createUser', created); + const id = (created.json as { id?: unknown } | null)?.id; + if (typeof id !== 'string') throw new Error('Clerk created a user without an id'); + if (phone !== null) handedOut.add(phone); + return { id, email, phone, password }; + } + throw everyPhoneTaken(', or seed without a phone'); + }, + async seedOrganization(seed: Seed): Promise> { + const members = seed.members ?? []; + for (const role of new Set(members.map((member) => member.role))) await roleReady(role); + const name = `${organizationNamePrefix(run)}${randomBytes(3).toString('hex')}`; + const made = await answered('createOrganization', { body: { name, created_by: seed.creator.id } }); + const id = (made.json as { id?: unknown } | null)?.id; + if (typeof id !== 'string') throw new Error('Clerk created an organization without an id'); + const params = { organization_id: id }; + for (const { user, role } of members) await answered('createMembership', { params, body: { user_id: user.id, role } }); + const domain = seed.suggestDomain === true ? newTestDomain(run) : null; + if (domain !== null) await answered('createDomain', { params, body: { name: domain, enrollment_mode: 'automatic_suggestion', verified: true } }); + return { id, name, domain } as SeededOrganization; + }, + async signInTicket(user) { + const minted = await ask('signInToken', { body: { user_id: user.id, expires_in_seconds: TICKET_SECONDS } }); + if (!ok(minted)) throw refusal('signInToken', minted); + const token = (minted.json as { token?: unknown } | null)?.token; + if (typeof token !== 'string') throw new Error('Clerk returned a sign-in token without a token'); + return new Secret('ticket', token); + }, + }; +} diff --git a/integration/expo-native/specs/support/config.ts b/integration/expo-native/specs/support/config.ts new file mode 100644 index 00000000000..2ede77495bc --- /dev/null +++ b/integration/expo-native/specs/support/config.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { E2EConfig } from 'e2e'; +import { mobile, type DeviceProvider } from '@e2e-dev/mobile'; +import { readAgent } from './agent.ts'; +import type { Device, Target, TestApp } from './inputs.ts'; +import { Secret } from './secret.ts'; + +export const ASSERTION_TIMEOUT_MS = 10_000; +export const TEST_TIMEOUT_MS = 240_000; + +export const PACKAGE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +type Env = Readonly>; + +export function engineDevice(device: Device): readonly string[] | DeviceProvider { + if (device.kind === 'local') return [device.id]; + const token = new Secret('session-bearer', readFileSync(device.tokenFile, 'utf8').trim()); + return { + name: 'borrowed', + acquire: async ({ env }) => ({ + id: device.id, + deviceId: device.id, + device: device.id, + daemon: token.use('e2e-provider-lease', (authToken) => ({ baseUrl: device.daemonUrl, authToken })), + client: { cwd: env.AGENT_DEVICE_STATE_DIR ?? join(env.HOME ?? homedir(), '.agent-device') }, + }), + release: async () => {}, + }; +} + +const requireOnlyForAnAgent = createRequire(import.meta.url); + +function agentConfig(env: Env): Pick { + const agent = readAgent(env); + if (agent === null) return {}; + const { createGateway } = requireOnlyForAnAgent('ai') as typeof import('ai'); + const gateway = agent.credential.key.use('gateway-provider', (apiKey) => createGateway({ apiKey })); + return { agents: { default: { model: gateway(agent.model), providerOptions: { gateway: { models: [agent.backup] } } } }, cache: 'off' }; +} + +export function composeE2EConfig(app: TestApp, target: Target, env: Env): E2EConfig { + return { + tests: ['specs/**/*.e2e.ts'], + targets: [ + { + name: target.platform, + engine: mobile({ platform: target.platform, device: engineDevice(target.device), session: target.session, videoTouches: false }), + app: { bundleId: app.id(target.platform), appPath: target.build.path ?? undefined }, + }, + ], + workers: 1, + timeout: TEST_TIMEOUT_MS, + assertionTimeout: ASSERTION_TIMEOUT_MS, + trace: 'off', + ...agentConfig(env), + }; +} diff --git a/integration/expo-native/specs/support/device.ts b/integration/expo-native/specs/support/device.ts new file mode 100644 index 00000000000..96b26ba2edb --- /dev/null +++ b/integration/expo-native/specs/support/device.ts @@ -0,0 +1,64 @@ +import { execFile } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { Device } from './inputs.ts'; +import { Secret } from './secret.ts'; +import type { Platform } from './types.ts'; + +export interface CommandLine { + readonly command: string; + readonly args: readonly string[]; +} + +export interface CommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +export type CommandRunner = (command: string, args: readonly string[]) => Promise; + +export function agentDevice(device: Device, platform: Platform): { readonly selector: readonly string[]; readonly env: Readonly> } { + const selector = ['--platform', platform, platform === 'ios' ? '--udid' : '--serial', device.id]; + if (device.kind === 'local') return { selector, env: {} }; + const token = new Secret('session-bearer', readFileSync(device.tokenFile, 'utf8').trim()); + return { + selector: [...selector, '--daemon-base-url', device.daemonUrl], + env: token.use('agent-device-daemon', (plain) => ({ AGENT_DEVICE_DAEMON_AUTH_TOKEN: plain })), + }; +} + +const REVERSE_FLAGS: ReadonlySet = new Set(['--list', '--no-rebind', '--remove', '--remove-all']); + +export function deviceToolCommand(platform: Platform, deviceId: string, args: readonly string[], adb = 'adb'): CommandLine | null { + if (platform !== 'android') return null; + const [subcommand, ...rest] = args; + const allowed = subcommand === 'shell' || (subcommand === 'reverse' && rest.every((arg) => REVERSE_FLAGS.has(arg) || /^tcp:\d{1,5}$/.test(arg))); + return allowed ? { command: adb, args: ['-s', deviceId, ...args] } : null; +} + +function localAdb(env: Readonly>, home: string): string { + const byDefault = process.platform === 'darwin' ? join(home, 'Library', 'Android', 'sdk') : join(home, 'Android', 'Sdk'); + const roots = [env.ANDROID_HOME, env.ANDROID_SDK_ROOT, byDefault].filter((root): root is string => root !== undefined && root !== ''); + return roots.map((root) => join(root, 'platform-tools', 'adb')).find((adb) => existsSync(adb)) ?? 'adb'; +} + +const run: CommandRunner = (command, args) => + new Promise((resolve) => { + execFile(command, [...args], (error, stdout, stderr) => { + if (error === null) resolve({ code: 0, stdout, stderr }); + else resolve(typeof error.code === 'number' ? { code: error.code, stdout, stderr } : { code: 127, stdout, stderr: stderr + error.message }); + }); + }); + +export async function deviceCommand( + device: Device, + platform: Platform, + args: readonly string[], + options: { readonly runner?: CommandRunner; readonly env?: Readonly>; readonly home?: string } = {}, +): Promise { + const local = deviceToolCommand(platform, device.id, args, localAdb(options.env ?? process.env, options.home ?? homedir())); + if (local === null) throw new Error(`no ${platform} device command starts with ${args[0] ?? 'nothing'}; on Android use adb shell or adb reverse arguments, and on iOS pass what the app needs as launch arguments`); + return (options.runner ?? run)(local.command, local.args); +} diff --git a/integration/expo-native/specs/support/evidence.ts b/integration/expo-native/specs/support/evidence.ts new file mode 100644 index 00000000000..f52c241b3bd --- /dev/null +++ b/integration/expo-native/specs/support/evidence.ts @@ -0,0 +1,65 @@ +import { appendFileSync } from 'node:fs'; + +export interface EvidenceMark { + readonly label: string; + readonly startedAt: number; + readonly endedAt: number; + readonly passed: boolean; +} + +export interface TapMark { + readonly sentAt: number; + readonly tappedAt: number; + readonly x: number; + readonly y: number; +} + +interface Box { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export const EVIDENCE_PACE = { + beatMs: 600, + resultHoldMs: 2_000, +} as const; + +export interface Marker { + readonly marksFile: string | null; + now(): number; + hold(ms: number): Promise; +} + +export function evidenceBlock(marker: Marker): (label: string, body: () => Promise) => Promise { + return async (label, body) => { + if (label.trim() === '') throw new Error('host.evidence needs a label that says what the block shows'); + const file = marker.marksFile; + if (file === null) return body(); + const startedAt = marker.now(); + let passed = false; + try { + const value = await body(); + passed = true; + await marker.hold(EVIDENCE_PACE.resultHoldMs); + return value; + } finally { + const mark: EvidenceMark = { label, startedAt, endedAt: marker.now(), passed }; + appendFileSync(file, `${JSON.stringify(mark)}\n`); + } + }; +} + +export function tapMarker(marker: Pick): (target: { boundingBox(): Promise }, tap: () => Promise) => Promise { + return async (target, tap) => { + const file = marker.marksFile; + if (file === null) return tap(); + const box = await target.boundingBox().catch(() => null); + const sentAt = marker.now(); + await tap(); + if (box === null) return; + const mark: TapMark = { sentAt, tappedAt: marker.now(), x: box.x + box.width / 2, y: box.y + box.height / 2 }; + appendFileSync(file, `${JSON.stringify(mark)}\n`); + }; +} diff --git a/integration/expo-native/specs/support/fill.ts b/integration/expo-native/specs/support/fill.ts new file mode 100644 index 00000000000..d8344e7f107 --- /dev/null +++ b/integration/expo-native/specs/support/fill.ts @@ -0,0 +1,98 @@ +import { onceTheRunnerIsFree, type BusyWait } from './busy-runner.ts'; +import { tapOnceUncovered } from './tapping.ts'; +import { typeConfirmed } from './typing.ts'; +import type { Platform } from './types.ts'; + +interface Frame { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface FieldNode { + inputValue(): Promise; + boundingBox(): Promise; +} + +export interface NamedField { + tap(options: { readonly timeout: number }): Promise; + all(): Promise; +} + +export interface FocusedInput extends FieldNode { + count(): Promise; + tap(options: { readonly timeout: number }): Promise; +} + +export interface FillDevice { + readonly platform: Platform; + readonly focused: FocusedInput; + readonly texts: { allTextContents(): Promise }; + type(command: readonly [string, ...string[]]): Promise; +} + +export interface FillPace { + readonly tapTimeoutMs: number; + readonly reads: number; + now(): number; + wait(): Promise; +} + +// The iOS simulator's accessibility frames lag about a second behind typed text, so a tap resolved sooner lands where a control sat before the keyboard moved it. +const IOS_FRAMES_SETTLE_AFTER_TYPING_MS = 1_500; + +export async function fillField(field: NamedField, text: string, device: FillDevice, pace: FillPace, busy: BusyWait): Promise { + const { focused } = device; + let typedAt: number | null = null; + const type = async (command: readonly [string, ...string[]]): Promise => { + try { + await device.type(command); + } finally { + typedAt = pace.now(); + } + }; + const tap = (): Promise => tapOnceUncovered(() => field.tap({ timeout: pace.tapTimeoutMs }), { timeoutMs: pace.tapTimeoutMs, now: pace.now, wait: pace.wait }, busy); + await tap(); + const read = (reading: () => Promise, unreadable: T): Promise => onceTheRunnerIsFree(reading, busy).catch(() => unreadable); + const frame = (): Promise => read(async () => ((await focused.count()) === 1 ? await focused.boundingBox() : null), null); + const wordsShown = (): Promise => read(async () => (await device.texts.allTextContents()).map((text) => text.replace(/\P{L}+/gu, '')), null); + const input = await frame(); + const wordsBeforeTyping = await wordsShown(); + let namedInput: FieldNode | null = null; + await typeConfirmed( + { + type: (value) => type(['type', value]), + async valueIfReadable() { + const now = await frame(); + if (input === null || now === null || now.x !== input.x || now.y !== input.y || now.width !== input.width || now.height !== input.height) return null; + const value = await read(() => focused.inputValue(), null); + if (value !== '') return value; + const words = await wordsShown(); + return wordsBeforeTyping !== null && words !== null && words.every((shown) => wordsBeforeTyping.includes(shown)) ? '' : null; + }, + valuesOfTheNamedNodes: () => + read(async () => { + const values: string[] = []; + for (const node of await field.all()) { + const value = await node.inputValue(); + if (value !== '') namedInput = node; + values.push(value); + } + return values; + }, []), + refocus: () => onceTheRunnerIsFree(() => focused.tap({ timeout: pace.tapTimeoutMs }), busy), + tapAgain: tap, + async replace(value) { + const box = input ?? (await namedInput?.boundingBox().catch(() => null)) ?? null; + if (box !== null) await type(['fill', String(Math.round(box.x + box.width / 2)), String(Math.round(box.y + box.height / 2)), value]); + }, + ...(device.platform === 'android' ? { nothingFocused: async () => (await focused.count()) !== 1 } : {}), + }, + text, + { reads: pace.reads, wait: pace.wait }, + busy, + ); + if (device.platform !== 'ios' || typedAt === null) return; + while (pace.now() - typedAt < IOS_FRAMES_SETTLE_AFTER_TYPING_MS) await pace.wait(); +} diff --git a/integration/expo-native/specs/support/inputs.ts b/integration/expo-native/specs/support/inputs.ts new file mode 100644 index 00000000000..55c0a25512e --- /dev/null +++ b/integration/expo-native/specs/support/inputs.ts @@ -0,0 +1,171 @@ +import { randomBytes } from 'node:crypto'; +import { Secret } from './secret.ts'; +import type { AppEntry, Platform, PublishableKey, RunId } from './types.ts'; + +type Env = Readonly>; + +export type Device = + | { readonly kind: 'local'; readonly id: string } + | { readonly kind: 'borrowed'; readonly id: string; readonly daemonUrl: string; readonly tokenFile: string }; + +export interface AppBuild { + readonly path: string | null; + readonly devServer: string | null; +} + +export interface Target { + readonly platform: Platform; + readonly device: Device; + readonly session: string; + readonly build: AppBuild; +} + +export type ClerkAccess = + | { readonly kind: 'secret-key'; readonly key: Secret<'clerk-secret-key'> } + | { readonly kind: 'stand-in'; readonly url: string; readonly tokenFile: string }; + +export interface ClerkInstance { + readonly publishableKey: PublishableKey; + readonly access: ClerkAccess; +} + +export interface Inputs { + readonly target: Target; + readonly clerk: ClerkInstance; + readonly run: RunId; + readonly evidenceMarks: string | null; +} + +export interface TestApp { + readonly platforms: readonly [Platform, ...Platform[]]; + id(platform: Platform): string; + entry(platform: Platform, devServer: string | null): AppEntry; +} + +const NAMES = { + platform: 'CLERK_E2E_PLATFORM', + device: 'CLERK_E2E_DEVICE', + session: 'CLERK_E2E_DEVICE_SESSION', + daemonUrl: 'CLERK_E2E_DEVICE_DAEMON_URL', + daemonTokenFile: 'CLERK_E2E_DEVICE_DAEMON_TOKEN_FILE', + appPath: 'CLERK_E2E_APP_PATH', + devServer: 'CLERK_E2E_DEV_SERVER', + publishableKey: 'CLERK_PUBLISHABLE_KEY', + secretKey: 'CLERK_SECRET_KEY', + apiUrl: 'CLERK_E2E_API_URL', + apiTokenFile: 'CLERK_E2E_API_TOKEN_FILE', + run: 'CLERK_E2E_RUN_ID', + evidenceMarks: 'CLERK_E2E_EVIDENCE_MARKS', +} as const; + +export const INPUT_VARIABLES: readonly string[] = Object.values(NAMES); + +const given = (env: Env, name: string): string | null => { + const value = env[name]?.trim(); + return value === undefined || value === '' ? null : value; +}; + +const DEVICES: Readonly> = { + ios: { id: /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i, expects: 'the UDID of a simulator; a name is not accepted', listedBy: 'xcrun simctl list devices booted' }, + android: { id: /^emulator-\d+$/, expects: 'the emulator- serial of an emulator; an AVD name, a host:port address, and the serial of a phone are not accepted', listedBy: 'adb devices' }, +}; + +function readPlatform(app: TestApp, env: Env): Platform { + const named = given(env, NAMES.platform); + const [only, ...others] = app.platforms; + if (named === null) { + if (others.length === 0) return only; + throw new Error(`${NAMES.platform} is not set, and this test app runs on ${app.platforms.join(' and ')}; set it to one of them`); + } + const platform = app.platforms.find((candidate) => candidate === named); + if (platform === undefined) throw new Error(`${NAMES.platform} is ${named}, and this test app runs on ${app.platforms.join(' and ')}`); + return platform; +} + +function readDevice(platform: Platform, env: Env): Device { + const id = given(env, NAMES.device); + const { id: shape, expects, listedBy } = DEVICES[platform]; + if (id === null) throw new Error(`${NAMES.device} is not set; set it to the id of a booted device, which \`${listedBy}\` prints`); + if (!shape.test(id)) throw new Error(`${NAMES.device} is ${id}, which is not ${expects}, so copy the id that \`${listedBy}\` prints`); + const daemonUrl = given(env, NAMES.daemonUrl); + if (daemonUrl === null) return { kind: 'local', id }; + if (!/^https?:\/\/\S+$/.test(daemonUrl)) throw new Error(`${NAMES.daemonUrl} is ${daemonUrl}, which is not the http or https address of an agent-device daemon`); + const tokenFile = given(env, NAMES.daemonTokenFile); + if (tokenFile === null) throw new Error(`${NAMES.daemonUrl} is set and ${NAMES.daemonTokenFile} is not; the daemon of a borrowed device needs its token file`); + return { kind: 'borrowed', id, daemonUrl, tokenFile }; +} + +export function readTarget(app: TestApp, env: Env): Target { + const platform = readPlatform(app, env); + return { + platform, + device: readDevice(platform, env), + session: given(env, NAMES.session) ?? `clerk-e2e-${platform}`, + build: { path: given(env, NAMES.appPath), devServer: given(env, NAMES.devServer) }, + }; +} + +const LOOPBACK = /^http:\/\/127\.0\.0\.1:\d{1,5}(\/|$)/; + +export function readClerk(env: Env): ClerkInstance { + const publishableKey = given(env, NAMES.publishableKey); + if (publishableKey === null) throw new Error(`${NAMES.publishableKey} is not set; set it to the publishable key of a development instance`); + if (!publishableKey.startsWith('pk_test_')) throw new Error(`${NAMES.publishableKey} is not a pk_test_ key; these tests create and sign in users, so they run against a development instance only`); + const secretKey = given(env, NAMES.secretKey); + const url = given(env, NAMES.apiUrl); + if (secretKey !== null && url !== null) throw new Error(`${NAMES.secretKey} and ${NAMES.apiUrl} are both set; set one, so there is one way to reach Clerk`); + if (secretKey !== null) { + if (!secretKey.startsWith('sk_test_')) throw new Error(`${NAMES.secretKey} is not an sk_test_ key; these tests create and sign in users, so they run against a development instance only`); + return { publishableKey: publishableKey as PublishableKey, access: { kind: 'secret-key', key: new Secret('clerk-secret-key', secretKey) } }; + } + if (url === null) throw new Error(`${NAMES.secretKey} is not set; set it to the secret key of the same development instance as ${NAMES.publishableKey}`); + if (!LOOPBACK.test(url)) throw new Error(`${NAMES.apiUrl} is ${url}, which is not an http://127.0.0.1: address; only a stand-in on this machine may take the place of Clerk's Backend API`); + const tokenFile = given(env, NAMES.apiTokenFile); + if (tokenFile === null) throw new Error(`${NAMES.apiUrl} is set and ${NAMES.apiTokenFile} is not; the stand-in at that address needs its token file`); + return { publishableKey: publishableKey as PublishableKey, access: { kind: 'stand-in', url, tokenFile } }; +} + +const RUN_ID = /^r\d{8}-(?