Skip to content

Commit ad66727

Browse files
antonisclaude
andcommitted
fix(e2e): Fix iOS E2E flakiness on Cirrus Labs Tart VMs
iOS E2E tests have been failing on every main commit since the migration to Cirrus Labs Tart VMs (nested virtualisation). The simulator is significantly slower to stabilise, causing Maestro's XCTest driver to lose communication with the app. Simulator configuration: - wait_for_boot: true — block until simulator fully boots - erase_before_boot: false — skip redundant erase (each flow uses clearState) - MAESTRO_DRIVER_STARTUP_TIMEOUT: 180000 (3 min) - Settings.app warm-up step to let SpringBoard finish post-boot init e2e-v2 test runner (cli.mjs): - Run each Maestro flow in its own process to isolate crashes (maestro test maestro shares a session — if crash.yml kills the app, subsequent flows fail because the XCTest driver loses the connection) - Per-flow retries (up to 3 attempts) for transient timing failures - execSync → execFileSync to avoid shell interpolation crash.yml: - Removed post-crash relaunch — unreliable on Tart VMs and unnecessary since each flow now runs in its own process Sample application test fixes: - Search all envelopes for app start transaction (may arrive separately) - Sort news envelopes by timestamp for consistent ordering - Exclude auto.app.start from time-to-display assertions - Per-flow retries in maestro.ts for transient failures Supersedes #5752 and #5755. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cbf87ac commit ad66727

File tree

7 files changed

+124
-37
lines changed

7 files changed

+124
-37
lines changed

.github/workflows/e2e-v2.yml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,12 +508,27 @@ jobs:
508508
with:
509509
model: ${{ env.IOS_DEVICE }}
510510
os_version: ${{ env.IOS_VERSION }}
511+
# Cirrus Labs Tart VMs need more time to fully boot the simulator before
512+
# Maestro can connect; without this the boot races with driver startup.
513+
wait_for_boot: true
514+
# Skip erasing the simulator before boot — each Maestro flow already
515+
# reinstalls the app via clearState, and the erase adds overhead that
516+
# makes the simulator less stable on nested-virtualisation Tart VMs.
517+
erase_before_boot: false
518+
519+
- name: Warm up iOS simulator
520+
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
521+
run: |
522+
# Tart VMs are slow after boot. Launch a stock app so SpringBoard
523+
# and system services finish post-boot init before Maestro connects.
524+
xcrun simctl launch booted com.apple.Preferences || true
525+
sleep 5
526+
xcrun simctl terminate booted com.apple.Preferences || true
511527
512528
- name: Run tests on iOS
513529
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
514530
env:
515-
# Increase timeout for Maestro iOS driver startup (default is 60s, some CI runners need more time)
516-
MAESTRO_DRIVER_STARTUP_TIMEOUT: 120000
531+
MAESTRO_DRIVER_STARTUP_TIMEOUT: 180000
517532
run: ./dev-packages/e2e-tests/cli.mjs ${{ matrix.platform }} --test
518533

519534
- name: Upload logs

.github/workflows/sample-application.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ concurrency:
1414
env:
1515
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
1616
MAESTRO_VERSION: '2.3.0'
17-
MAESTRO_DRIVER_STARTUP_TIMEOUT: 90000 # Increase timeout from default 30s to 90s for CI stability
17+
MAESTRO_DRIVER_STARTUP_TIMEOUT: 180000 # Increase timeout from default 30s to 180s for CI stability on Tart VMs
1818
RN_SENTRY_POD_NAME: RNSentry
1919
IOS_APP_ARCHIVE_PATH: sentry-react-native-sample.app.zip
2020
ANDROID_APP_ARCHIVE_PATH: sentry-react-native-sample.apk.zip
@@ -358,6 +358,17 @@ jobs:
358358
with:
359359
model: ${{ env.IOS_DEVICE }}
360360
os_version: ${{ env.IOS_VERSION }}
361+
wait_for_boot: true
362+
erase_before_boot: false
363+
364+
- name: Warm up iOS Simulator
365+
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
366+
run: |
367+
# Tart VMs are slow after boot. Launch a stock app so SpringBoard
368+
# and system services finish post-boot init before tests start.
369+
xcrun simctl launch booted com.apple.Preferences || true
370+
sleep 5
371+
xcrun simctl terminate booted com.apple.Preferences || true
361372
362373
- name: Run iOS Tests
363374
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}

