@@ -17,7 +17,7 @@ const signal = () => new AbortController().signal;
1717
1818function targetFixture ( ) {
1919 const state = { pid : 42 , launch : 'launch-a' , start : 'start-a' as string | null } ;
20- const run = vi . fn ( async ( args : string [ ] ) => ( {
20+ const run = vi . fn ( async ( args : string [ ] , _options ?: { timeoutMs ?: number } ) => ( {
2121 stdout :
2222 args [ 0 ] === 'spawn'
2323 ? `90\t0\tUIKitApplication:com.example.app.beta[wrong][rb-legacy]\n${ state . pid } \t0\tUIKitApplication:${ app } [${ state . launch } ][rb-legacy]`
@@ -27,11 +27,13 @@ function targetFixture() {
2727 stderr : '' ,
2828 exitCode : 0 ,
2929 } ) ) ;
30- const runCommand = vi . fn ( async ( ) => ( {
31- stdout : state . start ?? '' ,
32- stderr : '' ,
33- exitCode : state . start ? 0 : 1 ,
34- } ) ) ;
30+ const runCommand = vi . fn (
31+ async ( _tool : string , _args : string [ ] , _options ?: { timeoutMs ?: number } ) => ( {
32+ stdout : state . start ?? '' ,
33+ stderr : '' ,
34+ exitCode : state . start ? 0 : 1 ,
35+ } ) ,
36+ ) ;
3537 const provider = createLocalAppleToolProvider ( { simctl : { run } , runCommand } ) ;
3638 const resolve = createSimulatorSnapshotTargetResolver ( ) ;
3739 return {
@@ -137,3 +139,127 @@ test('an aborted request cannot reuse a cached target', async () => {
137139 expect ( fixture . runCommand ) . toHaveBeenCalledTimes ( 1 ) ;
138140 } ) ;
139141} ) ;
142+
143+ function deferredSpawn ( fixture : ReturnType < typeof targetFixture > ) {
144+ let release ! : ( ) => void ;
145+ const released = new Promise < void > ( ( resolve ) => {
146+ release = resolve ;
147+ } ) ;
148+ const respond = fixture . run . getMockImplementation ( ) ! ;
149+ fixture . run . mockImplementation ( async ( args : string [ ] ) =>
150+ args [ 0 ] === 'spawn' ? await released . then ( ( ) => respond ( args ) ) : await respond ( args ) ,
151+ ) ;
152+ return release ;
153+ }
154+
155+ test ( 'a slow discovery yields to the fallback after its wait budget and finishes in the background' , async ( ) => {
156+ const fixture = targetFixture ( ) ;
157+ const release = deferredSpawn ( fixture ) ;
158+ vi . useFakeTimers ( ) ;
159+ try {
160+ await withAppleToolProvider ( fixture . provider , async ( ) => {
161+ const pending = fixture . resolve ( ios , app , signal ( ) ) ;
162+ const rejected = expect ( pending ) . rejects . toMatchObject ( {
163+ details : { reason : 'simulator-target-discovery-pending' } ,
164+ } ) ;
165+ await vi . advanceTimersByTimeAsync ( 1_500 ) ;
166+ await rejected ;
167+ expect ( fixture . discoveryCount ( ) ) . toBe ( 1 ) ;
168+
169+ release ( ) ;
170+ await vi . advanceTimersByTimeAsync ( 0 ) ;
171+ // The finished discovery serves the next capture without a second simctl spawn.
172+ expect ( await fixture . resolve ( ios , app , signal ( ) ) ) . toMatchObject ( { pid : 42 } ) ;
173+ expect ( fixture . discoveryCount ( ) ) . toBe ( 1 ) ;
174+ } ) ;
175+ } finally {
176+ vi . useRealTimers ( ) ;
177+ }
178+ } ) ;
179+
180+ test ( 'captures that arrive during discovery join it instead of spawning their own' , async ( ) => {
181+ const fixture = targetFixture ( ) ;
182+ const release = deferredSpawn ( fixture ) ;
183+ await withAppleToolProvider ( fixture . provider , async ( ) => {
184+ const first = fixture . resolve ( ios , app , signal ( ) ) ;
185+ const second = fixture . resolve ( ios , app , signal ( ) ) ;
186+ release ( ) ;
187+ const targets = await Promise . all ( [ first , second ] ) ;
188+ expect ( targets [ 1 ] ) . toBe ( targets [ 0 ] ) ;
189+ expect ( fixture . discoveryCount ( ) ) . toBe ( 1 ) ;
190+ } ) ;
191+ } ) ;
192+
193+ test ( 'a cancelled caller leaves discovery running for the next capture' , async ( ) => {
194+ const fixture = targetFixture ( ) ;
195+ const release = deferredSpawn ( fixture ) ;
196+ await withAppleToolProvider ( fixture . provider , async ( ) => {
197+ const controller = new AbortController ( ) ;
198+ const cancelled = fixture . resolve ( ios , app , controller . signal ) ;
199+ controller . abort ( new Error ( 'request-ended' ) ) ;
200+ await expect ( cancelled ) . rejects . toThrow ( 'request-ended' ) ;
201+
202+ release ( ) ;
203+ expect ( await fixture . resolve ( ios , app , signal ( ) ) ) . toMatchObject ( { pid : 42 } ) ;
204+ expect ( fixture . discoveryCount ( ) ) . toBe ( 1 ) ;
205+ } ) ;
206+ } ) ;
207+
208+ test ( 'one discovery shares a single deadline across its simctl probes and the identity read' , async ( ) => {
209+ const fixture = targetFixture ( ) ;
210+ const release = deferredSpawn ( fixture ) ;
211+ vi . useFakeTimers ( ) ;
212+ try {
213+ await withAppleToolProvider ( fixture . provider , async ( ) => {
214+ const pending = fixture . resolve ( ios , app , signal ( ) ) ;
215+ pending . catch ( ( ) => undefined ) ;
216+ // The spawn ran 13s of the 15s discovery deadline before answering.
217+ await vi . advanceTimersByTimeAsync ( 13_000 ) ;
218+ release ( ) ;
219+ await vi . advanceTimersByTimeAsync ( 0 ) ;
220+ await expect ( fixture . resolve ( ios , app , signal ( ) ) ) . resolves . toMatchObject ( { pid : 42 } ) ;
221+
222+ const spawnOptions = fixture . run . mock . calls . find ( ( [ args ] ) => args [ 0 ] === 'spawn' ) ?. [ 1 ] ;
223+ expect ( spawnOptions ?. timeoutMs ) . toBe ( 15_000 ) ;
224+ const identityOptions = fixture . runCommand . mock . calls [ 0 ] ?. [ 2 ] ;
225+ expect ( identityOptions ?. timeoutMs ) . toBeGreaterThan ( 0 ) ;
226+ expect ( identityOptions ?. timeoutMs ) . toBeLessThanOrEqual ( 2_000 ) ;
227+ } ) ;
228+ } finally {
229+ vi . useRealTimers ( ) ;
230+ }
231+ } ) ;
232+
233+ test ( 'a failed runtime probe does not release the slot while the launch-job probe still runs' , async ( ) => {
234+ const fixture = targetFixture ( ) ;
235+ const release = deferredSpawn ( fixture ) ;
236+ const respond = fixture . run . getMockImplementation ( ) ! ;
237+ fixture . run . mockImplementation ( async ( args : string [ ] , options ) =>
238+ args [ 0 ] === 'list'
239+ ? { stdout : '' , stderr : 'simctl list failed' , exitCode : 1 }
240+ : await respond ( args , options ) ,
241+ ) ;
242+ vi . useFakeTimers ( ) ;
243+ try {
244+ await withAppleToolProvider ( fixture . provider , async ( ) => {
245+ for ( let attempt = 0 ; attempt < 3 ; attempt += 1 ) {
246+ const pending = fixture . resolve ( ios , app , signal ( ) ) ;
247+ const rejected = expect ( pending ) . rejects . toMatchObject ( {
248+ details : { reason : 'simulator-target-discovery-pending' } ,
249+ } ) ;
250+ await vi . advanceTimersByTimeAsync ( 1_500 ) ;
251+ await rejected ;
252+ }
253+ expect ( fixture . discoveryCount ( ) ) . toBe ( 1 ) ;
254+
255+ release ( ) ;
256+ await vi . advanceTimersByTimeAsync ( 0 ) ;
257+ // The settled discovery reports the runtime failure and frees the slot for a fresh probe.
258+ await expect ( fixture . resolve ( ios , app , signal ( ) ) ) . rejects . toMatchObject ( {
259+ details : { reason : 'simulator-runtime-probe-failed' } ,
260+ } ) ;
261+ } ) ;
262+ } finally {
263+ vi . useRealTimers ( ) ;
264+ }
265+ } ) ;
0 commit comments