Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions .github/workflows/_e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,15 @@ jobs:
# flows' way. Never fails the leg.
- name: Warm up (approve the deep-link confirmation once)
env:
# Which simulator the fixture server opens deep links on (open-link-ios.js).
SIM_UDID: ${{ steps.sim.outputs.udid }}
MAESTRO_DRIVER_STARTUP_TIMEOUT: '300000'
run: |
maestro test -e APP_ID=${{ env.APP_ID }} e2e/warmup/ios-approve-open-link.yaml || echo "warm-up flow did not pass (non-fatal)"
maestro test -e APP_ID=${{ env.APP_ID }} -e SIM_UDID="$SIM_UDID" e2e/warmup/ios-approve-open-link.yaml || echo "warm-up flow did not pass (non-fatal)"

- name: Run flows
env:
SIM_UDID: ${{ steps.sim.outputs.udid }}
# Passed through the environment so a tags value cannot break out of the
# command line via `${{ }}` text substitution.
TAGS: ${{ inputs.tags }}
Expand All @@ -196,7 +199,7 @@ jobs:
run: |
set -euo pipefail
# shellcheck disable=SC2086 # $TAGS is a list of flags and must word-split
maestro test -e APP_ID=${{ env.APP_ID }} $TAGS \
maestro test -e APP_ID=${{ env.APP_ID }} -e SIM_UDID="$SIM_UDID" $TAGS \
--format junit --output e2e-report.xml \
--debug-output "$PWD/maestro-debug" --flatten-debug-output \
e2e/flows/
Expand Down Expand Up @@ -226,6 +229,19 @@ jobs:
node scripts/e2e/junit-summary.mjs e2e-report.xml \
"ios · RN ${{ inputs.rn-version }} · iOS ${{ inputs.ios-runtime }}"

# The warm-up's logs land in ~/.maestro/tests. Copied into the workspace because
# upload-artifact roots the archive at the common ancestor of all paths: a $HOME path
# buried e2e-report.xml under work/<repo>/<repo>/, and the report job, which expects
# it at the artifact root, scored every iOS leg `missing-report`.
- name: Collect Maestro home logs
if: always()
run: |
if [ -d "$HOME/.maestro/tests" ]; then
rm -rf maestro-tests
cp -R "$HOME/.maestro/tests" maestro-tests
fi

