diff --git a/.changeset/expo-native-e2e-agent-device.md b/.changeset/expo-native-e2e-agent-device.md new file mode 100644 index 00000000000..24395220100 --- /dev/null +++ b/.changeset/expo-native-e2e-agent-device.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix the Android `` rendering no sign-in form when it is opened within a couple of seconds of `isLoaded` turning true. The native view now recreates itself once the Clerk Android SDK finishes loading instead of staying empty until it is dismissed and reopened. diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml index 9702cf7d505..8023b2919c2 100644 --- a/.github/workflows/expo-native-build.yml +++ b/.github/workflows/expo-native-build.yml @@ -27,7 +27,7 @@ env: SDK_PACK_DIR: /tmp/clerk-expo-pack E2E_INSTANCE_NAME: clerkstage-with-native-components BAPI_URL: https://api.clerkstage.dev - MAESTRO_VERSION: '2.8.0' + AGENT_DEVICE_VERSION: '0.20.10' jobs: native-build: @@ -137,7 +137,7 @@ jobs: GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)" pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w # expo-dev-client makes even release builds boot into the dev - # launcher (unreachable Metro in CI), which stalls every Maestro + # 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 @@ -187,6 +187,15 @@ jobs: distribution: temurin java-version: 17 + - name: Set up Gradle cache + if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' + uses: gradle/actions/setup-gradle@4733eaac7c1b0da527e4206b7671e0061de1ce37 # v6.3.0 + with: + # This workflow never runs on main, so the default (write from main + # only) would never seed the cache. + cache-read-only: false + add-job-summary: on-failure + - name: Prebuild Android fixture if: matrix.platform == 'android' && steps.native-build-cache.outputs.cache-hit != 'true' working-directory: ${{ env.FIXTURE_DIR }} @@ -214,32 +223,32 @@ jobs: path: ${{ steps.native-build-key.outputs.artifact }} key: ${{ steps.native-build-cache.outputs.cache-primary-key }} - - name: Cache maestro CLI + # setup-node above is skipped on a native-build cache hit. + - name: Set up Node for agent-device if: steps.keys.outputs.pk != '' + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.15.0 + + - name: Resolve Xcode version + id: xcode + if: matrix.platform == 'ios' && steps.keys.outputs.pk != '' + run: echo "version=$(xcodebuild -version | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT" + + # Exact key on purpose: agent-device advises against restore-key + # fallbacks for runner artifacts; prepare ios-runner recovers a stale one. + - name: Cache agent-device Apple runner + if: matrix.platform == 'ios' && steps.keys.outputs.pk != '' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - path: ~/.maestro - key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }} + path: ~/.agent-device/apple-runner/derived + key: agent-device-runner-${{ runner.os }}-${{ env.AGENT_DEVICE_VERSION }}-${{ steps.xcode.outputs.version }} - - name: Install maestro CLI + - name: Install agent-device CLI if: steps.keys.outputs.pk != '' run: | - set -o pipefail - if [ -x "$HOME/.maestro/bin/maestro" ]; then - echo "Using cached Maestro" - else - installed=0 - for i in 1 2 3; do - if curl -fLs --retry 3 --retry-delay 5 "https://get.maestro.mobile.dev" | MAESTRO_VERSION="$MAESTRO_VERSION" bash; then - if [ -x "$HOME/.maestro/bin/maestro" ]; then installed=1; break; fi - fi - echo "Maestro install attempt $i failed (or binary missing); retrying" - sleep 5 - done - [ "$installed" = 1 ] || { echo "::error::Maestro install failed after 3 attempts"; exit 1; } - fi - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - "$HOME/.maestro/bin/maestro" --version + npm install -g "agent-device@$AGENT_DEVICE_VERSION" + agent-device --version - name: Boot iOS simulator id: sim @@ -277,7 +286,7 @@ jobs: echo "user_id=$user_id" } >> "$GITHUB_OUTPUT" - - name: Run iOS e2e (Maestro) + - name: Run iOS e2e (agent-device) id: run_e2e_ios if: matrix.platform == 'ios' && steps.user.outputs.user_id != '' working-directory: ${{ env.FIXTURE_DIR }} @@ -295,18 +304,25 @@ 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 + # The one-time keyboard tutorial sheets carry their own Continue + # button, which makes the AuthView's Continue ambiguous. + for key in DidShowContinuousPathIntroduction DidShowGestureKeyboardIntroduction KeyboardDidShowProductivityTutorial UIKeyboardDidShowInternationalInfoIntroduction; do + xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.preferences "$key" -bool YES || true + done 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" + mkdir -p "$RUNNER_TEMP/agent-device-debug" xcrun simctl spawn "$SIM_UDID" log stream --style compact \ --predicate 'processImagePath CONTAINS "ClerkExpoNativeBuildFixture"' \ - > "$RUNNER_TEMP/maestro-debug/sim-console.log" 2>&1 & + > "$RUNNER_TEMP/agent-device-debug/sim-console.log" 2>&1 & LOG_PID=$! cd "$GITHUB_WORKSPACE/$E2E_DIR" + # On a cached build no xcodebuild has run, so without this the first + # snapshot pays the cold XCTest runner build and times out. + agent-device prepare ios-runner --platform ios --udid "$SIM_UDID" --timeout 600000 rc=0 - MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh \ - xcrun simctl terminate "$SIM_UDID" com.clerk.exponativebuildfixture || rc=$? + E2E_DEBUG_OUTPUT="$RUNNER_TEMP/agent-device-debug" ./run-flows.sh ios || rc=$? kill "$LOG_PID" 2>/dev/null || true exit "$rc" @@ -339,7 +355,7 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Run Android e2e (Maestro) + - name: Run Android e2e (agent-device) id: run_e2e_android if: matrix.platform == 'android' && steps.user.outputs.user_id != '' uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2 @@ -357,7 +373,7 @@ jobs: # scalar (>-) plus && keeps everything in one shell invocation. script: >- cd integration/tests/expo-native && - MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-android-flows.sh + E2E_DEBUG_OUTPUT="$RUNNER_TEMP/agent-device-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 @@ -371,26 +387,26 @@ jobs: curl -fsS -X DELETE "$BAPI_URL/v1/users/$USER_ID" \ -H "Authorization: Bearer $CLERK_SECRET_KEY" || true - # Test reports record flow env (and typed input) in plaintext; - # add-mask only covers step logs, not artifact contents. - - name: Scrub test credentials from Maestro debug output + # Session diagnostics record typed input in plaintext; add-mask only + # covers step logs, not artifact contents. + - name: Scrub test credentials from agent-device debug output if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure') env: CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }} run: | - [ -d "$RUNNER_TEMP/maestro-debug" ] || exit 0 + [ -d "$RUNNER_TEMP/agent-device-debug" ] || exit 0 [ -n "$CLERK_TEST_PASSWORD" ] || exit 0 # shellcheck disable=SC2016 - find "$RUNNER_TEMP/maestro-debug" -type f \ - \( -name '*.html' -o -name '*.json' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' \) \ + find "$RUNNER_TEMP/agent-device-debug" -type f \ + \( -name '*.html' -o -name '*.json' -o -name '*.ndjson' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' -o -name '*.ad' \) \ -exec perl -pi -e 's/\Q$ENV{CLERK_TEST_PASSWORD}\E/[REDACTED]/g' {} + - - name: Upload Maestro artifacts on e2e failure + - name: Upload agent-device artifacts on e2e failure if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure') uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: maestro-${{ matrix.platform }} - path: ${{ runner.temp }}/maestro-debug + name: agent-device-${{ matrix.platform }} + path: ${{ runner.temp }}/agent-device-debug retention-days: 7 - name: Report e2e outcome @@ -401,7 +417,7 @@ jobs: run: | outcome="$IOS_OUTCOME" [ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME" - echo "## Maestro e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY" + echo "## agent-device e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY" if [ "$outcome" = "failure" ]; then - echo "::error::Maestro e2e failed. See the maestro-${{ matrix.platform }} artifact." + echo "::error::agent-device e2e failed. See the agent-device-${{ matrix.platform }} artifact." fi diff --git a/integration/tests/expo-native/README.md b/integration/tests/expo-native/README.md new file mode 100644 index 00000000000..6a201375dd2 --- /dev/null +++ b/integration/tests/expo-native/README.md @@ -0,0 +1,40 @@ +# Expo native E2E + +Drives the `integration/templates/expo-native` fixture on an iOS simulator and +an Android emulator with [agent-device](https://github.com/callstack/agent-device). + +- `flows/*.sh` are the cross-platform tests, one per scenario. +- `flows/subflows/*.sh` are `run_flow`-only pieces shared by the tests. +- `lib.sh` is the verb table. Each function maps one step (`tap_on_id`, + `wait_visible_text`, `input_text`, ...) to one agent-device command, so a + flow reads as a list of steps and platform differences live in + `is_platform` branches. +- `run-flows.sh` runs a warmup launch, then every flow in its own session with + one clean-state retry, and writes a timing table to the job summary. + `run-android-flows.sh` installs the APK and captures logcat first. + +## Selectors + +Selectors use English text and labels because clerk-android ships no test +tags and both native SDKs localize every string, so devices must run the `en` +locale. clerk-ios accessibility identifiers such as `clerk.dismissButton` only +appear inside iOS-only branches. `text=` and `label=` match exactly, so +tapping `Continue` never hits the `Continue to ` title. Fixture testIDs +(`auth-state`, `open-auth-view-button`, ...) are matched with `id=`. + +Clean state is `settings clear-app-state` plus, on iOS, a simulator keychain +reset, because clerk-ios keeps device state in the keychain and agent-device +only clears the data container. + +## Run locally + +Build the fixture the way the workflow does and install it on a booted +simulator or emulator, then: + + export CLERK_TEST_EMAIL=... CLERK_TEST_PASSWORD=... + SIM_UDID= ./run-flows.sh ios + ./run-android-flows.sh + +`AGENT_DEVICE=` points at a specific binary. `E2E_DEBUG_OUTPUT=` +keeps a step log per attempt plus a failure screenshot and the agent-device +session state for attempts that failed. diff --git a/integration/tests/expo-native/config.yaml b/integration/tests/expo-native/config.yaml deleted file mode 100644 index 94e3659b694..00000000000 --- a/integration/tests/expo-native/config.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Maestro workspace config: https://docs.maestro.dev/reference/workspace-configuration -# Top-level files under flows/ are cross-platform tests; flows/subflows/ holds -# runFlow-only pieces that discovery never runs standalone. Selectors use -# English text/labels: clerk-android ships no testTag/resource-ids (clerk-ios -# does expose accessibility identifiers like clerk.auth.start.identifier, but -# shared flows can't rely on iOS-only ids), and both native SDKs localize all -# strings, so devices must run the en locale. -appId: com.clerk.exponativebuildfixture -flows: - - 'flows/*.yaml' -platform: - ios: - disableAnimations: true - android: - disableAnimations: true diff --git a/integration/tests/expo-native/flows/authview-detach-reattach.sh b/integration/tests/expo-native/flows/authview-detach-reattach.sh new file mode 100644 index 00000000000..d48cb2b60a1 --- /dev/null +++ b/integration/tests/expo-native/flows/authview-detach-reattach.sh @@ -0,0 +1,26 @@ +# Dismiss the native AuthView mid-flow and reopen it: the native view must +# survive detach/reattach and still complete a sign-in afterwards. Dismissal +# is the platform-native gesture: Android's system back button (doubling as +# the back-dismisses-AuthView regression), iOS's sheet Close control +# (clerk-ios exposes the stable clerk.dismissButton identifier). +# Also asserts the custom `logo` React element is rehosted into the native +# logo slot with a nonzero size, both on first mount and after reattach. +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +wait_visible_text 25000 "Welcome! Sign in to continue" "Welcome! Sign in to continue." +# Retrying wait (not assert_visible): the rehosted logo is sized on the first +# RN layout pass, so it can lag the welcome copy by a frame or two. +wait_visible_text 10000 "E2E Custom Logo" +if is_platform android; then + back_system +fi +if is_platform ios; then + tap_on_id clerk.dismissButton +fi +wait_visible_id open-auth-view-button 15000 +tap_on_id open-auth-view-button +wait_visible_text 15000 "E2E Custom Logo" +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/authview-detach-reattach.yaml b/integration/tests/expo-native/flows/authview-detach-reattach.yaml deleted file mode 100644 index ecb1c592d98..00000000000 --- a/integration/tests/expo-native/flows/authview-detach-reattach.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Dismiss the native AuthView mid-flow and reopen it: the native view must -# survive detach/reattach and still complete a sign-in afterwards. Dismissal -# is the platform-native gesture: Android's system back button (doubling as -# the back-dismisses-AuthView regression), iOS's sheet Close control -# (clerk-ios exposes the stable clerk.dismissButton identifier). -# Also asserts the custom `logo` React element is rehosted into the native -# logo slot with a nonzero size, both on first mount and after reattach. -appId: com.clerk.exponativebuildfixture -name: AuthView survives detach and reattach ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- extendedWaitUntil: - visible: 'Welcome! Sign in to continue\.?' - timeout: 25000 -# Retrying wait (not assertVisible): the rehosted logo is sized on the first -# RN layout pass, so it can lag the welcome copy by a frame or two. -- extendedWaitUntil: - visible: 'E2E Custom Logo' - timeout: 10000 -- runFlow: - when: - platform: Android - commands: - - back -- runFlow: - when: - platform: iOS - commands: - - tapOn: - id: 'clerk.dismissButton' -- extendedWaitUntil: - visible: - id: 'open-auth-view-button' - timeout: 15000 -- tapOn: - id: 'open-auth-view-button' -- extendedWaitUntil: - visible: 'E2E Custom Logo' - timeout: 15000 -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/flows/embedded-profile-host-back.sh b/integration/tests/expo-native/flows/embedded-profile-host-back.sh new file mode 100644 index 00000000000..0279feb8f96 --- /dev/null +++ b/integration/tests/expo-native/flows/embedded-profile-host-back.sh @@ -0,0 +1,31 @@ +# Embedded UserProfileView (onHostBack): internal navigation stays native, +# and the host-supplied root back button closes the screen from JS. +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +tap_on_id open-embedded-profile-button +# The internal push differs per platform: clerk-ios pushes a Security screen +# from the profile root, clerk-android uses tabs at the root and pushes the +# Manage account screen instead. The double-Back contract below is the same: +# both back buttons read 'Back' but only one exists at a time, so the first +# tap pops Clerk's internal stack and the second is the host chevron firing +# onHostBack. +if is_platform ios; then + wait_visible_text 20000 "Security" + tap_on_text "Security" + wait_visible_text 15000 "Password" "Passkeys" "Two-step verification" "Active devices" + tap_on_button_text "Back" + wait_visible_text 15000 "Security" +fi +if is_platform android; then + wait_visible_text 20000 "Edit profile" + tap_on_text "Manage account" + wait_visible_text 15000 "EMAIL ADDRESSES" "Add email address" + tap_on_button_text "Back" + wait_visible_text 15000 "Edit profile" +fi +tap_on_button_text "Back" +wait_visible_id open-embedded-profile-button 15000 +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/embedded-profile-host-back.yaml b/integration/tests/expo-native/flows/embedded-profile-host-back.yaml deleted file mode 100644 index 360a99e8ed7..00000000000 --- a/integration/tests/expo-native/flows/embedded-profile-host-back.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Embedded UserProfileView (onHostBack): internal navigation stays native, -# and the host-supplied root back button closes the screen from JS. -appId: com.clerk.exponativebuildfixture -name: Embedded UserProfileView host back round trip ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- tapOn: - id: 'open-embedded-profile-button' -# The internal push differs per platform: clerk-ios pushes a Security screen -# from the profile root, clerk-android uses tabs at the root and pushes the -# Manage account screen instead. The double-Back contract below is the same: -# both back buttons read 'Back' but only one exists at a time, so the first -# tap pops Clerk's internal stack and the second is the host chevron firing -# onHostBack. -- runFlow: - when: - platform: iOS - commands: - - extendedWaitUntil: - visible: 'Security' - timeout: 20000 - - tapOn: - text: 'Security' - - extendedWaitUntil: - visible: 'Password|Passkeys|Two-step verification|Active devices' - timeout: 15000 - - tapOn: 'Back' - - extendedWaitUntil: - visible: 'Security' - timeout: 15000 -- runFlow: - when: - platform: Android - commands: - - extendedWaitUntil: - visible: 'Edit profile' - timeout: 20000 - - tapOn: - text: 'Manage account' - - extendedWaitUntil: - visible: 'EMAIL ADDRESSES|Add email address' - timeout: 15000 - - tapOn: 'Back' - - extendedWaitUntil: - visible: 'Edit profile' - timeout: 15000 -- tapOn: 'Back' -- extendedWaitUntil: - visible: - id: 'open-embedded-profile-button' - timeout: 15000 -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/flows/google-sign-in-missing-credentials.sh b/integration/tests/expo-native/flows/google-sign-in-missing-credentials.sh new file mode 100644 index 00000000000..fd3507b8450 --- /dev/null +++ b/integration/tests/expo-native/flows/google-sign-in-missing-credentials.sh @@ -0,0 +1,4 @@ +# useSignInWithGoogle surfaces the missing-credentials error. +run_flow subflows/open-app.sh +tap_on_id google-sign-in-button +wait_visible_substring 15000 "Google Sign-In credentials not found" diff --git a/integration/tests/expo-native/flows/google-sign-in-missing-credentials.yaml b/integration/tests/expo-native/flows/google-sign-in-missing-credentials.yaml deleted file mode 100644 index 417319758f3..00000000000 --- a/integration/tests/expo-native/flows/google-sign-in-missing-credentials.yaml +++ /dev/null @@ -1,9 +0,0 @@ -appId: com.clerk.exponativebuildfixture -name: useSignInWithGoogle surfaces the missing-credentials error ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'google-sign-in-button' -- extendedWaitUntil: - visible: '.*Google Sign-In credentials not found.*' - timeout: 15000 diff --git a/integration/tests/expo-native/flows/session-persists-across-restart.sh b/integration/tests/expo-native/flows/session-persists-across-restart.sh new file mode 100644 index 00000000000..192ea664fee --- /dev/null +++ b/integration/tests/expo-native/flows/session-persists-across-restart.sh @@ -0,0 +1,14 @@ +# Sign in via the native AuthView, restart WITHOUT clearing state, and assert +# the session is restored from secure-store with no re-auth (bridge + token +# cache persistence). +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +stop_app +launch_app +wait_visible_text 45000 "signed in" +assert_visible_id user-id +# Leave the app signed out for whichever flow runs next. +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/session-persists-across-restart.yaml b/integration/tests/expo-native/flows/session-persists-across-restart.yaml deleted file mode 100644 index 95d8c900f6d..00000000000 --- a/integration/tests/expo-native/flows/session-persists-across-restart.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Sign in via the native AuthView, restart WITHOUT clearing state, and assert -# the session is restored from secure-store with no re-auth (bridge + token -# cache persistence). -appId: com.clerk.exponativebuildfixture -name: Session persists across app restart ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- stopApp -- launchApp -- extendedWaitUntil: - visible: 'signed in' - timeout: 45000 -- assertVisible: - id: 'user-id' -# Leave the app signed out for whichever flow runs next. -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/flows/sign-in.sh b/integration/tests/expo-native/flows/sign-in.sh new file mode 100644 index 00000000000..e8382709f3e --- /dev/null +++ b/integration/tests/expo-native/flows/sign-in.sh @@ -0,0 +1,8 @@ +# Native AuthView email+password sign-in, asserting the native->JS session +# sync, then JS-side sign-out asserting the reverse direction. +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/sign-in.yaml b/integration/tests/expo-native/flows/sign-in.yaml deleted file mode 100644 index 80645825691..00000000000 --- a/integration/tests/expo-native/flows/sign-in.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Native AuthView email+password sign-in, asserting the native->JS session -# sync, then JS-side sign-out asserting the reverse direction. -appId: com.clerk.exponativebuildfixture -name: Native AuthView sign-in syncs to JS ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/flows/subflows/_warmup.sh b/integration/tests/expo-native/flows/subflows/_warmup.sh new file mode 100644 index 00000000000..72b16bcc46b --- /dev/null +++ b/integration/tests/expo-native/flows/subflows/_warmup.sh @@ -0,0 +1,5 @@ +# Cold-boot warmup, not a test. Lets the JS bundle parse and the a11y tree +# populate once, so the first real flow does not eat the cold-start cost and +# flake its in-flow waits. Excluded from the per-flow loop (lives in subflows/). +launch_app --clear-state +wait_visible_id auth-state 90000 diff --git a/integration/tests/expo-native/flows/subflows/_warmup.yaml b/integration/tests/expo-native/flows/subflows/_warmup.yaml deleted file mode 100644 index bde6f57e9f3..00000000000 --- a/integration/tests/expo-native/flows/subflows/_warmup.yaml +++ /dev/null @@ -1,12 +0,0 @@ -# Cold-boot warmup, not a test. Lets the JS bundle parse and the a11y tree -# populate once, so the first real flow doesn't eat the cold-start cost and -# flake its in-flow waits. Excluded from the per-flow loop (lives in subflows/). -appId: com.clerk.exponativebuildfixture ---- -- launchApp: - clearState: true - clearKeychain: true -- extendedWaitUntil: - visible: - id: 'auth-state' - timeout: 90000 diff --git a/integration/tests/expo-native/flows/subflows/assert-signed-in.sh b/integration/tests/expo-native/flows/subflows/assert-signed-in.sh new file mode 100644 index 00000000000..e7761be0fcf --- /dev/null +++ b/integration/tests/expo-native/flows/subflows/assert-signed-in.sh @@ -0,0 +1,4 @@ +# Subflow: assert the JS layer observed the native sign-in (bridge sync). +# wait_visible_* retries until timeout; assert_visible_* does not. +wait_visible_text 30000 "signed in" +assert_visible_id user-id diff --git a/integration/tests/expo-native/flows/subflows/assert-signed-in.yaml b/integration/tests/expo-native/flows/subflows/assert-signed-in.yaml deleted file mode 100644 index 65bcc55159d..00000000000 --- a/integration/tests/expo-native/flows/subflows/assert-signed-in.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Subflow: assert the JS layer observed the native sign-in (bridge sync). -# extendedWaitUntil retries until timeout; assertVisible does not. -appId: com.clerk.exponativebuildfixture ---- -- extendedWaitUntil: - visible: 'signed in' - timeout: 30000 -- assertVisible: - id: 'user-id' diff --git a/integration/tests/expo-native/flows/subflows/assert-signed-out.sh b/integration/tests/expo-native/flows/subflows/assert-signed-out.sh new file mode 100644 index 00000000000..412ba802e80 --- /dev/null +++ b/integration/tests/expo-native/flows/subflows/assert-signed-out.sh @@ -0,0 +1,2 @@ +# Subflow: assert the JS layer is signed out. +wait_visible_text 20000 "signed out" diff --git a/integration/tests/expo-native/flows/subflows/assert-signed-out.yaml b/integration/tests/expo-native/flows/subflows/assert-signed-out.yaml deleted file mode 100644 index 72986f7cae9..00000000000 --- a/integration/tests/expo-native/flows/subflows/assert-signed-out.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Subflow: assert the JS layer is signed out. -appId: com.clerk.exponativebuildfixture ---- -- extendedWaitUntil: - visible: 'signed out' - timeout: 20000 diff --git a/integration/tests/expo-native/flows/subflows/open-app.sh b/integration/tests/expo-native/flows/subflows/open-app.sh new file mode 100644 index 00000000000..d6ee7d73624 --- /dev/null +++ b/integration/tests/expo-native/flows/subflows/open-app.sh @@ -0,0 +1,7 @@ +# Subflow: launch from a clean state and land signed out. +launch_app --clear-state +wait_visible_id auth-state 30000 +# Wait out clerk-js init ('loading') with a retrying wait; assert_visible +# never retries. +wait_visible_text 45000 "signed in" "signed out" +assert_visible_text "signed out" diff --git a/integration/tests/expo-native/flows/subflows/open-app.yaml b/integration/tests/expo-native/flows/subflows/open-app.yaml deleted file mode 100644 index 681f2d289c4..00000000000 --- a/integration/tests/expo-native/flows/subflows/open-app.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Subflow: launch from a clean state and land signed out. -appId: com.clerk.exponativebuildfixture ---- -- launchApp: - clearState: true - clearKeychain: true -- extendedWaitUntil: - visible: - id: 'auth-state' - timeout: 30000 -# Text-only matching from here on: iOS failed combined id+text filters on RN -# Text elements in CI while id-only and text-only both match. Also wait out -# clerk-js init ('loading') with a retrying wait; assertVisible never retries. -- extendedWaitUntil: - visible: 'signed (in|out)' - timeout: 45000 -- assertVisible: 'signed out' diff --git a/integration/tests/expo-native/flows/subflows/sign-in-email-password.sh b/integration/tests/expo-native/flows/subflows/sign-in-email-password.sh new file mode 100644 index 00000000000..359a4a600de --- /dev/null +++ b/integration/tests/expo-native/flows/subflows/sign-in-email-password.sh @@ -0,0 +1,71 @@ +# Subflow: enter email + password into the native AuthView and submit. +# Requires CLERK_TEST_EMAIL and CLERK_TEST_PASSWORD env vars. +wait_visible_text 25000 "Welcome! Sign in to continue" "Welcome! Sign in to continue." +# The AuthView sometimes renders its welcome text a beat before the email +# field; don't tap until the field is actually present. The placeholder +# varies by instance config (email-only vs email+username). +wait_visible_text 25000 "Enter your email" "Enter your email or username" + +# The retry gates on reaching the next step, so a mangled identifier gets a +# clean second attempt. +enter_identifier_and_continue() { + tap_on_text "Enter your email" "Enter your email or username" + wait_for_animation_to_end 2000 + # A remounted AuthView prefills the last-used identifier (Clerk persists it + # in secure-store, surviving clearState). Only retype when the field does + # not already hold the right value. + if ! is_visible_text "$CLERK_TEST_EMAIL"; then + input_text "$IDENTIFIER_FIELD" "$CLERK_TEST_EMAIL" + fi + # Wait for the typed value to land before tapping: the tap can race the + # recomposition that enables the button, and this also catches a mangled + # identifier inside the retry instead of at the next screen's timeout. + wait_visible_text 5000 "$CLERK_TEST_EMAIL" + wait_for_animation_to_end 2000 + tap_on_text "Continue" + # Which first factor comes next depends on instance config AND SDK: + # clerk-ios can go email-code-first where clerk-android goes + # password-first. Accept either screen. + wait_visible_text 15000 "Enter your password" "Check your email" +} +retry 2 enter_identifier_and_continue + +# Email-link-first instances (clerk-android prefers it when offered) cannot be +# automated; switch to the password strategy. Never taken on the CI instance. +if is_visible_text "Open email app"; then + tap_on_text "Use another method" + tap_on_text "Sign in with your password" +fi +# Email-code-first: for +clerk_test@ emails the documented test code is +# 424242: https://clerk.com/docs/testing/test-emails-and-phones +if is_visible_text "Check your email"; then + type_text "424242" + wait_for_animation_to_end 5000 +fi +if is_visible_text "Enter your password"; then + tap_on_text "Enter your password" + input_text "$PASSWORD_FIELD" "$CLERK_TEST_PASSWORD" + # The password is masked so its value can't be asserted; settle for the + # screen going stable so the tap doesn't race the enabling recomposition. + wait_for_animation_to_end 2000 + tap_on_text "Continue" + # The screen is stable while the request is in flight, so a settle alone + # returns too early for the conditional below. + wait_for_animation_to_end 5000 + wait_visible_text_optional 15000 "Check your email" "signed in" +fi +# Some instances ask for the email code after the password instead. +if is_visible_text "Check your email"; then + type_text "424242" + wait_for_animation_to_end 5000 +fi +# Android Google Password Manager may prompt to save the password. +if is_visible_substring "Google Password Manager"; then + tap_on_text "Not now" "Never" + wait_for_animation_to_end 2000 +fi +# iOS system Save Password / iCloud Keychain prompt overlays the app. +if is_visible_text "Save Password" "Strong Password" "Use Strong Password" "AutoFill Passwords"; then + tap_on_text "Not Now" "Never for This Website" "Don't Save" || true + wait_for_animation_to_end 2000 +fi diff --git a/integration/tests/expo-native/flows/subflows/sign-in-email-password.yaml b/integration/tests/expo-native/flows/subflows/sign-in-email-password.yaml deleted file mode 100644 index 69df6b2991c..00000000000 --- a/integration/tests/expo-native/flows/subflows/sign-in-email-password.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# Subflow: enter email + password into the native AuthView and submit. -# Requires CLERK_TEST_EMAIL and CLERK_TEST_PASSWORD env vars. -appId: com.clerk.exponativebuildfixture ---- -- extendedWaitUntil: - visible: 'Welcome! Sign in to continue\.?' - timeout: 25000 -# The AuthView sometimes renders its welcome text a beat before the email -# field; don't tap until the field is actually present. The placeholder -# varies by instance config (email-only vs email+username). -- extendedWaitUntil: - visible: 'Enter your email( or username)?' - timeout: 25000 -# Clear any pre-populated identifier (Clerk persists the last-used one in -# secure-store, surviving clearState). On iOS eraseText is cursor-dependent, -# so Select-all via the edit menu when it shows (the menu is unreliable on a -# remounted AuthView). On Android eraseText clears the whole field, and the -# long-press can open Gboard's features panel over the screen instead of the -# edit menu, so skip the menu dance there. The retry gates on reaching the -# password step, so a mangled identifier gets a clean second attempt. -- retry: - maxRetries: 2 - commands: - - tapOn: - text: 'Enter your email( or username)?' - - waitForAnimationToEnd: - timeout: 2000 - # A remounted AuthView prefills the last-used identifier, which is the - # same email this flow signs in with, and clearing a prefilled field is - # cursor-position dependent on Android. Only clear and retype when the - # field does not already hold the right value. - - runFlow: - when: - notVisible: ${CLERK_TEST_EMAIL} - commands: - - runFlow: - when: - platform: iOS - commands: - - longPressOn: - text: 'Enter your email( or username)?' - - runFlow: - when: - visible: 'Select All' - commands: - - tapOn: - text: 'Select All' - - eraseText: 100 - - inputText: ${CLERK_TEST_EMAIL} - # Wait for the typed value to land before tapping: the tap can race the - # recomposition that enables the button, and this also catches a mangled - # identifier inside the retry instead of at the next screen's timeout. - - extendedWaitUntil: - visible: ${CLERK_TEST_EMAIL} - timeout: 5000 - - waitForAnimationToEnd: - timeout: 2000 - - tapOn: - text: 'Continue' - index: 0 - # Which first factor comes next depends on instance config AND SDK: - # clerk-ios can go email-code-first where clerk-android goes - # password-first. Accept either screen. - - extendedWaitUntil: - visible: 'Enter your password|Check your email' - timeout: 15000 -# Email-code-first: for +clerk_test@ emails the documented test code is -# 424242: https://clerk.com/docs/testing/test-emails-and-phones -- runFlow: - when: - visible: 'Check your email' - commands: - - inputText: '424242' - - waitForAnimationToEnd: - timeout: 5000 -- runFlow: - when: - visible: 'Enter your password' - commands: - - tapOn: - text: 'Enter your password' - - eraseText: 50 - - inputText: ${CLERK_TEST_PASSWORD} - # The password is masked so its value can't be asserted; settle for the - # screen going stable so the tap doesn't race the enabling recomposition. - - waitForAnimationToEnd: - timeout: 2000 - - tapOn: - text: 'Continue' - index: 0 - - waitForAnimationToEnd: - timeout: 5000 -# Some instances ask for the email code after the password instead. -- runFlow: - when: - visible: 'Check your email' - commands: - - inputText: '424242' - - waitForAnimationToEnd: - timeout: 5000 -# Android Google Password Manager may prompt to save the password. -- runFlow: - when: - visible: '.*Google Password Manager.*' - commands: - - tapOn: - text: 'Not now|Never' - - waitForAnimationToEnd: - timeout: 2000 -# iOS system Save Password / iCloud Keychain prompt overlays the app. -- runFlow: - when: - visible: 'Save Password|Strong Password|Use Strong Password|AutoFill Passwords' - commands: - - tapOn: - text: 'Not Now|Never for This Website|Don.t Save' - optional: true - - waitForAnimationToEnd: - timeout: 2000 diff --git a/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.sh b/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.sh new file mode 100644 index 00000000000..486d0ad3fdc --- /dev/null +++ b/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.sh @@ -0,0 +1,26 @@ +# Native UserButton -> account sheet -> native sign-out, asserting the JS +# layer observes it (the reverse bridge direction from sign-in.sh). +# clerk-android exposes no test identifiers (no testTag / resource-ids), so +# shared selectors are English text / accessibility labels. +# +# Ends with a second sign-in in the SAME app process (no restart, no clean +# state): a past regression had the second native sign-in complete natively +# while the JS SDK never observed it. Every other flow cold-launches with +# clean state, so this is the only place that path is exercised. +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +# iOS labels the trigger "Open account"; Android "Open user profile". +tap_on_text "Open account" "Open user profile" +wait_visible_text 15000 "Manage account" +assert_visible_text "Sign out" +tap_on_text "Sign out" +run_flow subflows/assert-signed-out.sh +# Second sign-in without restarting the app: the remounted AuthView must +# work and the JS layer must observe the new session. +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.yaml b/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.yaml deleted file mode 100644 index eb1a2a12cb7..00000000000 --- a/integration/tests/expo-native/flows/user-button-sign-out-re-sign-in.yaml +++ /dev/null @@ -1,36 +0,0 @@ -# Native UserButton -> account sheet -> native sign-out, asserting the JS -# layer observes it (the reverse bridge direction from sign-in.yaml). -# clerk-android exposes no test identifiers (no testTag / resource-ids), so -# shared selectors are English text / accessibility labels. -# -# Ends with a second sign-in in the SAME app process (no restart, no -# clearState): a past regression had the second native sign-in complete -# natively while the JS SDK never observed it. Every other flow cold-launches -# with clearState, so this is the only place that path is exercised. -appId: com.clerk.exponativebuildfixture -name: UserButton native sign-out, then same-process re-sign-in ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -# iOS labels the trigger "Open account"; Android "Open user profile". -- tapOn: - text: 'Open (account|user profile)' -- extendedWaitUntil: - visible: 'Manage account' - timeout: 15000 -- assertVisible: 'Sign out' -- tapOn: - text: 'Sign out' -- runFlow: subflows/assert-signed-out.yaml -# Second sign-in without restarting the app: the remounted AuthView must -# work and the JS layer must observe the new session. -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/flows/user-profile-custom-pages.sh b/integration/tests/expo-native/flows/user-profile-custom-pages.sh new file mode 100644 index 00000000000..18fc6c4a20e --- /dev/null +++ b/integration/tests/expo-native/flows/user-profile-custom-pages.sh @@ -0,0 +1,25 @@ +# A custom page's React Native content is mounted as a child of the native host +# and rehosted into the destination its row pushes, then survives the trip back. +run_flow subflows/open-app.sh +tap_on_id open-auth-view-button +run_flow subflows/sign-in-email-password.sh +run_flow subflows/assert-signed-in.sh +tap_on_id open-embedded-profile-button +# Retrying wait: the row list lands a frame after the native profile paints. +wait_visible_text 20000 "E2E Custom Page" +tap_on_text "E2E Custom Page" +wait_visible_text 15000 "Rehosted RN body" +# The Android destination is a bare AndroidView with no back chrome, unlike the +# iOS page which clerk-ios pushes onto its own NavigationStack. +if is_platform ios; then + tap_on_button_text "Back" +fi +if is_platform android; then + back_system +fi +wait_visible_text 15000 "E2E Custom Page" +# The host chevron firing onHostBack, which reads 'Back' on both platforms. +tap_on_button_text "Back" +wait_visible_id open-embedded-profile-button 15000 +tap_on_id sign-out-button +run_flow subflows/assert-signed-out.sh diff --git a/integration/tests/expo-native/flows/user-profile-custom-pages.yaml b/integration/tests/expo-native/flows/user-profile-custom-pages.yaml deleted file mode 100644 index 0b408084b51..00000000000 --- a/integration/tests/expo-native/flows/user-profile-custom-pages.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# A custom page's React Native content is mounted as a child of the native host -# and rehosted into the destination its row pushes, then survives the trip back. -appId: com.clerk.exponativebuildfixture -name: UserProfileView renders a custom page ---- -- runFlow: subflows/open-app.yaml -- tapOn: - id: 'open-auth-view-button' -- runFlow: subflows/sign-in-email-password.yaml -- runFlow: subflows/assert-signed-in.yaml -- tapOn: - id: 'open-embedded-profile-button' -# Retrying wait: the row list lands a frame after the native profile paints. -- extendedWaitUntil: - visible: 'E2E Custom Page' - timeout: 20000 -- tapOn: - text: 'E2E Custom Page' -- extendedWaitUntil: - visible: 'Rehosted RN body' - timeout: 15000 -# The Android destination is a bare AndroidView with no back chrome, unlike the -# iOS page which clerk-ios pushes onto its own NavigationStack. -- runFlow: - when: - platform: iOS - commands: - - tapOn: 'Back' -- runFlow: - when: - platform: Android - commands: - - back -- extendedWaitUntil: - visible: 'E2E Custom Page' - timeout: 15000 -# The host chevron firing onHostBack, which reads 'Back' on both platforms. -- tapOn: 'Back' -- extendedWaitUntil: - visible: - id: 'open-embedded-profile-button' - timeout: 15000 -- tapOn: - id: 'sign-out-button' -- runFlow: subflows/assert-signed-out.yaml diff --git a/integration/tests/expo-native/lib.sh b/integration/tests/expo-native/lib.sh new file mode 100644 index 00000000000..751c29c8701 --- /dev/null +++ b/integration/tests/expo-native/lib.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Verb table: one function per Maestro verb, one agent-device call each, so a +# flow file reads like the YAML it replaced. Sourced by run-flows.sh. + +: "${AGENT_DEVICE:=agent-device}" +: "${PLATFORM:?PLATFORM (ios|android) is required}" +: "${AD_SESSION:?AD_SESSION is required}" +: "${APP_ID:=com.clerk.exponativebuildfixture}" +: "${FLOWS_DIR:=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/flows}" + +is_platform() { [ "$PLATFORM" = "$1" ]; } + +ad() { + local target=() + if is_platform ios && [ -n "${SIM_UDID:-}" ]; then target=(--udid "$SIM_UDID"); fi + "$AGENT_DEVICE" "$@" --session "$AD_SESSION" --platform "$PLATFORM" ${target[@]+"${target[@]}"} +} + +# Prints agent-device output only on failure to keep CI logs to the errors. +ad_run() { + local rc=0 + AD_OUT=$(ad "$@" 2>&1) || rc=$? + [ $rc -eq 0 ] || printf '%s\n' "$AD_OUT" + return $rc +} + +# wait/is reject ambiguous selectors; a second match still proves visibility. +ad_visible() { + local rc=0 + AD_OUT=$(ad "$@" 2>&1) || rc=$? + if [ $rc -ne 0 ] && [[ $AD_OUT == *AMBIGUOUS_MATCH* ]]; then rc=0; fi + [ $rc -eq 0 ] || printf '%s\n' "$AD_OUT" + return $rc +} + +step() { echo " - $*"; } + +selector_text() { + local sel="" alt + for alt in "$@"; do sel+="${sel:+ || }text=\"$alt\""; done + printf '%s' "$sel" +} + +force_stop() { + if is_platform ios; then + xcrun simctl terminate "${SIM_UDID:-booted}" "$APP_ID" >/dev/null 2>&1 || true + else + adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true + fi +} + +# --clear-state is Maestro's clearState + clearKeychain; clear-app-state never +# touches the iOS keychain, where clerk-ios keeps device state. +launch_app() { + if [ "${1:-}" = "--clear-state" ]; then + step "launchApp clearState clearKeychain" + force_stop + ad_run settings clear-app-state "$APP_ID" + if is_platform ios; then xcrun simctl keychain "${SIM_UDID:-booted}" reset; fi + else + step "launchApp" + fi + ad_run open "$APP_ID" --relaunch +} + +stop_app() { + step "stopApp" + force_stop +} + +tap_on_id() { + step "tapOn id=$1" + ad_run press "id=\"$1\"" +} +tap_on_text() { + step "tapOn text=$*" + ad_run press "$(selector_text "$@")" +} +# iOS labels both the nav bar and its button "Back", so the role disambiguates; +# Compose labels only the group around an unlabeled button. +tap_on_button_text() { + step "tapOn button text=$*" + if is_platform ios; then + ad_run press "role=button $(selector_text "$@")" + else + ad_run press "$(selector_text "$@")" + fi +} + +# fill replaces the value, so Maestro's eraseText has no counterpart. +input_text() { + step "inputText into $1" + ad fill "$1" "$2" >/dev/null 2>&1 +} +# For one-time-code boxes, which expose no field selector. +type_text() { + step "inputText (focused field)" + ad_run type "$1" +} + +wait_visible_id() { + step "extendedWaitUntil id=$1 (${2}ms)" + ad_visible wait "id=\"$1\"" "$2" +} +wait_visible_text() { + local ms=$1 + shift + step "extendedWaitUntil text=$* (${ms}ms)" + ad_visible wait "$(selector_text "$@")" "$ms" +} +# Gates a conditional rather than asserting, so a timeout is not a failure. +wait_visible_text_optional() { + local ms=$1 + shift + step "waitUntil (optional) text=$* (${ms}ms)" + ad_visible wait "$(selector_text "$@")" "$ms" >/dev/null 2>&1 || true +} +wait_visible_substring() { + step "extendedWaitUntil substring=$2 (${1}ms)" + ad_run find text "$2" wait "$1" +} + +assert_visible_id() { + step "assertVisible id=$1" + ad_visible is exists "id=\"$1\"" +} +assert_visible_text() { + step "assertVisible text=$*" + ad_visible is exists "$(selector_text "$@")" +} + +is_visible_text() { ad_visible is exists "$(selector_text "$@")" >/dev/null 2>&1; } +is_visible_substring() { [[ $(ad find text "$1" exists 2>/dev/null) == *"Found: true"* ]]; } + +# waitForAnimationToEnd never fails a Maestro flow, so the timeout is swallowed. +wait_for_animation_to_end() { + step "waitForAnimationToEnd (${1}ms)" + ad wait stable 500 "$1" >/dev/null 2>&1 || true +} + +back_system() { + step "back" + ad_run back --system +} + +run_flow() { + step "runFlow $1" + source "$FLOWS_DIR/$1" +} + +# maxRetries=N means N more attempts after the first, as in Maestro. +retry() { + local max=$1 fn=$2 attempt + for attempt in $(seq 0 "$max"); do + if ( + set -e + "$fn" + ); then return 0; fi + [ "$attempt" -lt "$max" ] && step "retry $fn (attempt $((attempt + 1)) failed)" + done + return 1 +} + +ad_screenshot() { ad screenshot "$1" >/dev/null 2>&1 || true; } +ad_close() { ad close >/dev/null 2>&1 || true; } +ad_session_dir() { printf '%s/sessions/%s' "$("$AGENT_DEVICE" session state-dir 2>/dev/null)" "$AD_SESSION"; } + +# placeholder is not a selector key, so fields are targeted by role and id. +if is_platform ios; then + IDENTIFIER_FIELD='role=textfield id="clerk.auth.start.identifier"' + PASSWORD_FIELD='role=securetextfield id="clerk.auth.signIn.password"' +else + IDENTIFIER_FIELD='role=edittext' + PASSWORD_FIELD='role=edittext' +fi diff --git a/integration/tests/expo-native/run-android-flows.sh b/integration/tests/expo-native/run-android-flows.sh index 29a8e165b84..52164f6a65f 100755 --- a/integration/tests/expo-native/run-android-flows.sh +++ b/integration/tests/expo-native/run-android-flows.sh @@ -18,11 +18,11 @@ stop_logcat() { } trap stop_logcat EXIT -if [ -n "${MAESTRO_DEBUG_OUTPUT:-}" ]; then - mkdir -p "$MAESTRO_DEBUG_OUTPUT" +if [ -n "${E2E_DEBUG_OUTPUT:-}" ]; then + mkdir -p "$E2E_DEBUG_OUTPUT" adb logcat -c || true - adb logcat -v threadtime > "$MAESTRO_DEBUG_OUTPUT/android-logcat.log" 2>&1 & + adb logcat -v threadtime > "$E2E_DEBUG_OUTPUT/android-logcat.log" 2>&1 & logcat_pid=$! fi -./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture +./run-flows.sh android diff --git a/integration/tests/expo-native/run-flows.sh b/integration/tests/expo-native/run-flows.sh index b3eb7b7f64c..1a5a44e1cff 100755 --- a/integration/tests/expo-native/run-flows.sh +++ b/integration/tests/expo-native/run-flows.sh @@ -1,50 +1,66 @@ #!/usr/bin/env bash -# Runs every top-level Maestro flow (flows/*.yaml; flows/subflows/ are -# runFlow-only pieces) as one CLI invocation per flow, so a hang or crash in -# one flow can't poison the rest, with one clean-state retry per flow. -# Whole-flow retry can mask app instability (the Maestro docs discourage it), -# so it is capped at a single retry purely to absorb emulator/simulator flake. +# Runs every top-level flow (flows/*.sh; flows/subflows/ are run_flow-only +# pieces) in its own agent-device session, so a hang or crash in one flow +# can't poison the rest, with one clean-state retry per flow. Whole-flow retry +# can mask app instability, so it is capped at a single retry purely to absorb +# emulator/simulator flake. # -# Usage: ./run-flows.sh [force-stop command...] -# CI iOS: ./run-flows.sh xcrun simctl terminate "$SIM_UDID" com.clerk.exponativebuildfixture -# CI Android: ./run-flows.sh adb shell am force-stop com.clerk.exponativebuildfixture -# Local: ./run-flows.sh +# Usage: ./run-flows.sh +# CI iOS: SIM_UDID=... ./run-flows.sh ios (app installed by caller) +# CI Android: ./run-android-flows.sh (installs, then runs android) # -# Required env: CLERK_TEST_EMAIL, CLERK_TEST_PASSWORD -# Optional env: MAESTRO_DEBUG_OUTPUT (directory for CI debug artifacts) +# Required env: CLERK_TEST_EMAIL, CLERK_TEST_PASSWORD; SIM_UDID on ios +# Optional env: E2E_DEBUG_OUTPUT (directory for CI debug artifacts), +# AGENT_DEVICE (binary, default: agent-device on PATH) set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" -# Runs the official maestro CLI. maestro-runner was tried and reverted: its -# drivers mangle typed text and resolve text selectors by substring, so -# tapOn 'Continue' hits the 'Continue to ' title instead of the button. -command -v maestro >/dev/null 2>&1 || { - echo "maestro is required: https://docs.maestro.dev" +export PLATFORM=${1:?platform (ios|android) is required} +export AGENT_DEVICE=${AGENT_DEVICE:-agent-device} +export APP_ID=com.clerk.exponativebuildfixture +export FLOWS_DIR=$PWD/flows + +command -v "$AGENT_DEVICE" >/dev/null 2>&1 || { + echo "agent-device is required: https://github.com/callstack/agent-device" exit 1 } - : "${CLERK_TEST_EMAIL:?CLERK_TEST_EMAIL is required}" : "${CLERK_TEST_PASSWORD:?CLERK_TEST_PASSWORD is required}" +if [ "$PLATFORM" = ios ]; then : "${SIM_UDID:?SIM_UDID is required on ios}"; fi -force_stop() { if [ "$#" -gt 0 ]; then "$@" >/dev/null 2>&1 || true; fi; } +debug_root=${E2E_DEBUG_OUTPUT:-${TMPDIR:-/tmp}/clerk-expo-agent-device-runner} -run_flow() { - local output_name=$1 - shift - local output_root=${MAESTRO_DEBUG_OUTPUT:-${TMPDIR:-/tmp}/clerk-expo-maestro-runner} - - maestro test \ - --debug-output "$output_root/$output_name" \ - --flatten-debug-output \ +with_lib() { + ( + source ./lib.sh "$@" + ) || true } -record_result() { - local flow=$1 - local result=$2 - local attempts=$3 - local duration=$4 +# One attempt of one flow in a fresh session. Failure evidence goes to +# $debug_root// before the session is closed. +run_flow_attempt() { + local name=$1 flow=$2 + local out_dir=$debug_root/$name + mkdir -p "$out_dir" + export AD_SESSION=e2e-$name + local rc=0 + ( + set -e + source ./lib.sh + echo "Flow $flow" + source "$flow" + ) 2>&1 | tee "$out_dir/steps.log" || rc=${PIPESTATUS[0]} + if [ "$rc" -ne 0 ]; then + with_lib ad_screenshot "$out_dir/failure.png" + cp -R "$(with_lib ad_session_dir)" "$out_dir/session" 2>/dev/null || true + fi + with_lib ad_close + return "$rc" +} +record_result() { + local flow=$1 result=$2 attempts=$3 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" @@ -53,7 +69,7 @@ record_result() { if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { - echo '### Maestro flow timings' + echo '### agent-device flow timings' echo '| Flow | Result | Attempts | Duration |' echo '| --- | --- | ---: | ---: |' } >> "$GITHUB_STEP_SUMMARY" @@ -63,52 +79,47 @@ fi warmup_started=$SECONDS warmup_result=failed for warmup_attempt in 1 2; do - if run_flow "warmup-attempt-$warmup_attempt" flows/subflows/_warmup.yaml; then + if run_flow_attempt "warmup-attempt-$warmup_attempt" flows/subflows/_warmup.sh; then warmup_result=passed break fi - force_stop "$@" + with_lib 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" +record_result "_warmup" "$warmup_result" "$warmup_attempt" "$((SECONDS - warmup_started))" if [ "$warmup_result" != passed ]; then - echo "::error::Warmup failed after 2 attempts; aborting Maestro flows" + echo "::error::Warmup failed after 2 attempts; aborting flows" exit 1 fi -# Force-stop so the first launchApp clearState doesn't race the warm process. -force_stop "$@" +# Force-stop so the first clean-state launch doesn't race the warm process. +with_lib force_stop -# Every flows/*.yaml is a cross-platform test (platform differences live in -# per-step `when: platform:` conditionals); flows/subflows/ are runFlow-only. +# Every flows/*.sh is a cross-platform test (platform differences live in +# is_platform branches); flows/subflows/ are run_flow-only. status=0 -for flow in flows/*.yaml; do +for flow in flows/*.sh; do [ -e "$flow" ] || continue flow_started=$SECONDS flow_result=failed for attempt in 1 2; do - if run_flow "${flow##*/}-attempt-$attempt" \ - -e CLERK_TEST_EMAIL="$CLERK_TEST_EMAIL" \ - -e CLERK_TEST_PASSWORD="$CLERK_TEST_PASSWORD" \ - "$flow"; then + if run_flow_attempt "${flow##*/}-attempt-$attempt" "$flow"; then flow_result=passed break fi if [ "$attempt" -eq 2 ]; then echo "::error::Flow $flow failed after 2 attempts" status=1 - force_stop "$@" + with_lib force_stop break fi echo "::warning::Flow $flow failed attempt $attempt, retrying after 10s..." - force_stop "$@" + with_lib force_stop sleep 10 done - flow_duration=$((SECONDS - flow_started)) - record_result "$flow" "$flow_result" "$attempt" "$flow_duration" + record_result "$flow" "$flow_result" "$attempt" "$((SECONDS - flow_started))" done exit $status diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt index 40d39f1119a..8d5d9e49506 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt @@ -10,12 +10,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.clerk.api.Clerk import com.clerk.api.FrameworkIntegrationApi import com.clerk.api.ui.ClerkDesign @@ -100,6 +103,16 @@ class ClerkAuthNativeView(context: Context, appContext: AppContext) : ClerkCompo @Composable private fun AuthContent() { + // An AuthView composed before Clerk has loaded the environment renders no + // form and never recovers, so recreate it once loaded. + val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() + key(isInitialized) { + AuthContentBody() + } + } + + @Composable + private fun AuthContentBody() { AuthView( modifier = Modifier.fillMaxSize(), clerkTheme = authTheme(),