Skip to content

Commit 4656a84

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 4656a84

34 files changed

Lines changed: 786 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: 58 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,28 @@ 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. This is the only thing the repo still needs
318+
# agent-device itself for; the specs reach the device through
319+
# touchpress. Pinned to touchpress's own version, because a CLI at a
320+
# different version replaces the daemon and drops its sessions.
321+
./node_modules/.bin/agent-device prepare ios-runner --platform ios --udid "$SIM_UDID" --timeout 600000
321322
rc=0
322-
MAESTRO_DEBUG_OUTPUT="$RUNNER_TEMP/maestro-debug" ./run-flows.sh \
323-
xcrun simctl terminate "$SIM_UDID" com.clerk.exponativebuildfixture || rc=$?
323+
pnpm test:integration:expo-native --project=ios || rc=$?
324324
kill "$LOG_PID" 2>/dev/null || true
325325
exit "$rc"
326326
@@ -353,7 +353,7 @@ jobs:
353353
disable-animations: false
354354
script: echo "Generated AVD snapshot for caching."
355355

356-
- name: Run Android e2e (Maestro)
356+
- name: Run Android e2e
357357
id: run_e2e_android
358358
if: matrix.platform == 'android' && steps.user.outputs.user_id != ''
359359
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
@@ -370,9 +370,11 @@ jobs:
370370
# The action runs each script line in a separate sh -c; the folded
371371
# scalar (>-) plus && keeps everything in one shell invocation.
372372
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"
373+
mkdir -p "$RUNNER_TEMP/e2e-debug" &&
374+
adb install -r "$GITHUB_WORKSPACE/$FIXTURE_DIR/android/app/build/outputs/apk/release/app-release.apk" &&
375+
adb logcat -c &&
376+
(adb logcat -v threadtime > "$RUNNER_TEMP/e2e-debug/android-logcat.log" 2>&1 &) &&
377+
pnpm test:integration:expo-native --project=android
376378
377379
# Runs before the artifact upload so the credentials are already dead
378380
# by the time the debug files become downloadable.
@@ -385,26 +387,30 @@ jobs:
385387
curl -fsS -X DELETE "$BAPI_URL/v1/users/$USER_ID" \
386388
-H "Authorization: Bearer $CLERK_SECRET_KEY" || true
387389
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
390+
# A driver error can quote the text it was handed, and add-mask only
391+
# covers step logs, not artifact contents.
392+
- name: Scrub test credentials from the e2e report
391393
if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure')
392394
env:
393395
CLERK_TEST_PASSWORD: ${{ steps.user.outputs.password }}
394396
run: |
395-
[ -d "$RUNNER_TEMP/maestro-debug" ] || exit 0
396397
[ -n "$CLERK_TEST_PASSWORD" ] || exit 0
398+
mkdir -p "$RUNNER_TEMP/e2e-debug"
399+
for dir in "$E2E_DIR/playwright-report" "$E2E_DIR/test-results"; do
400+
[ -d "$dir" ] || continue
401+
cp -R "$dir" "$RUNNER_TEMP/e2e-debug/"
402+
done
397403
# 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' \) \
404+
find "$RUNNER_TEMP/e2e-debug" -type f \
405+
\( -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' \) \
400406
-exec perl -pi -e 's/\Q$ENV{CLERK_TEST_PASSWORD}\E/[REDACTED]/g' {} +
401407
402-
- name: Upload Maestro artifacts on e2e failure
408+
- name: Upload e2e artifacts on failure
403409
if: always() && (steps.run_e2e_ios.outcome == 'failure' || steps.run_e2e_android.outcome == 'failure')
404410
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
405411
with:
406-
name: maestro-${{ matrix.platform }}
407-
path: ${{ runner.temp }}/maestro-debug
412+
name: expo-native-e2e-${{ matrix.platform }}
413+
path: ${{ runner.temp }}/e2e-debug
408414
retention-days: 7
409415

410416
- name: Report e2e outcome
@@ -415,7 +421,7 @@ jobs:
415421
run: |
416422
outcome="$IOS_OUTCOME"
417423
[ "$outcome" = "skipped" ] && outcome="$ANDROID_OUTCOME"
418-
echo "## Maestro e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY"
424+
echo "## Expo native e2e (${{ matrix.platform }}): $outcome" >> "$GITHUB_STEP_SUMMARY"
419425
if [ "$outcome" = "failure" ]; then
420-
echo "::error::Maestro e2e failed. See the maestro-${{ matrix.platform }} artifact."
426+
echo "::error::Expo native e2e failed. See the expo-native-e2e-${{ matrix.platform }} artifact."
421427
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: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
// Matches 'signed in' and 'signed out' but not 'loading', so the gate holds
27+
// until clerk-js has finished initialising rather than until the element
28+
// merely exists. That is what the old warmup flow bought: on a cold CI
29+
// emulator clerk-js can take past a minute, and waiting for it here charges
30+
// the time to launchTimeout instead of to the first assertion that runs.
31+
readyWhen: { text: 'signed' },
32+
actionTimeout: 20_000,
33+
// Every spec clears state and relaunches itself, and that has to happen
34+
// inside the test rather than in the fixture that would precede it.
35+
// The per-test relaunch would only add a launch nothing reads.
36+
relaunch: 'per-worker',
37+
// CI boots a fresh device per job; a claim left by a killed run must not
38+
// fail the next one.
39+
onDeviceInUse: 'reclaim',
40+
// A cold Release build on a CI emulator can take a minute to first paint.
41+
launchTimeout: 120_000,
42+
// Unset means the one booted device, which is what both CI jobs give us and
43+
// what avoids agent-device's AVD-name-with-spaces translation entirely.
44+
deviceName,
45+
},
46+
47+
projects: [
48+
{
49+
name: 'setup-ios',
50+
testMatch: /preflight\.setup\.ts/,
51+
use: { platform: 'ios' },
52+
},
53+
{
54+
name: 'setup-android',
55+
testMatch: /preflight\.setup\.ts/,
56+
use: { platform: 'android' },
57+
},
58+
{
59+
name: 'ios',
60+
dependencies: ['setup-ios'],
61+
testIgnore: /preflight\.setup\.ts/,
62+
use: { platform: 'ios' },
63+
},
64+
{
65+
name: 'android',
66+
dependencies: ['setup-android'],
67+
testIgnore: /preflight\.setup\.ts/,
68+
use: { platform: 'android' },
69+
},
70+
],
71+
});
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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 Clerk's own buttons through `tapControl`, which is a whole-string text tap.
51+
Jetpack Compose puts the label on a container beside an unlabeled button, and
52+
touchpress retargets onto it. The report shows that as `retarget to @eNN`.
53+
54+
Clearing state, going back, and typing into a focused field are `device`
55+
methods. `clearState` relaunches and waits for the ready gate itself, and
56+
`clearKeychain` is separate because the simulator keychain belongs to every app
57+
on it, so `openApp` calls it first.
58+
59+
## When a spec fails
60+
61+
The failure message carries the locator, what was expected, the names closest to
62+
it on screen, and the whole accessibility tree. Read that before changing a
63+
selector, and never add a sleep. CI uploads the HTML report, which carries a
64+
screenshot and a tree listing per failed test, as `expo-native-e2e-<platform>`.

0 commit comments

Comments
 (0)