From ef889c220e50825754632f0354bf08334728fd11 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 14:35:52 -0700 Subject: [PATCH 01/12] test(expo): Run Maestro tests after native builds --- .changeset/faster-expo-maestro.md | 2 + .github/workflows/expo-native-build.yml | 97 ++----------------------- 2 files changed, 7 insertions(+), 92 deletions(-) create mode 100644 .changeset/faster-expo-maestro.md diff --git a/.changeset/faster-expo-maestro.md b/.changeset/faster-expo-maestro.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/faster-expo-maestro.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 8903b647f0c..eb0c92f951e 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -32,9 +32,9 @@ env: jobs: native-build: if: ${{ github.head_ref != 'changeset-release/main' }} - name: Expo ${{ matrix.expo-sdk }} Build / ${{ matrix.platform }} + name: Expo ${{ matrix.expo-sdk }} ${{ matrix.run-e2e == true && 'Build + E2E' || 'Build' }} / ${{ matrix.platform }} runs-on: ${{ matrix.runner }} - timeout-minutes: 45 + timeout-minutes: ${{ matrix.run-e2e == true && 60 || 45 }} strategy: fail-fast: false matrix: @@ -156,93 +156,6 @@ jobs: working-directory: ${{ env.FIXTURE_DIR }} run: pnpm build:ios - # tar the .app so its bundle structure survives the artifact round-trip. - - name: Package iOS app for e2e - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - working-directory: ${{ env.FIXTURE_DIR }} - run: | - tar -C ios/build/Build/Products/Release-iphonesimulator \ - -cf "$RUNNER_TEMP/ClerkExpoNativeBuildFixture.app.tar" \ - ClerkExpoNativeBuildFixture.app - - - name: Upload iOS fixture app - if: matrix.platform == 'ios' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: expo-fixture-ios - path: ${{ runner.temp }}/ClerkExpoNativeBuildFixture.app.tar - retention-days: 1 - if-no-files-found: error - - - name: Upload Android fixture app - if: matrix.platform == 'android' && matrix.run-e2e == true && steps.keys.outputs.pk != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: expo-fixture-android - path: ${{ env.FIXTURE_DIR }}/android/app/build/outputs/apk/release/app-release.apk - retention-days: 1 - if-no-files-found: error - - e2e: - name: E2E / ${{ matrix.platform }} - needs: native-build - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - platform: ios - runner: blacksmith-6vcpu-macos-26 - - platform: android - runner: blacksmith-8vcpu-ubuntu-2204 - - steps: - - name: Checkout repo - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - - # Only Node (for the key resolver) is needed; the e2e job builds nothing. - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24.15.0 - - - name: Check e2e secret availability - id: gate - 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?); skipping e2e." - 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" - - - name: Download fixture app - if: steps.keys.outputs.pk != '' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: expo-fixture-${{ matrix.platform }} - path: ${{ runner.temp }}/fixture-app - - # Maestro is a JVM app and needs a JDK on the Linux runner (macOS ships one). - - name: Set up JDK 17 - if: matrix.platform == 'android' && steps.keys.outputs.pk != '' - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 - with: - distribution: temurin - java-version: 17 - - name: Cache Maestro if: steps.keys.outputs.pk != '' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -314,6 +227,7 @@ jobs: # Burn-in: e2e cannot fail the check yet. Flip to hard-fail in a # follow-up once the suite has proven quiet. continue-on-error: true + working-directory: ${{ env.FIXTURE_DIR }} env: CLERK_TEST_EMAIL: ${{ steps.user.outputs.email }} CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} @@ -328,8 +242,7 @@ jobs: xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCapitalization -bool NO || true xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCorrection -bool NO || true xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.Prediction -bool NO || true - tar -C "$RUNNER_TEMP/fixture-app" -xf "$RUNNER_TEMP/fixture-app/ClerkExpoNativeBuildFixture.app.tar" - xcrun simctl install "$SIM_UDID" "$RUNNER_TEMP/fixture-app/ClerkExpoNativeBuildFixture.app" + xcrun simctl install "$SIM_UDID" ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app # Stream the app's console output into the debug artifact so a hang has # actionable evidence (keychain/network errors) instead of just screenshots. mkdir -p "$RUNNER_TEMP/maestro-debug" @@ -391,7 +304,7 @@ jobs: # The action runs each script line in a separate sh -c; the folded # scalar (>-) plus && keeps everything in one shell invocation. script: >- - adb install -r "$RUNNER_TEMP/fixture-app/app-release.apk" && + adb install -r integration/templates/expo-native/android/app/build/outputs/apk/release/app-release.apk && cd integration/tests/expo-native && MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture From a845a9390c224b982f9809e564e1961fee73b9a6 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 15:13:28 -0700 Subject: [PATCH 02/12] test(e2e): fail fast and improve Expo Maestro diagnostics --- .github/workflows/expo-native-build.yml | 4 +- .../expo-native/flows/subflows/_warmup.yaml | 1 + .../expo-native/flows/subflows/open-app.yaml | 12 +---- .../tests/expo-native/run-android-flows.sh | 34 +++++++++++++ integration/tests/expo-native/run-flows.sh | 51 +++++++++++++++++-- 5 files changed, 86 insertions(+), 16 deletions(-) create mode 100755 integration/tests/expo-native/run-android-flows.sh diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index eb0c92f951e..0b75a958dc7 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -304,9 +304,9 @@ jobs: # The action runs each script line in a separate sh -c; the folded # scalar (>-) plus && keeps everything in one shell invocation. script: >- - adb install -r integration/templates/expo-native/android/app/build/outputs/apk/release/app-release.apk && cd integration/tests/expo-native && - MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture + MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-android-flows.sh + "$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" # Runs before the artifact upload so the credentials are already dead # by the time the debug files become downloadable. diff --git a/integration/tests/expo-native/flows/subflows/_warmup.yaml b/integration/tests/expo-native/flows/subflows/_warmup.yaml index 4de07d419a9..bde6f57e9f3 100644 --- a/integration/tests/expo-native/flows/subflows/_warmup.yaml +++ b/integration/tests/expo-native/flows/subflows/_warmup.yaml @@ -5,6 +5,7 @@ appId: com.clerk.exponativebuildfixture --- - launchApp: clearState: true + clearKeychain: true - extendedWaitUntil: visible: id: 'auth-state' diff --git a/integration/tests/expo-native/flows/subflows/open-app.yaml b/integration/tests/expo-native/flows/subflows/open-app.yaml index b0aeb35ed36..681f2d289c4 100644 --- a/integration/tests/expo-native/flows/subflows/open-app.yaml +++ b/integration/tests/expo-native/flows/subflows/open-app.yaml @@ -3,6 +3,7 @@ appId: com.clerk.exponativebuildfixture --- - launchApp: clearState: true + clearKeychain: true - extendedWaitUntil: visible: id: 'auth-state' @@ -13,15 +14,4 @@ appId: com.clerk.exponativebuildfixture - extendedWaitUntil: visible: 'signed (in|out)' timeout: 45000 -# clearState does not wipe the iOS Keychain, so a session from a previous -# flow (or retry attempt) can survive the relaunch. Sign out via JS first. -- runFlow: - when: - visible: 'signed in' - commands: - - tapOn: - id: 'sign-out-button' - - extendedWaitUntil: - visible: 'signed out' - timeout: 15000 - assertVisible: 'signed out' diff --git a/integration/tests/expo-native/run-android-flows.sh b/integration/tests/expo-native/run-android-flows.sh new file mode 100755 index 00000000000..0461829088e --- /dev/null +++ b/integration/tests/expo-native/run-android-flows.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +apk_path=${1:?APK path is required} +command -v adb >/dev/null 2>&1 || { + echo 'adb is required' + exit 1 +} + +adb install -r "$apk_path" + +logcat_pid= +stop_logcat() { + local running_pid + [ -n "$logcat_pid" ] || return + for running_pid in $(jobs -pr); do + if [ "$running_pid" = "$logcat_pid" ]; then + kill "$logcat_pid" >/dev/null 2>&1 || true + break + fi + done + wait "$logcat_pid" 2>/dev/null || true +} +trap stop_logcat EXIT + +if [ -n "${MAESTRO_DEBUG_OUTPUT:-}" ]; then + mkdir -p "$MAESTRO_DEBUG_OUTPUT" + adb logcat -c || true + adb logcat -v threadtime > "$MAESTRO_DEBUG_OUTPUT/android-logcat.log" 2>&1 & + logcat_pid=$! +fi + +./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture diff --git a/integration/tests/expo-native/run-flows.sh b/integration/tests/expo-native/run-flows.sh index e08d864d7a5..dff2ea9459d 100755 --- a/integration/tests/expo-native/run-flows.sh +++ b/integration/tests/expo-native/run-flows.sh @@ -30,10 +30,50 @@ if [ -n "${MAESTRO_DEBUG_OUTPUT:-}" ]; then debug_args=(--debug-output "$MAESTRO_DEBUG_OUTPUT" --flatten-debug-output) fi +record_result() { + local flow=$1 + local result=$2 + local attempts=$3 + local duration=$4 + + echo "Flow $flow: $result after $attempts attempt(s) in ${duration}s" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + printf '| `%s` | %s | %s | %ss |\n' "$flow" "$result" "$attempts" "$duration" >> "$GITHUB_STEP_SUMMARY" + fi +} + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo '### Maestro flow timings' + echo '| Flow | Result | Attempts | Duration |' + echo '| --- | --- | ---: | ---: |' + } >> "$GITHUB_STEP_SUMMARY" +fi + # Warmup: parse the JS bundle + populate the a11y tree once so the first real -# flow doesn't flake on cold-start; force-stop afterwards so the first -# launchApp clearState doesn't race a still-foregrounded app process. -maestro test ${debug_args+"${debug_args[@]}"} flows/subflows/_warmup.yaml || true +# flow doesn't flake on cold-start. A repeated failure means the app is not +# runnable, so abort instead of timing out and retrying every real flow. +warmup_started=$SECONDS +warmup_result=failed +for warmup_attempt in 1 2; do + if maestro test ${debug_args+"${debug_args[@]}"} flows/subflows/_warmup.yaml; then + warmup_result=passed + break + fi + force_stop "$@" + if [ "$warmup_attempt" -eq 1 ]; then + echo "::warning::Warmup failed attempt 1, retrying after 10s..." + sleep 10 + fi +done +warmup_duration=$((SECONDS - warmup_started)) +record_result "_warmup" "$warmup_result" "$warmup_attempt" "$warmup_duration" +if [ "$warmup_result" != passed ]; then + echo "::error::Warmup failed after 2 attempts; aborting Maestro flows" + exit 1 +fi + +# Force-stop so the first launchApp clearState doesn't race the warm process. force_stop "$@" # Every flows/*.yaml is a cross-platform test (platform differences live in @@ -41,12 +81,15 @@ force_stop "$@" status=0 for flow in flows/*.yaml; do [ -e "$flow" ] || continue + flow_started=$SECONDS + flow_result=failed for attempt in 1 2; do if maestro test \ --env CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" \ --env CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" \ ${debug_args+"${debug_args[@]}"} \ "$flow"; then + flow_result=passed break fi if [ "$attempt" -eq 2 ]; then @@ -58,5 +101,7 @@ for flow in flows/*.yaml; do force_stop "$@" sleep 10 done + flow_duration=$((SECONDS - flow_started)) + record_result "$flow" "$flow_result" "$attempt" "$flow_duration" done exit $status From fb2f739ebff9135c67f913a5d322ba6c86729881 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 15:45:38 -0700 Subject: [PATCH 03/12] test(e2e): Pin react-dom versions --- .github/workflows/expo-native-build.yml | 8 ++++++++ integration/templates/expo-native/package.sdk-57.json | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 0b75a958dc7..0d3384befca 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -100,6 +100,14 @@ jobs: 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 diff --git a/integration/templates/expo-native/package.sdk-57.json b/integration/templates/expo-native/package.sdk-57.json index 8d80ee8f8ce..9523431c830 100644 --- a/integration/templates/expo-native/package.sdk-57.json +++ b/integration/templates/expo-native/package.sdk-57.json @@ -11,7 +11,8 @@ }, "dependencies": { "expo": "~57.0.1", - "react": "19.2.7", + "react": "19.2.8", + "react-dom": "19.2.8", "react-native": "0.86.0" }, "devDependencies": { From e7e4da590b0371fa516a4d1bb7d3269a23639f30 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 16:09:28 -0700 Subject: [PATCH 04/12] test(e2e): handle Expo JS sign-in verification --- .../expo-native/components/JsSignInForm.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/integration/templates/expo-native/components/JsSignInForm.tsx b/integration/templates/expo-native/components/JsSignInForm.tsx index 5a76cb98cae..3ec69d89e82 100644 --- a/integration/templates/expo-native/components/JsSignInForm.tsx +++ b/integration/templates/expo-native/components/JsSignInForm.tsx @@ -21,6 +21,32 @@ export function JsSignInForm({ onStatus }: { onStatus: (status: string) => void reportError(error.message ?? ''); return; } + + if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_client_trust') { + const supportsEmailCode = signIn.supportedSecondFactors.some(factor => factor.strategy === 'email_code'); + if (!supportsEmailCode) { + reportError(`Unsupported sign-in state: ${signIn.status}`); + return; + } + + const { error: sendCodeError } = await signIn.mfa.sendEmailCode(); + if (sendCodeError) { + reportError(sendCodeError.message ?? ''); + return; + } + + const { error: verifyCodeError } = await signIn.mfa.verifyEmailCode({ code: '424242' }); + if (verifyCodeError) { + reportError(verifyCodeError.message ?? ''); + return; + } + } + + if (signIn.status !== 'complete' || !signIn.createdSessionId) { + reportError(`Sign-in incomplete: ${signIn.status}`); + return; + } + const { error: finalizeError } = await signIn.finalize(); if (finalizeError) { reportError(finalizeError.message ?? ''); From 672b352fb150b88fb84d6e2206122eb4d2567bff Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 16:21:03 -0700 Subject: [PATCH 05/12] ci(e2e): cache Expo native builds --- .github/workflows/expo-native-build.yml | 71 ++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 0d3384befca..cf03032ed3b 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -66,16 +66,64 @@ jobs: with: persist-credentials: false - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - name: Compute native build cache key + id: native-build-key + env: + PLATFORM: ${{ matrix.platform }} + run: | + build_hash="$( + git ls-files -s -- \ + .npmrc \ + package.json \ + pnpm-lock.yaml \ + pnpm-workspace.yaml \ + tsconfig.json \ + turbo.json \ + packages/backend \ + packages/clerk-js \ + packages/expo \ + packages/expo-google-signin \ + packages/expo-passkeys \ + packages/headless \ + packages/localizations \ + packages/msw \ + packages/react \ + packages/shared \ + packages/testing \ + packages/ui \ + "$FIXTURE_DIR" | + git hash-object --stdin + )" + if [ "$PLATFORM" = "android" ]; then + artifact="$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" + else + artifact="$FIXTURE_DIR/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app" + fi + echo "hash=$build_hash" >> "$GITHUB_OUTPUT" + echo "artifact=$artifact" >> "$GITHUB_OUTPUT" + + - name: Restore native build + id: native-build-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + 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 }} + + - if: steps.native-build-cache.outputs.cache-hit != 'true' + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - 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' run: pnpm install --frozen-lockfile - name: Build and pack Clerk packages + if: steps.native-build-cache.outputs.cache-hit != 'true' run: | pnpm --filter @clerk/expo... build mkdir -p "$SDK_PACK_DIR" @@ -83,6 +131,7 @@ jobs: pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR" - name: Install fixture dependencies + if: steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} env: EXPO_SDK: ${{ matrix.expo-sdk }} @@ -131,6 +180,7 @@ jobs: run: node scripts/resolve-instance-keys.mjs INTEGRATION_STAGING_INSTANCE_KEYS "$E2E_INSTANCE_NAME" - 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 }} @@ -138,32 +188,39 @@ jobs: echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env - name: Set up JDK 17 - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: 17 - name: Prebuild Android fixture - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm expo prebuild --clean --platform android - name: Build Android fixture - if: ${{ matrix.platform == 'android' }} + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm build:android - name: Prebuild iOS fixture - if: ${{ matrix.platform == 'ios' }} + if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm expo prebuild --clean --platform ios - name: Build iOS fixture - if: ${{ matrix.platform == 'ios' }} + if: matrix.platform == 'ios' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} run: pnpm build:ios + - name: Save native build + if: steps.native-build-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ steps.native-build-key.outputs.artifact }} + key: ${{ steps.native-build-cache.outputs.cache-primary-key }} + - name: Cache Maestro if: steps.keys.outputs.pk != '' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 From 106762e55f5a6442eb30f8eedfe0ed19a265338d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 16:23:03 -0700 Subject: [PATCH 06/12] ci(e2e): narrow Expo build cache inputs --- .github/workflows/expo-native-build.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index cf03032ed3b..74ac9c0d47d 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -79,18 +79,8 @@ jobs: pnpm-workspace.yaml \ tsconfig.json \ turbo.json \ - packages/backend \ - packages/clerk-js \ packages/expo \ packages/expo-google-signin \ - packages/expo-passkeys \ - packages/headless \ - packages/localizations \ - packages/msw \ - packages/react \ - packages/shared \ - packages/testing \ - packages/ui \ "$FIXTURE_DIR" | git hash-object --stdin )" From c7e7c03d9a9ed7d1ea3c174d27b14a3d0a3d6984 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 16:26:11 -0700 Subject: [PATCH 07/12] ci(e2e): include Expo runtime cache inputs --- .github/workflows/expo-native-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 74ac9c0d47d..66877cb95b6 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -79,8 +79,11 @@ jobs: pnpm-workspace.yaml \ tsconfig.json \ turbo.json \ + packages/clerk-js \ packages/expo \ packages/expo-google-signin \ + packages/react \ + packages/shared \ "$FIXTURE_DIR" | git hash-object --stdin )" From 58264f7ddc0eb27553d728862e7212a1be0884c5 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 16:55:32 -0700 Subject: [PATCH 08/12] ci(e2e): Always setup jdk --- .github/workflows/expo-native-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 66877cb95b6..01f664256ce 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -181,7 +181,7 @@ jobs: echo "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=${E2E_PK:-$FIXTURE_PUBLISHABLE_KEY}" > .env - name: Set up JDK 17 - if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' + if: matrix.platform == 'android' uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin From 83f89ba021ab4ec5eedc4b38a36367aebcdbefc4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 17:54:26 -0700 Subject: [PATCH 09/12] chore: guards --- integration/tests/expo-native/run-android-flows.sh | 2 +- integration/tests/expo-native/run-flows.sh | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/tests/expo-native/run-android-flows.sh b/integration/tests/expo-native/run-android-flows.sh index 0461829088e..f7a5b629a05 100755 --- a/integration/tests/expo-native/run-android-flows.sh +++ b/integration/tests/expo-native/run-android-flows.sh @@ -13,7 +13,7 @@ adb install -r "$apk_path" logcat_pid= stop_logcat() { local running_pid - [ -n "$logcat_pid" ] || return + [ -n "$logcat_pid" ] || return 0 for running_pid in $(jobs -pr); do if [ "$running_pid" = "$logcat_pid" ]; then kill "$logcat_pid" >/dev/null 2>&1 || true diff --git a/integration/tests/expo-native/run-flows.sh b/integration/tests/expo-native/run-flows.sh index dff2ea9459d..41f74bfb232 100755 --- a/integration/tests/expo-native/run-flows.sh +++ b/integration/tests/expo-native/run-flows.sh @@ -95,6 +95,7 @@ for flow in flows/*.yaml; do if [ "$attempt" -eq 2 ]; then echo "::error::Flow $flow failed after 2 attempts" status=1 + force_stop "$@" break fi echo "::warning::Flow $flow failed attempt $attempt, retrying after 10s..." From 21886e28e919eb403db603f3478f42e95019aa27 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 18:03:05 -0700 Subject: [PATCH 10/12] chore: Fix JS auth state recovery after rejecting a divergent native client so signed-in sessions can still sign out --- .changeset/calm-clients-recover.md | 5 +++ .../ClerkProvider.nativeClientSync.test.tsx | 28 +++++++++++---- .../expo/src/provider/nativeClientSync.tsx | 36 ++++++++++++++----- 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 .changeset/calm-clients-recover.md diff --git a/.changeset/calm-clients-recover.md b/.changeset/calm-clients-recover.md new file mode 100644 index 00000000000..ece4f3e2793 --- /dev/null +++ b/.changeset/calm-clients-recover.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix JS auth state recovery after rejecting a divergent native client so signed-in sessions can still sign out. diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 14efd8ca1d1..ba5a4622cf3 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -822,12 +822,26 @@ describe('ClerkProvider native client sync', () => { // compare against values captured before the refresh, not a reference that mutated underneath. const client: Record = { id: 'client_1', - signedInSessions: [signedInSession], + sessions: [signedInSession], lastActiveSessionId: 'session_1', + get signedInSessions() { + return this.sessions; + }, }; + client.__internal_toSnapshot = vi.fn(() => ({ + id: client.id, + sessions: client.sessions, + last_active_session_id: client.lastActiveSessionId, + })); + client.fromJSON = vi.fn(snapshot => { + client.id = snapshot.id; + client.sessions = snapshot.sessions; + client.lastActiveSessionId = snapshot.last_active_session_id; + return client; + }); client.fetch = vi.fn().mockImplementation(() => { client.id = 'client_2'; - client.signedInSessions = []; + client.sessions = []; client.lastActiveSessionId = null; return Promise.resolve(client); }); @@ -864,12 +878,14 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(client.fetch).toHaveBeenCalled(); + expect(client.fromJSON).toHaveBeenCalled(); }); - // The refreshed client belongs to a different client id and carries no signed-in sessions, so - // it must be discarded rather than applied over the session JS already has. - expect(originalUpdateClient).not.toHaveBeenCalled(); + expect(client.id).toBe('client_1'); + expect(client.sessions).toEqual([signedInSession]); + expect(client.signedInSessions).toEqual([signedInSession]); + expect(client.lastActiveSessionId).toBe('session_1'); + expect(originalUpdateClient).toHaveBeenCalledWith(client); expect(mocks.clerkInstance.session).toBe(signedInSession); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index d1efe9df72b..528317e73da 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,4 +1,4 @@ -import type { ClientResource, SignedInSessionResource } from '@clerk/shared/types'; +import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types'; import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; @@ -31,6 +31,7 @@ export type SyncableClerkInstance = { type RefreshableClientResource = ClientResource & { fetch?: (options?: { fetchMaxTries?: number }) => Promise; + fromJSON?: (data: ClientJSONSnapshot) => ClientResource; }; type NativeRefreshFromJsOptions = { @@ -222,24 +223,36 @@ function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise; tokenCache: TokenCache | undefined; }): Promise { - const previousClientSnapshot = snapshotClientIdentity(clerkInstance.client); + const previousClientSnapshot = snapshotClientState(clerkInstance.client); const restorePreviousDeviceToken = async () => { if (!rejectForeignSessionlessClient || !shouldSyncDeviceToken || previousDeviceToken === undefined) { @@ -295,6 +308,13 @@ async function refreshJsClientFromNativeState({ if (refreshedClient) { if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { await restorePreviousDeviceToken(); + const restoredClient = restoreClientSnapshot(previousClientSnapshot); + if (restoredClient) { + clerkInstance.updateClient?.(restoredClient); + await reconcileJsActiveSessionFromClient({ + clerkInstance, + }); + } return true; } From 7128192fa42359a6e276e9dc53371c1f054d44d2 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Mon, 27 Jul 2026 18:03:48 -0700 Subject: [PATCH 11/12] Delete .changeset/faster-expo-maestro.md --- .changeset/faster-expo-maestro.md | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .changeset/faster-expo-maestro.md diff --git a/.changeset/faster-expo-maestro.md b/.changeset/faster-expo-maestro.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/faster-expo-maestro.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- From 5d3e40ff866032242fb976f8155711279ffb2bb4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 27 Jul 2026 18:10:17 -0700 Subject: [PATCH 12/12] chore: clean up --- .../expo-native/components/JsSignInForm.tsx | 5 -- .../tests/expo-native/run-android-flows.sh | 8 +--- integration/tests/expo-native/run-flows.sh | 4 +- .../ClerkProvider.nativeClientSync.test.tsx | 46 +++++++++---------- .../expo/src/provider/nativeClientSync.tsx | 27 +++++------ 5 files changed, 36 insertions(+), 54 deletions(-) diff --git a/integration/templates/expo-native/components/JsSignInForm.tsx b/integration/templates/expo-native/components/JsSignInForm.tsx index 3ec69d89e82..2d0b062937b 100644 --- a/integration/templates/expo-native/components/JsSignInForm.tsx +++ b/integration/templates/expo-native/components/JsSignInForm.tsx @@ -2,11 +2,6 @@ import { useSignIn } from '@clerk/expo'; import { useState } from 'react'; import { Button, StyleSheet, TextInput } from 'react-native'; -/** - * Headless JS-runtime sign-in so the JS client owns the session, which the - * native-token-divergence flow requires (the native AuthView would create the - * session on the native client instead). - */ export function JsSignInForm({ onStatus }: { onStatus: (status: string) => void }) { const { signIn } = useSignIn(); const [identifier, setIdentifier] = useState(''); diff --git a/integration/tests/expo-native/run-android-flows.sh b/integration/tests/expo-native/run-android-flows.sh index f7a5b629a05..29a8e165b84 100755 --- a/integration/tests/expo-native/run-android-flows.sh +++ b/integration/tests/expo-native/run-android-flows.sh @@ -12,14 +12,8 @@ adb install -r "$apk_path" logcat_pid= stop_logcat() { - local running_pid [ -n "$logcat_pid" ] || return 0 - for running_pid in $(jobs -pr); do - if [ "$running_pid" = "$logcat_pid" ]; then - kill "$logcat_pid" >/dev/null 2>&1 || true - break - fi - done + kill "$logcat_pid" >/dev/null 2>&1 || true wait "$logcat_pid" 2>/dev/null || true } trap stop_logcat EXIT diff --git a/integration/tests/expo-native/run-flows.sh b/integration/tests/expo-native/run-flows.sh index 41f74bfb232..4c3172f4fec 100755 --- a/integration/tests/expo-native/run-flows.sh +++ b/integration/tests/expo-native/run-flows.sh @@ -50,9 +50,7 @@ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then } >> "$GITHUB_STEP_SUMMARY" fi -# Warmup: parse the JS bundle + populate the a11y tree once so the first real -# flow doesn't flake on cold-start. A repeated failure means the app is not -# runnable, so abort instead of timing out and retrying every real flow. +# Warm up the JS bundle and accessibility tree before running the flows. warmup_started=$SECONDS warmup_result=failed for warmup_attempt in 1 2; do diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index ba5a4622cf3..beb485fcb37 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -817,34 +817,34 @@ describe('ClerkProvider native client sync', () => { }; const originalUpdateClient = mocks.clerkInstance.updateClient; - // Clerk JS mutates the client resource in place and resolves with that same object, so the - // refreshed client must not be modelled as a distinct object: the foreign-client guard has to - // compare against values captured before the refresh, not a reference that mutated underneath. - const client: Record = { + const client = { id: 'client_1', sessions: [signedInSession], - lastActiveSessionId: 'session_1', + lastActiveSessionId: 'session_1' as string | null, get signedInSessions() { return this.sessions; }, + __internal_toSnapshot() { + return { + id: this.id, + sessions: this.sessions, + last_active_session_id: this.lastActiveSessionId, + }; + }, + fromJSON(snapshot: { id: string; sessions: (typeof signedInSession)[]; last_active_session_id: string | null }) { + this.id = snapshot.id; + this.sessions = snapshot.sessions; + this.lastActiveSessionId = snapshot.last_active_session_id; + return this; + }, + fetch() { + this.id = 'client_2'; + this.sessions = []; + this.lastActiveSessionId = null; + return Promise.resolve(this); + }, }; - client.__internal_toSnapshot = vi.fn(() => ({ - id: client.id, - sessions: client.sessions, - last_active_session_id: client.lastActiveSessionId, - })); - client.fromJSON = vi.fn(snapshot => { - client.id = snapshot.id; - client.sessions = snapshot.sessions; - client.lastActiveSessionId = snapshot.last_active_session_id; - return client; - }); - client.fetch = vi.fn().mockImplementation(() => { - client.id = 'client_2'; - client.sessions = []; - client.lastActiveSessionId = null; - return Promise.resolve(client); - }); + const restoreClient = vi.spyOn(client, 'fromJSON'); mocks.clerkInstance.client = client; mocks.clerkInstance.session = signedInSession; @@ -878,7 +878,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(client.fromJSON).toHaveBeenCalled(); + expect(restoreClient).toHaveBeenCalled(); }); expect(client.id).toBe('client_1'); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 528317e73da..5c564d92f32 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -226,32 +226,27 @@ function fetchRefreshedJsClient(clerkInstance: SyncableClerkInstance): Promise ClientResource) | null; }; function snapshotClientState(client: ClientResource | null | undefined): ClientStateSnapshot { const resource = client as RefreshableClientResource | undefined; - const canRestore = resource && typeof resource.fromJSON === 'function'; + const fromJSON = resource?.fromJSON?.bind(resource); + let restore: ClientStateSnapshot['restore'] = null; + + if (resource && fromJSON) { + const state = resource.__internal_toSnapshot(); + restore = () => fromJSON(state); + } return { id: client?.id ?? null, hasSignedInSession: Boolean(client && getDefaultSignedInSession(client)), - resource: canRestore ? resource : null, - state: canRestore ? resource.__internal_toSnapshot() : null, + restore, }; } -function restoreClientSnapshot(snapshot: ClientStateSnapshot): ClientResource | null { - if (!snapshot.resource || !snapshot.state || typeof snapshot.resource.fromJSON !== 'function') { - return null; - } - - return snapshot.resource.fromJSON(snapshot.state); -} - -// Refreshing mutates the client in place and resolves with that same object, so this compares -// against values captured before the refresh rather than a reference that mutated underneath. +// Client.fetch mutates the resource, so compare against pre-fetch values. function isForeignSessionlessClient(previousSnapshot: ClientStateSnapshot, refreshedClient: ClientResource): boolean { if (!previousSnapshot.id || !refreshedClient.id || previousSnapshot.id === refreshedClient.id) { return false; @@ -308,7 +303,7 @@ async function refreshJsClientFromNativeState({ if (refreshedClient) { if (rejectForeignSessionlessClient && isForeignSessionlessClient(previousClientSnapshot, refreshedClient)) { await restorePreviousDeviceToken(); - const restoredClient = restoreClientSnapshot(previousClientSnapshot); + const restoredClient = previousClientSnapshot.restore?.(); if (restoredClient) { clerkInstance.updateClient?.(restoredClient); await reconcileJsActiveSessionFromClient({