Skip to content

Replace custom device-lane CI with a standard Expo smoke suite - #212

Merged
arun279 merged 16 commits into
feat/expo-nativefrom
ci/standard-expo-ci
Oct 24, 2026
Merged

arun279 merged 16 commits into
feat/expo-nativefrom
ci/standard-expo-ci

Conversation

@arun279

@arun279 arun279 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Why

Replace the custom device lanes with six ordered Maestro smoke flows on GitHub Actions. Expo documents running Maestro against a built app and using fingerprint and repack to reuse a compatible native build. This PR uses those tools and removes the repository's tap proxy, patched runner, lane tracking, screenshot diff pipeline and native performance probe.

What CI runs

Job What it does
check Runs pnpm check: lint, formatting, spelling, types, architecture, unused code, duplication, quality budget, bundle checks, Vitest and native Jest.
render-performance, audit, actionlint, codeql Retains the existing checks.
footprint Posts a report-only PR comment with product, test, CI and comment line changes and JavaScript bundle sizes. Downloads check's exported bundles from the same run.
android-e2e Builds a release x86_64 APK on a fingerprint cache miss; otherwise repacks the cached APK with the current JavaScript and signs it with the cached debug keystore. Runs the smoke flows with reactivecircus/android-emulator-runner and the fake Trakt server.
ios-e2e Builds a release simulator app on a fingerprint cache miss; otherwise repacks the cached app. Boots an iPhone simulator for the selected Xcode SDK and runs the same smoke flows against the fake.

Pull requests run the checks without draft or path filters; a new push cancels the PR's older run. Pushes on main, feat/expo-native and release/* seed native build caches. The workflow also supports scheduled and manual runs. The footprint comment is limited to same-repository, non-Dependabot pull requests.

Maestro is the stock CLI, pinned to 2.11.0. Android runs the suite once. If the iOS suite fails, it runs once more after resetting the fake server and reinstalling the app, following the one-retry policy offered by EAS retries: 1. The flows have no retries, optional steps or retryTapIfNoChange. They wait for and assert on-screen results; these assertions do not independently prove persistence across a restart or server synchronization.

The Android launcher is disabled before testing. This follows Google's ATD rationale: remove or disable unrelated apps and services, including Launcher3QuickStep, to reduce resource use during app tests. This job still uses the default API 35 image, not an ATD image. On iOS, apsd and chronod are disabled during simulator setup.

Native builds are saved immediately after compilation so a later smoke failure does not discard them. Gradle's caches and wrapper are restored only when the Android native build cache misses, using the runner OS and lockfile hash. The emulator package cache includes env.ImageVersion, which runner-images documents as available on GitHub-hosted runners. The AVD snapshot cache remains separate.

Both platforms upload screenshots on every non-cancelled run. Android uploads Maestro debug output and the fake-server log on failure. iOS uploads debug output and the fake-server log on every non-cancelled run, including both attempts when a rerun occurs.

Six smoke flows

  1. Sign in through the device-code flow and check the seeded queue.
  2. Open Midnight Cartography from Up Next, mark S2 E3 with show detail's continue button, and check S2 E4 on the button and queue row. On Android, also swipe The Quiet Frontier's row right and wait for its specific S2 E10 mark confirmation. No later flow depends on that row or its old episode.
  3. Visit all four tabs and check their content.
  4. Open Harbor Lights from Library, mark S3 E5 in its already expanded season, check the marked control, then open a movie.
  5. Search for Harbor Lights and open its detail screen.
  6. Open Profile and check the sign-out confirmation dialog and both buttons. The flow stops before confirming sign-out.

The flows run in that order. Only sign-in cold-starts the app; the other flows preserve the running app and return to Up Next. Screenshots capture the visited screens in light mode.

Removed

  • The tap proxy, simulator touch scripts, patched Maestro runner, supervisor, failure reporter, freeze checks and tap lead-in checks.
  • Lane health, the e2e rollup, first-attempt checks, earlier-run scans and the repeat-device-lanes workflow. Repeated runs use workflow_dispatch on ci.yml.
  • Custom native fingerprint and JavaScript re-embedding scripts, replaced by Expo's CLIs.
  • Pixel diffs, PR media assembly, dark-mode and large-text sweeps, and the native performance probe and its workflow.
  • Custom emulator, simulator readiness, accessibility, host snapshot and launch verification helpers. The workflow retains the two iOS daemon settings described above.
  • Native size and complexity measurements and footprint gating. The footprint job now reports JavaScript bundle sizes, without PR-body bypasses.
  • The product test harness: readiness markers, operation and trace channels, persistent Undo behavior, Metro swaps and the Babel plugin. The smoke builds use release code configured for the fake API and its fixed clock.
  • Old .maestro/ci, .maestro/flows and .maestro/perf flows, harness-only Jest tests, unused test IDs, obsolete configuration entries and the unused xctest spelling entry.
  • 47 files under test/ci (36 test suites and 11 fixtures), plus test/support/leaked-processes.ts. Remaining CI tests are adapted to the new jobs. The stall guard keeps its 150-second inactivity limit without referring to the deleted PR media test.

