Skip to content

Commit abc6abf

Browse files
committed
ci(e2e): replace maestro with touchpress in the expo native e2e
The seven expo-native flows now run on the Playwright runner through touchpress, which drives the same simulator and emulator through agent-device. Each Maestro flow becomes one spec and each subflow a function, so runFlow's implicit environment is replaced by arguments. The warmup flow is gone: touchpress's readyWhen gate holds the worker's launch until the bundle renders, which is what the warmup bought. The per-flow bash runner is gone too, replaced by the runner's own retries, per-test timeouts and HTML report. Clearing app state, the platform back gesture and typing into a focused field have no touchpress equivalent, so device-control.ts reaches the agent-device client directly on the session touchpress already opened.
1 parent 88a965c commit abc6abf

35 files changed

Lines changed: 864 additions & 659 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.github/workflows/expo-native-build.yml‎

Lines changed: 55 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- main
88
paths:
99
- '.github/workflows/expo-native-build.yml'
10+
- 'integration/playwright.expo-native.config.ts'
1011
- 'integration/templates/expo-native/**'
1112
- 'integration/tests/expo-native/**'
1213
- 'packages/expo/**'
@@ -27,7 +28,9 @@ env:
2728
SDK_PACK_DIR: /tmp/clerk-expo-pack
2829
E2E_INSTANCE_NAME: clerkstage-with-native-components
2930
BAPI_URL: https://api.clerkstage.dev
30-
MAESTRO_VERSION: '2.8.0'
31+
# Only keys the runner cache. Keep it equal to the agent-device version
32+
# pinned in the root package.json, or the cache silently stops hitting.
33+
AGENT_DEVICE_VERSION: '0.20.10'
3134

3235
jobs:
3336
native-build:
@@ -103,16 +106,18 @@ jobs:
103106
# Bump the version when native build commands change.
104107
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 }}
105108

106-
- if: steps.native-build-cache.outputs.cache-hit != 'true'
109+
- if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true
107110
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
108-
- if: steps.native-build-cache.outputs.cache-hit != 'true'
111+
- if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true
109112
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
110113
with:
111114
node-version: 24.15.0
112115
cache: pnpm
113116

117+
# An e2e job installs even on a native-build cache hit: the suite runs on
118+
# Playwright and touchpress out of the workspace's own node_modules.
114119
- name: Install monorepo dependencies
115-
if: steps.native-build-cache.outputs.cache-hit != 'true'
120+
if: steps.native-build-cache.outputs.cache-hit != 'true' || matrix.run-e2e == true
116121
run: pnpm install --frozen-lockfile
117122

118123
- name: Build and pack Clerk packages
@@ -137,8 +142,8 @@ jobs:
137142
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
138143
pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" -w
139144
# expo-dev-client makes even release builds boot into the dev
140-
# launcher (unreachable Metro in CI), which stalls every Maestro
141-
# flow on a blank screen. Skip it on e2e jobs only.
145+
# launcher (unreachable Metro in CI), which stalls every e2e spec on
146+
# a blank screen. Skip it on e2e jobs only.
142147
DEV_CLIENT="expo-dev-client"
143148
if [ "$RUN_E2E" = "true" ]; then DEV_CLIENT=""; fi
144149
pnpm expo install expo-auth-session expo-constants expo-crypto $DEV_CLIENT expo-secure-store expo-web-browser
@@ -223,32 +228,21 @@ jobs:
223228
path: ${{ steps.native-build-key.outputs.artifact }}
224229
key: ${{ steps.native-build-cache.outputs.cache-primary-key }}
225230

