Skip to content

Commit 89680f8

Browse files
committed
Retry failing iOS E2E flows once and stop rerunning real failures
Each RNTester iOS flow had 5 attempts inside the job, and the whole job (both flavors, every flow, all setup) was retried up to 3 times on top, so a flow could run 15 times. The runner also stopped at the first flow that exhausted its attempts, hiding later failures. - Give each flow 2 attempts, rebooting the simulator before the retry. - Keep running the remaining flows after a failure and report every failed flow at the end, with a step summary. - Persist per-flow results between CI attempts (same format as the Android runner) so retries skip flows that already passed. - Only retry the workflow when a job stopped before running every flow. A flow that failed both attempts fails the run immediately. - Upload the recording, JUnit report and Maestro debug output for failed attempts only.
1 parent 3b88cb2 commit 89680f8

5 files changed

Lines changed: 454 additions & 161 deletions

File tree

‎.github/actions/maestro-ios/action.yml‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ inputs:
2626
required: false
2727
default: ''
2828
description: Maestro device OS name, such as iOS-26-2
29+
test-state-path:
30+
required: false
31+
default: ''
32+
description: The path used to persist per-flow test results between retries
2933

3034
runs:
3135
using: composite
@@ -73,24 +77,14 @@ runs:
7377
"${{ inputs.flavor }}" \
7478
"${{ inputs.working-directory }}" \
7579
"${{ inputs.device-model }}" \
76-
"${{ inputs.device-os }}"
77-
- name: Store video record
78-
if: always()
79-
uses: ./.github/actions/upload-artifact
80-
with:
81-
name: e2e_ios_${{ inputs.app-id }}_report_${{ inputs.flavor }}_NewArch
82-
overwrite: true
83-
path: |
84-
video_record_1.mov
85-
video_record_2.mov
86-
video_record_3.mov
87-
video_record_4.mov
88-
video_record_5.mov
89-
report.xml
80+
"${{ inputs.device-os }}" \
81+
"${{ inputs.test-state-path }}"
9082
- name: Store Logs
91-
if: failure() && steps.run-tests.outcome == 'failure'
83+
if: always()
9284
uses: ./.github/actions/upload-artifact
9385
with:
9486
name: maestro-logs-${{ inputs.app-id }}-${{ inputs.flavor }}-NewArch
9587
overwrite: true
88+
if-no-files-found: ignore
89+
# Contains the recording, report and debug output of failed attempts only
9690
path: /tmp/MaestroLogs

‎.github/workflow-scripts/__tests__/maestro-ios-test.js‎

Lines changed: 145 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,32 @@ jest.mock('child_process', () => ({
1111
execSync: jest.fn(),
1212
spawn: jest.fn(),
1313
}));
14-
jest.mock('fs', () => ({
15-
existsSync: jest.fn(),
16-
lstatSync: jest.fn(),
17-
readdirSync: jest.fn(),
18-
}));
1914

2015
const childProcess = require('child_process');
2116
const {EventEmitter} = require('events');
2217
const fs = require('fs');
18+
const os = require('os');
19+
const path = require('path');
2320

24-
const {executeFlows, findAvailableSimulator} = require('../maestro-ios');
21+
const {
22+
collectFlows,
23+
executeFlowSuite,
24+
findAvailableSimulator,
25+
loadState,
26+
runMaestroFlow,
27+
} = require('../maestro-ios');
2528

2629
describe('Maestro iOS runner', () => {
30+
let temporaryDirectory;
31+
2732
beforeEach(() => {
2833
jest.resetAllMocks();
34+
temporaryDirectory = fs.mkdtempSync(
35+
path.join(os.tmpdir(), 'maestro-ios-test-'),
36+
);
37+
jest.spyOn(console, 'error').mockImplementation(() => {});
38+
jest.spyOn(console, 'info').mockImplementation(() => {});
39+
jest.spyOn(console, 'log').mockImplementation(() => {});
2940
childProcess.spawn.mockImplementation(() => {
3041
const recordingProcess = new EventEmitter();
3142
recordingProcess.pid = 1;
@@ -37,112 +48,170 @@ describe('Maestro iOS runner', () => {
3748
});
3849
});
3950

40-
it('executes each YAML flow separately and skips other files', async () => {
41-
fs.existsSync.mockReturnValue(true);
42-
fs.lstatSync.mockImplementation(path => ({
43-
isDirectory: () => path === 'flows/',
44-
}));
45-
fs.readdirSync.mockReturnValue(['second.yaml', 'image.png', 'first.yml']);
51+
afterEach(() => {
52+
jest.restoreAllMocks();
53+
fs.rmSync(temporaryDirectory, {recursive: true, force: true});
54+
});
4655

47-
await executeFlows('com.example', 'device-id', 'flows/', 'Hermes');
56+
it('collects YAML flows in a stable order and skips helpers and other files', () => {
57+
const helpersDirectory = path.join(temporaryDirectory, 'helpers');
58+
const nestedDirectory = path.join(temporaryDirectory, 'nested');
59+
fs.mkdirSync(helpersDirectory);
60+
fs.mkdirSync(nestedDirectory);
61+
fs.writeFileSync(path.join(temporaryDirectory, 'second.yaml'), 'appId: x');
62+
fs.writeFileSync(path.join(temporaryDirectory, 'image.png'), 'not a flow');
63+
fs.writeFileSync(path.join(temporaryDirectory, 'first.yml'), 'appId: x');
64+
fs.writeFileSync(path.join(helpersDirectory, 'search.yml'), 'appId: x');
65+
fs.writeFileSync(path.join(nestedDirectory, 'third.yml'), 'appId: x');
4866

49-
expect(childProcess.execSync).toHaveBeenCalledTimes(2);
50-
expect(childProcess.execSync.mock.calls[0][0]).toContain(
51-
'test "flows/first.yml"',
52-
);
53-
expect(childProcess.execSync.mock.calls[1][0]).toContain(
54-
'test "flows/second.yaml"',
55-
);
67+
expect(collectFlows(temporaryDirectory)).toEqual([
68+
path.join(temporaryDirectory, 'first.yml'),
69+
path.join(nestedDirectory, 'third.yml'),
70+
path.join(temporaryDirectory, 'second.yaml'),
71+
]);
5672
});
5773

58-
it('retries only the failing flow', async () => {
59-
fs.existsSync.mockReturnValue(false);
60-
childProcess.execSync.mockImplementationOnce(() => {
61-
throw new Error('Maestro driver failed');
74+
it('retries a failing flow once on a rebooted simulator and keeps going', async () => {
75+
const flows = ['first.yml', 'second.yml', 'third.yml'].map(file =>
76+
path.join(temporaryDirectory, file),
77+
);
78+
const statePath = path.join(temporaryDirectory, 'state', 'results.json');
79+
const executeFlow = jest.fn(async (appId, udid, flow) => {
80+
if (flow.endsWith('second.yml')) {
81+
throw new Error('failed assertion');
82+
}
6283
});
84+
const resetSimulator = jest.fn();
6385

64-
await executeFlows('com.example', 'device-id', 'flow.yml', 'Hermes');
86+
await expect(
87+
executeFlowSuite({
88+
flows,
89+
appId: 'com.example',
90+
udid: 'device-id',
91+
state: loadState(statePath),
92+
statePath,
93+
executeFlow,
94+
resetSimulator,
95+
}),
96+
).rejects.toThrow('1 Maestro flow(s) failed');
6597

66-
expect(childProcess.execSync).toHaveBeenCalledTimes(2);
67-
for (const call of childProcess.execSync.mock.calls) {
68-
expect(call[0]).toContain('test "flow.yml"');
69-
}
98+
expect(executeFlow.mock.calls.map(call => [call[2], call[3]])).toEqual([
99+
[flows[0], 1],
100+
[flows[1], 1],
101+
[flows[1], 2],
102+
[flows[2], 1],
103+
]);
104+
expect(resetSimulator).toHaveBeenCalledTimes(1);
105+
expect(resetSimulator).toHaveBeenCalledWith('device-id');
106+
expect(Object.values(loadState(statePath).flows)).toEqual([
107+
{status: 'passed', attempts: 1},
108+
{status: 'failed', attempts: 2, error: 'failed assertion'},
109+
{status: 'passed', attempts: 1},
110+
]);
70111
});
71112

72-
it('waits for the recorder to exit before starting the next flow', async () => {
73-
fs.existsSync.mockReturnValue(true);
74-
fs.lstatSync.mockImplementation(path => ({
75-
isDirectory: () => path === 'flows/',
76-
}));
77-
fs.readdirSync.mockReturnValue(['first.yml', 'second.yml']);
113+
it('passes a flow that succeeds on its retry', async () => {
114+
const flow = path.join(temporaryDirectory, 'flaky.yml');
115+
const statePath = path.join(temporaryDirectory, 'results.json');
116+
const executeFlow = jest
117+
.fn()
118+
.mockRejectedValueOnce(new Error('flaky'))
119+
.mockResolvedValueOnce();
120+
121+
await executeFlowSuite({
122+
flows: [flow],
123+
appId: 'com.example',
124+
udid: 'device-id',
125+
state: loadState(statePath),
126+
statePath,
127+
executeFlow,
128+
resetSimulator: jest.fn(),
129+
});
130+
131+
expect(Object.values(loadState(statePath).flows)).toEqual([
132+
{status: 'passed', attempts: 2},
133+
]);
134+
});
135+
136+
it('only runs flows that have not passed in a previous CI attempt', async () => {
137+
const flows = ['first.yml', 'second.yml'].map(file =>
138+
path.join(temporaryDirectory, file),
139+
);
140+
const statePath = path.join(temporaryDirectory, 'results.json');
141+
await expect(
142+
executeFlowSuite({
143+
flows,
144+
appId: 'com.example',
145+
udid: 'device-id',
146+
state: loadState(statePath),
147+
statePath,
148+
executeFlow: jest.fn(async (appId, udid, flow) => {
149+
if (flow.endsWith('second.yml')) {
150+
throw new Error('failed assertion');
151+
}
152+
}),
153+
resetSimulator: jest.fn(),
154+
}),
155+
).rejects.toThrow();
156+
157+
const retry = jest.fn();
158+
await executeFlowSuite({
159+
flows,
160+
appId: 'com.example',
161+
udid: 'device-id',
162+
state: loadState(statePath),
163+
statePath,
164+
executeFlow: retry,
165+
resetSimulator: jest.fn(),
166+
});
78167

168+
expect(retry).toHaveBeenCalledTimes(1);
169+
expect(retry.mock.calls[0][2]).toBe(flows[1]);
170+
});
171+
172+
it('waits for the recorder to exit before returning', async () => {
79173
const recordingProcess = new EventEmitter();
80174
recordingProcess.pid = 1;
81175
recordingProcess.kill = jest.fn(() => true);
82176
childProcess.spawn.mockReturnValueOnce(recordingProcess);
83177

84-
const execution = executeFlows(
178+
let finished = false;
179+
const execution = runMaestroFlow(
85180
'com.example',
86181
'device-id',
87-
'flows/',
88-
'Hermes',
89-
);
182+
'flows/first.yml',
183+
1,
184+
).then(() => {
185+
finished = true;
186+
});
90187

91188
await new Promise(resolve =>
92189
jest.requireActual('timers').setImmediate(resolve),
93190
);
94191

95192
expect(recordingProcess.kill).toHaveBeenCalledWith('SIGINT');
96-
expect(childProcess.execSync).toHaveBeenCalledTimes(1);
97-
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
193+
expect(childProcess.execSync.mock.calls[0][0]).toContain(
194+
'test "flows/first.yml"',
195+
);
196+
expect(finished).toBe(false);
98197

99198
recordingProcess.emit('exit', 0, null);
100199
await execution;
101-
102-
expect(childProcess.execSync).toHaveBeenCalledTimes(2);
103-
expect(childProcess.spawn).toHaveBeenCalledTimes(2);
200+
expect(finished).toBe(true);
104201
});
105202

106-
it('skips helper directories while recursing into flow directories', async () => {
107-
fs.existsSync.mockReturnValue(true);
108-
fs.lstatSync.mockImplementation(path => ({
109-
isDirectory: () => !path.endsWith('.yml'),
110-
}));
111-
fs.readdirSync.mockImplementation(path =>
112-
path === 'flows/' ? ['helpers', 'nested'] : ['flow.yml'],
113-
);
114-
115-
await executeFlows('com.example', 'device-id', 'flows/', 'Hermes');
116-
117-
expect(fs.readdirSync).not.toHaveBeenCalledWith('flows/helpers');
118-
expect(childProcess.execSync).toHaveBeenCalledTimes(1);
119-
expect(childProcess.execSync.mock.calls[0][0]).toContain(
120-
'test "flows/nested/flow.yml"',
121-
);
122-
});
123-
124-
it('rejects after exhausting retries and stops every recorder', async () => {
125-
const consoleError = jest
126-
.spyOn(console, 'error')
127-
.mockImplementation(() => {});
128-
fs.existsSync.mockReturnValue(false);
203+
it('stops the recorder when Maestro fails', async () => {
129204
const error = new Error('Maestro driver failed');
130205
childProcess.execSync.mockImplementation(() => {
131206
throw error;
132207
});
133208

134209
await expect(
135-
executeFlows('com.example', 'device-id', 'flow.yml', 'Hermes'),
210+
runMaestroFlow('com.example', 'device-id', 'flow.yml', 2),
136211
).rejects.toBe(error);
137212

138-
expect(childProcess.execSync).toHaveBeenCalledTimes(5);
139-
expect(childProcess.spawn).toHaveBeenCalledTimes(5);
140-
for (const {value: recordingProcess} of childProcess.spawn.mock.results) {
141-
expect(recordingProcess.kill).toHaveBeenCalledWith('SIGINT');
142-
}
143-
expect(consoleError).toHaveBeenCalledWith(
144-
'Failed to execute flow flow.yml after 5 attempts.',
145-
);
213+
const {value: recordingProcess} = childProcess.spawn.mock.results[0];
214+
expect(recordingProcess.kill).toHaveBeenCalledWith('SIGINT');
146215
});
147216

148217
it('selects an iPhone Pro simulator from the latest runtime', () => {

0 commit comments

Comments
 (0)