scripts/mock-trakt and packages/core/test/harness are unchanged. The unused hidden Atlas file is no longer an upload path, and the footprint artifact download destination matches the bundle directory. The footprint job no longer requests actions: read for a same-run download.

Where each deleted flow's checks live now

Deleted flow What it checked Covered by
launch, returning-user-app-idle sign in; queue without skeleton on relaunch smoke sign-in; composition-root "renders the persisted queue before any network response or timer"
up-next-mark-and-undo mark advances the row; Undo restores it smoke up-next (show-detail continue button and Android swipe); up-next "shows a mark and its undo in the queue without waiting on a timer"
swipe-mark-and-stop swipe right marks, left stops, Undo, a short drag commits nothing smoke up-next checks the Android right swipe and its mark confirmation; up-next "marks from a swipe…", "stops the show from a swipe the other way…"; swipe-row "arms visibly, and only once the drag is past the commit distance", "locks both directions to the commit distance…"
up-next-lapsed-drawer drawer opens; a show marked from it returns to the queue up-next "holds the idle shows in a collapsed drawer…"; new up-next "files a show marked from the drawer back into the queue"
show-detail-bulk-mark "Mark 2 remaining" confirm, season marked, Undo, related shows smoke detail (mark an episode); show-detail "uses the iOS action sheet for remaining…", "…Android dialog…", "caps related shows at six…"; core mark-pipeline "patches seasons + episode detail optimistically, and undo restores them"
episode-sheet mark from the sheet, Undo, pager, spoiler reveal, close episode-sheet "marks from the sheet and exposes the same reversible snackbar", "replaces the episode route…", "reveals only the selected episode…", "closes from its close button…"
movie-detail remove a play, mark then Undo, Add to Watchlist, related, rewatch opens History smoke detail (opens a movie); movie-detail "renders the marked face and removes only the resolved play", "marks an unwatched movie and undoes only that newly landed play", "toggles watchlist membership from %s with Undo", "caps related movies at six…", "refuses a rewatched film and opens movie history"
history day groups and play counts, remove a play then Undo, filter miss, month jump history "collapses one item per local day, counts plays…", "Undo awaits its own %s removal outcome", "filters loaded titles…", "uses the month window…"; account-layout month-jump cases
library chips, segments, not started, empty chip, filter miss and clear, sort, tile opens detail smoke tabs and detail; library "shows the chip's own slice…", "lists everything never started…", "says what each empty chip is for", "filters on the settled query…", "reorders the grid from the sort menu"; navigation-round-trips "reaches every route…"
search browse grids, results, tracked title offers no add, add settles to "In library", recent term, no results smoke search; search "opens on a browse grid…", "offers no add for a title already in the library", "flashes Added for 600 ms…", "re-runs a rested query…", "blames the spelling…"
calendar day header, rows carry no mark control, a row opens its show smoke tabs; calendar "groups by the viewer's day…", "offers nothing to mark: a row is the show it opens and no more"
navigation-round-trips History link and back, a deep link closes Profile or a sheet, TV off leads to Settings, rewatched movie leads to History navigation-round-trips "%s returns where it started"; account-layout "dismisses Profile and opens the target…", "dismisses only the sheet…"; new up-next "leads to Settings from the turned-off queue"; movie-detail "refuses a rewatched film…"
pull-to-refresh a pull runs a sync pass on Up Next and History ui/pull-to-refresh "shows the indicator exactly while the sync pass runs"; busy-state "shows every sync it starts…"
account identity and stats, theme, media switches, version, links, attribution, sign-out confirm and Cancel, refusal with unsynced writes, failed revoke still signs out smoke sign-out; profile "shows identity…"; settings "changes theme…", "persists each toggle and immediately removes the disabled medium from Library", "shows the injected store version and hands account management to Trakt"; profile "confirms sign-out in the %s platform shape", "shows an announced refusal and enables retry after %s"; core create-auth-store "clears the token only after teardown succeeds, even if revocation fails"
tabs four tabs, each tab root smoke tabs; tab-roots "mounts each tab root once…"

Both new tests were checked against mutations: making TvShowsOff push /profile fails the first, and making classifyUpNextShow return null or "lapsed" for a pending advance fails the second.

Deliberate reductions

  • Undo, left-swipe stop, short drags, season expansion and completed sign-out remain covered in JavaScript tests. Android right-swipe marking also has a device smoke assertion; iOS uses the show-detail continue button.
  • There are no dark-mode or large-text sweeps, per-screen pixel diffs or native performance probes in PR CI.
  • Footprint reports JavaScript bundle sizes only; it does not measure native app size or gate growth.