226-
- name: Cache maestro CLI
227-
if: steps.keys.outputs.pk != ''
231+
- name: Resolve Xcode version
232+
id: xcode
233+
if: matrix.platform == 'ios' && steps.keys.outputs.pk != ''
234+
run: echo "version=$(xcodebuild -version | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT"
235+
236+
# agent-device builds an XCTest runner the first time it drives a
237+
# simulator, which costs minutes. Exact key on purpose: agent-device
238+
# advises against restore-key fallbacks here, and prepare recovers a
239+
# stale one anyway.
240+
- name: Cache agent-device Apple runner
241+
if: matrix.platform == 'ios' && steps.keys.outputs.pk != ''
228242
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
229243
with:
230-
path: ~/.maestro
231-
key: maestro-${{ runner.os }}-${{ env.MAESTRO_VERSION }}
232-
233-
- name: Install maestro CLI
234-
if: steps.keys.outputs.pk != ''
235-
run: |
236-
set -o pipefail
237-
if [ -x "$HOME/.maestro/bin/maestro" ]; then
238-
echo "Using cached Maestro"
239-
else
240-
installed=0
241-
for i in 1 2 3; do
242-
if curl -fLs --retry 3 --retry-delay 5 "https://get.maestro.mobile.dev" | MAESTRO_VERSION="$MAESTRO_VERSION" bash; then
243-
if [ -x "$HOME/.maestro/bin/maestro" ]; then installed=1; break; fi
244-
fi
245-
echo "Maestro install attempt $i failed (or binary missing); retrying"
246-
sleep 5
247-
done
248-
[ "$installed" = 1 ] || { echo "::error::Maestro install failed after 3 attempts"; exit 1; }
249-
fi
250-
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
251-
"$HOME/.maestro/bin/maestro" --version
244+
path: ~/.agent-device/apple-runner/derived
245+
key: agent-device-runner-${{ runner.os }}-${{ env.AGENT_DEVICE_VERSION }}-${{ steps.xcode.outputs.version }}
252246

253247
- name: Boot iOS simulator
254248
id: sim
@@ -286,7 +280,7 @@ jobs:
286280
echo "user_id=$user_id"
287281
} >> "$GITHUB_OUTPUT"
288282
289-
- name: Run iOS e2e (Maestro)
283+
- name: Run iOS e2e
290284
id: run_e2e_ios
291285
if: matrix.platform == 'ios' && steps.user.outputs.user_id != ''
292286
working-directory: ${{ env.FIXTURE_DIR }}
@@ -305,22 +299,25 @@ jobs:
305299
xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.AutoCorrection -bool NO || true
306300
xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.Prediction -bool NO || true
307301
# The one-time keyboard tutorial sheets carry their own Continue
308-
# button, which can hijack taps on the AuthView's Continue.
302+
# button, which makes the AuthView's Continue ambiguous.
309303
for key in DidShowContinuousPathIntroduction DidShowGestureKeyboardIntroduction KeyboardDidShowProductivityTutorial UIKeyboardDidShowInternationalInfoIntroduction; do
310304
xcrun simctl spawn "$SIM_UDID" defaults write com.apple.keyboard.preferences "$key" -bool YES || true
311305
done
312306
xcrun simctl install "$SIM_UDID" ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app
313307
# Stream the app's console output into the debug artifact so a hang has
314308
# actionable evidence (keychain/network errors) instead of just screenshots.
315-
mkdir -p "$RUNNER_TEMP/maestro-debug"
309+
mkdir -p "$RUNNER_TEMP/e2e-debug"
316310
xcrun simctl spawn "$SIM_UDID" log stream --style compact \
317311
--predicate 'processImagePath CONTAINS "ClerkExpoNativeBuildFixture"' \
318-
> "$RUNNER_TEMP/maestro-debug/sim-console.log" 2>&1 &
312+
> "$RUNNER_TEMP/e2e-debug/sim-console.log" 2>&1 &
319313
LOG_PID=$!
320-
cd "$GITHUB_WORKSPACE/$E2E_DIR"
314+
cd "$GITHUB_WORKSPACE"
315+
# On a native-build cache hit no xcodebuild has run, so without this
316+
# the first snapshot pays for the cold XCTest runner build and the
317+
# launch times out.
318+
./node_modules/.bin/agent-device prepare ios-runner --platform ios --udid "$SIM_UDID" --timeout 600000
321319
rc=0
322-
MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh \
323-
xcrun simctl terminate "$SIM_UDID" com.clerk.exponativebuildfixture || rc=$?
320+
pnpm test:integration:expo-native --project=ios || rc=$?
324321
kill "$LOG_PID" 2>/dev/null || true
325322
exit "$rc"
326323
@@ -353,7 +350,7 @@ jobs:
353350
disable-animations: false
354351
script: echo "Generated AVD snapshot for caching."
355352

