Skip to content

Commit 41ad081

Browse files
alwxclaude
andauthored
fix(e2e): Retry iOS E2E flows when app fails to reach "E2E Tests Ready" (#6448)
* fix(e2e): Retry iOS E2E flows when app fails to reach "E2E Tests Ready" On Cirrus Labs Tart VMs, roughly one `maestro test` flow per run of `e2e-v2.yml` on iOS intermittently fails immediately after `launchApp clearState` with `Assert that "E2E Tests Ready" is visible... FAILED`. The failure duration (14-19s) is far below the 5-minute `extendedWaitUntil` in `assertTestReady.yml`, indicating the Maestro driver or app connection drops on the fresh launch rather than a real UI regression. Retry the flow once, but only when the failure output matches this specific signature — real assertion failures elsewhere (e.g. `assertEventIdVisible`, `App crashed`) are surfaced immediately so regressions are not masked. Maestro's CLI exit code is a flat 0/1 (`TestCommand.kt:461`), so the pattern must be detected in stdout. The retry logs a distinct `[Flaky] ... — retrying (2/2)` line and a `(attempt 2/2)` suffix on the passing line so CI output makes it obvious when the flake occurred. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(e2e): Break out of retry loop on non-flake failure The `else` branch handling a non-retryable failure logged `[Failed]` but did not exit the inner `for (attempt)` loop, so a real failure (anything without the `E2E Tests Ready` flake signature) would still run `maestro test` a second time. That contradicts the "surface regressions immediately" invariant this retry logic was written to preserve — if the second attempt happened to pass, a real regression would be silently masked. Caught by both Cursor Bugbot and Sentry's autofix reviewer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b3d858c commit 41ad081

1 file changed

Lines changed: 55 additions & 28 deletions

File tree

dev-packages/e2e-tests/cli.mjs

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -373,42 +373,69 @@ if (actions.includes('test')) {
373373

374374
const results = [];
375375

376+
// Retry only on the known Cirrus Labs Tart VM flake where the app fails
377+
// to reach "E2E Tests Ready" after a fresh launchApp. Maestro's exit code
378+
// is a flat 0/1 (see TestCommand.kt), so the pattern must be detected in
379+
// stdout. Real assertion failures elsewhere (e.g. assertEventIdVisible)
380+
// are surfaced immediately.
381+
const READY_ASSERTION_FLAKE = 'Assert that "E2E Tests Ready" is visible... FAILED';
382+
const MAX_ATTEMPTS = 2;
383+
376384
// Run each flow in its own process to prevent crash cascade —
377385
// when crash.yml kills the app, a shared Maestro session would fail
378386
// all subsequent flows.
379387
console.log('Waiting for flows to complete...');
380388
for (const flow of flowFiles) {
381389
const flowPath = path.join('maestro', flow);
382-
const startTime = Date.now();
383-
try {
384-
execFileSync('maestro', [
385-
'test',
386-
flowPath,
387-
'--env', `APP_ID=${appId}`,
388-
'--env', `SENTRY_AUTH_TOKEN=${sentryAuthToken}`,
389-
'--debug-output', 'maestro-logs',
390-
'--flatten-debug-output',
391-
], {
392-
stdio: 'pipe',
393-
cwd: e2eDir,
394-
});
395-
const elapsed = Math.round((Date.now() - startTime) / 1000);
396-
const name = flow.replace('.yml', '');
397-
results.push({ name, passed: true, elapsed });
398-
console.log(`[Passed] ${name} (${elapsed}s)`);
399-
} catch (error) {
400-
const elapsed = Math.round((Date.now() - startTime) / 1000);
401-
const name = flow.replace('.yml', '');
402-
const output = (error.stdout?.toString() || '') + (error.stderr?.toString() || '');
403-
const detail = output.split('\n').find(l =>
404-
l.includes('App crashed') || l.includes('Element not found') || l.includes('FAILED')) || '';
405-
results.push({ name, passed: false, elapsed, detail });
406-
console.log(`[Failed] ${name} (${elapsed}s)${detail ? ` (${detail.trim()})` : ''}`);
407-
// Dump Maestro output for failed flows to aid debugging
408-
if (output) {
409-
console.log(`\n--- ${name} output ---\n${output.trim()}\n--- end ${name} output ---\n`);
390+
const name = flow.replace('.yml', '');
391+
let lastElapsed = 0;
392+
let lastOutput = '';
393+
let lastDetail = '';
394+
let passed = false;
395+
396+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
397+
const startTime = Date.now();
398+
try {
399+
execFileSync('maestro', [
400+
'test',
401+
flowPath,
402+
'--env', `APP_ID=${appId}`,
403+
'--env', `SENTRY_AUTH_TOKEN=${sentryAuthToken}`,
404+
'--debug-output', 'maestro-logs',
405+
'--flatten-debug-output',
406+
], {
407+
stdio: 'pipe',
408+
cwd: e2eDir,
409+
});
410+
lastElapsed = Math.round((Date.now() - startTime) / 1000);
411+
passed = true;
412+
const suffix = attempt > 1 ? ` (attempt ${attempt}/${MAX_ATTEMPTS})` : '';
413+
console.log(`[Passed] ${name} (${lastElapsed}s)${suffix}`);
414+
break;
415+
} catch (error) {
416+
lastElapsed = Math.round((Date.now() - startTime) / 1000);
417+
lastOutput = (error.stdout?.toString() || '') + (error.stderr?.toString() || '');
418+
lastDetail = lastOutput.split('\n').find(l =>
419+
l.includes('App crashed') || l.includes('Element not found') || l.includes('FAILED')) || '';
420+
421+
const isReadyFlake = lastOutput.includes(READY_ASSERTION_FLAKE);
422+
const canRetry = isReadyFlake && attempt < MAX_ATTEMPTS;
423+
424+
if (canRetry) {
425+
console.log(`[Flaky] ${name} (${lastElapsed}s)${lastDetail ? ` (${lastDetail.trim()})` : ''} — retrying (${attempt + 1}/${MAX_ATTEMPTS})`);
426+
// Brief pause to let the simulator/driver settle before retrying
427+
execFileSync('sleep', ['5']);
428+
continue;
429+
}
430+
console.log(`[Failed] ${name} (${lastElapsed}s)${lastDetail ? ` (${lastDetail.trim()})` : ''}`);
431+
if (lastOutput) {
432+
console.log(`\n--- ${name} output ---\n${lastOutput.trim()}\n--- end ${name} output ---\n`);
433+
}
434+
break;
410435
}
411436
}
437+
438+
results.push({ name, passed, elapsed: lastElapsed, detail: passed ? '' : lastDetail });
412439
}
413440

414441
const failedFlows = results.filter(r => !r.passed).map(r => r.name);

0 commit comments

Comments
 (0)