# Every path here must stay inside the workspace (see the step above).
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v7
Expand All @@ -234,7 +250,7 @@ jobs:
path: |
e2e-report.xml
maestro-debug/**
maestro-tests/**
post-mortem/**
device.log
~/.maestro/tests/**
if-no-files-found: ignore
28 changes: 25 additions & 3 deletions e2e/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,27 @@ How to run the suite and how to add a flow: [`README.md`](README.md). The CI mat
- **Zero retries.** A flow is never re-run to turn it green — a retry hides exactly the
race conditions this suite exists to catch. A flow that proves unstable gets quarantined
(`tags: [flaky]`) with an issue, and drops out of the gate until it is fixed. The single
exception is transport, not behaviour: `e2e/shared/open-scenario.yaml` retries the
`openLink` command itself, because `simctl openurl` on hosted macOS runners has timed
out before the app received anything. What the app then shows is never retried.
exception is transport, not behaviour: on iOS the deep link is opened by the fixture
server (`POST /__open-link`, `e2e/fixtures/open-link.mjs`), which retries `simctl
openurl`, because on hosted macOS runners that command has timed out
(NSPOSIXErrorDomain 60) while the link still arrived seconds later. It cannot be done in
the flow: Maestro's `retry` and `optional` only catch `MaestroException`, and this
failure is an `IllegalStateException`, so a `retry` around `openLink` never ran a second
attempt. A link delivered twice is harmless (the screen is keyed on the scenario). What
the app then shows is never retried.
- **iOS: the first deep link on a fresh simulator can raise "Open in app?", and the link
after that confirmation never reaches JS.** The iOS leg runs
`e2e/warmup/ios-approve-open-link.yaml` once before the suite to take that confirmation
out of the real flows' way; the shared subflow still handles it defensively.
- **iOS: an unanswered "Open in app?" prompt outlives the app and hides it.** The prompt
belongs to SpringBoard: it survives `stopApp`, `clearState` and `launchApp`, and while it
is up the app is missing from the hierarchy, so `e2e-host-ready` never becomes visible.
On a slow `macos-15` runner the prompt came up only after the warm-up had stopped
waiting for it (no prompt 20 s after `openLink`), and that leg lost all 10 flows.
`launch-app.yaml` therefore taps Open on a prompt that is already up after launch and
relaunches. Open rather than Cancel, so the approval sticks. With no prompt the check
takes ~7 s per launch (Maestro's fixed lookup time for a `when: visible` that is false;
7.5 s measured in CI), which did not show up in leg durations against runner variance.
- v7 API only in the test app: `useVideoPlayer` + `VideoView` + `useEvent`,
`player.seekTo()`. No `<Video>` component, no v6 `drm` prop.
- No check becomes *required* in branch protection before 10–15 consecutive clean runs
Expand Down Expand Up @@ -93,6 +107,14 @@ How to run the suite and how to add a flow: [`README.md`](README.md). The CI mat
padding equal to `StatusBar.currentHeight`, the first marker renders under the status
bar and Maestro drops it from the hierarchy as invisible ("Skipping invisible child").
Local runs on API 34 never showed this; API 35/36 do.
- **API 34 kills an app launched within ~1 s of `pm clear`.** Clearing state removes the
previous task; when that removal's 1 s destroy timeout fires, Android 14 kills the
package's current process ("Destroy timeout of remove-task" then "Killing <pid> (adj
-10000): remove task" in logcat), which by then is the one Maestro just started. The
activity survives without a React root: a white screen and no `e2e-host-ready`.
`launchApp: {clearState: true}` starts the app ~0.9 s after the clear on a hosted runner,
which cost 5 of 30 flows across the API 34 nightly legs and none on API 35/36. Hence
`launch-app.yaml` runs `clearState`, waits 2.5 s on Android, then `launchApp`.
- **Taps are immediate on Android, deferred on iOS.** The iOS driver only delivers a tap
once the player goes idle (below); the Android driver delivers it at once. Flows that
must work on both use absolute actions (`btn-play`, `btn-seek-1`, `btn-loop-on`), never
Expand Down
11 changes: 7 additions & 4 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ CI matrix: [`CI_MATRIX_DESIGN.md`](CI_MATRIX_DESIGN.md).
./e2e/fixtures/generate.sh

# 2. Serve fixtures (dependency-free — the same server CI starts via
# .github/actions/e2e-setup/action.yml; run it from the repo root and leave it running)
# .github/actions/e2e-setup/action.yml; run it from the repo root and leave it running).
# iOS flows also open their deep links through it, on the booted simulator; with more
# than one simulator booted, start it with E2E_SIM_UDID=<udid>.
node e2e/fixtures/serve.mjs e2e/fixtures/media 8090

# 3. Pick the React Native version and install.
Expand Down Expand Up @@ -87,8 +89,9 @@ However a flow was drafted, the committed artifact is plain YAML, reviewed by ha
- No external network in flows — local fixtures only.
- Zero retries on anything the flows assert. A flaky flow gets quarantined
(`tags: [flaky]`) with an issue, never retried into a false green — a retry hides the
race the flow just caught. The one retry in the suite wraps the `openLink` transport in
`e2e/shared/open-scenario.yaml` (hosted simulators have timed out inside `simctl openurl`
before the app saw anything); a scenario screen or marker that does not appear still
race the flow just caught. The one retry in the suite is on the iOS deep-link transport:
`e2e/shared/open-scenario.yaml` asks the fixture server to open the link, and the server
retries `simctl openurl` when it times out on a hosted simulator (Maestro's own `retry`
does not catch that failure); a scenario screen or marker that does not appear still
fails the flow.
- Bugfix in an E2E-coverable area ⇒ the PR includes a reproducing flow.
53 changes: 53 additions & 0 deletions e2e/fixtures/open-link.mjs
Comment thread
mziolkowskii marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Opens a scenario deep link on the iOS simulator for a Maestro flow (POST /__open-link,
// called from e2e/shared/open-link-ios.js), retrying `simctl openurl` when it times out.
//
// Why not Maestro's own `openLink`: on a hosted macOS runner `simctl openurl` has timed
// out (NSPOSIXErrorDomain 60) while the link still arrived seconds later, and Maestro's
// `retry`/`optional` do not catch that failure (it is not a MaestroException). Only the
// transport is retried; what the app then shows is asserted by the flow, never retried
// (the zero-retries rule in e2e/CONTEXT.md).
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);

// Deliberately narrower than the app's parser: this value becomes a process argument.
const SCENARIO_LINK = /^rnvtest:\/\/scenario\/[a-z0-9]+(?:-[a-z0-9]+)*$/;

const SIMULATOR_UDID = /^[0-9A-F]{8}(?:-[0-9A-F]{4}){3}-[0-9A-F]{12}$/i;

// The link from `?url=<link>`, or null unless it is exactly one scenario link.
export function scenarioLink(requestUrl) {
const values = new URL(requestUrl, 'http://x').searchParams.getAll('url');
return values.length === 1 && SCENARIO_LINK.test(values[0]) ? values[0] : null;
}

// The simulator from `&device=<udid>`: undefined when absent, null when not a UDID.
export function simulatorDevice(requestUrl) {
const values = new URL(requestUrl, 'http://x').searchParams.getAll('device');
if (values.length === 0 || (values.length === 1 && values[0] === '')) return undefined;
return values.length === 1 && SIMULATOR_UDID.test(values[0]) ? values[0] : null;
}

export async function openLink({
link,
device = 'booted',
xcrun = 'xcrun',
attempts = 3,
pauseMs = 2000,
// simctl's own timeout is ~13 s; this only bounds a hung process.
run = (file, args) => execFileAsync(file, args, { timeout: 60_000 }),
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
}) {
let error = '';
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
await run(xcrun, ['simctl', 'openurl', device, link]);
return { ok: true, attempts: attempt };
} catch (err) {
error = err.message;
if (attempt < attempts) await sleep(pauseMs);
}
}
return { ok: false, attempts, error };
}
109 changes: 109 additions & 0 deletions e2e/fixtures/open-link.test.mjs
Comment thread
mziolkowskii marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, test, expect } from 'bun:test';
import { scenarioLink, simulatorDevice, openLink } from './open-link.mjs';

const request = (link) => `/__open-link?url=${encodeURIComponent(link)}`;

describe('scenarioLink', () => {
test('accepts a scenario deep link', () => {
expect(scenarioLink(request('rnvtest://scenario/mp4'))).toBe('rnvtest://scenario/mp4');
expect(scenarioLink(request('rnvtest://scenario/broken-manifest'))).toBe('rnvtest://scenario/broken-manifest');
expect(scenarioLink(request('rnvtest://scenario/error-404'))).toBe('rnvtest://scenario/error-404');
});

test('rejects anything that is not exactly a scenario deep link', () => {
for (const link of [
'https://example.com',
'rnvtest://other/mp4',
'rnvtest://scenario/',
'rnvtest://scenario/mp4/extra',
'rnvtest://scenario/mp4?x=1',
'rnvtest://scenario/MP4',
'rnvtest://scenario/mp4 --help',
'rnvtest://scenario/mp4;rm',
' rnvtest://scenario/mp4',
]) {
expect(scenarioLink(request(link))).toBeNull();
}
});

test('rejects a missing or repeated url parameter', () => {
expect(scenarioLink('/__open-link')).toBeNull();
expect(scenarioLink('/__open-link?url=')).toBeNull();
expect(
scenarioLink('/__open-link?url=rnvtest%3A%2F%2Fscenario%2Fmp4&url=rnvtest%3A%2F%2Fscenario%2Fhls')
).toBeNull();
});
});

describe('simulatorDevice', () => {
const udid = '7D16E67F-1E34-43CB-BD36-B95CCE24981A';

test('is undefined when the request names no device', () => {
expect(simulatorDevice('/__open-link?url=x')).toBeUndefined();
expect(simulatorDevice('/__open-link?url=x&device=')).toBeUndefined();
});

test('accepts a simulator UDID', () => {
expect(simulatorDevice(`/__open-link?url=x&device=${udid}`)).toBe(udid);
expect(simulatorDevice(`/__open-link?url=x&device=${udid.toLowerCase()}`)).toBe(udid.toLowerCase());
});

test('is null for anything else, including simctl keywords and repeats', () => {
for (const device of ['booted', 'all', '--help', `${udid} x`, 'iPhone%2017']) {
expect(simulatorDevice(`/__open-link?url=x&device=${device}`)).toBeNull();
}
expect(simulatorDevice(`/__open-link?url=x&device=${udid}&device=${udid}`)).toBeNull();
});
});

describe('openLink', () => {
const link = 'rnvtest://scenario/mp4';
const timedOut = Object.assign(new Error('Simulator device failed to open'), { code: 60 });

// `run` fails `failures` times, then succeeds.
function harness(failures) {
const calls = [];
const pauses = [];
return {
calls,
pauses,
run: async (file, args) => {
calls.push([file, ...args]);
if (calls.length <= failures) throw timedOut;
},
sleep: async (ms) => void pauses.push(ms),
};
}

test('runs `simctl openurl` once when it succeeds', async () => {
const h = harness(0);
expect(await openLink({ link, run: h.run, sleep: h.sleep })).toEqual({ ok: true, attempts: 1 });
expect(h.calls).toEqual([['xcrun', 'simctl', 'openurl', 'booted', link]]);
expect(h.pauses).toEqual([]);
});

test('targets the given simulator and xcrun binary', async () => {
const h = harness(0);
await openLink({ link, run: h.run, sleep: h.sleep, device: 'ABCD-1234', xcrun: '/tmp/fake-xcrun' });
expect(h.calls).toEqual([['/tmp/fake-xcrun', 'simctl', 'openurl', 'ABCD-1234', link]]);
});

test('tries again after a failure, pausing in between', async () => {
const h = harness(2);
expect(await openLink({ link, run: h.run, sleep: h.sleep, pauseMs: 250 })).toEqual({ ok: true, attempts: 3 });
expect(h.calls).toHaveLength(3);
expect(h.pauses).toEqual([250, 250]);
});

test('gives up after `attempts` failures and reports the last error', async () => {
const h = harness(Infinity);
expect(await openLink({ link, run: h.run, sleep: h.sleep, attempts: 3 })).toEqual({
ok: false,
attempts: 3,
error: 'Simulator device failed to open',
});
expect(h.calls).toHaveLength(3);
// No pause after the last attempt: nothing follows it.
expect(h.pauses).toHaveLength(2);
});
});
35 changes: 35 additions & 0 deletions e2e/fixtures/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { createServer } from 'node:http';
import { createReadStream, statSync } from 'node:fs';
import { join, normalize, extname, resolve } from 'node:path';
import { openLink, scenarioLink, simulatorDevice } from './open-link.mjs';

const [, , rootArg = 'e2e/fixtures/media', portArg = '8090'] = process.argv;
const root = resolve(rootArg);
Expand All @@ -15,9 +16,43 @@ const TYPES = {
'.m3u8': 'application/vnd.apple.mpegurl',
};

const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);

// The one route that is not a file: iOS flows open their deep link through here (see
// open-link.mjs). It runs a process, so it answers loopback only, and only for a scenario
// link. The simulator comes from `&device=<udid>`, else E2E_SIM_UDID, else the booted one.
async function handleOpenLink(req, res) {
const json = (status, body) =>
res.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));

if (!LOOPBACK.has(req.socket.remoteAddress)) return json(403, { ok: false, error: 'loopback only' });
if (req.method !== 'POST') return json(405, { ok: false, error: 'POST only' });
const link = scenarioLink(req.url);
if (!link) return json(400, { ok: false, error: 'expected ?url=rnvtest://scenario/<name>' });
const device = simulatorDevice(req.url);
if (device === null) return json(400, { ok: false, error: 'device must be a simulator UDID' });

const result = await openLink({
link,
device: device || process.env.E2E_SIM_UDID || 'booted',
xcrun: process.env.E2E_XCRUN || 'xcrun',
pauseMs: Number(process.env.E2E_OPEN_LINK_PAUSE_MS || 2000),
});
console.log(`[fixtures] open-link ${link}: ${JSON.stringify(result)}`);
return json(result.ok ? 200 : 502, result);
}

const server = createServer((req, res) => {
try {
const path = decodeURIComponent(new URL(req.url, 'http://x').pathname);

if (path === '/__open-link') {
handleOpenLink(req, res).catch((err) => {
console.error(`[fixtures] open-link failed: ${err.message}`);
res.destroy();
});
return;
}
const file = join(root, normalize(path));

if (!file.startsWith(root)) {
Expand Down
Loading
Loading