356-
- name: Run Android e2e (Maestro)
353+
- name: Run Android e2e
357354
id: run_e2e_android
358355
if: matrix.platform == 'android' && steps.user.outputs.user_id != ''
359356
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
@@ -370,9 +367,11 @@ jobs:
370367
# The action runs each script line in a separate sh -c; the folded
371368
# scalar (>-) plus && keeps everything in one shell invocation.
372369
script: >-
373-
cd integration/tests/expo-native &&
374-
MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-android-flows.sh
375-
"$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk"
370+
mkdir -p "$RUNNER_TEMP/e2e-debug" &&
371+
adb install -r "$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" &&
372+
adb logcat -c &&
373+
(adb logcat -v threadtime > "$RUNNER_TEMP/e2e-debug/android-logcat.log" 2>&1 &) &&
374+
pnpm test:integration:expo-native --project=android
376375
377376
# Runs before the artifact upload so the credentials are already dead
378377
# by the time the debug files become downloadable.
@@ -385,26 +384,30 @@ jobs:
385384
curl -fsS -X DELETE "$BAPI_URL/v1/users/$USER_ID" \
386385
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true
387386
388-
# Test reports record flow env (and typed input) in plaintext;
389-
# add-mask only covers step logs, not artifact contents.
390-
- name: Scrub test credentials from Maestro debug output
387+
# A driver error can quote the text it was handed, and add-mask only
388+
# covers step logs, not artifact contents.
389+
- name: Scrub test credentials from the e2e report
391390
if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure')
392391
env:
393392
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
394393
run: |
395-
[ -d "$RUNNER_TEMP/maestro-debug" ] || exit 0
396394
[ -n "$CLERK_TEST_PASSWORD" ] || exit 0
395+
mkdir -p "$RUNNER_TEMP/e2e-debug"
396+
for dir in "$E2E_DIR/playwright-report" "$E2E_DIR/test-results"; do
397+
[ -d "$dir" ] || continue
398+
cp -R "$dir" "$RUNNER_TEMP/e2e-debug/"
399+
done
397400
# shellcheck disable=SC2016
398-
find "$RUNNER_TEMP/maestro-debug" -type f \
399-
\( -name '*.html' -o -name '*.json' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' \) \
401+
find "$RUNNER_TEMP/e2e-debug" -type f \
402+
\( -name '*.html' -o -name '*.json' -o -name '*.jsonl' -o -name '*.md' -o -name '*.log' -o -name '*.txt' -o -name '*.xml' -o -name '*.yaml' -o -name '*.zip' \) \
400403
-exec perl -pi -e 's/\Q$ENV{CLERK_TEST_PASSWORD}\E/[REDACTED]/g' {} +
401404
402-
- name: Upload Maestro artifacts on e2e failure
405+
- name: Upload e2e artifacts on failure
403406
if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure')
404407
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
405408
with:
406-
name: maestro-${{ matrix.platform }}
407-
path: ${{ runner.temp }}/maestro-debug
409+
name: expo-native-e2e-${{ matrix.platform }}
410+
path: ${{ runner.temp }}/e2e-debug
408411
retention-days: 7
409412

410413
- name: Report e2e outcome
@@ -415,7 +418,7 @@ jobs:
415418
run: |
416419
outcome="$IOS_OUTCOME"
417420
[ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME"
418-
echo "## Maestro e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY"
421+
echo "## Expo native e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY"
419422
if [ "$outcome" = "failure" ]; then
420-
echo "::error::Maestro e2e failed. See the maestro-${{ matrix.platform }} artifact."
423+
echo "::error::Expo native e2e failed. See the expo-native-e2e-${{ matrix.platform }} artifact."
421424
fi

‎integration/playwright.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ config({ path: path.resolve(import.meta.dirname, '.env.local') });
99

1010
export const common: PlaywrightTestConfig = {
1111
testDir: './tests',
12+
// Device specs, run by playwright.expo-native.config.ts against a booted
13+
// simulator or emulator. Nothing here can drive one.
14+
testIgnore: '**/expo-native/**',
1215
snapshotDir: './tests/snapshots',
1316
fullyParallel: true,
1417
forbidOnly: !!process.env.CI,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/* eslint-disable turbo/no-undeclared-env-vars */
2+
3+
import { defineConfig } from '@playwright/test';
4+
import type { TouchpressOptions } from 'touchpress';
5+
6+
const deviceName = process.env.E2E_DEVICE_NAME;
7+
8+
export default defineConfig<TouchpressOptions>({
9+
testDir: './tests/expo-native',
10+
forbidOnly: !!process.env.CI,
11+
// A spec that needs its retry every run is a bug, not a flake. One absorbs
12+
// emulator and simulator noise, and no more.
13+
retries: process.env.CI ? 1 : 0,
14+
// One device, so one worker.
15+
workers: 1,
16+
// The heaviest spec signs in twice through the native AuthView.
17+
timeout: 300_000,
18+
expect: { timeout: 15_000 },
19+
reporter: process.env.CI
20+
? [['list'], ['html', { open: 'never', outputFolder: './tests/expo-native/playwright-report' }]]
21+
: 'list',
22+
outputDir: './tests/expo-native/test-results',
23+
24+
use: {
25+
app: 'com.clerk.exponativebuildfixture',
26+
// The JS bundle has parsed once this renders. clerk-js is still
27+
// initialising ('loading'), so every spec waits for 'signed out' as well.
28+
readyWhen: { testId: 'auth-state' },
29+
actionTimeout: 20_000,
30+
// Every spec clears state and relaunches itself, and that has to happen
31+
// inside the test rather than in the fixture that would precede it.
32+
// The per-test relaunch would only add a launch nothing reads.
33+
relaunch: 'per-worker',
34+
// CI boots a fresh device per job; a claim left by a killed run must not
35+
// fail the next one.
36+
onDeviceInUse: 'reclaim',
37+
// A cold Release build on a CI emulator can take a minute to first paint.
38+
launchTimeout: 120_000,
39+
// Unset means the one booted device, which is what both CI jobs give us and
40+
// what avoids agent-device's AVD-name-with-spaces translation entirely.
41+
deviceName,
42+
},
43+
44+
projects: [
45+
{
46+
name: 'setup-ios',
47+
testMatch: /preflight\.setup\.ts/,
48+
use: { platform: 'ios' },
49+
},
50+
{
51+
name: 'setup-android',
52+
testMatch: /preflight\.setup\.ts/,
53+
use: { platform: 'android' },
54+
},
55+
{
56+
name: 'ios',
57+
dependencies: ['setup-ios'],
58+
testIgnore: /preflight\.setup\.ts/,
59+
use: { platform: 'ios' },
60+
},
61+
{
62+
name: 'android',
63+
dependencies: ['setup-android'],
64+
testIgnore: /preflight\.setup\.ts/,
65+
use: { platform: 'android' },
66+
},
67+
],
68+
});
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
*.png
1+
test-results/
2+
playwright-report/
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Expo native e2e
2+
3+
Drives the `@clerk/expo` native components (`AuthView`, `UserButton`,
4+
`UserProfileView`) on a real simulator or emulator, through
5+
[touchpress](https://github.com/wobsoriano/touchpress) on the Playwright runner.
6+
Every spec asserts across the native-to-JS bridge: a sign-in completed by the
7+
native SDK has to show up in `useAuth()`, and a JS sign-out has to reach the
8+
native side.
9+
10+
The app under test is the fixture in `integration/templates/expo-native`.
11+
12+
## Running it
13+
14+
You need a booted device, the fixture installed on it, and a test user.
15+
16+
```sh
17+
# iOS
18+
xcrun simctl install booted \
19+
integration/templates/expo-native/ios/build/Build/Products/Release-iphonesimulator/ClerkExpoNativeBuildFixture.app
20+
21+
# Android
22+
adb install -r integration/templates/expo-native/android/app/build/outputs/apk/release/app-release.apk
23+
```
24+
25+
```sh
26+
export CLERK_TEST_EMAIL=... CLERK_TEST_PASSWORD=...
27+
pnpm test:integration:expo-native --project=ios
28+
pnpm test:integration:expo-native --project=android
29+
```
30+
31+
One booted device per platform. The config names no device, so it takes the one
32+
that is booted; set `E2E_DEVICE_NAME` if you keep several running. `preflight`
33+
fails in about a second when nothing is booted, rather than once per spec.
34+
35+
## Writing a spec
36+
37+
`flows.ts` holds what every spec shares: `openApp`, `signInWithEmailPassword`,
38+
`expectSignedIn`, `expectSignedOut`, and `tapControl`. Import `test` and `expect`
39+
from there rather than from `touchpress`, so the spec gets the `control` fixture.
40+
41+
Selectors are English text and accessibility labels, because clerk-android ships
42+
no test identifiers and both native SDKs localize every string. The devices have
43+
to run the `en` locale.
44+
45+
Two rules the tree enforces, both explained where they are implemented:
46+
47+
- Match whole strings on anything near an icon or a heading. `getByText` is a
48+
substring match, and `'Security'` also finds the image labelled
49+
`icon-security`.
50+
- Tap through `tapControl` rather than a locator. Jetpack Compose puts the label
51+
on a container beside an unlabeled button, and the driver will not tap it.
52+
53+
`device-control.ts` is the escape hatch for the three device operations
54+
touchpress does not model: clearing app state, the platform back gesture, and
55+
typing into a focused field. Reach for it only when no locator can express the
56+
target.
57+
58+
## When a spec fails
59+
60+
The failure message carries the locator, what was expected, the names closest to
61+
it on screen, and the whole accessibility tree. Read that before changing a
62+
selector, and never add a sleep. CI uploads the HTML report, which carries a
63+
screenshot and a tree listing per failed test, as `expo-native-e2e-<platform>`.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect, expectSignedIn, expectSignedOut, openApp, signInWithEmailPassword, test } from './flows';
2+
3+
// Dismiss the native AuthView mid-flow and reopen it: the native view must
4+
// survive detach/reattach and still complete a sign-in afterwards. Dismissal is
5+
// the platform-native gesture: Android's system back button (doubling as the
6+
// back-dismisses-AuthView regression), iOS's sheet Close control (clerk-ios
7+
// exposes the stable clerk.dismissButton identifier).
8+
// Also asserts the custom `logo` React element is rehosted into the native logo
9+
// slot, both on first mount and after reattach.
10+
test('AuthView survives detach and reattach', async ({ device, control }) => {
11+
await openApp(device, control);
12+
await device.getByTestId('open-auth-view-button').tap();
13+
await expect(device.getByText(/Welcome! Sign in to continue\.?/)).toBeVisible({ timeout: 25_000 });
14+
// The rehosted logo is sized on the first React Native layout pass, so it can
15+
// lag the welcome copy by a frame or two.
16+
await expect(device.getByText('E2E Custom Logo')).toBeVisible({ timeout: 10_000 });
17+
18+
if (control.platform === 'android') {
19+
await control.systemBack();
20+
} else {
21+
await device.getByTestId('clerk.dismissButton').tap();
22+
}
23+
24+
await expect(device.getByTestId('open-auth-view-button')).toBeVisible({ timeout: 15_000 });
25+
await device.getByTestId('open-auth-view-button').tap();
26+
await expect(device.getByText('E2E Custom Logo')).toBeVisible({ timeout: 15_000 });
27+
28+
await signInWithEmailPassword(device, control);
29+
await expectSignedIn(device);
30+
await device.getByTestId('sign-out-button').tap();
31+
await expectSignedOut(device);
32+
});

0 commit comments

Comments
 (0)