diff --git a/.changeset/expo-verify-ci.md b/.changeset/expo-verify-ci.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-ci.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/verify-clerk-expo/SKILL.md b/.claude/skills/verify-clerk-expo/SKILL.md index 24b25e38e8f..402301def1b 100644 --- a/.claude/skills/verify-clerk-expo/SKILL.md +++ b/.claude/skills/verify-clerk-expo/SKILL.md @@ -13,8 +13,6 @@ No change to `@clerk/expo` UI or auth behavior is done until a `run` on the real Run every command from the repo root. In the prose below, `doctor`, `up`, `run`, `screen`, `attach`, and `down` are verbs of that CLI. The tests and the CLI are the Node package `integration/expo-native/`, and paths that begin `specs/`, `src/`, `test/`, or `.verify/` are inside it. `features/` and `references/` are beside this file. Every verb but `attach` takes `--platform ios|android`, and iOS is the default. Every verb takes `--json` and then prints one `{ "ok": ... }` object. Exit codes are 0 for success, 1 for a failing spec, 2 for a usage error, and 3 for a failed precondition. Every error prints a `fix` line. -CI runs none of these specs. A regression test that must run on every pull request belongs in `integration/tests/expo-native/`, which `.github/workflows/expo-native-build.yml` runs against a Release build of the same test app with the same `e2e` engine. - The test app links `@clerk/expo`, `@clerk/expo-biometrics`, and `@clerk/expo-google-signin` from the workspace. It does not install `@clerk/expo-passkeys`, so the skill cannot verify passkeys. ## Launch @@ -65,7 +63,7 @@ A worktree can hold one lane of each platform. The two lanes share the watch bui A Mac has four iOS lanes and two Android lanes, shared by every worktree on it. When all are taken, `up` and `run` fail with `POOL_FULL`, and `--wait ` on either verb waits for a lane. The CLI drives only the simulators and emulators that it creates. [Local devices](references/devices.md) says how to find a lane's UDID or serial. -The test app is built locally on macOS only. On a Linux machine that can run the emulator, the CLI picks the local backend for Android, and the build then fails with `UNSUPPORTED`. +On a Linux machine that can run the emulator, the CLI picks the local backend for Android and builds the test app on that machine, as the `Verify end-to-end tests` workflow does on its Linux runner. ## Doctor @@ -128,7 +126,7 @@ Assert on what a user sees, and look in the native Clerk views first. The code s The home is the test app's own screen in a verify launch. With no active session it shows `Signed out` (`e2e.auth.signedOut`) and a `Sign in` button (`e2e.auth.signIn`) that opens AuthView in a modal. With an active session it shows the UserButton, `Signed in as ` (`e2e.auth.signedIn`), the user ID (`e2e.auth.userId`), the session ID (`e2e.auth.sessionId`), and a `Sign out` button (`e2e.auth.signOut`). The home reads each of these from the hook that a customer's app would use. The signed-out text and the buttons come from `useAuth`, the email and the user ID from `useUser`, and the session ID from `useSession`. A hook that keeps a stale value after a sign-out leaves its text on the home, and `host.expectSignedOut` then fails. A pending session counts as signed out on the home. -The test app shows the home again when a full-screen AuthView or a custom form completes its flow, when the close button of the full-screen AuthView calls `onDismiss`, and when the Back button on the root of the embedded profile calls `onHostBack`. The native modules and token cache screens have no way back. It draws nothing on or around a native view. While Clerk loads or a ticket signs in, it shows a spinner. The two full-screen AuthViews also show the spinner until `useAuthViewState().isLoaded` is true, so AuthView on screen after one of those taps proves that flag. When a launch cannot start, the test app shows `Something went wrong` and the reason (`e2e.launch.error`). A launch without verify inputs shows none of this. It shows the home in the test app's `App.tsx`, which `integration/tests/expo-native/` drives. +The test app shows the home again when a full-screen AuthView or a custom form completes its flow, when the close button of the full-screen AuthView calls `onDismiss`, and when the Back button on the root of the embedded profile calls `onHostBack`. The native modules and token cache screens have no way back. It draws nothing on or around a native view. While Clerk loads or a ticket signs in, it shows a spinner. The two full-screen AuthViews also show the spinner until `useAuthViewState().isLoaded` is true, so AuthView on screen after one of those taps proves that flag. When a launch cannot start, the test app shows `Something went wrong` and the reason (`e2e.launch.error`). A launch without verify inputs shows the same home, signed out, with the publishable key the app was built with (`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY`). A build with no key shows the error screen. The test app has a screen or a small flow of its own only where the native views cannot prove a thing. The custom sign-in and sign-up forms drive `useSignIn` and `useSignUp`. The test app reads the `@clerk/expo` token cache once, as the app starts and before Clerk has loaded, and the `Token cache` screen says whether a client token was kept from the last launch. `Sign in with a logo` opens the home's modal AuthView with a React Native view as its `logo`. The `Embedded profile` screen is UserProfileView inline with one custom page, `isDismissible={false}`, and `onHostBack`. The `Native modules` screen has one button for `useSignInWithGoogle` and one for `useBiometricCredentials`, each with its result as text. The test app looks and behaves as an app does for a real user. When a change needs a flow that no native view covers, add a screen that a real app would have, and assert on the outcome as a user sees it. @@ -138,7 +136,7 @@ A spec file that needs other settings than the standard ones has a settings file ### Check your work -Golden specs under `specs/golden//` are committed and cover the feature map in `features/`, which starts at `features/README.md`. Run the features your change touches, on both platforms when the change is not specific to one. For new work: +Golden specs under `specs/golden//` are committed and cover the feature map in `features/`, which starts at `features/README.md`. Prove your own change, on both platforms when the change is not specific to one, and leave the rest of the golden specs to PR CI (`.github/workflows/verify-e2e.yml`), which runs every one of them once the pull request is ready for review. When your change is to behavior a golden spec already covers, that spec is your proof: run it. Run another feature's specs yourself only when you changed code that feature shares and want to know before CI does. For new work: 1. Write a spec under `specs/explored/`, which is gitignored. It imports the fixture as `'../fixtures.ts'`. 2. Run it by path. @@ -179,7 +177,7 @@ $ integration/expo-native/bin/control-clerk-expo attach --pr --scre `attach` posts one comment per run and PR with `gh pr comment --attach`. It needs a `gh` whose `gh pr comment` has that flag, and it fails with a fix when the flag is missing. It refuses a run that is tainted, that has a failing spec or no passing one, or whose `app.log` names a user that the run did not create. -Attach the focused run, not the regression run. Run your new or changed spec on its own and attach that run, so the PR video shows only the behavior the change is about. Run the golden specs for every feature you touched in a separate `run`, cite its run id in the PR as regression evidence, and leave its video in `.verify/runs/`. +Attach the run of your own change. Run your new or changed spec on its own and attach that run, so the PR video shows only the behavior the change is about. You do not owe a regression run: PR CI (`.github/workflows/verify-e2e.yml`) runs every golden spec on the pull request and reports them there. If you ran other golden specs anyway, cite that run's id in the PR and leave its video in `.verify/runs/`. ## Cleanup @@ -210,8 +208,10 @@ If a worktree is removed without `down`, the next `up` or `run` in any worktree ## For maintainers of the tests and the CLI -- `src/core/`, `src/platform/ios/`, `src/platform/android/`, `specs/support/`, `specs/fixtures.ts`, `e2e.config.ts`, `testing/`, and every test but `test/host.test.ts` and `test/freshness.test.ts` are shared with the same package in clerk-ios and clerk-android. Change them there first, then copy them here. `doctor`'s `core-drift` check fails when `src/core/`, `specs/support/`, `specs/fixtures.ts`, or `e2e.config.ts` differs from `src/core/MANIFEST`, and `node src/core/manifest.ts --write` in the package directory regenerates the manifest. Nothing under `specs/` or `e2e.config.ts` imports the CLI, and `test/seam.test.ts` fails when a file does. -- `src/host.ts`, `src/fixture.ts`, and `src/freshness.ts` are this repository's own: the build of the test app, the Metro ports, and the check that Metro serves current JS. `specs/app.ts` names the test app and its entry for a dev client, and `specs/native.ts` holds the per-platform locators for the native views and the locators of the home's links. Both are this repository's own too. +- `src/core/`, `src/platform/ios/`, `src/platform/android/`, `specs/support/`, `specs/fixtures.ts`, `e2e.config.ts`, `testing/`, and every test but `test/host.test.ts`, `test/freshness.test.ts`, and `test/native-build.test.ts` are shared with the same package in clerk-ios and clerk-android. Change them there first, then copy them here. `doctor`'s `core-drift` check fails when `src/core/`, `specs/support/`, `specs/fixtures.ts`, or `e2e.config.ts` differs from `src/core/MANIFEST`, and `node src/core/manifest.ts --write` in the package directory regenerates the manifest. Nothing under `specs/` or `e2e.config.ts` imports the CLI, and `test/seam.test.ts` fails when a file does. +- `src/host.ts`, `src/fixture.ts`, `src/native-build.ts`, and `src/freshness.ts` are this repository's own: the build of the test app, the native build that CI keeps between runs, the Metro ports, and the check that Metro serves current JS. `specs/app.ts` names the test app and its entry for a dev client, and `specs/native.ts` holds the per-platform locators for the native views and the locators of the home's links. Both are this repository's own too. - `npm test --prefix integration/expo-native` runs the CLI's unit tests, with no network, key, or device. `npm run typecheck --prefix integration/expo-native` runs `tsc`. The `Expo Native Runner Tests` job in `.github/workflows/ci.yml` runs both on Linux when a pull request changes the package, the test app, or a package the test app links. - `run --github-report` hands the results of the run to `@e2e-dev/github` as one report. The reporter writes the report to the job summary. With a `GITHUB_TOKEN` that may write pull request comments, it also posts one comment on the pull request and updates that comment on later runs. The reporter never changes the exit code, and nothing is reported for a run with a tainted file. +- `.github/workflows/verify-e2e.yml`, the `Verify end-to-end tests` workflow, runs `up --backend local`, `run --all --retries 1 --github-report`, and `down` on a runner for each platform, with a device and a Clerk application for each. It starts on a pull request to `main` that changes the package, the test app, or one of the three packages the test app links. It runs for a pull request that is not a draft, and a draft or a pull request from a fork gets a notice instead. Start it by hand with `gh workflow run verify-e2e.yml --ref `. A failing spec shows on the pull request and is not required for a merge, and a test that fails and then passes on its one retry is `flaky` and does not fail the job. The runners are GitHub-hosted, `macos-26` and `ubuntu-24.04`, and the repository variables `VERIFY_CI_RUNNER_IOS` and `VERIFY_CI_RUNNER_ANDROID` name other labels. The Platform API key comes from the `MOBILE_VERIFICATION_PLATFORM_API_KEY` repository secret. The workflow sets `VERIFY_LOCAL_BUILD=standalone` and `VERIFY_NATIVE_CACHE` ([freshness.md](references/freshness.md)). It uploads each run's `run.json`, app log, driver logs, video, screenshots, and e2e's `report.json`, `junit.xml`, summary, and failure pages for three days, and only when no secret is found in the run. `bin/boot-ios-simulators.sh wait` waits until a booted simulator is ready, and the workflow calls it. +- The `Expo` workflow, `.github/workflows/expo-native-build.yml`, runs nothing on a device. It builds the test app as a Release app on Expo SDK 54, 55, and 57 for each platform, and runs the Android unit tests of `@clerk/expo-biometrics`. - `SKILL.md`, `references/`, and `features/` are in `.claude/skills/verify-clerk-expo/`. `.cursor/skills/verify-clerk-expo` is a symlink to that directory, so edit only the `.claude` copy. diff --git a/.claude/skills/verify-clerk-expo/references/freshness.md b/.claude/skills/verify-clerk-expo/references/freshness.md index 22dd5b0b8f5..1cde5c53b8d 100644 --- a/.claude/skills/verify-clerk-expo/references/freshness.md +++ b/.claude/skills/verify-clerk-expo/references/freshness.md @@ -33,7 +33,31 @@ Fast Refresh stays on. When you save a JS change while an app from an earlier ru The check dates a Metro revision it has not seen before by the second it was built. If two edits land within the same second, or an edit is reverted while Metro's watcher is still behind, the check can, rarely, launch a bundle one edit older than `dist`. Touching files cannot force a newer revision, because Metro skips modules whose transform key did not change. +## A standalone build has none of this + +The `Verify end-to-end tests` workflow sets `VERIFY_LOCAL_BUILD=standalone`, so its devices run a standalone Release app with the JS embedded, with no watch build, no Metro, and none of the three checks. + +## Troubleshooting + ## Troubleshooting - A machine behind an HTTPS debugging proxy needs the proxy's CA trusted by `Clerk Verify Template iOS` before lanes are cloned from it. `doctor` reports it as `proxy-trust` and prints the fix. - If `up` says port 8082 (or another lane's port) already serves a Metro this worktree did not start, stop the process that listens on it: `lsof -nP -iTCP:8082 -sTCP:LISTEN` finds it. + +## For maintainers: the standalone build in CI + +`VERIFY_LOCAL_BUILD=standalone` makes a local lease build a standalone Release app in the working tree, with the JS embedded. `up` and `run` then start no watch build and no Metro, and the build key covers the JS inputs. Every JS edit then changes the key, so the next `up` or `run` builds again, and that build stops this worktree's Metro and watch build if a dev client left them running. Without `VERIFY_NATIVE_CACHE` it rebuilds the app natively. Unset, or set to `dev-client`, a local lease gets the dev client. + +`VERIFY_NATIVE_CACHE=` keeps the app of a standalone build in `/-/`. The fingerprint covers what decides the native build: + +- That platform's native inputs, listed under [What forces a native rebuild](#what-forces-a-native-rebuild). +- What `@expo/fingerprint`, which `expo` installs, reports for the installed fixture on that platform. That is the app config as Expo resolves it, the config plugin files of the workspace packages, and every native module that autolinking links. +- The resolved version of every Expo and React Native package in the test app's `pnpm-lock.yaml`: `expo`, `expo-*`, `@expo/*`, `react-native`, `react-native-*`, `@react-native*/*`, and `hermes-*`. +- The Xcode version on iOS. +- `src/fixture.ts` and `src/native-build.ts`. + +A JS edit does not change it, and a new version of a JS-only package does not change it either. When `@expo/fingerprint` cannot be loaded, fails, or leaves out the app config or the autolinking result, the fingerprint covers the whole lockfile in place of the second and third items, and the build prints `native fingerprint covers the whole pnpm-lock.yaml, because `. + +A later standalone build with the same fingerprint runs no `expo prebuild`, `xcodebuild`, or Gradle. It builds the workspace packages, exports the bundle from the working tree with `expo export:embed`, compiles it with the test app's `hermesc`, and puts it in a copy of the kept app. The build fails unless the app then holds exactly the bundle it compiled. It builds natively instead, and prints `not reused:` with the reason, when the kept app holds no Hermes bundle, when it runs another Hermes bytecode version, when an Android bundle has image assets, or when the Android SDK has no build-tools 35 or newer to align the APK again. On Android the app is signed with the generated project's debug keystore when the working tree has one and with a new key otherwise, so a device that already holds the app under another key needs it uninstalled first. + +A job of the workflow that had to build natively stores the app as a run artifact named `verify-expo-native--` for seven days, and a later job takes it only from a run of the same branch of this repository. When there is none, the job builds natively. Unset, nothing is kept and every standalone build is a native build. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 50e8c080f05..17668c977ca 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -7,3 +7,13 @@ self-hosted-runner: labels: - blacksmith-8vcpu-ubuntu-2204 - blacksmith-6vcpu-macos-26 + +paths: + # actionlint 1.7.x predates GitHub's background steps and rejects them: + # https://github.com/rhysd/actionlint/issues/693 + # Remove once actionlint supports those keys. Until then, a step in this file + # with neither `run` nor `uses` is not reported either. + .github/workflows/verify-e2e.yml: + ignore: + - 'unexpected key "background" for step to run shell command' + - 'step must run script with "run" section or run action with "uses" section' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a032601372e..5cc3ff55bfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,6 @@ jobs: .github/workflows/ci.yml .github/actionlint.yaml integration/templates/expo-native - integration/tests/expo-native packages/expo packages/expo-biometrics packages/expo-google-signin diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index baba13fb3cd..5c83b6dc756 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -7,9 +7,7 @@ on: - main paths: - '.github/workflows/expo-native-build.yml' - - 'integration/e2e.expo-native.config.ts' - 'integration/templates/expo-native/**' - - 'integration/tests/expo-native/**' - 'packages/expo/**' - 'packages/expo-biometrics/**' - 'packages/expo-google-signin/**' @@ -24,22 +22,15 @@ concurrency: env: FIXTURE_DIR: integration/templates/expo-native - E2E_DIR: integration/tests/expo-native FIXTURE_PUBLISHABLE_KEY: pk_test_ZHVtbXkuY2xlcmsuYWNjb3VudHMuZGV2JA SDK_PACK_DIR: /tmp/clerk-expo-pack - E2E_INSTANCE_NAME: clerkstage-with-native-components - BAPI_URL: https://api.clerkstage.dev - IOS_SIMULATORS: '2' - E2E_TELEMETRY_DISABLED: '1' jobs: native-build: if: ${{ github.head_ref != 'changeset-release/main' && (github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false) }} - name: Native ${{ matrix.run-e2e == true && 'E2E' || 'Build' }} (${{ matrix.platform }}, sdk ${{ matrix.expo-sdk }}) + name: Native Build (${{ matrix.platform }}, sdk ${{ matrix.expo-sdk }}) runs-on: ${{ matrix.runner }} - timeout-minutes: ${{ matrix.run-e2e == true && 60 || 45 }} - env: - CLERK_TEST_USERNAME_PREFIX: e2e_${{ github.run_id }}_${{ matrix.platform }}_ + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -59,11 +50,9 @@ jobs: - expo-sdk: 57 platform: android runner: blacksmith-8vcpu-ubuntu-2204 - run-e2e: true - expo-sdk: 57 platform: ios runner: blacksmith-6vcpu-macos-26 - run-e2e: true steps: - name: Checkout repo @@ -107,18 +96,18 @@ jobs: with: path: ${{ steps.native-build-key.outputs.artifact }} # Bump the version when native build commands change. - key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ matrix.run-e2e == true && 'e2e' || 'build' }}-${{ env.E2E_INSTANCE_NAME }}-${{ steps.native-build-key.outputs.hash }} + key: expo-native-build-v1-${{ runner.os }}-${{ matrix.expo-sdk }}-${{ matrix.platform }}-${{ steps.native-build-key.outputs.hash }} - - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + - if: steps.native-build-cache.outputs.cache-hit != 'true' uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + - if: steps.native-build-cache.outputs.cache-hit != 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24.15.0 cache: pnpm - name: Install monorepo dependencies - if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true + if: steps.native-build-cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Build and pack Clerk packages @@ -135,7 +124,6 @@ jobs: working-directory: ${{ env.FIXTURE_DIR }} env: EXPO_SDK: ${{ matrix.expo-sdk }} - RUN_E2E: ${{ matrix.run-e2e == true && 'true' || 'false' }} run: | cp "package.sdk-$EXPO_SDK.json" package.json pnpm install --no-frozen-lockfile @@ -144,49 +132,13 @@ jobs: GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)" pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$BIOMETRICS_TARBALL" -w - # expo-dev-client makes even release builds boot into the dev - # launcher (unreachable Metro in CI), which stalls every e2e - # flow on a blank screen. Skip it on e2e jobs only. - DEV_CLIENT="expo-dev-client" - if [ "$RUN_E2E" = "true" ]; then DEV_CLIENT=""; fi - pnpm expo install expo-auth-session expo-constants expo-crypto $DEV_CLIENT expo-secure-store expo-web-browser - if [ "$RUN_E2E" = "true" ]; then - REACT_VERSION=$(node -p 'require("react/package.json").version') - REACT_DOM_VERSION=$(node -p 'require("react-dom/package.json").version') - if [ "$REACT_VERSION" != "$REACT_DOM_VERSION" ]; then - echo "::error::React version mismatch: react@$REACT_VERSION, react-dom@$REACT_DOM_VERSION" - exit 1 - fi - fi - - - name: Check e2e secret availability - id: gate - if: matrix.run-e2e == true - env: - KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: | - if [ -n "$KEYS" ]; then - echo "ok=true" >> "$GITHUB_OUTPUT" - else - echo "ok=false" >> "$GITHUB_OUTPUT" - echo "::notice::INTEGRATION_STAGING_INSTANCE_KEYS unavailable (fork PR?); running build-only." - fi - - - name: Resolve Clerk instance keys - id: keys - if: steps.gate.outputs.ok == 'true' - continue-on-error: true - env: - INTEGRATION_STAGING_INSTANCE_KEYS: ${{ secrets.INTEGRATION_STAGING_INSTANCE_KEYS }} - run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME" + pnpm expo install expo-auth-session expo-constants expo-crypto expo-dev-client expo-secure-store expo-web-browser - name: Write fixture .env if: steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} - env: - E2E_PK: ${{ steps.keys.outputs.pk }} run: | - echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env + echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=$FIXTURE_PUBLISHABLE_KEY" > .env - name: Set up JDK 17 if: matrix.platform == 'android' @@ -239,131 +191,3 @@ jobs: with: path: ${{ steps.native-build-key.outputs.artifact }} key: ${{ steps.native-build-cache.outputs.cache-primary-key }} - - - name: Boot iOS simulators - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - run: ${{ env.E2E_DIR }}/boot-ios-simulators.sh boot "$IOS_SIMULATORS" - - - name: Cache iOS automation runner - if: matrix.platform == 'ios' && steps.keys.outputs.pk != '' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: ~/.agent-device - key: agent-device-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: agent-device-${{ runner.os }}- - - - name: Run iOS e2e - id: run_e2e_ios - if: matrix.platform == 'ios' && steps.keys.outputs.sk != '' - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - CLERK_API_URL: ${{ env.BAPI_URL }} - run: | - "$E2E_DIR/boot-ios-simulators.sh" wait - mkdir -p "$RUNNER_TEMP/e2e-device-logs" - log_pids=() - trap 'kill "${log_pids[@]}" 2>/dev/null || true; wait "${log_pids[@]}" 2>/dev/null || true' EXIT - for udid in ${CLERK_TEST_DEVICES//,/ }; do - xcrun simctl install "$udid" "$FIXTURE_DIR/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app" - xcrun simctl spawn "$udid" log stream --style compact \ - --predicate 'processImagePath CONTAINS "ClerkExpoNativeBuildFixture"' \ - > "$RUNNER_TEMP/e2e-device-logs/$udid-console.log" 2>&1 & - log_pids+=($!) - done - pnpm test:integration:expo-native:ios --reporter list,junit,markdown - - - name: Enable KVM - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: AVD cache - id: avd-cache - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-34-google_apis-x86_64-v1 - - - name: Create AVD snapshot - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' && steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - with: - api-level: 34 - target: google_apis - arch: x86_64 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim - disable-animations: false - script: echo "Generated AVD snapshot for caching." - - - name: Run Android e2e - id: run_e2e_android - if: matrix.platform == 'android' && steps.keys.outputs.sk != '' - uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - CLERK_API_URL: ${{ env.BAPI_URL }} - with: - api-level: 34 - target: google_apis - arch: x86_64 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim - disable-animations: true - # The action runs each script line in a separate sh -c; the folded - # scalar (>-) plus && keeps everything in one shell invocation. - script: >- - mkdir -p "$RUNNER_TEMP/e2e-device-logs" && - adb install -r "$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" && - (adb logcat -v threadtime > "$RUNNER_TEMP/e2e-device-logs/logcat.log" 2>&1 &) && - pnpm test:integration:expo-native:android --reporter list,junit,markdown - - - name: Delete leftover test users - if: always() && steps.keys.outputs.sk != '' - env: - CLERK_SECRET_KEY: ${{ steps.keys.outputs.sk }} - run: | - curl -fsS -G "$BAPI_URL/v1/users" --data-urlencode "limit=100" --data-urlencode "query=$CLERK_TEST_USERNAME_PREFIX" -H "Authorization: Bearer $CLERK_SECRET_KEY" | - jq -r --arg p "$CLERK_TEST_USERNAME_PREFIX" '.[] | select((.username // "") | startswith($p)) | .id' | - while read -r user_id; do - curl -fsS --retry 3 --retry-delay 1 -o /dev/null -X DELETE "$BAPI_URL/v1/users/$user_id" -H "Authorization: Bearer $CLERK_SECRET_KEY" || - echo "::warning::Could not delete leftover test user $user_id" - done - - - name: Upload e2e artifacts - if: always() && steps.keys.outputs.sk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: e2e-${{ matrix.platform }} - include-hidden-files: true - path: | - integration/.e2e - ${{ runner.temp }}/e2e-device-logs - if-no-files-found: warn - retention-days: 7 - - - name: Report e2e outcome - if: always() && steps.keys.outputs.sk != '' - env: - IOS_OUTCOME: ${{ steps.run_e2e_ios.outcome }} - ANDROID_OUTCOME: ${{ steps.run_e2e_android.outcome }} - run: | - outcome="$IOS_OUTCOME" - [ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME" - echo "## Native e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY" - if [ -f integration/.e2e/summary.md ]; then - cat integration/.e2e/summary.md >> "$GITHUB_STEP_SUMMARY" - fi - if [ "$outcome" = "failure" ]; then - echo "::error::Native e2e failed. See the e2e-${{ matrix.platform }} artifact." - fi - flaky=$(jq -r '[.run.results[] | select(.status == "flaky") | .file] | join(", ")' integration/.e2e/report.json 2>/dev/null || true) - if [ -n "$flaky" ]; then - echo "::error::Flaky flow(s), failed then passed on the retry: $flaky" - exit 1 - fi diff --git a/.github/workflows/verify-e2e.yml b/.github/workflows/verify-e2e.yml new file mode 100644 index 00000000000..bf723e0e10d --- /dev/null +++ b/.github/workflows/verify-e2e.yml @@ -0,0 +1,290 @@ +name: Verify end-to-end tests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - main + paths: + - 'integration/expo-native/**' + - '!integration/expo-native/**/*.md' + - '.github/workflows/verify-e2e.yml' + - 'integration/templates/expo-native/**' + - 'packages/expo/**' + - 'packages/expo-biometrics/**' + - 'packages/expo-google-signin/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verify-e2e-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + plan: + if: ${{ github.head_ref != 'changeset-release/main' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.plan.outputs.run }} + steps: + - name: Decide whether the end-to-end tests can run + id: plan + env: + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + HAS_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY != '' }} + DRAFT: ${{ github.event.pull_request.draft == true }} + run: | + set -euo pipefail + + skip_reason="" + if [ "$DRAFT" = "true" ]; then + skip_reason="The end-to-end tests did not run. This pull request is a draft. They run when it is marked ready for review." + elif [ "$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" ]; then + skip_reason="The end-to-end tests did not run. They need a repository secret, and this pull request comes from a fork ($HEAD_REPOSITORY)." + elif [ "$HAS_PLATFORM_API_KEY" != "true" ]; then + skip_reason="The end-to-end tests did not run. The MOBILE_VERIFICATION_PLATFORM_API_KEY secret is not set for this repository." + fi + + if [ -n "$skip_reason" ]; then + echo "::notice title=End-to-end tests skipped::$skip_reason" + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + e2e-tests: + needs: plan + if: needs.plan.outputs.run == 'true' + runs-on: ${{ matrix.runner }} + timeout-minutes: 75 + permissions: + contents: read + actions: read + pull-requests: write + strategy: + fail-fast: false + matrix: + include: + - platform: ios + runner: ${{ vars.VERIFY_CI_RUNNER_IOS || 'macos-26' }} + - platform: android + runner: ${{ vars.VERIFY_CI_RUNNER_ANDROID || 'ubuntu-24.04' }} + env: + PACKAGE: integration/expo-native + CLI: integration/expo-native/bin/control-clerk-expo + PLATFORM: ${{ matrix.platform }} + VERIFY_LOCAL_BUILD: standalone + VERIFY_NATIVE_CACHE: integration/expo-native/.verify/native + VERIFY_THROWAWAY_HOURS: '2' + steps: + - name: Checkout repo + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.15.0 + cache: ${{ matrix.platform == 'android' && 'pnpm' || '' }} + + - name: Set up JDK 21 + if: matrix.platform == 'android' + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: 21 + + - name: Enable KVM + if: matrix.platform == 'android' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Prepare the device image + id: device-image + background: true + timeout-minutes: 15 + env: + IOS_RUNTIME: com.apple.CoreSimulator.SimRuntime.iOS-26-5 + run: | + set -euo pipefail + if [ "$PLATFORM" = "ios" ]; then + if ! xcrun simctl list runtimes available -j | jq -e --arg id "$IOS_RUNTIME" '.runtimes[] | select(.identifier == $id)' > /dev/null; then + echo "::error::This runner image has no $IOS_RUNTIME. It has: $(xcrun simctl list runtimes available -j | jq -r '[.runtimes[].identifier] | join(", ")')" + exit 1 + fi + udid="$(xcrun simctl create "Clerk Verify Template iOS" "iPhone 17 Pro" "$IOS_RUNTIME")" + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + CLERK_TEST_DEVICES="$udid" integration/expo-native/bin/boot-ios-simulators.sh wait + xcrun simctl shutdown "$udid" + else + IMAGE="system-images;android-36;google_apis;x86_64" + for attempt in 1 2 3; do + if "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "$IMAGE" emulator platform-tools < /dev/null > "$RUNNER_TEMP/sdkmanager.log" 2>&1; then + break + fi + echo "::warning::sdkmanager could not install $IMAGE on attempt $attempt" + grep -v '^\[' "$RUNNER_TEMP/sdkmanager.log" | tail -20 || true + if [ "$attempt" = "3" ]; then + exit 1 + fi + done + echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" + fi + + - name: Install monorepo dependencies + run: pnpm install --frozen-lockfile + + - name: Install the test package + id: install + run: npm ci --prefix "$PACKAGE" --no-audit --no-fund + + - name: Name the native build that this commit needs + id: native + run: node "$PACKAGE/src/fixture.ts" native "$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Fetch a native build that an earlier run made + id: kept + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.head_ref || github.ref_name }} + NATIVE_ID: ${{ steps.native.outputs.id }} + NATIVE_DIR: ${{ steps.native.outputs.dir }} + NATIVE_APP: ${{ steps.native.outputs.app }} + run: | + set -euo pipefail + + fetch() { + local name="verify-expo-native-$NATIVE_ID" artifact unpacked + artifact="$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" --jq "[.artifacts[] | select(.expired == false and .workflow_run.head_repository_id == .workflow_run.repository_id and .workflow_run.head_branch == \$ENV.BRANCH)] | sort_by(.created_at) | last | .id // empty")" || return 1 + if [ -z "$artifact" ]; then + return 1 + fi + unpacked="$(mktemp -d "$RUNNER_TEMP/native-build.XXXXXX")" || return 1 + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact/zip" > "$unpacked.zip" || return 1 + unzip -q "$unpacked.zip" -d "$unpacked" || return 1 + mkdir -p "$NATIVE_DIR" || return 1 + if [ "$PLATFORM" = "ios" ]; then + mv "$unpacked" "$NATIVE_DIR/$NATIVE_APP" || return 1 + else + mv "$unpacked/$NATIVE_APP" "$NATIVE_DIR/$NATIVE_APP" || return 1 + fi + echo "Took the native build from artifact $artifact ($name)." + } + + if fetch; then + echo "found=kept" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "No native build of this branch is kept for $NATIVE_ID, so this job builds it." + echo "found=none" >> "$GITHUB_OUTPUT" + + - name: Wait for the device image + wait: device-image + + - name: Build the fixture and boot a device + timeout-minutes: 30 + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + run: | + "$CLI" up --platform "$PLATFORM" --backend local + + - name: Keep the native build for later runs + if: ${{ !cancelled() && steps.kept.outputs.found == 'none' }} + continue-on-error: true + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verify-expo-native-${{ steps.native.outputs.id }} + path: ${{ steps.native.outputs.dir }}/${{ steps.native.outputs.app }} + include-hidden-files: true + retention-days: 7 + if-no-files-found: warn + + - name: Cache the XCTest runner + if: matrix.platform == 'ios' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.agent-device/apple-runner + key: verify-agent-device-${{ runner.os }}-${{ hashFiles('integration/expo-native/package-lock.json') }} + + - name: Compile the XCTest runner before the first spec launches + if: matrix.platform == 'ios' + timeout-minutes: 20 + run: | + set -euo pipefail + udid="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).deviceId' "$PACKAGE/.verify/leases/ios.json")" + AGENT_DEVICE_STATE_DIR="$GITHUB_WORKSPACE/$PACKAGE/.verify/agent-device" \ + "$PACKAGE/node_modules/.bin/agent-device" prepare ios-runner --platform ios --udid "$udid" --timeout 900000 \ + || echo "::warning::prepare ios-runner failed; the run starts its own agent-device daemon, and the first test waits for the XCTest runner" + + - name: Launch the app once before the first spec + if: matrix.platform == 'ios' + timeout-minutes: 5 + run: | + "$CLI" screen --platform ios > /dev/null || echo "::warning::the first launch failed here; the first spec pays for the cold start" + + - name: Run the golden specs + timeout-minutes: 30 + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + GITHUB_TOKEN: ${{ github.token }} + run: | + "$CLI" run --all --platform "$PLATFORM" --backend local --retries 1 --github-report + + - name: Release the device and delete the Clerk application + if: always() && steps.install.outcome == 'success' + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }} + run: | + "$CLI" down + + - name: Check that every run is sealed + id: evidence + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + node -e ' + const fs = require("fs"); + const path = require("path"); + const runs = path.join(process.env.PACKAGE, ".verify", "runs"); + const ids = fs.existsSync(runs) ? fs.readdirSync(runs) : []; + let upload = ids.length > 0; + for (const id of ids) { + let record = null; + try { record = JSON.parse(fs.readFileSync(path.join(runs, id, "run.json"), "utf8")); } catch {} + if (record === null || record.sealed !== true || record.tainted.length > 0) { + upload = false; + console.log("::warning::Run " + id + " was not sealed, or a file in it holds a secret value. No evidence is uploaded."); + } + } + fs.appendFileSync(process.env.GITHUB_OUTPUT, "upload=" + upload + "\n"); + ' + + - name: Upload the evidence + if: ${{ !cancelled() && steps.evidence.outputs.upload == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: verify-e2e-${{ matrix.platform }} + overwrite: true + include-hidden-files: true + path: | + ${{ env.PACKAGE }}/.verify/runs/*/run.json + ${{ env.PACKAGE }}/.verify/runs/*/app.log + ${{ env.PACKAGE }}/.verify/runs/*/driver + ${{ env.PACKAGE }}/.verify/runs/*/video.mp4 + ${{ env.PACKAGE }}/.verify/runs/*/screenshots + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/report.json + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/junit.xml + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/summary.md + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/failures + ${{ env.PACKAGE }}/.verify/runs/*/e2e*/artifacts + retention-days: 3 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index f0aef8bbd7a..24d1b33d3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ packages/*/examples/*/package-lock.json packages/*/examples/*/yarn.lock /test-results/ /playwright-report/ -/integration/.e2e/ /playwright/.cache/ # integration testing diff --git a/integration/e2e.expo-native.config.ts b/integration/e2e.expo-native.config.ts deleted file mode 100644 index 88c47c58e57..00000000000 --- a/integration/e2e.expo-native.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { mobile } from '@e2e-dev/mobile'; -import type { E2EConfig } from 'e2e'; - -const app = { bundleId: 'com.clerk.exponativebuildfixture' }; -const devices = process.env.CLERK_TEST_DEVICES?.split(','); - -export default { - tests: 'tests/expo-native/*.e2e.ts', - targets: [ - { name: 'ios', engine: mobile({ platform: 'ios', device: devices }), app }, - { name: 'android', engine: mobile({ platform: 'android', device: devices }), app }, - ], - workers: devices?.length ?? 1, - timeout: 300_000, -} satisfies E2EConfig; diff --git a/integration/expo-native/README.md b/integration/expo-native/README.md index f8dd66d3aca..bf701f93695 100644 --- a/integration/expo-native/README.md +++ b/integration/expo-native/README.md @@ -13,17 +13,19 @@ You need Node 24.8 or newer on 24, Xcode and a booted iOS simulator, or the Andr ```sh cd integration/expo-native npm ci +node src/fixture.ts ios export CLERK_E2E_PLATFORM=ios xcrun simctl list devices booted export CLERK_E2E_DEVICE= -export CLERK_E2E_APP_PATH= -export CLERK_E2E_DEV_SERVER=http://localhost:8081 +export CLERK_E2E_APP_PATH= export CLERK_PUBLISHABLE_KEY=pk_test_... export CLERK_SECRET_KEY=sk_test_... npx e2e run specs/golden/custom-flow-sign-in/complete.e2e.ts ``` -The test app is built from `integration/templates/expo-native`. `integration/expo-native/bin/control-clerk-expo up --platform ios` builds it as a dev client, leaves it under `.verify/builds/`, and starts the Metro server that the dev client loads its bundle from. The `metro` line that `up` prints has the port. `CLERK_E2E_APP_PATH` is the `.app` or `.apk` in that directory, and `CLERK_E2E_DEV_SERVER` is `http://localhost:`. After `up` ends, no CLI process is running, and Metro stays up until `down`. +`node src/fixture.ts ios` runs `pnpm install`, builds the packages the test app uses, and builds the test app in `integration/templates/expo-native` as a standalone app. A standalone app carries its JavaScript, so the run needs no Metro server. `node src/fixture.ts android` builds the APK for an emulator. + +To run against a dev client instead, `integration/expo-native/bin/control-clerk-expo up --platform ios` builds one, leaves it under `.verify/builds/`, and starts the Metro server it loads its bundle from. The `metro` line that `up` prints has the port. Set `CLERK_E2E_APP_PATH` to the `.app` or `.apk` in that directory and `CLERK_E2E_DEV_SERVER` to `http://localhost:`. After `up` ends, no CLI process is running, and Metro stays up until `down`. `--video on`, `--retries`, `--grep`, and `--output` are e2e's own flags. e2e writes its report and its screenshots to `.e2e/`, which git ignores. diff --git a/integration/expo-native/bin/boot-ios-simulators.sh b/integration/expo-native/bin/boot-ios-simulators.sh new file mode 100755 index 00000000000..3a3aad13b0b --- /dev/null +++ b/integration/expo-native/bin/boot-ios-simulators.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Usage: ./boot-ios-simulators.sh wait +set -euo pipefail + +wait_ready() { + local udid key + IFS=, read -r -a udids <<< "${CLERK_TEST_DEVICES:?CLERK_TEST_DEVICES is required}" + for udid in "${udids[@]}"; do + xcrun simctl spawn "$udid" defaults write com.apple.UIKit UIAnimationDragCoefficient -float 0.01 || true + xcrun simctl spawn "$udid" defaults write -g ApplePersistenceIgnoreState -bool YES || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.ContinuousPath -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.AutoCapitalization -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.AutoCorrection -bool NO || true + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.Prediction -bool NO || true + # the keyboard tutorial sheets have their own Continue button that steals taps + for key in DidShowContinuousPathIntroduction DidShowGestureKeyboardIntroduction KeyboardDidShowProductivityTutorial UIKeyboardDidShowInternationalInfoIntroduction; do + xcrun simctl spawn "$udid" defaults write com.apple.keyboard.preferences "$key" -bool YES || true + done + done +} + +case "${1:-}" in + wait) wait_ready ;; + *) echo "usage: $0 wait" >&2; exit 2 ;; +esac diff --git a/integration/expo-native/src/fixture.ts b/integration/expo-native/src/fixture.ts index 91bcf309112..a280005cb33 100644 --- a/integration/expo-native/src/fixture.ts +++ b/integration/expo-native/src/fixture.ts @@ -1,15 +1,27 @@ import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { run } from './core/exec.ts'; import { VerifyFailure, type Platform } from './core/types.ts'; +import { + embedJavaScript, + expoFingerprint, + installedNativeInputs, + keepNativeBuild, + keptNativeBuild, + nativeFingerprint, + type ExpoFingerprint, + type KeptNativeBuild, +} from './native-build.ts'; import { resolveJavaHome, sdkRoot } from './platform/android/sdk.ts'; export const IOS_PRODUCT = 'ClerkExpoNativeBuildFixture'; export const WORKTREE = fileURLToPath(new URL('../../../', import.meta.url)); export const FIXTURE = join(WORKTREE, 'integration', 'templates', 'expo-native'); -export type BuildProduct = 'dev-client'; +export type BuildProduct = 'dev-client' | 'standalone'; interface Recipe { readonly configuration: 'Debug' | 'Release'; @@ -29,6 +41,13 @@ const RECIPES: Readonly> = { expoPackages: [...EXPO_PACKAGES, 'expo-dev-client'], label: 'dev client', }, + standalone: { + configuration: 'Release', + gradle: [':app:createBundleReleaseJsAndAssets', '--rerun', 'assembleRelease'], + apk: join('release', 'app-release.apk'), + expoPackages: EXPO_PACKAGES, + label: 'JS embedded', + }, }; const SHARED_NATIVE_INPUTS = [ @@ -54,10 +73,30 @@ const PLATFORM_NATIVE_INPUTS: Readonly> = { android: ['packages/expo/android', 'packages/expo-google-signin/android', 'packages/expo-biometrics/android'], }; +const BUNDLE_INPUTS = [ + '.npmrc', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'turbo.json', + 'packages/clerk-js', + 'packages/expo', + 'packages/expo-biometrics', + 'packages/expo-google-signin', + 'packages/react', + 'packages/shared', + 'integration/templates/expo-native', +] as const; + export function nativeInputs(platform: Platform): readonly string[] { return [...PLATFORM_NATIVE_INPUTS[platform], ...SHARED_NATIVE_INPUTS]; } +export function buildInputs(platform: Platform, product: BuildProduct): readonly string[] { + return product === 'standalone' ? [...nativeInputs(platform), ...BUNDLE_INPUTS] : nativeInputs(platform); +} + export function artifact(platform: Platform, product: BuildProduct, fixture: string = FIXTURE): string { const recipe = RECIPES[product]; return platform === 'ios' @@ -121,6 +160,7 @@ export interface FixtureBuild { readonly product: BuildProduct; readonly nativeKey: string; readonly buildPackages: boolean; + readonly nativeCache?: string | null; readonly progress: (line: string) => void; } @@ -128,10 +168,23 @@ export interface FixtureSite { readonly worktree: string; readonly fixture: string; readonly must: typeof mustStep; + readonly toolchain?: (platform: Platform) => Promise; + readonly expoFingerprint?: ExpoFingerprint; +} + +async function installedToolchain(platform: Platform): Promise { + if (platform === 'android') return 'gradle'; + const xcode = await run('xcodebuild', ['-version']); + return xcode.code === 0 ? xcode.stdout.trim() : 'no xcodebuild'; } const THIS_CHECKOUT: FixtureSite = { worktree: WORKTREE, fixture: FIXTURE, must: mustStep }; +export function nativeCacheDir(env: NodeJS.ProcessEnv = process.env, worktree: string = WORKTREE): string | null { + const asked = env.VERIFY_NATIVE_CACHE; + return asked === undefined || asked === '' ? null : resolve(worktree, asked); +} + const nativeProjectMarker = (site: FixtureSite, platform: Platform) => join(site.fixture, platform, '.verify-native-project'); @@ -142,9 +195,17 @@ export function nativeProjectIsCurrent(site: FixtureSite, platform: Platform, wa ); } -async function generateNativeProject(site: FixtureSite, build: FixtureBuild, wanted: string): Promise { - const { platform, progress } = build; - rmSync(nativeProjectMarker(site, platform), { force: true }); +const dependenciesMarker = (site: FixtureSite) => join(site.fixture, 'node_modules', '.verify-dependencies'); + +function dependenciesOf(site: FixtureSite, product: BuildProduct): string { + const template = createHash('sha256') + .update(readFileSync(join(site.fixture, 'package.sdk-57.json'))) + .digest('hex'); + return `${product} ${template}`; +} + +async function installDependencies(site: FixtureSite, product: BuildProduct): Promise { + rmSync(dependenciesMarker(site), { force: true }); cpSync(join(site.fixture, 'package.sdk-57.json'), join(site.fixture, 'package.json')); await site.must( 'pnpm add the workspace packages', @@ -157,7 +218,50 @@ async function generateNativeProject(site: FixtureSite, build: FixtureBuild, wan ], site.fixture, ); - await site.must('expo install', 'pnpm', ['expo', 'install', ...RECIPES[build.product].expoPackages], site.fixture); + await site.must('expo install', 'pnpm', ['expo', 'install', ...RECIPES[product].expoPackages], site.fixture); + writeFileSync(dependenciesMarker(site), dependenciesOf(site, product)); +} + +async function ensureDependencies(site: FixtureSite, product: BuildProduct): Promise { + const marker = dependenciesMarker(site); + if (existsSync(marker) && readFileSync(marker, 'utf8') === dependenciesOf(site, product)) return; + await installDependencies(site, product); +} + +export async function locateNativeBuild( + cache: string, + platform: Platform, + site: FixtureSite = THIS_CHECKOUT, + note: (line: string) => void = () => undefined, +): Promise { + await ensureDependencies(site, 'standalone'); + const { installed, wholeLockfile } = await installedNativeInputs( + site.fixture, + platform, + site.expoFingerprint ?? expoFingerprint, + ); + if (wholeLockfile !== null) + note(`build native fingerprint covers the whole pnpm-lock.yaml, because ${wholeLockfile}`); + const fingerprint = await nativeFingerprint({ + platform, + worktree: site.worktree, + inputs: nativeInputs(platform), + files: [fileURLToPath(import.meta.url), fileURLToPath(new URL('./native-build.ts', import.meta.url))], + toolchain: await (site.toolchain ?? installedToolchain)(platform), + installed, + }); + return keptNativeBuild(cache, platform, fingerprint, basename(artifact(platform, 'standalone', site.fixture))); +} + +async function generateNativeProject( + site: FixtureSite, + build: FixtureBuild, + wanted: string, + dependencies: 'install' | 'installed', +): Promise { + const { platform, progress } = build; + rmSync(nativeProjectMarker(site, platform), { force: true }); + if (dependencies === 'install') await installDependencies(site, build.product); progress(`build expo prebuild --clean --platform ${platform}`); await site.must('expo prebuild', 'pnpm', ['expo', 'prebuild', '--clean', '--platform', platform], site.fixture); writeFileSync(nativeProjectMarker(site, platform), wanted); @@ -184,11 +288,33 @@ export async function buildFixture(build: FixtureBuild, site: FixtureSite = THIS site.worktree, ); } + const cache = product === 'standalone' ? (build.nativeCache ?? null) : null; + const kept = cache === null ? null : await locateNativeBuild(cache, platform, site, progress); + if (kept !== null && existsSync(kept.app)) { + const into = artifact(platform, product, site.fixture); + progress(`build native ${kept.id} an app built from these native inputs is kept, so no native build runs`); + const java = platform === 'android' ? resolveJavaHome() : null; + if (java !== null && !java.ok) throw new VerifyFailure('NOT_READY', java.detail, java.fix); + const embedded = await embedJavaScript({ + platform, + fixture: site.fixture, + from: kept.app, + into, + iosExecutable: IOS_PRODUCT, + android: java === null ? null : { sdk: sdkRoot(), javaHome: java.home }, + must: site.must, + }); + if (embedded.ok) { + progress(`build bundle ${embedded.bundle} exported from this checkout and embedded in that app`); + return into; + } + progress(`build native ${kept.id} not reused: ${embedded.why}`); + } const wanted = `${product} ${build.nativeKey}`; if (nativeProjectIsCurrent(site, platform, wanted)) { progress(`build the ${platform} project was generated from these native inputs, so expo prebuild is skipped`); } else { - await generateNativeProject(site, build, wanted); + await generateNativeProject(site, build, wanted, kept === null ? 'install' : 'installed'); } if (platform === 'ios') { progress(`build xcodebuild ${recipe.configuration} (${recipe.label})`); @@ -227,5 +353,33 @@ export async function buildFixture(build: FixtureBuild, site: FixtureSite = THIS }, ); } - return artifact(platform, product, site.fixture); + const built = artifact(platform, product, site.fixture); + if (kept !== null) { + keepNativeBuild(kept, built); + progress(`build native ${kept.id} kept in ${relative(site.worktree, kept.dir)} for later builds`); + } + return built; +} + +async function printKeptNativeBuild(platform: Platform): Promise { + const cache = nativeCacheDir(); + if (cache === null) throw new VerifyFailure('USAGE', 'VERIFY_NATIVE_CACHE is not set', 'set it to a directory'); + const kept = await locateNativeBuild(cache, platform, THIS_CHECKOUT, line => console.error(line)); + console.log(`id=${kept.id}`); + console.log(`dir=${relative(WORKTREE, kept.dir)}`); + console.log(`app=${basename(kept.app)}`); +} + +if (import.meta.main) { + const [verb, platform] = process.argv.slice(2); + if (verb !== 'native' || (platform !== 'ios' && platform !== 'android')) { + console.error('usage: fixture.ts native ios|android'); + process.exit(2); + } + try { + await printKeptNativeBuild(platform); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } } diff --git a/integration/expo-native/src/host.ts b/integration/expo-native/src/host.ts index 4a8bb643425..270bf4f2bac 100644 --- a/integration/expo-native/src/host.ts +++ b/integration/expo-native/src/host.ts @@ -11,19 +11,29 @@ import { writeFileSync, } from 'node:fs'; import { createRequire } from 'node:module'; -import { join, relative } from 'node:path'; +import { basename, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isRunning, run, sleep } from './core/exec.ts'; import { LOCAL_POOL, VerifyFailure, + type BackendKind, type HostAdapter, type Platform, type RuntimeProcess, type ScratchPath, } from './core/types.ts'; import { takeSlotLock } from './core/workspace.ts'; -import { FIXTURE, IOS_PRODUCT, WORKTREE, buildFixture, mustStep, nativeInputs } from './fixture.ts'; +import { + FIXTURE, + IOS_PRODUCT, + WORKTREE, + buildFixture, + buildInputs, + mustStep, + nativeCacheDir, + type BuildProduct, +} from './fixture.ts'; import { APP_ID, app } from '../specs/app.ts'; import { confirmServed, @@ -443,9 +453,20 @@ async function ensureServed( return current; } -function keepBuild(platform: Platform, built: string, into: string): string { +export function productFor(backend: BackendKind, env: NodeJS.ProcessEnv = process.env): BuildProduct { + const asked = env.VERIFY_LOCAL_BUILD; + if (asked === undefined || asked === '' || asked === 'dev-client') return 'dev-client'; + if (asked === 'standalone') return 'standalone'; + throw new VerifyFailure( + 'USAGE', + `VERIFY_LOCAL_BUILD=${asked} is not dev-client or standalone`, + 'unset VERIFY_LOCAL_BUILD or set it to standalone', + ); +} + +function keepBuild(built: string, into: string): string { mkdirSync(into, { recursive: true }); - const path = join(into, platform === 'ios' ? `${IOS_PRODUCT}.app` : 'app-debug.apk'); + const path = join(into, basename(built)); rmSync(path, { recursive: true, force: true }); cpSync(built, path, { recursive: true, verbatimSymlinks: true }); return path; @@ -457,31 +478,28 @@ export const host: HostAdapter = { platforms: ['ios', 'android'], githubRepo: GITHUB_REPO, appId: app.id, - buildInputs: platform => nativeInputs(platform), + buildInputs: (platform, backend) => buildInputs(platform, productFor(backend)), async build(platform, key, into, progress) { - if (process.platform !== 'darwin') - throw new VerifyFailure( - 'UNSUPPORTED', - `the expo-native fixture is built locally on macOS only, and this machine runs ${process.platform}`, - 'run this command on a Mac', - ); + const product = productFor('local'); const path = await withFixtureLock(progress, async () => { - const watching = readRuntime('watch') !== null; + const watching = product === 'dev-client' && readRuntime('watch') !== null; if (watching) progress('build the running watch build keeps packages/expo/dist current, so turbo build is skipped'); else stopRuntime(); const built = await buildFixture({ platform, - product: 'dev-client', + product, nativeKey: key, buildPackages: !watching, + nativeCache: nativeCacheDir(), progress, }); - return keepBuild(platform, built, into); + return keepBuild(built, into); }); return { platform, key, appId: APP_ID, path: path as ScratchPath, source: 'local' }; }, async runtime(lease, progress) { + if (productFor(lease.backend) === 'standalone') return { devServer: null, processes: [] }; const port = metroPort(lease); return withCleanup( stopRuntime, diff --git a/integration/expo-native/src/native-build.ts b/integration/expo-native/src/native-build.ts new file mode 100644 index 00000000000..57707874f5e --- /dev/null +++ b/integration/expo-native/src/native-build.ts @@ -0,0 +1,429 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + closeSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + readSync, + renameSync, + rmSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { basename, delimiter, dirname, join } from 'node:path'; +import { run } from './core/exec.ts'; +import { VerifyFailure, type Platform } from './core/types.ts'; + +export type Must = ( + what: string, + command: string, + args: readonly string[], + cwd: string, + env?: Readonly>, +) => Promise; + +export interface FingerprintInputs { + readonly platform: Platform; + readonly worktree: string; + readonly inputs: readonly string[]; + readonly files: readonly string[]; + readonly toolchain: string; + readonly installed: string; +} + +export async function nativeFingerprint(spec: FingerprintInputs): Promise { + const listed = await run( + 'git', + ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...spec.inputs], + { + cwd: spec.worktree, + }, + ); + if (listed.code !== 0) + throw new VerifyFailure( + 'NOT_READY', + `git ls-files failed: ${listed.stderr.trim()}`, + 'run {cli} from inside a git worktree', + ); + const hash = createHash('sha256'); + hash.update(spec.platform).update('\0').update(spec.toolchain).update('\0').update(spec.installed).update('\0'); + const tracked = [...new Set(listed.stdout.split('\0').filter(file => file.length > 0))].sort(); + for (const file of tracked) { + const path = join(spec.worktree, file); + hash.update(file).update('\0'); + hash.update(existsSync(path) ? readFileSync(path) : 'deleted').update('\0'); + } + for (const file of spec.files) { + hash.update(basename(file)).update('\0'); + hash.update(existsSync(file) ? readFileSync(file) : 'missing').update('\0'); + } + return hash.digest('hex').slice(0, 16); +} + +export type ExpoFingerprint = (fixture: string, platform: Platform) => Promise; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +export const expoFingerprint: ExpoFingerprint = async (fixture, platform) => { + const fromFixture = createRequire(join(fixture, 'package.json')); + const fromExpo = createRequire(fromFixture.resolve('expo/package.json')); + const tool: unknown = fromExpo('@expo/fingerprint'); + if (!isRecord(tool) || typeof tool.createFingerprintAsync !== 'function') + throw new Error('it has no createFingerprintAsync'); + return tool.createFingerprintAsync(fixture, { + platforms: [platform], + ignorePaths: ['ios/**/*', 'android/**/*'], + silent: true, + }); +}; + +type NativeSources = { readonly ok: true; readonly sources: string } | { readonly ok: false; readonly why: string }; + +const PNPM_PEERS = /(node_modules\/\.pnpm\/(?:@[^/+@]+\+)?[^/@]+@[^/_]+)_[^/]*(?=\/)/g; + +function nativeSources(result: unknown, platform: Platform): NativeSources { + if (!isRecord(result) || !Array.isArray(result.sources)) return { ok: false, why: 'it returned no sources' }; + const lines: string[] = []; + const named = new Set(); + for (const source of result.sources) { + if (!isRecord(source)) return { ok: false, why: 'it returned a source that is not an object' }; + const { type, id, filePath, hash, contents } = source; + if (type === 'contents' && typeof id === 'string' && (typeof contents === 'string' || Buffer.isBuffer(contents))) { + named.add(id); + lines.push(`contents ${id} ${contents.toString()}`); + } else if ( + (type === 'file' || type === 'dir') && + typeof filePath === 'string' && + (typeof hash === 'string' || hash === null) + ) { + if (hash !== null) lines.push(`${type} ${filePath} ${hash}`); + } else { + return { ok: false, why: `it returned a ${String(type)} source this CLI cannot read` }; + } + } + for (const id of ['expoConfig', `expoAutolinkingConfig:${platform}`]) + if (!named.has(id)) return { ok: false, why: `it left out ${id}` }; + return { + ok: true, + sources: lines + .map(line => line.replace(PNPM_PEERS, '$1')) + .sort() + .join('\n'), + }; +} + +const LOCKED_PACKAGE = /^ {2}'?((?:@[^/@\s]+\/)?[^@\s'/]+)@([^:'(\s]+)/gm; +const BUILDS_THE_NATIVE_PROJECT = /^(?:@expo\/|@react-native[^/]*\/|expo(?:-|$)|react-native(?:-|$)|hermes-)/; + +function nativeToolVersions(lockfile: string): readonly string[] { + const versions = new Set(); + for (const [, name = '', version = ''] of lockfile.matchAll(LOCKED_PACKAGE)) + if (BUILDS_THE_NATIVE_PROJECT.test(name)) versions.add(`${name}@${version}`); + return [...versions].sort(); +} + +export interface InstalledNativeInputs { + readonly installed: string; + readonly wholeLockfile: string | null; +} + +export async function installedNativeInputs( + fixture: string, + platform: Platform, + expo: ExpoFingerprint, +): Promise { + const file = join(fixture, 'pnpm-lock.yaml'); + if (!existsSync(file)) + throw new VerifyFailure( + 'NOT_READY', + `${file} is missing, so nothing says which packages the fixture installed`, + 'delete integration/templates/expo-native/node_modules, then rerun {cli} up', + ); + const lockfile = readFileSync(file, 'utf8'); + const tools = nativeToolVersions(lockfile); + const read: NativeSources = + tools.length === 0 + ? { ok: false, why: 'pnpm-lock.yaml names no Expo or React Native package' } + : await expo(fixture, platform).then( + result => { + const sources = nativeSources(result, platform); + return sources.ok ? sources : { ok: false, why: `@expo/fingerprint cannot be relied on: ${sources.why}` }; + }, + (error: unknown) => ({ + ok: false, + why: `@expo/fingerprint did not run: ${error instanceof Error ? error.message : String(error)}`, + }), + ); + return read.ok + ? { installed: ['native packages', ...tools, read.sources].join('\n'), wholeLockfile: null } + : { installed: `whole lockfile\n${lockfile}`, wholeLockfile: read.why }; +} + +export interface KeptNativeBuild { + readonly id: string; + readonly dir: string; + readonly app: string; +} + +export function keptNativeBuild( + cache: string, + platform: Platform, + fingerprint: string, + appName: string, +): KeptNativeBuild { + const id = `${platform}-${fingerprint}`; + return { id, dir: join(cache, id), app: join(cache, id, appName) }; +} + +export function keepNativeBuild(kept: KeptNativeBuild, built: string): void { + const incoming = `${kept.dir}.incoming`; + rmSync(incoming, { recursive: true, force: true }); + mkdirSync(incoming, { recursive: true }); + cpSync(built, join(incoming, basename(kept.app)), { recursive: true, verbatimSymlinks: true }); + rmSync(kept.dir, { recursive: true, force: true }); + renameSync(incoming, kept.dir); +} + +const HERMES_MAGIC = 'c61fbc03c103191f'; + +function hermesVersion(bundle: Buffer): number | null { + if (bundle.length < 12 || bundle.subarray(0, 8).toString('hex') !== HERMES_MAGIC) return null; + return bundle.readUInt32LE(8); +} + +function head(file: string, bytes: number): Buffer { + const buffer = Buffer.alloc(bytes); + const fd = openSync(file, 'r'); + try { + return buffer.subarray(0, readSync(fd, buffer, 0, bytes, 0)); + } finally { + closeSync(fd); + } +} + +const ANDROID_BUNDLE = 'assets/index.android.bundle'; +const IOS_BUNDLE = 'main.jsbundle'; + +function apkEntry(apk: string, entry: string): Buffer | null { + try { + return execFileSync('unzip', ['-p', apk, entry], { + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } +} + +function embeddedBundle(platform: Platform, app: string): Buffer | null { + if (platform === 'android') return apkEntry(app, ANDROID_BUNDLE); + const file = join(app, IOS_BUNDLE); + return existsSync(file) ? readFileSync(file) : null; +} + +function filesUnder(dir: string): readonly string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => join(entry.parentPath, entry.name)); +} + +function hermesc(fixture: string): string { + const fromFixture = createRequire(join(fixture, 'package.json')); + const fromReactNative = createRequire(fromFixture.resolve('react-native/package.json')); + const os = process.platform === 'darwin' ? 'osx-bin' : process.platform === 'win32' ? 'win64-bin' : 'linux64-bin'; + return join(dirname(fromReactNative.resolve('hermes-compiler/package.json')), 'hermesc', os, 'hermesc'); +} + +const PAGE_ALIGNING_BUILD_TOOLS = 35; + +function pageAligningBuildTools(sdk: string): string | null { + const root = join(sdk, 'build-tools'); + const versions = existsSync(root) + ? readdirSync(root).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) + : []; + const found = versions.find( + version => Number.parseInt(version, 10) >= PAGE_ALIGNING_BUILD_TOOLS && existsSync(join(root, version, 'zipalign')), + ); + return found === undefined ? null : join(root, found); +} + +interface ApkTools { + readonly buildTools: string; + readonly javaHome: string; +} + +export interface Embed { + readonly platform: Platform; + readonly fixture: string; + readonly from: string; + readonly into: string; + readonly iosExecutable: string; + readonly android: { readonly sdk: string; readonly javaHome: string } | null; + readonly must: Must; +} + +export type Embedded = { readonly ok: true; readonly bundle: string } | { readonly ok: false; readonly why: string }; + +async function signApk(embed: Embed, apk: ApkTools, work: string, bundle: string): Promise { + const tools = apk.buildTools; + const env = { + JAVA_HOME: apk.javaHome, + PATH: `${join(apk.javaHome, 'bin')}${delimiter}${process.env.PATH ?? ''}`, + }; + const stage = join(work, 'stage'); + mkdirSync(join(stage, 'assets'), { recursive: true }); + copyFileSync(bundle, join(stage, ANDROID_BUNDLE)); + const edited = join(work, 'edited.apk'); + const aligned = join(work, 'aligned.apk'); + copyFileSync(embed.from, edited); + await embed.must('zip -d', 'zip', ['-q', '-d', edited, ANDROID_BUNDLE], work); + await embed.must('zip', 'zip', ['-q', '-0', '-X', edited, ANDROID_BUNDLE], stage); + await embed.must('zipalign', join(tools, 'zipalign'), ['-f', '-P', '16', '4', edited, aligned], work); + let keystore = join(embed.fixture, 'android', 'app', 'debug.keystore'); + if (!existsSync(keystore)) { + keystore = join(work, 'debug.keystore'); + await embed.must( + 'keytool', + join(apk.javaHome, 'bin', 'keytool'), + [ + '-genkeypair', + '-keystore', + keystore, + '-storepass', + 'android', + '-keypass', + 'android', + '-alias', + 'androiddebugkey', + '-keyalg', + 'RSA', + '-keysize', + '2048', + '-validity', + '30', + '-dname', + 'CN=Android Debug,O=Android,C=US', + ], + work, + env, + ); + } + mkdirSync(dirname(embed.into), { recursive: true }); + rmSync(embed.into, { force: true }); + await embed.must( + 'apksigner', + join(tools, 'apksigner'), + [ + 'sign', + '--ks', + keystore, + '--ks-pass', + 'pass:android', + '--ks-key-alias', + 'androiddebugkey', + '--key-pass', + 'pass:android', + '--out', + embed.into, + aligned, + ], + work, + env, + ); +} + +function placeInApp(embed: Embed, bundle: string, assets: string): void { + rmSync(embed.into, { recursive: true, force: true }); + mkdirSync(dirname(embed.into), { recursive: true }); + cpSync(embed.from, embed.into, { recursive: true, verbatimSymlinks: true }); + copyFileSync(bundle, join(embed.into, IOS_BUNDLE)); + rmSync(join(embed.into, 'assets'), { recursive: true, force: true }); + if (existsSync(join(assets, 'assets'))) + cpSync(join(assets, 'assets'), join(embed.into, 'assets'), { recursive: true }); + const executables = [ + join(embed.into, embed.iosExecutable), + ...filesUnder(join(embed.into, 'Frameworks')).filter( + file => basename(dirname(file)) === `${basename(file)}.framework`, + ), + ]; + for (const file of executables) if (existsSync(file)) chmodSync(file, 0o755); +} + +export async function embedJavaScript(embed: Embed): Promise { + const before = embeddedBundle(embed.platform, embed.from); + const wanted = before === null ? null : hermesVersion(before); + if (wanted === null) return { ok: false, why: 'it holds no Hermes bundle to replace' }; + const buildTools = embed.android === null ? null : pageAligningBuildTools(embed.android.sdk); + const apk = embed.android === null || buildTools === null ? null : { buildTools, javaHome: embed.android.javaHome }; + if (embed.platform === 'android' && apk === null) + return { + ok: false, + why: `the Android SDK has no build-tools ${PAGE_ALIGNING_BUILD_TOOLS} or newer, which aligning the APK again needs`, + }; + const work = mkdtempSync(join(tmpdir(), 'verify-expo-embed-')); + try { + const manifest = JSON.parse(readFileSync(join(embed.fixture, 'package.json'), 'utf8')) as { main?: string }; + const source = join(work, 'bundle.js'); + const assets = join(work, 'assets'); + const compiled = join(work, 'bundle.hbc'); + mkdirSync(assets); + await embed.must( + 'expo export:embed', + 'pnpm', + [ + 'expo', + 'export:embed', + '--platform', + embed.platform, + '--dev', + 'false', + '--reset-cache', + '--entry-file', + join(embed.fixture, manifest.main ?? 'index.js'), + '--bundle-output', + source, + '--assets-dest', + assets, + '--minify', + 'false', + ], + embed.fixture, + ); + await embed.must( + 'hermesc', + hermesc(embed.fixture), + ['-w', '-emit-binary', '-max-diagnostic-width=80', '-O', '-out', compiled, source], + work, + ); + const made = hermesVersion(head(compiled, 12)); + if (made !== wanted) + return { ok: false, why: `it runs Hermes bytecode ${wanted}, and this checkout compiles ${made ?? 'none'}` }; + if (apk !== null && embed.platform === 'android') { + if (filesUnder(assets).length > 0) + return { ok: false, why: 'this checkout bundles image assets, which an APK takes only from a resource build' }; + await signApk(embed, apk, work, compiled); + } else { + placeInApp(embed, compiled, assets); + } + const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); + const after = embeddedBundle(embed.platform, embed.into); + const expected = sha(readFileSync(compiled)); + if (after === null || sha(after) !== expected) + throw new VerifyFailure( + 'BUILD_FAILED', + `${embed.into} does not hold the bundle that was just made from this checkout`, + 'unset VERIFY_NATIVE_CACHE, then rerun {cli} up', + ); + return { ok: true, bundle: expected.slice(0, 12) }; + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/integration/expo-native/test/native-build.test.ts b/integration/expo-native/test/native-build.test.ts new file mode 100644 index 00000000000..eed14a7f590 --- /dev/null +++ b/integration/expo-native/test/native-build.test.ts @@ -0,0 +1,337 @@ +import '../testing/git-env.ts'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { artifact, buildFixture, locateNativeBuild, nativeCacheDir, type FixtureSite } from '../src/fixture.ts'; +import { VerifyFailure } from '../src/core/types.ts'; +import { nativeFingerprint } from '../src/native-build.ts'; + +const hermes = (version: number, body: string): Buffer => { + const header = Buffer.alloc(12); + Buffer.from('c61fbc03c103191f', 'hex').copy(header); + header.writeUInt32LE(version, 8); + return Buffer.concat([header, Buffer.from(body)]); +}; + +const lockfile = (versions: Readonly>): string => + [ + "lockfileVersion: '9.0'", + 'packages:', + ...Object.entries(versions).map(([name, version]) => ` ${name.startsWith('@') ? `'${name}@${version}'` : `${name}@${version}`}:`), + 'snapshots:', + ...Object.entries(versions).map(([name, version]) => ` '${name}@${version}(@babel/core@7.29.7)': {}`), + ].join('\n'); + +const LOCKED = { + expo: '57.0.23', + 'expo-modules-core': '57.0.21', + '@expo/config-plugins': '57.0.10', + 'react-native': '0.86.0', + 'electron-to-chromium': '1.5.447', + '@babel/core': '7.29.7', +}; + +interface Linked { + readonly modulesCore?: string; + readonly babel?: string; + readonly config?: string; + readonly clerkIos?: string; + readonly generated?: boolean; +} + +const expoSources = ({ + modulesCore = '57.0.21', + babel = '7.29.7', + config = 'portrait', + clerkIos = 'aa', + generated = false, +}: Linked = {}) => { + const core = `node_modules/.pnpm/expo-modules-core@${modulesCore}_@babel+core@${babel}_react@19.2.8/node_modules/expo-modules-core`; + return { + hash: `${modulesCore} ${babel} ${config} ${clerkIos}`, + sources: [ + { type: 'dir', filePath: '../../../packages/expo/ios', reasons: ['expoAutolinkingIos'], hash: clerkIos }, + { type: 'dir', filePath: core, reasons: ['expoAutolinkingIos'], hash: null }, + { + type: 'contents', + id: 'expoAutolinkingConfig:ios', + contents: JSON.stringify({ modules: [{ packageName: 'expo-modules-core', pods: [{ podspecDir: core }] }] }), + reasons: ['expoAutolinkingIos'], + hash: `${modulesCore} ${babel}`, + }, + { type: 'contents', id: 'expoConfig', contents: JSON.stringify({ orientation: config }), reasons: ['expoConfig'], hash: config }, + ...(generated ? [{ type: 'dir', filePath: 'ios', reasons: ['bareNativeDir'], hash: null }] : []), + ], + }; +}; + +interface Checkout { + readonly site: FixtureSite; + readonly cache: string; + readonly steps: string[]; + readonly write: (file: string, content: string) => void; + expo: unknown; + nativeBytecode: number; + compilerBytecode: number; +} + +function checkout(): Checkout { + const worktree = mkdtempSync(join(tmpdir(), 'verify-expo-native-')); + const fixture = join(worktree, 'integration', 'templates', 'expo-native'); + const write = (file: string, content: string) => { + mkdirSync(dirname(join(worktree, file)), { recursive: true }); + writeFileSync(join(worktree, file), content); + }; + execFileSync('git', ['init', '-q', worktree]); + mkdirSync(join(worktree, 'node_modules')); + write('integration/templates/expo-native/package.sdk-57.json', '{"main":"index.js"}'); + write('integration/templates/expo-native/.gitignore', '/ios/\n/node_modules/\n/package.json\n/pnpm-lock.yaml\n'); + write('integration/templates/expo-native/index.js', 'screen one'); + write('packages/expo/ios/ClerkExpo.swift', 'native one'); + write('packages/expo/src/index.ts', 'js one'); + const steps: string[] = []; + const app = artifact('ios', 'standalone', fixture); + const state: Checkout = { + site: { + worktree, + fixture, + must: async () => undefined, + toolchain: async () => 'Xcode 26.4', + expoFingerprint: async () => { + if (state.expo instanceof Error) throw state.expo; + return state.expo; + }, + }, + cache: join(worktree, 'cache'), + steps, + write, + expo: expoSources(), + nativeBytecode: 98, + compilerBytecode: 98, + }; + const must: FixtureSite['must'] = async (what, _command, args) => { + steps.push(what); + const js = readFileSync(join(worktree, 'packages/expo/src/index.ts'), 'utf8'); + if (what === 'pnpm add the workspace packages') { + for (const name of ['react-native', 'hermes-compiler']) { + mkdirSync(join(fixture, 'node_modules', name), { recursive: true }); + writeFileSync(join(fixture, 'node_modules', name, 'package.json'), '{}'); + } + writeFileSync(join(fixture, 'pnpm-lock.yaml'), lockfile(LOCKED)); + } + if (what === 'expo prebuild') { + rmSync(join(fixture, 'ios'), { recursive: true, force: true }); + mkdirSync(join(fixture, 'ios')); + } + if (what === 'xcodebuild') { + mkdirSync(app, { recursive: true }); + writeFileSync(join(app, 'main.jsbundle'), hermes(state.nativeBytecode, `built with ${js}`)); + writeFileSync(join(app, 'ClerkExpoNativeBuildFixture'), 'binary'); + } + if (what === 'expo export:embed') writeFileSync(args[args.indexOf('--bundle-output') + 1]!, js); + if (what === 'hermesc') + writeFileSync(args[args.indexOf('-out') + 1]!, hermes(state.compilerBytecode, `embedded ${readFileSync(args.at(-1)!, 'utf8')}`)); + }; + return Object.assign(state, { site: { ...state.site, must } }); +} + +const build = (at: Checkout, nativeCache: string | null = at.cache, nativeKey = "key") => + buildFixture( + { platform: "ios", product: "standalone", nativeKey, buildPackages: false, nativeCache, progress: () => undefined }, + at.site, + ); +const bundleOf = (app: string) => readFileSync(join(app, 'main.jsbundle')).subarray(12).toString(); +const NATIVE_BUILD = ['pnpm add the workspace packages', 'expo install', 'expo prebuild', 'xcodebuild']; +const EMBED = ['expo export:embed', 'hermesc']; + +describe('the native build that is kept between builds', () => { + it('is named by the native inputs, the packages that decide the native build, and the toolchain, and by nothing JS-only', async () => { + const at = checkout(); + const notes: string[] = []; + const id = async (site: FixtureSite = at.site) => (await locateNativeBuild(at.cache, 'ios', site, line => notes.push(line))).id; + const lock = (versions: Readonly>) => + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), lockfile({ ...LOCKED, ...versions })); + const first = await id(); + assert.match(first, /^ios-[0-9a-f]{16}$/); + const seen = new Set([first]); + const holds = async (what: string) => assert.equal(await id(), first, what); + const moves = async (what: string, site?: FixtureSite) => { + const next = await id(site); + assert.equal(seen.has(next), false, what); + seen.add(next); + }; + + at.write('packages/expo/src/index.ts', 'js two'); + at.write('integration/templates/expo-native/index.js', 'screen two'); + await holds('a JS edit'); + lock({ 'electron-to-chromium': '1.5.446' }); + await holds('another version of a JS-only package'); + lock({ '@babel/core': '7.29.6' }); + at.expo = expoSources({ babel: '7.29.6' }); + await holds('another version of a JS-only package that pnpm names in the directory of a native one'); + lock({}); + at.expo = expoSources({ generated: true }); + await holds('a native project that expo prebuild generated, which Expo lists and does not hash'); + at.expo = expoSources(); + + lock({ expo: '57.0.24' }); + await moves('another expo'); + lock({ '@expo/config-plugins': '57.0.9' }); + await moves('another version of a package that generates the native project and links no native code'); + lock({}); + at.expo = expoSources({ modulesCore: '57.0.20' }); + await moves('another version of a linked native package'); + at.expo = expoSources({ config: 'landscape' }); + await moves('another app config'); + at.expo = expoSources({ clerkIos: 'bb' }); + await moves('other native files in a linked workspace package'); + at.expo = expoSources(); + at.write('packages/expo/ios/ClerkExpo.swift', 'native two'); + await moves('a native edit'); + await moves('another Xcode', { ...at.site, toolchain: async () => 'Xcode 26.5' }); + assert.deepEqual(notes, []); + }); + + it('is named by the whole lockfile, and says why, when what Expo reports cannot be relied on', async () => { + const at = checkout(); + const notes: string[] = []; + const id = async () => (await locateNativeBuild(at.cache, 'ios', at.site, line => notes.push(line))).id; + const lock = (versions: Readonly>) => + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), lockfile({ ...LOCKED, ...versions })); + const narrow = await id(); + const android = expoSources().sources.map(source => + source.id === 'expoAutolinkingConfig:ios' ? { ...source, id: 'expoAutolinkingConfig:android' } : source, + ); + const unreliable: readonly (readonly [unknown, RegExp])[] = [ + [new Error("Cannot find module 'expo/package.json'"), /because @expo\/fingerprint did not run: Cannot find module 'expo\/package.json'$/], + [{ sources: android }, /because @expo\/fingerprint cannot be relied on: it left out expoAutolinkingConfig:ios$/], + [{ sources: [...expoSources().sources, { type: 'archive', filePath: 'a.zip', hash: 'cc' }] }, /it returned a archive source this CLI cannot read$/], + [{ hash: 'only a hash' }, /it returned no sources$/], + ]; + for (const [reported, why] of unreliable) { + at.expo = reported; + lock({}); + const wide = await id(); + assert.notEqual(wide, narrow); + assert.match(notes.at(-1) ?? '', /^build native fingerprint covers the whole pnpm-lock\.yaml, because /); + assert.match(notes.at(-1) ?? '', why); + lock({ 'electron-to-chromium': '1.5.446' }); + assert.notEqual(await id(), wide); + } + at.expo = expoSources(); + writeFileSync(join(at.site.fixture, 'pnpm-lock.yaml'), "lockfileVersion: '12.0'\n"); + const unread = await id(); + assert.match(notes.at(-1) ?? '', /because pnpm-lock\.yaml names no Expo or React Native package$/); + assert.notEqual(unread, narrow); + rmSync(join(at.site.fixture, 'pnpm-lock.yaml')); + await assert.rejects(id(), (error: VerifyFailure) => error.code === 'NOT_READY' && /pnpm-lock\.yaml is missing/.test(error.message)); + }); + + it('reads the fingerprint from the files as they are on disk, tracked or not', async () => { + const at = checkout(); + const inputs = { platform: 'ios' as const, worktree: at.site.worktree, inputs: ['packages/expo/ios'], files: [], toolchain: '', installed: '' }; + const untracked = await nativeFingerprint(inputs); + at.write('packages/expo/ios/New.swift', 'added'); + assert.notEqual(await nativeFingerprint(inputs), untracked); + }); + + it('is built once, and a later build with new JS only exports the bundle and puts it in that app', async () => { + const at = checkout(); + const app = await build(at); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(bundleOf(app), 'built with js one'); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + assert.equal(existsSync(kept.app), true); + + at.steps.length = 0; + at.write('packages/expo/src/index.ts', 'js two'); + const rebuilt = await build(at); + assert.deepEqual(at.steps, EMBED); + assert.equal(rebuilt, app); + assert.equal(bundleOf(rebuilt), 'embedded js two'); + assert.equal(readFileSync(join(rebuilt, 'ClerkExpoNativeBuildFixture'), 'utf8'), 'binary'); + assert.equal(bundleOf(kept.app), 'built with js one'); + }); + + it('takes an app that something else put in the cache for the same fingerprint', async () => { + const at = checkout(); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + mkdirSync(kept.app, { recursive: true }); + writeFileSync(join(kept.app, 'main.jsbundle'), hermes(98, 'built elsewhere with other JS')); + at.steps.length = 0; + assert.equal(bundleOf(await build(at)), 'embedded js one'); + assert.deepEqual(at.steps, EMBED); + }); + + it('builds natively again when the native inputs changed', async () => { + const at = checkout(); + await build(at); + at.steps.length = 0; + at.write('packages/expo/ios/ClerkExpo.swift', 'native two'); + await build(at, at.cache, 'key-2'); + assert.deepEqual(at.steps, ['expo prebuild', 'xcodebuild']); + }); + + it('builds natively when the kept app runs another Hermes bytecode than this checkout compiles, and keeps the new app', async () => { + const at = checkout(); + await build(at); + at.steps.length = 0; + at.compilerBytecode = 99; + at.nativeBytecode = 99; + const lines: string[] = []; + const app = await buildFixture( + { platform: 'ios', product: 'standalone', nativeKey: 'key', buildPackages: false, nativeCache: at.cache, progress: line => lines.push(line) }, + at.site, + ); + assert.deepEqual(at.steps, [...EMBED, 'xcodebuild']); + assert.match(lines.join('\n'), /not reused: it runs Hermes bytecode 98, and this checkout compiles 99/); + assert.equal(bundleOf(app), 'built with js one'); + assert.equal(bundleOf((await locateNativeBuild(at.cache, 'ios', at.site)).app), 'built with js one'); + }); + + it('builds natively when the kept app holds no bundle', async () => { + const at = checkout(); + const kept = await locateNativeBuild(at.cache, 'ios', at.site); + mkdirSync(kept.app, { recursive: true }); + at.steps.length = 0; + await build(at); + assert.deepEqual(at.steps, ['expo prebuild', 'xcodebuild']); + }); + + it('installs the standalone packages again after a dev client build installed its own', async () => { + const at = checkout(); + const standalone = (await locateNativeBuild(at.cache, 'ios', at.site)).id; + await buildFixture( + { platform: 'ios', product: 'dev-client', nativeKey: 'key', buildPackages: false, progress: () => undefined }, + at.site, + ); + at.steps.length = 0; + assert.equal((await locateNativeBuild(at.cache, 'ios', at.site)).id, standalone); + assert.deepEqual(at.steps, ['pnpm add the workspace packages', 'expo install']); + at.steps.length = 0; + await locateNativeBuild(at.cache, 'ios', at.site); + assert.deepEqual(at.steps, []); + }); + + it('is not used without VERIFY_NATIVE_CACHE, and never for a dev client', async () => { + assert.equal(nativeCacheDir({}), null); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '' }), null); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '.verify/native' }, '/work'), '/work/.verify/native'); + assert.equal(nativeCacheDir({ VERIFY_NATIVE_CACHE: '/abs/native' }, '/work'), '/abs/native'); + const at = checkout(); + await build(at, null); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(existsSync(at.cache), false); + at.steps.length = 0; + await buildFixture( + { platform: 'ios', product: 'dev-client', nativeKey: 'key', buildPackages: false, nativeCache: at.cache, progress: () => undefined }, + at.site, + ); + assert.deepEqual(at.steps, NATIVE_BUILD); + assert.equal(existsSync(at.cache), false); + rmSync(at.site.worktree, { recursive: true, force: true }); + }); +}); diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx index 7c8802393e7..cd31678f24d 100644 --- a/integration/templates/expo-native/App.tsx +++ b/integration/templates/expo-native/App.tsx @@ -1,183 +1,18 @@ -import { ClerkProvider, useAuth, useUser } from '@clerk/expo'; -import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'; -import { tokenCache } from '@clerk/expo/token-cache'; -import { useState } from 'react'; -import { Button, LogBox, Modal, StyleSheet, Text, View } from 'react-native'; +import { type ReactElement } from 'react'; +import { LogBox } from 'react-native'; -import { BiometricAvailabilityButton } from './components/BiometricAvailabilityButton'; -import { GoogleSignInButton } from './components/GoogleSignInButton'; import { readVerifyLaunch } from './verify/launch'; import { logRequests, VerifyHost } from './verify/VerifyHost'; -const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY; const verifyLaunch = readVerifyLaunch(); -if (verifyLaunch) { +if (verifyLaunch.runId !== null) { LogBox.ignoreAllLogs(); - if (verifyLaunch.debugLogging) { - logRequests(); - } -} else if (!publishableKey) { - throw new Error('Missing EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'); } - -function NativeBuildFixture() { - const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false }); - const { user } = useUser(); - const [isAuthOpen, setIsAuthOpen] = useState(false); - const [isProfileOpen, setIsProfileOpen] = useState(false); - const [signOutResult, setSignOutResult] = useState(null); - - if (isProfileOpen) { - return ( - - - - Rehosted RN body - - ), - }, - ]} - isDismissible={false} - onHostBack={() => setIsProfileOpen(false)} - /> - - - ); - } - - return ( - - - Clerk Expo Native Fixture - {isSignedIn && } - - - {isLoaded ? `signed ${isSignedIn ? 'in' : 'out'}` : 'loading'} - {user?.id && {user.id}} - {signOutResult && {`sign out: ${signOutResult}`}} -