@@ -32,6 +32,7 @@ import {
3232 writeRunnerLease ,
3333} from '../runner-lease.ts' ;
3434
35+ const mockPrepareAccessibility = vi . fn ( ) ;
3536const mockIsProcessAlive = vi . fn ( ) ;
3637const mockIsProcessGroupAlive = vi . fn ( ) ;
3738const mockRunAppleToolCommand = vi . fn ( ) ;
@@ -50,12 +51,14 @@ beforeEach(() => {
5051 signalProcessGroupBestEffort : mockSignalProcessGroupBestEffort ,
5152 runAppleToolCommand : mockRunAppleToolCommand ,
5253 runXcrun : mockRunXcrun ,
54+ enableIosSimulatorAppAccessibility : mockPrepareAccessibility ,
5355 } ) ;
5456 vi . useFakeTimers ( ) ;
5557 mockIsProcessAlive . mockReturnValue ( true ) ;
5658 mockIsProcessGroupAlive . mockReturnValue ( false ) ;
5759 mockRunAppleToolCommand . mockResolvedValue ( { exitCode : 0 , stdout : '' , stderr : '' } ) ;
5860 mockRunXcrun . mockResolvedValue ( { exitCode : 0 , stdout : '' , stderr : '' } ) ;
61+ mockPrepareAccessibility . mockResolvedValue ( undefined ) ;
5962} ) ;
6063
6164afterEach ( ( ) => {
@@ -148,19 +151,35 @@ test('simulator disposal terminates runner container apps while it still owns th
148151 vi . useRealTimers ( ) ;
149152 mockIsProcessAlive . mockReturnValue ( false ) ;
150153 const lease = makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-disposal-own' } ) ;
151- const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , { lease } ) ;
154+ const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
155+ lease,
156+ xctestrunPath : lease . xctestrunPath ,
157+ } ) ;
158+ mockRunAppleToolCommand . mockResolvedValue ( { exitCode : 1 , stdout : '' , stderr : '' } ) ;
159+ let tokenDuringRepair : string | null = null ;
160+ let terminationsDuringRepair = 0 ;
161+ mockPrepareAccessibility . mockImplementation ( async ( ) => {
162+ tokenDuringRepair = currentRunnerLeaseOwnerToken ( IOS_SIMULATOR . id ) ;
163+ terminationsDuringRepair = simulatorTerminateCalls ( ) . length ;
164+ } ) ;
152165 writeRunnerLease ( lease ) ;
153166
154167 await disposeRunnerSession ( session , { graceful : false , waitTimeoutMs : 1 } ) ;
155168
156169 expect ( simulatorTerminateCalls ( ) ) . not . toEqual ( [ ] ) ;
170+ expect ( mockPrepareAccessibility ) . toHaveBeenCalledExactlyOnceWith ( IOS_SIMULATOR ) ;
171+ expect ( tokenDuringRepair ) . toBe ( lease . ownerToken ) ;
172+ expect ( terminationsDuringRepair ) . toBeGreaterThan ( 0 ) ;
173+ expect ( currentRunnerLeaseOwnerToken ( IOS_SIMULATOR . id ) ) . toBeNull ( ) ;
157174} ) ;
158175
159176test ( 'simulator disposal skips container-app termination after a foreign takeover replaced the lease' , async ( ) => {
160177 vi . useRealTimers ( ) ;
161178 mockIsProcessAlive . mockReturnValue ( false ) ;
179+ mockRunAppleToolCommand . mockResolvedValue ( { exitCode : 1 , stdout : '' , stderr : '' } ) ;
162180 const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
163181 lease : makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-disposal-loser' } ) ,
182+ xctestrunPath : makeRunnerLease ( { deviceId : IOS_SIMULATOR . id } ) . xctestrunPath ,
164183 } ) ;
165184 // The successor's runner lives in the same container bundles on the shared
166185 // simulator; terminating them here would stop the new owner's runner.
@@ -171,18 +190,21 @@ test('simulator disposal skips container-app termination after a foreign takeove
171190 await disposeRunnerSession ( session , { graceful : false , waitTimeoutMs : 1 } ) ;
172191
173192 expect ( simulatorTerminateCalls ( ) ) . toEqual ( [ ] ) ;
193+ expect ( mockPrepareAccessibility ) . not . toHaveBeenCalled ( ) ;
174194 expect ( mockCleanupTempFile ) . toHaveBeenCalledWith ( session . launch . local ! . xctestrunPath ) ;
175195} ) ;
176196
177197test ( 'disposal serializes behind a successor reclaim window and never terminates its runner' , async ( ) => {
178198 vi . useRealTimers ( ) ;
179199 mockIsProcessAlive . mockReturnValue ( false ) ;
200+ mockRunAppleToolCommand . mockResolvedValue ( { exitCode : 1 , stdout : '' , stderr : '' } ) ;
180201 const loserLease = makeRunnerLease ( {
181202 deviceId : IOS_SIMULATOR . id ,
182203 ownerToken : 'owner-toctou-loser' ,
183204 } ) ;
184205 const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
185206 lease : loserLease ,
207+ xctestrunPath : loserLease . xctestrunPath ,
186208 } ) ;
187209 writeRunnerLease ( loserLease ) ;
188210
@@ -198,6 +220,7 @@ test('disposal serializes behind a successor reclaim window and never terminates
198220 await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
199221 expect ( disposalSettled ) . toBe ( false ) ;
200222 expect ( simulatorTerminateCalls ( ) ) . toEqual ( [ ] ) ;
223+ expect ( mockPrepareAccessibility ) . not . toHaveBeenCalled ( ) ;
201224 releaseRunnerLease ( loserLease ) ;
202225 writeRunnerLease (
203226 makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-toctou-successor' } ) ,
@@ -206,9 +229,89 @@ test('disposal serializes behind a successor reclaim window and never terminates
206229 await disposal ;
207230
208231 expect ( simulatorTerminateCalls ( ) ) . toEqual ( [ ] ) ;
232+ expect ( mockPrepareAccessibility ) . not . toHaveBeenCalled ( ) ;
209233 expect ( currentRunnerLeaseOwnerToken ( IOS_SIMULATOR . id ) ) . toBe ( 'owner-toctou-successor' ) ;
210234} ) ;
211235
236+ test ( 'disposal skips repair when a runner process group survives termination' , async ( ) => {
237+ const lease = makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-still-running' } ) ;
238+ const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
239+ lease,
240+ xctestrunPath : lease . xctestrunPath ,
241+ } ) ;
242+ writeRunnerLease ( lease ) ;
243+ mockIsProcessAlive . mockReturnValue ( false ) ;
244+ mockIsProcessGroupAlive . mockReturnValue ( true ) ;
245+ const disposal = disposeRunnerSession ( session , { graceful : false , abort : true } ) ;
246+ await vi . advanceTimersByTimeAsync ( 2_000 ) ;
247+ await disposal ;
248+ expect ( mockPrepareAccessibility ) . not . toHaveBeenCalled ( ) ;
249+ expect ( currentRunnerLeaseOwnerToken ( IOS_SIMULATOR . id ) ) . toBeNull ( ) ;
250+ } ) ;
251+
252+ test ( 'disposal waits for the matching xcodebuild to disappear before repair' , async ( ) => {
253+ const lease = makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-wait-exit' } ) ;
254+ const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
255+ lease,
256+ xctestrunPath : lease . xctestrunPath ,
257+ } ) ;
258+ writeRunnerLease ( lease ) ;
259+ mockIsProcessAlive . mockReturnValue ( false ) ;
260+ let probes = 0 ;
261+ mockRunAppleToolCommand . mockImplementation ( async ( command ) =>
262+ command === '/usr/bin/pgrep' && probes ++ > 0
263+ ? { exitCode : 1 , stdout : '' , stderr : '' }
264+ : execResult ( ) ,
265+ ) ;
266+ const disposal = disposeRunnerSession ( session , { graceful : false , abort : true } ) ;
267+ await vi . advanceTimersByTimeAsync ( 0 ) ;
268+ expect ( mockPrepareAccessibility ) . not . toHaveBeenCalled ( ) ;
269+ await vi . advanceTimersByTimeAsync ( 50 ) ;
270+ await disposal ;
271+ expect ( mockPrepareAccessibility ) . toHaveBeenCalledOnce ( ) ;
272+ } ) ;
273+
274+ test ( 'a failed or timed-out termination probe is retried, not read as a live runner' , async ( ) => {
275+ const lease = makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-probe-retry' } ) ;
276+ const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
277+ lease,
278+ xctestrunPath : lease . xctestrunPath ,
279+ } ) ;
280+ writeRunnerLease ( lease ) ;
281+ mockIsProcessAlive . mockReturnValue ( false ) ;
282+ let probes = 0 ;
283+ mockRunAppleToolCommand . mockImplementation ( async ( command ) => {
284+ if ( command !== '/usr/bin/pgrep' ) return execResult ( ) ;
285+ probes += 1 ;
286+ if ( probes === 1 ) throw new Error ( 'probe timed out' ) ;
287+ return probes === 2
288+ ? { exitCode : 2 , stdout : '' , stderr : 'probe error' }
289+ : { exitCode : 1 , stdout : '' , stderr : '' } ;
290+ } ) ;
291+ const disposal = disposeRunnerSession ( session , { graceful : false , abort : true } ) ;
292+ await vi . advanceTimersByTimeAsync ( 200 ) ;
293+ await disposal ;
294+ // The thrown probe and the exit-2 probe are both retried; only the third (exit 1) confirms.
295+ expect ( probes ) . toBe ( 3 ) ;
296+ expect ( mockPrepareAccessibility ) . toHaveBeenCalledOnce ( ) ;
297+ } ) ;
298+
299+ test ( 'disposal still releases its lease when accessibility preparation fails' , async ( ) => {
300+ vi . useRealTimers ( ) ;
301+ const lease = makeRunnerLease ( { deviceId : IOS_SIMULATOR . id , ownerToken : 'owner-repair-failed' } ) ;
302+ const session = makeRunnerSession ( IOS_SIMULATOR , Promise . resolve ( execResult ( ) ) , {
303+ lease,
304+ xctestrunPath : lease . xctestrunPath ,
305+ } ) ;
306+ writeRunnerLease ( lease ) ;
307+ mockIsProcessAlive . mockReturnValue ( false ) ;
308+ mockRunAppleToolCommand . mockResolvedValue ( { exitCode : 1 , stdout : '' , stderr : '' } ) ;
309+ mockPrepareAccessibility . mockRejectedValue ( new Error ( 'unavailable' ) ) ;
310+ await disposeRunnerSession ( session , { graceful : false , waitTimeoutMs : 1 } ) ;
311+ expect ( mockPrepareAccessibility ) . toHaveBeenCalledOnce ( ) ;
312+ expect ( currentRunnerLeaseOwnerToken ( IOS_SIMULATOR . id ) ) . toBeNull ( ) ;
313+ } ) ;
314+
212315test ( 'a leased cleanup selects the launch named by the artifact the lease recorded' , async ( ) => {
213316 // The launch is found by matching its argv, and a lease knows the artifact that launch was
214317 // started with. Detaching is why following the path rather than the token matters: it rewrites
0 commit comments