@@ -14,6 +14,7 @@ import {
1414 createLaunchConfirmationPort ,
1515 LAUNCH_CONFIRMATION_FOREIGN_APP_REASON ,
1616 type LaunchConfirmationPort ,
17+ URL_OWNER_LOOKUP_TIMEOUT_MS ,
1718} from './launch-confirmation.ts' ;
1819
1920vi . mock ( '@agent-device/host-kit/diagnostics' , async ( importOriginal ) => {
@@ -195,10 +196,19 @@ test.each([
195196
196197/**
197198 * A CoreSimulator that never answers: each spawn settles only when its exec timeout fires or its
198- * signal aborts, the way `exec.ts` ends a wedged child.
199+ * signal aborts, the way `exec.ts` ends a wedged child. `answers` lets named spawns answer at once,
200+ * so the wedge can sit behind a listing that succeeded.
199201 */
200- function wedgedCoreSimulator ( ) : AppleToolProvider & { spawns : ( ) => number } {
202+ function wedgedCoreSimulator (
203+ answers : Readonly < Record < string , string > > = { } ,
204+ ) : AppleToolProvider & { spawns : ( ) => number } {
201205 let spawns = 0 ;
206+ const answered = ( key : string ) : ExecResult | undefined => {
207+ const stdout = answers [ key ] ;
208+ if ( stdout === undefined ) return undefined ;
209+ spawns += 1 ;
210+ return { stdout, stderr : '' , exitCode : 0 } as ExecResult ;
211+ } ;
202212 const hang = async ( options ?: ExecOptions ) : Promise < ExecResult > =>
203213 await new Promise ( ( _resolve , reject ) => {
204214 spawns += 1 ;
@@ -220,13 +230,65 @@ function wedgedCoreSimulator(): AppleToolProvider & { spawns: () => number } {
220230 } ) ;
221231 return {
222232 whichCommand : async ( ) => true ,
223- runCommand : async ( _cmd , _args , options ) => await hang ( options ) ,
224- simctl : { run : async ( _args , options ) => await hang ( options ) } ,
233+ runCommand : async ( cmd , args , options ) =>
234+ answered ( `${ cmd } ${ args [ args . length - 1 ] } ` ) ?? ( await hang ( options ) ) ,
235+ simctl : { run : async ( args , options ) => answered ( args [ 0 ] ?? '' ) ?? ( await hang ( options ) ) } ,
225236 devicectl : { run : async ( _args , options ) => await hang ( options ) } ,
226237 spawns : ( ) => spawns ,
227238 } ;
228239}
229240
241+ const LISTED_APP = JSON . stringify ( {
242+ 'com.example.app' : {
243+ ApplicationType : 'User' ,
244+ Path : '/apps/Example.app' ,
245+ CFBundleName : 'Example' ,
246+ } ,
247+ } ) ;
248+
249+ test . each ( [
250+ [ 'one Info.plist read hangs after the app list answered' , { listapps : LISTED_APP } ] ,
251+ [
252+ 'the app list answered in a format only plutil can convert, and that conversion hangs' ,
253+ { listapps : '{ "com.example.app" = { ApplicationType = User; }; }' } ,
254+ ] ,
255+ ] ) (
256+ 'the owner lookup is bounded as a whole: %s and the open is left unanswered within its budget' ,
257+ async ( _case , answers ) => {
258+ // Only the clock is faked: the lookup stats the Info.plist on the real file system before it
259+ // spawns plutil, and that I/O must complete before the budget is advanced past.
260+ vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' , 'Date' ] } ) ;
261+ vi . mocked ( emitDiagnostic ) . mockClear ( ) ;
262+ try {
263+ const { interactor, acceptAlert } = confirmationRunner ( ) ;
264+ const coreSimulator = wedgedCoreSimulator ( answers ) ;
265+ let answered : unknown = 'pending' ;
266+ const answer = withAppleToolProvider ( coreSimulator , async ( ) =>
267+ answerSimulatorLaunchConfirmation (
268+ simulator ,
269+ { url : 'example://automation' , appBundleId : 'com.example.app' } ,
270+ Promise . resolve ( interactor ) ,
271+ new AbortController ( ) . signal ,
272+ ) . then ( ( value ) => ( answered = value ) ) ,
273+ ) ;
274+
275+ while ( coreSimulator . spawns ( ) < 2 ) await new Promise ( ( resolve ) => setImmediate ( resolve ) ) ;
276+ await vi . advanceTimersByTimeAsync ( URL_OWNER_LOOKUP_TIMEOUT_MS + 1 ) ;
277+ expect ( answered ) . toBeUndefined ( ) ;
278+ await answer ;
279+ expect ( coreSimulator . spawns ( ) ) . toBe ( 2 ) ;
280+ expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
281+ expect ( emitDiagnostic ) . toHaveBeenCalledWith (
282+ expect . objectContaining ( {
283+ data : expect . objectContaining ( { reason : 'step-failed' , step : 'url-owner' } ) ,
284+ } ) ,
285+ ) ;
286+ } finally {
287+ vi . useRealTimers ( ) ;
288+ }
289+ } ,
290+ ) ;
291+
230292function confirmationRunner ( ) {
231293 const acceptAlert = vi . fn ( async ( ) => ( { } ) ) ;
232294 const interactor = { readAlert : async ( ) => CONFIRMATION , acceptAlert } as unknown as Interactor ;
0 commit comments