@@ -7,15 +7,27 @@ import type { RunnerSession } from '../runner-session-types.ts';
77const {
88 mockEnsureRunnerSession,
99 mockExecuteRunnerCommandWithSession,
10+ mockEmitDiagnostic,
1011 mockInvalidateRunnerSession,
1112 mockStopRunnerSession,
1213} = vi . hoisted ( ( ) => ( {
1314 mockEnsureRunnerSession : vi . fn ( ) ,
1415 mockExecuteRunnerCommandWithSession : vi . fn ( ) ,
16+ mockEmitDiagnostic : vi . fn ( ) ,
1517 mockInvalidateRunnerSession : vi . fn ( ) ,
1618 mockStopRunnerSession : vi . fn ( ) ,
1719} ) ) ;
1820
21+ vi . mock ( '../../../utils/diagnostics.ts' , async ( ) => {
22+ const actual = await vi . importActual < typeof import ( '../../../utils/diagnostics.ts' ) > (
23+ '../../../utils/diagnostics.ts' ,
24+ ) ;
25+ return {
26+ ...actual ,
27+ emitDiagnostic : mockEmitDiagnostic ,
28+ } ;
29+ } ) ;
30+
1931vi . mock ( '../runner-session.ts' , async ( ) => {
2032 const actual =
2133 await vi . importActual < typeof import ( '../runner-session.ts' ) > ( '../runner-session.ts' ) ;
@@ -151,6 +163,11 @@ test('mutating commands do not restart or replay after command send failure', as
151163 ] ) ;
152164 assert . equal ( mockStopRunnerSession . mock . calls . length , 0 ) ;
153165 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
166+ assertDiagnosticDecision ( {
167+ decision : 'retained' ,
168+ reason : 'unknown_lifecycle_state' ,
169+ lifecycleState : 'notAccepted' ,
170+ } ) ;
154171} ) ;
155172
156173test ( 'mutating commands recover cached responses before invalidating after command send failure' , async ( ) => {
@@ -168,6 +185,11 @@ test('mutating commands recover cached responses before invalidating after comma
168185
169186 assert . deepEqual ( result , { message : 'tapped' } ) ;
170187 assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
188+ assertDiagnosticDecision ( {
189+ decision : 'skipped' ,
190+ reason : 'completed_with_retained_response' ,
191+ lifecycleState : 'completed' ,
192+ } ) ;
171193 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
172194 const sentCommand = mockExecuteRunnerCommandWithSession . mock . calls [ 0 ] ?. [ 2 ] ;
173195 const statusCommand = mockExecuteRunnerCommandWithSession . mock . calls [ 1 ] ?. [ 2 ] ;
@@ -193,6 +215,65 @@ test('mutating commands keep invalidating when status cannot find the command',
193215 [ session , 'transport_error_after_command_send' ] ,
194216 ] ) ;
195217 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
218+ assertDiagnosticDecision ( {
219+ decision : 'retained' ,
220+ reason : 'unknown_lifecycle_state' ,
221+ lifecycleState : 'notAccepted' ,
222+ } ) ;
223+ } ) ;
224+
225+ test ( 'mutating commands keep invalidating when status recovery probe fails' , async ( ) => {
226+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
227+
228+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
229+ mockExecuteRunnerCommandWithSession
230+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
231+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'status probe failed' ) ) ;
232+
233+ await assert . rejects ( ( ) =>
234+ runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
235+ ) ;
236+
237+ assert . deepEqual ( mockInvalidateRunnerSession . mock . calls , [
238+ [ session , 'transport_error_after_command_send' ] ,
239+ ] ) ;
240+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
241+ assertDiagnosticDecision ( {
242+ decision : 'retained' ,
243+ reason : 'status_probe_failed' ,
244+ } ) ;
245+ } ) ;
246+
247+ test ( 'mutating commands keep invalidating when status reports an unknown lifecycle state' , async ( ) => {
248+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
249+
250+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
251+ mockExecuteRunnerCommandWithSession
252+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
253+ . mockResolvedValueOnce ( {
254+ lifecycleState : 'paused' ,
255+ } ) ;
256+
257+ await assert . rejects (
258+ ( ) => runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
259+ ( error : unknown ) => {
260+ assert . ok ( error instanceof AppError ) ;
261+ assert . match ( error . message , / l i f e c y c l e s t a t u s w a s " p a u s e d " / ) ;
262+ assert . equal ( error . details ?. recovery , 'lifecycle_state_not_recoverable' ) ;
263+ assert . match ( String ( error . details ?. hint ) , / c o n s e r v a t i v e i n v a l i d a t i o n p a t h / ) ;
264+ return true ;
265+ } ,
266+ ) ;
267+
268+ assert . deepEqual ( mockInvalidateRunnerSession . mock . calls , [
269+ [ session , 'transport_error_after_command_send' ] ,
270+ ] ) ;
271+ assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
272+ assertDiagnosticDecision ( {
273+ decision : 'retained' ,
274+ reason : 'unknown_lifecycle_state' ,
275+ lifecycleState : 'paused' ,
276+ } ) ;
196277} ) ;
197278
198279test ( 'read-only commands retry when completed status has no retained response' , async ( ) => {
@@ -211,6 +292,11 @@ test('read-only commands retry when completed status has no retained response',
211292 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 3 ) ;
212293 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 1 ] ?. [ 2 ] . command , 'status' ) ;
213294 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls [ 2 ] ?. [ 2 ] . command , 'snapshot' ) ;
295+ assertDiagnosticDecision ( {
296+ decision : 'skipped' ,
297+ reason : 'read_only_completed_without_retained_response' ,
298+ lifecycleState : 'completed' ,
299+ } ) ;
214300} ) ;
215301
216302test ( 'read-only commands retry when status shows in-flight work' , async ( ) => {
@@ -245,6 +331,7 @@ test('mutating commands report recovery guidance when completed status has no re
245331 assert . ok ( error instanceof AppError ) ;
246332 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 / ) ;
247333 assert . equal ( error . details ?. recovery , 'completed_without_retained_response' ) ;
334+ assert . match ( String ( error . details ?. hint ) , / k e p t t h e s e s s i o n o p e n / ) ;
248335 assert . match ( String ( error . details ?. hint ) , / w i l l n o t r e p l a y / ) ;
249336 assert . match ( String ( error . details ?. hint ) , / s n a p s h o t - i / ) ;
250337 assert . equal ( error . details ?. transportError , 'fetch failed' ) ;
@@ -254,6 +341,11 @@ test('mutating commands report recovery guidance when completed status has no re
254341
255342 assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
256343 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
344+ assertDiagnosticDecision ( {
345+ decision : 'skipped' ,
346+ reason : 'completed_without_retained_response' ,
347+ lifecycleState : 'completed' ,
348+ } ) ;
257349} ) ;
258350
259351test ( 'mutating commands preserve runner failure details from status recovery' , async ( ) => {
@@ -284,6 +376,41 @@ test('mutating commands preserve runner failure details from status recovery', a
284376
285377 assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
286378 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
379+ assertDiagnosticDecision ( {
380+ decision : 'skipped' ,
381+ reason : 'runner_reported_failure' ,
382+ lifecycleState : 'failed' ,
383+ } ) ;
384+ } ) ;
385+
386+ test ( 'mutating commands use recovery guidance when failed status has no runner hint' , async ( ) => {
387+ const session = makeRunnerSession ( { port : 8100 , ready : true } ) ;
388+
389+ mockEnsureRunnerSession . mockResolvedValueOnce ( session ) ;
390+ mockExecuteRunnerCommandWithSession
391+ . mockRejectedValueOnce ( new AppError ( 'COMMAND_FAILED' , 'fetch failed' ) )
392+ . mockResolvedValueOnce ( {
393+ lifecycleState : 'failed' ,
394+ lifecycleErrorMessage : 'Runner command failed after dispatch' ,
395+ } ) ;
396+
397+ await assert . rejects (
398+ ( ) => runIosRunnerCommand ( IOS_SIMULATOR , { command : 'tap' , x : 120 , y : 240 } ) ,
399+ ( error : unknown ) => {
400+ assert . ok ( error instanceof AppError ) ;
401+ assert . equal ( error . message , 'Runner command failed after dispatch' ) ;
402+ assert . match ( String ( error . details ?. hint ) , / k e p t t h e s e s s i o n o p e n / ) ;
403+ assert . match ( String ( error . details ?. hint ) , / d i d n o t r e p l a y / ) ;
404+ return true ;
405+ } ,
406+ ) ;
407+
408+ assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
409+ assertDiagnosticDecision ( {
410+ decision : 'skipped' ,
411+ reason : 'runner_reported_failure' ,
412+ lifecycleState : 'failed' ,
413+ } ) ;
287414} ) ;
288415
289416test ( 'mutating commands report wait-and-inspect guidance when status shows in-flight work' , async ( ) => {
@@ -300,7 +427,7 @@ test('mutating commands report wait-and-inspect guidance when status shows in-fl
300427 assert . ok ( error instanceof AppError ) ;
301428 assert . match ( error . message , / " t a p " i s s t i l l s t a r t e d / ) ;
302429 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 / ) ;
430+ assert . match ( String ( error . details ?. hint ) , / k e p t t h e s e s s i o n o p e n / ) ;
304431 assert . match ( String ( error . details ?. hint ) , / s n a p s h o t - i / ) ;
305432 assert . equal ( error . details ?. transportError , 'fetch failed' ) ;
306433 return true ;
@@ -309,6 +436,11 @@ test('mutating commands report wait-and-inspect guidance when status shows in-fl
309436
310437 assert . equal ( mockInvalidateRunnerSession . mock . calls . length , 0 ) ;
311438 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 2 ) ;
439+ assertDiagnosticDecision ( {
440+ decision : 'skipped' ,
441+ reason : 'command_still_in_flight' ,
442+ lifecycleState : 'started' ,
443+ } ) ;
312444} ) ;
313445
314446test ( 'mutating commands invalidate the retry session without replaying again' , async ( ) => {
@@ -331,8 +463,31 @@ test('mutating commands invalidate the retry session without replaying again', a
331463 [ freshSession , 'transport_error_after_retry_command_send' ] ,
332464 ] ) ;
333465 assert . equal ( mockExecuteRunnerCommandWithSession . mock . calls . length , 3 ) ;
466+ assertDiagnosticDecision ( {
467+ decision : 'retained' ,
468+ reason : 'unknown_lifecycle_state' ,
469+ lifecycleState : 'notAccepted' ,
470+ } ) ;
334471} ) ;
335472
473+ function assertDiagnosticDecision ( expected : {
474+ decision : 'skipped' | 'retained' ;
475+ reason : string ;
476+ lifecycleState ?: string ;
477+ } ) : void {
478+ assert . ok (
479+ mockEmitDiagnostic . mock . calls . some ( ( [ event ] ) => {
480+ return (
481+ event . phase === 'ios_runner_command_invalidation_decision' &&
482+ event . data ?. decision === expected . decision &&
483+ event . data ?. reason === expected . reason &&
484+ event . data ?. lifecycleState === expected . lifecycleState
485+ ) ;
486+ } ) ,
487+ `missing invalidation decision diagnostic ${ JSON . stringify ( expected ) } ` ,
488+ ) ;
489+ }
490+
336491function makeRunnerSession ( overrides : Partial < RunnerSession > = { } ) : RunnerSession {
337492 return {
338493 sessionId : `session-${ overrides . port ?? 8100 } ` ,
0 commit comments