Net lines (against the base)

These counts use git diff --numstat from the merge base with origin/feat/expo-native through the final working tree, including the review edits awaiting commit. They count physical lines, including blanks and comments. The base is 8af0c8c4.

Category Added Removed Net
Product (packages/*/src, packages/native/app, Metro and Babel config) 46 766 -720
Tests (packages/*/test, packages/native/__tests__, test, .maestro) 336 10,359 -10,023
CI (.github, scripts) 203 5,028 -4,825
Other (configs, README, lockfile, patches) 135 265 -130
Total 720 16,418 -15,698

Tracked text-file line totals, before and after (binary assets excluded):

Bucket Before After
.github 2,106 919
.maestro 1,691 264
test/ci 9,008 1,939
scripts (excluding mock-trakt) 4,876 1,238
scripts (all text files) 7,635 3,997
scripts/mock-trakt (text files) 2,759 2,759
packages/core/test/harness 3,648 3,648
maestro-ids.test.ts 603 0

Validation

  • pnpm exec actionlint: passed with actionlint 1.7.12.
  • pnpm exec vitest run test/ci: 28 files and 125 tests passed.
  • heavy-lock.sh run ci-followup -- pnpm check: passed with Node 22.23.4. Vitest: 127 files, 1,192 passes and 15 expected failures. Native Jest: 138 suites and 1,078 tests passed; 2 suites and 6 tests skipped.
  • git diff --check: passed.
  • The review edits were checked locally without running a device. The new Android swipe still needs its CI device run after the lead commits and pushes these edits.

Dependency advisory

This pull request also carries the fix for GHSA-mrhx-c7xc-m3w5 (zod before 4.6.6 can exhaust memory validating a large array), because it changes the lockfile and the audit gates on it. packages/core asks for ^4.6.6 and a pnpm override moves Expo CLI's zod 3 copy too; it only uses basic schema builders in its dev-tools plugin. The lockfile now holds a single zod, 4.6.7. The braces audit ignore no longer applies and is removed.

Merging this PR

Before merging, update the feat/expo-native branch ruleset's required checks:

  • Remove native-android, native-ios, native-e2e, ui-screenshots-ios-dark, ui-screenshots-android-dark and footprint.
  • Add ios-e2e and android-e2e if missing.
  • Keep the other required checks. .github/required-checks.json already lists the new job names for release validation.

Apply the same ruleset change to main when feat/expo-native lands there. This PR does not change GitHub's branch rulesets automatically.

The Maestro lanes ran a special build: Metro swapped in readiness and timing
markers, a perf command channel, a scroll and pull trace, and an Undo
snackbar that never dismissed itself. The store build carried stand-ins
instead, so the app under test differed from the app users get.

Delete the harness modules, the Metro swap and the Babel plugin that
stripped perf hooks, and keep each call site's store behaviour. Undo
snackbars now always dismiss on their timer, as in the store app. The fixed
test clock (EXPO_PUBLIC_CLOCK_START) stays, because the fake Trakt server's
seed counts from it.

Delete the tests that only exercised harness code and maestro-ids.test.ts,
drop test ids nothing reads any more, and add Jest coverage for two
behaviours only the deleted device flows checked: a show marked from the
lapsed drawer files back into the queue, and the TV-off empty state leads
to Settings.
Pull request CI now runs what ordinary Expo apps run: lint, types, Jest and
Vitest in check, plus six Maestro smoke flows on an Android emulator and an
iOS simulator. The flows sign in through the device code against the fake
Trakt server, mark the next episode from Up Next, visit every tab, open a
show and a movie and mark an episode, search, and sign out. Each one asserts
the state its taps produce and takes screenshots of what it visits.

Each e2e job fingerprints the native project with the @expo/fingerprint CLI
and keeps the release build in actions/cache under that hash. On a hit,
@expo/repack-app swaps the commit's JavaScript into the cached build; on a
miss the job builds and saves the cache straight away, so a failing flow
does not throw the build away. The emulator comes from
reactivecircus/android-emulator-runner with its documented AVD cache, the
simulator from xcrun simctl, and Maestro from its official installer at a
pinned version. Nothing retries.

Delete what only served the old lanes: the tap proxy and patched Maestro
runner, lane health and first-attempt checks, the simulator daemon,
spotlight and accessibility tweaks (except disabling apsd and chronod, which
slowed the first launch), the JavaScript re-embed scripts, the pixel diff
and PR media, the perf probe, the native size measurements, the old flows,
and the test/ci tests and config entries for all of them. The footprint job
becomes a report-only PR comment of line counts and JavaScript bundle sizes.
CI can be dispatched by hand, which is how the suite's determinism is
checked.
Sign-out now lives on Profile, so the smoke flow signs out there. Keep the
Library sort id the new tests read, drop the harness exclusion the new glyph
check used, and leave the old account and library flows deleted.
pnpm audit no longer reports GHSA-vfj7-8cjw-p6xm against the locked braces,
so check-stale-audit-ignores fails on any change that touches the lockfile.
Show detail opens with the next episode's season expanded and lists seasons
newest first, so Season 1 starts below the fold and Season 3's episodes sit
above it.
…rd-expo-ci

