@@ -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
2015const childProcess = require ( 'child_process' ) ;
2116const { EventEmitter} = require ( 'events' ) ;
2217const 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
2629describe ( '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