@@ -135,9 +135,9 @@ test('mutating commands do not restart or replay after command send failure', as
135135 const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
136136
137137 mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
138- mockExecuteRunnerCommandWithSession . mockRejectedValueOnce (
139- new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) ,
140- ) ;
138+ mockExecuteRunnerCommandWithSession
139+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
140+ . mockResolvedValueOnce ( { lifecycleState : 'notAccepted' } ) ;
141141
142142 await assert . rejects ( ( ) =>
143143 runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
@@ -150,7 +150,165 @@ test('mutating commands do not restart or replay after command send failure', as
150150 'transport_error_after_command_send' ,
151151 ] ) ;
152152 assert . equal ( mockStopRunnerSession . mock . calls . length , 0 ) ;
153- assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 1 ) ;
153+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
154+ } ) ;
155+
156+ test ( 'mutating commands recover cached responses before invalidating after command send failure' , async ( ) => {
157+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
158+
159+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
160+ mockExecuteRunnerCommandWithSession
161+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
162+ . mockResolvedValueOnce ( {
163+ lifecycleState : 'completed' ,
164+ lifecycleResponseJson : JSON . stringify ( { ok : true , data : { message : 'tapped' } } ) ,
165+ } ) ;
166+
167+ const result = await runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ;
168+
169+ assert . deepEqual ( result , { message : 'tapped' } ) ;
170+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
171+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
172+ const sentCommand = mockExecuteRunnerCommandWithSession . mock . calls [ 0 ] ?. [ 2 ] ;
173+ const statusCommand = mockExecuteRunnerCommandWithSession . mock . calls [ 1 ] ?. [ 2 ] ;
174+ assert . equal ( statusCommand . command , 'status' ) ;
175+ assert . equal ( statusCommand . statusCommandId , sentCommand . commandId ) ;
176+ } ) ;
177+
178+ test ( 'mutating commands keep invalidating when status cannot find the command' , async ( ) => {
179+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
180+
181+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
182+ mockExecuteRunnerCommandWithSession
183+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
184+ . mockResolvedValueOnce ( {
185+ lifecycleState : 'notAccepted' ,
186+ } ) ;
187+
188+ await assert . rejects ( ( ) =>
189+ runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
190+ ) ;
191+
192+ assert . deepEqual ( mockInvalidateRunnerSession . mock . calls , [
193+ [ session , 'transport_error_after_command_send' ] ,
194+ ] ) ;
195+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
196+ } ) ;
197+
198+ test ( 'read-only commands retry when completed status has no retained response' , async ( ) => {
199+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
200+
201+ mockEnsureRunnerSession . mockResolvedValue ( session ) ;
202+ mockExecuteRunnerCommandWithSession
203+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
204+ . mockResolvedValueOnce ( { lifecycleState : 'completed' } )
205+ . mockResolvedValueOnce ( { nodes : [ ] , truncated : false } ) ;
206+
207+ const result = await runIosRunnerCommand ( IOS_SIMULATOR , { command : 'snapshot' } ) ;
208+
209+ assert . deepEqual ( result , { nodes : [ ] , truncated : false } ) ;
210+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
211+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 3 ) ;
212+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 1 ] ?. [ 2 ] . command , 'status' ) ;
213+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 2 ] ?. [ 2 ] . command , 'snapshot' ) ;
214+ } ) ;
215+
216+ test ( 'read-only commands retry when status shows in-flight work' , async ( ) => {
217+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
218+
219+ mockEnsureRunnerSession . mockResolvedValue ( session ) ;
220+ mockExecuteRunnerCommandWithSession
221+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
222+ . mockResolvedValueOnce ( { lifecycleState : 'started' } )
223+ . mockResolvedValueOnce ( { nodes : [ ] , truncated : false } ) ;
224+
225+ const result = await runIosRunnerCommand ( IOS_SIMULATOR , { command : 'snapshot' } ) ;
226+
227+ assert . deepEqual ( result , { nodes : [ ] , truncated : false } ) ;
228+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
229+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 3 ) ;
230+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 1 ] ?. [ 2 ] . command , 'status' ) ;
231+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 2 ] ?. [ 2 ] . command , 'snapshot' ) ;
232+ } ) ;
233+
234+ test ( 'mutating commands report recovery guidance when completed status has no retained response' , async ( ) => {
235+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
236+
237+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
238+ mockExecuteRunnerCommandWithSession
239+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
240+ . mockResolvedValueOnce ( { lifecycleState : 'completed' } ) ;
241+
242+ await assert . rejects (
243+ ( ) => runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
244+ ( error : unknown ) => {
245+ assert . ok ( error instanceof AppError ) ;
246+ assert . match ( error . message , / " t a p " c o m p l e t e d a f t e r t h e t r a n s p o r t r e s p o n s e w a s l o s t / ) ;
247+ assert . equal ( error . details ?. recovery , 'completed_without_retained_response' ) ;
248+ assert . match ( String ( error . details ?. hint ) , / w i l l n o t r e p l a y / ) ;
249+ assert . match ( String ( error . details ?. hint ) , / s n a p s h o t - i / ) ;
250+ assert . equal ( error . details ?. transportError , 'fetch failed' ) ;
251+ return true ;
252+ } ,
253+ ) ;
254+
255+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
256+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
257+ } ) ;
258+
259+ test ( 'mutating commands preserve runner failure details from status recovery' , async ( ) => {
260+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
261+
262+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
263+ mockExecuteRunnerCommandWithSession
264+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
265+ . mockResolvedValueOnce ( {
266+ lifecycleState : 'failed' ,
267+ lifecycleErrorCode : 'AMBIGUOUS_MATCH' ,
268+ lifecycleErrorMessage : 'Found 2 matching buttons' ,
269+ lifecycleErrorHint : 'Use a more specific selector.' ,
270+ } ) ;
271+
272+ await assert . rejects (
273+ ( ) => runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
274+ ( error : unknown ) => {
275+ assert . ok ( error instanceof AppError ) ;
276+ assert . equal ( error . code , 'AMBIGUOUS_MATCH' ) ;
277+ assert . equal ( error . message , 'Found 2 matching buttons' ) ;
278+ assert . equal ( error . details ?. recovery , 'runner_reported_failure' ) ;
279+ assert . equal ( error . details ?. hint , 'Use a more specific selector.' ) ;
280+ assert . equal ( error . details ?. transportError , 'fetch failed' ) ;
281+ return true ;
282+ } ,
283+ ) ;
284+
285+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
286+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
287+ } ) ;
288+
289+ test ( 'mutating commands report wait-and-inspect guidance when status shows in-flight work' , async ( ) => {
290+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
291+
292+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
293+ mockExecuteRunnerCommandWithSession
294+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
295+ . mockResolvedValueOnce ( { lifecycleState : 'started' } ) ;
296+
297+ await assert . rejects (
298+ ( ) => runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
299+ ( error : unknown ) => {
300+ assert . ok ( error instanceof AppError ) ;
301+ assert . match ( error . message , / " t a p " i s s t i l l s t a r t e d / ) ;
302+ assert . equal ( error . details ?. recovery , 'command_still_in_flight' ) ;
303+ assert . match ( String ( error . details ?. hint ) , / m a y s t i l l f i n i s h / ) ;
304+ assert . match ( String ( error . details ?. hint ) , / s n a p s h o t - i / ) ;
305+ assert . equal ( error . details ?. transportError , 'fetch failed' ) ;
306+ return true ;
307+ } ,
308+ ) ;
309+
310+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
311+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
154312} ) ;
155313
156314test ( 'mutating commands invalidate the retry session without replaying again' , async ( ) => {
@@ -160,7 +318,8 @@ test('mutating commands invalidate the retry session without replaying again', a
160318 mockEnsureRunnerSession . mockResolvedValueOnce ( staleSession ) . mockResolvedValueOnce ( freshSession ) ;
161319 mockExecuteRunnerCommandWithSession
162320 . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'Runner did not accept connection' ) )
163- . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) ) ;
321+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
322+ . mockResolvedValueOnce ( { lifecycleState : 'notAccepted' } ) ;
164323
165324 await assert . rejects ( ( ) =>
166325 runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
@@ -171,7 +330,7 @@ test('mutating commands invalidate the retry session without replaying again', a
171330 [ staleSession , 'runner_connect_failed_before_command_send' ] ,
172331 [ freshSession , 'transport_error_after_retry_command_send' ] ,
173332 ] ) ;
174- assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
333+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 3 ) ;
175334} ) ;
176335
177336function makeRunnerSession ( overrides : Partial < RunnerSession > = { } ) : RunnerSession {
0 commit comments