# Conflicts:
#	packages/native/__tests__/perf-channel.test.tsx
#	packages/native/__tests__/runtime-boot.test.tsx
#	packages/native/src/ui/perf-channel.ts
On Android, Maestro did not find Season 1's episode rows after the season was
expanded, although the screen showed them. Expanding a season is covered by
season-row.test.tsx, so the smoke flow no longer scrolls to and expands
Season 1.
A run failed because Android's launcher (Quickstep) stopped responding over
Cue's welcome screen on a freshly booted emulator. Cue itself had drawn its
first screen in 2.8 s. The ATD system image is Android's image for automated
tests: it removes Launcher3QuickStep, SystemUI and other apps tests do not use
(developer.android.com/studio/test/managed-devices#gmd-atd).
android-emulator-runner tests itself on aosp_atd at API 34.
The ATD image captured every Android screenshot blank, because it turns off
hardware rendering. Instead the emulator gets the runner's four vCPUs
(android-emulator-runner's cores input; the default is two), since the
launcher stopped responding on a software-rendered emulator. Repeat runs will
show whether that is enough.
Two of three runs failed on "Quickstep isn't responding": Android's launcher,
not Cue, stopped responding over Cue's screen. Four emulator cores did not
change that. Android's Automated Test Device image leaves Launcher3QuickStep
out because tests do not need a launcher
(developer.android.com/studio/test/managed-devices#gmd-atd), but that image
also turns off hardware rendering and captured every screenshot blank. So the
default image keeps rendering and the flows disable the launcher package.
In 50 repeat runs, two iOS taps went to SpringBoard's app switcher right after
Maestro stopped and relaunched Cue between flows, so later flows now launch
with stopApp: false and switch to Up Next. The simulator now runs the iOS
runtime matching the selected Xcode SDK; it had picked iOS 26.4. The sign-out
flow stops at the confirmation dialog: the confirm tap was cancelled by the
simulator in one run, and profile.test.tsx covers confirming sign-out. The
Android emulator package is cached with actions/cache
(docs.github.com/actions/reference/workflows-and-actions/dependency-caching),
since one run failed unpacking a fresh download before the emulator started.
…rd-expo-ci

# Conflicts:
#	packages/native/__tests__/ui/pull-trace.test.tsx
#	packages/native/src/ui/harness.tsx
In one of 50 repeat runs the tap reached Cue and the dialog appeared, but the
simulator's accessibility service stalled for about 33 s, past the 30 s wait.
After the fixable causes were removed, 50 repeat runs passed 50/50 on Android
and 48/50 on iOS; the remaining iOS failure is the simulator cancelling a
synthesized tap, which has no documented fix (Maestro's iOS tap retry is a
no-op, mobile-dev-inc/Maestro#3685). As EAS Maestro jobs do with retries: 1,
a failed iOS run is repeated once after resetting the fake Trakt server and
reinstalling the app. Both runs' screenshots and logs are uploaded, and a real
defect still fails both. Android stays a single run.
The Android smoke flow now swipes a queue row to mark it, so a gesture library
change that broke or inverted the swipe fails on a device rather than only in
Jest's fake. Gradle's cache is restored only when the native build cache
missed, instead of 1.6 GB on every run, and the emulator package cache is keyed
on the runner image version. Also drops an unused permission, an upload path
nothing reads, a stale comment basis and an unused spelling entry.
Zod before 4.6.6 can exhaust memory validating a large array against an
uncapped array schema; Cue's core parses Trakt responses with it. A pnpm
override moves every copy to 4.6.6 or later, including Expo CLI's zod 3, which
only uses basic schema builders (string, object, enum, array, literal, union)
in its dev-tools plugin. The braces audit ignore no longer applies and is
removed.
@github-actions

Copy link
Copy Markdown

Pull request footprint

measurement base to head
Product code lines in core/src, native/src, native/app, and native/modules -620
Test lines in test, packages/*/{test,__tests__,e2e}, and .maestro -9199
CI and tooling lines in .github and scripts -4565
Product comment lines identified by a comment prefix -2
JavaScript bundle at head size
Expo ios bundle 5623 kB
Expo android bundle 5662 kB

@arun279
arun279 merged commit b2b3aa1 into feat/expo-native Oct 24, 2026
10 checks passed
@arun279
arun279 deleted the ci/standard-expo-ci branch October 24, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant