Skip to content

Commit 685defa

Browse files
antonisclaude
andcommitted
fix(ci): Fix E2E test flakiness with stable checks instead of retries
Replace retry-based approach with deterministic fixes: - Per-flow process isolation in cli.mjs (no retries, crash.yml runs last) - Proper simulator boot wait using xcrun simctl bootstatus + Settings.app warm-up - Search all envelopes for app start transaction (handles slow VM delivery) - Sort envelopes by timestamp for consistent ordering - Allow-list for TTID/TTFD ops (navigation, ui.load) instead of fragile deny-list - Bump MAESTRO_DRIVER_STARTUP_TIMEOUT to 180s for Cirrus Labs Tart VMs - Use wait_for_boot + erase_before_boot on simulator-action Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d5079ff commit 685defa

6 files changed

Lines changed: 123 additions & 43 deletions

File tree

.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+
wait_for_boot: true
512+
erase_before_boot: false
513+
514+
- name: Wait for iOS simulator to be fully ready
515+
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
516+
run: |
517+
# Wait for boot to complete at the system level
518+
xcrun simctl bootstatus booted -b
519+
# Launch and dismiss Settings.app to ensure SpringBoard and system services
520+
# are fully initialized — this avoids Maestro connecting to a half-booted
521+
# simulator on Cirrus Labs Tart VMs.
522+
xcrun simctl launch booted com.apple.Preferences
523+
sleep 5
524+
xcrun simctl terminate booted com.apple.Preferences
511525
512526
- name: Run tests on iOS
513527
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
514528
env:
515-
# Increase timeout for Maestro iOS driver startup (default is 60s, some CI runners need more time)
516-
MAESTRO_DRIVER_STARTUP_TIMEOUT: 120000
529+
# Increase timeout for Maestro iOS driver startup from default 60s to 180s.
530+
# Cirrus Labs Tart VMs can be slow to respond after simulator boot.
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: 11 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 Cirrus Labs 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
@@ -332,6 +332,16 @@ jobs:
332332
with:
333333
model: ${{ env.IOS_DEVICE }}
334334
os_version: ${{ env.IOS_VERSION }}
335+
wait_for_boot: true
336+
erase_before_boot: false
337+
338+
- name: Wait for iOS simulator to be fully ready
339+
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}
340+
run: |
341+
xcrun simctl bootstatus booted -b
342+
xcrun simctl launch booted com.apple.Preferences
343+
sleep 5
344+
xcrun simctl terminate booted com.apple.Preferences
335345
336346
- name: Run iOS Tests
337347
if: ${{ steps.platform-check.outputs.skip != 'true' && matrix.platform == 'ios' }}

dev-packages/e2e-tests/cli.mjs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -290,35 +290,73 @@ if (actions.includes('test')) {
290290
if (!sentryAuthToken) {
291291
console.log('Skipping maestro test due to unavailable or empty SENTRY_AUTH_TOKEN');
292292
} else {
293-
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-
);
305-
} finally {
306-
// Always redact sensitive data, even if the test fails
307-
const redactScript = `
308-
if [[ "$(uname)" == "Darwin" ]]; then
309-
find ./maestro-logs -type f -exec sed -i '' "s/${sentryAuthToken}/[REDACTED]/g" {} +
310-
echo 'Redacted sensitive data from logs on MacOS'
311-
else
312-
find ./maestro-logs -type f -exec sed -i "s/${sentryAuthToken}/[REDACTED]/g" {} +
313-
echo 'Redacted sensitive data from logs on Ubuntu'
314-
fi
315-
`;
293+
const maestroDir = path.join(e2eDir, 'maestro');
294+
const flowFiles = fs.readdirSync(maestroDir)
295+
.filter(f => f.endsWith('.yml') && !f.startsWith('utils'))
296+
.sort((a, b) => {
297+
// Run crash.yml last — it kills the app, and running it last avoids
298+
// any potential simulator instability affecting subsequent flows.
299+
if (a === 'crash.yml') return 1;
300+
if (b === 'crash.yml') return -1;
301+
return a.localeCompare(b);
302+
});
316303

304+
console.log(`Discovered ${flowFiles.length} Maestro flows: ${flowFiles.join(', ')}`);
305+
306+
const failedFlows = [];
307+
308+
// Run each flow in its own process to prevent crash cascade —
309+
// when crash.yml kills the app, a shared Maestro session would fail
310+
// all subsequent flows.
311+
for (const flow of flowFiles) {
312+
const flowPath = path.join('maestro', flow);
313+
console.log(`\n--- Running flow: ${flow} ---`);
317314
try {
318-
execSync(redactScript, { stdio: 'inherit', cwd: e2eDir, shell: '/bin/bash' });
315+
execFileSync('maestro', [
316+
'test',
317+
flowPath,
318+
'--env', `APP_ID=${appId}`,
319+
'--env', `SENTRY_AUTH_TOKEN=${sentryAuthToken}`,
320+
'--debug-output', 'maestro-logs',
321+
'--flatten-debug-output',
322+
], {
323+
stdio: 'inherit',
324+
cwd: e2eDir,
325+
});
326+
console.log(`--- Flow ${flow}: PASSED ---`);
319327
} catch (error) {
320-
console.warn('Failed to redact sensitive data from logs:', error.message);
328+
console.error(`--- Flow ${flow}: FAILED ---`);
329+
failedFlows.push(flow);
321330
}
322331
}
332+
333+
// Always redact sensitive data, even if some tests failed
334+
try {
335+
const logDir = path.join(e2eDir, 'maestro-logs');
336+
if (fs.existsSync(logDir)) {
337+
const redactFiles = (dir) => {
338+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
339+
const fullPath = path.join(dir, entry.name);
340+
if (entry.isDirectory()) {
341+
redactFiles(fullPath);
342+
} else {
343+
const content = fs.readFileSync(fullPath, 'utf8');
344+
if (content.includes(sentryAuthToken)) {
345+
fs.writeFileSync(fullPath, content.replaceAll(sentryAuthToken, '[REDACTED]'));
346+
}
347+
}
348+
}
349+
};
350+
redactFiles(logDir);
351+
console.log('Redacted sensitive data from logs');
352+
}
353+
} catch (error) {
354+
console.warn('Failed to redact sensitive data from logs:', error.message);
355+
}
356+
357+
if (failedFlows.length > 0) {
358+
console.error(`\nFailed flows: ${failedFlows.join(', ')}`);
359+
process.exit(1);
360+
}
323361
}
324362
}

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

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

7-
- launchApp
8-
9-
- runFlow: utils/assertTestReady.yml
7+
# Verify the app can recover from a native crash by fully resetting
8+
# and relaunching (killApp + launchApp clearState + assertTestReady).
9+
- runFlow: utils/launchTestAppClear.yml

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,23 @@ 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+
// The app start transaction may arrive in a separate envelope on slow CI VMs,
35+
// so search all matching envelopes instead of just the first one.
36+
const allEnvelopes = sentryServer.getAllEnvelopes(
37+
containingTransactionWithName('ErrorsScreen'),
38+
);
39+
40+
let appStartTransaction: EventItem | undefined;
41+
for (const envelope of allEnvelopes) {
42+
const items = envelope[1];
43+
const transactions = items.filter(([header]) => header.type === 'transaction') as EventItem[];
44+
appStartTransaction = transactions.find(([_header, payload]) => {
45+
const event = payload as any;
46+
return event.transaction === 'ErrorsScreen' &&
47+
event.contexts?.trace?.origin === 'auto.app.start';
48+
});
49+
if (appStartTransaction) break;
50+
}
4351

4452
expect(appStartTransaction).toBeDefined();
4553

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

Lines changed: 12 additions & 3 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 — envelope delivery order may vary on slow CI VMs,
46+
// but test assertions depend on chronological 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,11 @@ 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+
// Only navigation and app start transactions have time-to-display measurements.
75+
// Filter with an allow-list — other ops like 'ui.action.touch' or
76+
// 'navigation.processing' do not include TTID/TTFD.
77+
const op = item?.[1]?.contexts?.trace?.op;
78+
return op === 'navigation' || op === 'ui.load';
7079
})
7180
.forEach(envelope => {
7281
expectToContainTimeToDisplayMeasurements(

0 commit comments

Comments
 (0)