dev-packages/e2e-tests/cli.mjs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -290,18 +290,44 @@ if (actions.includes('test')) {
290290
if (!sentryAuthToken) {
291291
console.log('Skipping maestro test due to unavailable or empty SENTRY_AUTH_TOKEN');
292292
} else {
293+
// Discover top-level flow files (shared utilities live in utils/).
294+
const maestroDir = path.join(e2eDir, 'maestro');
295+
const flowFiles = fs.readdirSync(maestroDir)
296+
.filter(f => f.endsWith('.yml') && !fs.statSync(path.join(maestroDir, f)).isDirectory())
297+
.sort();
298+
299+
const maestroEnvArgs = [
300+
'--env', `APP_ID=${appId}`,
301+
'--env', `SENTRY_AUTH_TOKEN=${sentryAuthToken}`,
302+
];
303+
304+
// Run each flow in its own maestro process to isolate crashes.
305+
// Retry failed flows up to 3 times — Tart VMs have transient timing
306+
// issues where the app or XCTest driver momentarily lose responsiveness.
307+
const maxAttempts = 3;
308+
const failed = [];
293309
try {
294-
execSync(
295-
`maestro test maestro \
296-
--env=APP_ID="${appId}" \
297-
--env=SENTRY_AUTH_TOKEN="${sentryAuthToken}" \
298-
--debug-output maestro-logs \
299-
--flatten-debug-output`,
300-
{
301-
stdio: 'inherit',
302-
cwd: e2eDir,
303-
},
304-
);
310+
for (const flowFile of flowFiles) {
311+
let passed = false;
312+
for (let attempt = 1; attempt <= maxAttempts && !passed; attempt++) {
313+
try {
314+
execFileSync('maestro', [
315+
'test', `maestro/${flowFile}`, ...maestroEnvArgs,
316+
'--debug-output', 'maestro-logs',
317+
'--flatten-debug-output',
318+
], {
319+
stdio: 'inherit',
320+
cwd: e2eDir,
321+
});
322+
passed = true;
323+
} catch (error) {
324+
if (attempt < maxAttempts) {
325+
console.warn(`Flow ${flowFile} failed (attempt ${attempt}/${maxAttempts}), retrying…`);
326+
}
327+
}
328+
}
329+
if (!passed) failed.push(flowFile);
330+
}
305331
} finally {
306332
// Always redact sensitive data, even if the test fails
307333
const redactScript = `
@@ -320,5 +346,10 @@ if (actions.includes('test')) {
320346
console.warn('Failed to redact sensitive data from logs:', error.message);
321347
}
322348
}
349+
350+
if (failed.length > 0) {
351+
console.error(`Failed flows: ${failed.join(', ')}`);
352+
process.exit(1);
353+
}
323354
}
324355
}

dev-packages/e2e-tests/maestro/crash.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,5 @@ jsEngine: graaljs
44
- runFlow: utils/launchTestAppClear.yml
55
- tapOn: "Crash"
66

7-
- launchApp
8-
9-
- runFlow: utils/assertTestReady.yml
7+
# No post-crash assertions needed. Each flow runs in its own maestro
8+
# process, so the next flow starts fresh via launchTestAppClear.yml.

samples/react-native/e2e/tests/captureErrorScreenTransaction/captureErrorsScreenTransaction.test.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,19 @@ describe('Capture Errors Screen Transaction', () => {
3131
});
3232

3333
it('envelope contains transaction context', async () => {
34-
const envelope = getErrorsEnvelope();
35-
36-
const items = envelope[1];
37-
const transactions = items.filter(([header]) => header.type === 'transaction');
38-
const appStartTransaction = transactions.find(([_header, payload]) => {
39-
const event = payload as any;
40-
return event.transaction === 'ErrorsScreen' &&
41-
event.contexts?.trace?.origin === 'auto.app.start';
42-
});
34+
// Search all envelopes for the app start transaction, not just the first match.
35+
// On slow Android emulators, the app start transaction may arrive in a different envelope.
36+
const allErrorsEnvelopes = sentryServer.getAllEnvelopes(
37+
containingTransactionWithName('ErrorsScreen'),
38+
);
39+
const appStartTransaction = allErrorsEnvelopes
40+
.flatMap(env => env[1])
41+
.filter(([header]) => (header as { type?: string }).type === 'transaction')
42+
.find(([_header, payload]) => {
43+
const event = payload as any;
44+
return event.transaction === 'ErrorsScreen' &&
45+
event.contexts?.trace?.origin === 'auto.app.start';
46+
});
4347

4448
expect(appStartTransaction).toBeDefined();
4549

samples/react-native/e2e/tests/captureSpaceflightNewsScreenTransaction/captureSpaceflightNewsScreenTransaction.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ describe('Capture Spaceflight News Screen Transaction', () => {
4242
await waitForSpaceflightNewsTx;
4343

4444
newsEnvelopes = sentryServer.getAllEnvelopes(containingNewsScreen);
45+
// Sort by transaction timestamp to ensure consistent ordering regardless of arrival time.
46+
// On slow CI VMs (e.g., Cirrus Labs Tart), envelopes may arrive out of order.
47+
newsEnvelopes.sort((a, b) => {
48+
const aItem = getItemOfTypeFrom<EventItem>(a, 'transaction');
49+
const bItem = getItemOfTypeFrom<EventItem>(b, 'transaction');
50+
return (aItem?.[1].timestamp ?? 0) - (bItem?.[1].timestamp ?? 0);
51+
});
4552
allTransactionEnvelopes = sentryServer.getAllEnvelopes(
4653
containingTransaction,
4754
);
@@ -64,9 +71,12 @@ describe('Capture Spaceflight News Screen Transaction', () => {
6471
allTransactionEnvelopes
6572
.filter(envelope => {
6673
const item = getItemOfTypeFrom<EventItem>(envelope, 'transaction');
67-
// Only check navigation transactions, not user interaction transactions
68-
// User interaction transactions (ui.action.touch) don't have time-to-display measurements
69-
return item?.[1]?.contexts?.trace?.op !== 'ui.action.touch';
74+
const traceContext = item?.[1]?.contexts?.trace;
75+
// Exclude user interaction transactions (no time-to-display measurements)
76+
if (traceContext?.op === 'ui.action.touch') return false;
77+
// Exclude app start transactions (have app_start_cold measurements, not time-to-display)
78+
if (traceContext?.origin === 'auto.app.start') return false;
79+
return true;
7080
})
7181
.forEach(envelope => {
7282
expectToContainTimeToDisplayMeasurements(
@@ -122,7 +132,7 @@ describe('Capture Spaceflight News Screen Transaction', () => {
122132
});
123133

124134
it('contains exactly two articles requests spans', () => {
125-
// This test ensures we are to tracing requests multiple times on different layers
135+
// This test ensures we are tracing requests multiple times on different layers
126136
// fetch > xhr > native
127137

128138
const item = getFirstNewsEventItem();
Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
import { spawn } from 'node:child_process';
22
import path from 'node:path';
33

4-
/**
5-
* Run a Maestro test and return a promise that resolves when the test is finished.
6-
*
7-
* @param test - The path to the Maestro test file relative to the `e2e` directory.
8-
* @returns A promise that resolves when the test is finished.
9-
*/
10-
export const maestro = async (test: string) => {
4+
const MAX_ATTEMPTS = 3;
5+
6+
const runMaestro = (test: string): Promise<void> => {
117
return new Promise((resolve, reject) => {
128
const process = spawn('maestro', ['test', test, '--format', 'junit'], {
139
cwd: path.join(__dirname, '..'),
@@ -22,3 +18,24 @@ export const maestro = async (test: string) => {
2218
});
2319
});
2420
};
21+
22+
/**
23+
* Run a Maestro test with retries to handle transient failures on slow CI VMs.
24+
*
25+
* @param test - The path to the Maestro test file relative to the `e2e` directory.
26+
* @returns A promise that resolves when the test passes.
27+
*/
28+
export const maestro = async (test: string) => {
29+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
30+
try {
31+
await runMaestro(test);
32+
return;
33+
} catch (error) {
34+
if (attempt < MAX_ATTEMPTS) {
35+
console.warn(`Maestro attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying...`);
36+
} else {
37+
throw error;
38+
}
39+
}
40+
}
41+
};

0 commit comments

Comments
 (0)