@@ -3,6 +3,7 @@ import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contr
33import type { Interactor } from '@agent-device/contracts/interactor-types' ;
44import type { DeviceInfo } from '@agent-device/kernel/device' ;
55import { AppError } from '@agent-device/kernel/errors' ;
6+ import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts' ;
67import {
78 answerLaunchConfirmation ,
89 createLaunchConfirmationPort ,
@@ -11,10 +12,7 @@ import {
1112} from './launch-confirmation.ts' ;
1213
1314vi . mock ( './core/app-resolution.ts' , ( ) => ( {
14- listIosApps : vi . fn ( async ( ) => [
15- { bundleId : 'com.example.other' , name : 'Other App' } ,
16- { bundleId : 'com.example.app' , name : 'Example App' } ,
17- ] ) ,
15+ resolveIosSimulatorDeepLinkBundleId : vi . fn ( async ( ) => 'com.example.app' ) ,
1816} ) ) ;
1917
2018const simulator : DeviceInfo = {
@@ -34,47 +32,54 @@ function alertNotFound(): AppError {
3432 } ) ;
3533}
3634
37- const BUDGET_MS = 60_000 ;
35+ /** How `exec.ts` rejects a spawn that outlives its timeout; `allowFailure` does not absorb it. */
36+ function spawnTimeout ( ) : AppError {
37+ return new AppError ( 'COMMAND_FAILED' , 'xcrun timed out' , { timeoutMs : 10_000 } ) ;
38+ }
3839
39- const INSTALLED_APPS = [
40- { bundleId : 'com.example.other' , name : 'Other App' } ,
41- { bundleId : 'com.example.app' , name : 'Example App' } ,
42- ] ;
40+ const BUDGET_MS = 60_000 ;
41+ const CONFIRMATION = { message : 'Open in “Example App”?' , items : [ 'Cancel' , 'Open' ] } ;
4342
4443function port (
4544 readAlert : ( ) => Promise < Record < string , unknown > > ,
46- installedApps : readonly { bundleId : string ; name : string } [ ] = INSTALLED_APPS ,
45+ legs : Readonly < {
46+ resolveUrlOwner ?: ( ) => Promise < string | undefined > ;
47+ acceptAlert ?: ( ) => Promise < unknown > ;
48+ } > = { } ,
4749) {
48- const acceptAlert = vi . fn ( async ( ) => ( { } ) ) ;
49- const listInstalledApps = vi . fn ( async ( ) => installedApps ) ;
50+ const acceptAlert = vi . fn ( legs . acceptAlert ?? ( async ( ) => ( { } ) ) ) ;
51+ const resolveUrlOwner = vi . fn ( legs . resolveUrlOwner ?? ( async ( ) => 'com.example.app' ) ) ;
5052 const value : LaunchConfirmationPort = {
5153 appBundleId : 'com.example.app' ,
5254 readAlert : vi . fn ( readAlert ) ,
5355 acceptAlert,
54- listInstalledApps ,
56+ resolveUrlOwner ,
5557 } ;
56- return { port : value , acceptAlert, listInstalledApps } ;
58+ return { port : value , acceptAlert, resolveUrlOwner } ;
5759}
5860
5961test . each ( [
6062 [ 'curly' , 'Open in “Example App”?' ] ,
6163 [ 'straight' , 'Open in "Example App"?' ] ,
62- ] ) ( 'accepts a confirmation that names the session app with %s quotes' , async ( _quotes , message ) => {
63- const { port : device , acceptAlert } = port ( async ( ) => ( {
64- message,
65- items : [ 'Cancel' , 'Open' ] ,
66- } ) ) ;
67-
68- await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBe ( 'accepted' ) ;
69- expect ( device . readAlert ) . toHaveBeenCalledOnce ( ) ;
70- expect ( acceptAlert ) . toHaveBeenCalledOnce ( ) ;
71- } ) ;
64+ [ 'localized-name' , 'Open in “Beispiel-App”?' ] ,
65+ ] ) (
66+ 'accepts a confirmation (%s title) when the URL owner is the session app' ,
67+ async ( _case , message ) => {
68+ const { port : device , acceptAlert } = port ( async ( ) => ( {
69+ message,
70+ items : [ 'Cancel' , 'Open' ] ,
71+ } ) ) ;
72+
73+ await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBe ( 'accepted' ) ;
74+ expect ( device . readAlert ) . toHaveBeenCalledOnce ( ) ;
75+ expect ( acceptAlert ) . toHaveBeenCalledOnce ( ) ;
76+ } ,
77+ ) ;
7278
73- test ( 'a confirmation naming another installed app is never accepted and fails naming both apps' , async ( ) => {
74- const { port : device , acceptAlert } = port ( async ( ) => ( {
75- message : 'Open in “Other App”?' ,
76- items : [ 'Cancel' , 'Open' ] ,
77- } ) ) ;
79+ test ( 'a confirmation for a URL another app owns is never accepted, whatever name it shows' , async ( ) => {
80+ const { port : device , acceptAlert } = port ( async ( ) => CONFIRMATION , {
81+ resolveUrlOwner : async ( ) => 'com.example.other' ,
82+ } ) ;
7883
7984 const failure = await answerLaunchConfirmation ( device , BUDGET_MS ) . catch (
8085 ( error : unknown ) => error ,
@@ -84,7 +89,6 @@ test('a confirmation naming another installed app is never accepted and fails na
8489 expect ( ( failure as AppError ) . code ) . toBe ( 'COMMAND_FAILED' ) ;
8590 expect ( ( failure as AppError ) . details ) . toMatchObject ( {
8691 reason : LAUNCH_CONFIRMATION_FOREIGN_APP_REASON ,
87- appName : 'Other App' ,
8892 foreignAppBundleId : 'com.example.other' ,
8993 sessionAppBundleId : 'com.example.app' ,
9094 } ) ;
@@ -95,98 +99,113 @@ test('no alert costs one read and answers nothing', async () => {
9599 const {
96100 port : device ,
97101 acceptAlert,
98- listInstalledApps ,
102+ resolveUrlOwner ,
99103 } = port ( async ( ) => {
100104 throw alertNotFound ( ) ;
101105 } ) ;
102106
103107 await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBeUndefined ( ) ;
104108 expect ( device . readAlert ) . toHaveBeenCalledOnce ( ) ;
105- expect ( listInstalledApps ) . not . toHaveBeenCalled ( ) ;
109+ expect ( resolveUrlOwner ) . not . toHaveBeenCalled ( ) ;
106110 expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
107111} ) ;
108112
109113test ( 'an alert that is not a launch confirmation is left for the caller' , async ( ) => {
110114 const {
111115 port : device ,
112116 acceptAlert,
113- listInstalledApps ,
117+ resolveUrlOwner ,
114118 } = port ( async ( ) => ( {
115119 message : 'Allow “Example App” to use your location?' ,
116120 items : [ 'Allow Once' , 'Don’t Allow' ] ,
117121 } ) ) ;
118122
119123 await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBeUndefined ( ) ;
120- expect ( listInstalledApps ) . not . toHaveBeenCalled ( ) ;
124+ expect ( resolveUrlOwner ) . not . toHaveBeenCalled ( ) ;
121125 expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
122126} ) ;
123127
124- test . each ( [
125- [ 'nothing installed carries' , [ { bundleId : 'com.example.other' , name : 'Other App' } ] ] ,
126- [
127- 'two installed bundles share' ,
128- [
129- { bundleId : 'com.example.app' , name : 'Example App' } ,
130- { bundleId : 'com.example.app.dev' , name : 'Example App' } ,
131- ] ,
132- ] ,
133- ] ) (
134- 'a confirmation naming an app %s is neither accepted nor reported as foreign' ,
135- async ( _case , installedApps ) => {
136- const { port : device , acceptAlert } = port (
137- async ( ) => ( { message : 'Open in “Example App”?' , items : [ 'Cancel' , 'Open' ] } ) ,
138- installedApps ,
139- ) ;
140-
141- await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBeUndefined ( ) ;
142- expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
143- } ,
144- ) ;
145-
146- test ( 'a failed alert read leaves the open unanswered' , async ( ) => {
147- const { port : device , acceptAlert } = port ( async ( ) => {
148- throw new AppError ( 'COMMAND_FAILED' , 'runner unavailable' , { reason : 'runner-start-failed' } ) ;
128+ test ( 'a URL no single installed app owns is neither accepted nor reported as foreign' , async ( ) => {
129+ const { port : device , acceptAlert } = port ( async ( ) => CONFIRMATION , {
130+ resolveUrlOwner : async ( ) => undefined ,
149131 } ) ;
150132
151133 await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBeUndefined ( ) ;
152134 expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
153135} ) ;
154136
155- test ( 'an alert read that outlives the launch budget leaves the open unanswered' , async ( ) => {
137+ test . each ( [
138+ [
139+ 'the alert read fails' ,
140+ port ( async ( ) => {
141+ throw new AppError ( 'COMMAND_FAILED' , 'runner unavailable' , { reason : 'runner-start-failed' } ) ;
142+ } ) ,
143+ ] ,
144+ [
145+ 'the URL owner lookup rejects' ,
146+ port ( async ( ) => CONFIRMATION , {
147+ resolveUrlOwner : async ( ) => {
148+ throw spawnTimeout ( ) ;
149+ } ,
150+ } ) ,
151+ ] ,
152+ [
153+ 'the accept rejects' ,
154+ port ( async ( ) => CONFIRMATION , {
155+ acceptAlert : async ( ) => {
156+ throw spawnTimeout ( ) ;
157+ } ,
158+ } ) ,
159+ ] ,
160+ ] ) ( 'the open is left unanswered when %s' , async ( _case , { port : device } ) => {
161+ await expect ( answerLaunchConfirmation ( device , BUDGET_MS ) ) . resolves . toBeUndefined ( ) ;
162+ } ) ;
163+
164+ test . each ( [
165+ [ 'the alert read' , { read : BUDGET_MS * 2 , owner : 0 , accept : 0 } ] ,
166+ [ 'the URL owner lookup' , { read : BUDGET_MS / 2 , owner : BUDGET_MS * 0.75 , accept : 0 } ] ,
167+ [ 'the accept' , { read : BUDGET_MS / 3 , owner : BUDGET_MS / 3 , accept : BUDGET_MS / 2 } ] ,
168+ ] ) ( '%s spending the rest of one shared budget leaves the open unanswered' , async ( _leg , delays ) => {
156169 vi . useFakeTimers ( ) ;
157170 try {
158- const { port : device , acceptAlert } = port (
159- ( ) =>
160- new Promise ( ( resolve ) => {
161- setTimeout ( ( ) => resolve ( { message : 'Open in “Example App”?' } ) , BUDGET_MS * 2 ) ;
162- } ) ,
163- ) ;
171+ const after = < T > ( ms : number , value : T ) =>
172+ new Promise < T > ( ( resolve ) => {
173+ setTimeout ( ( ) => resolve ( value ) , ms ) ;
174+ } ) ;
175+ const { port : device } = port ( async ( ) => await after ( delays . read , CONFIRMATION ) , {
176+ resolveUrlOwner : async ( ) => await after ( delays . owner , 'com.example.app' ) ,
177+ acceptAlert : async ( ) => await after ( delays . accept , { } ) ,
178+ } ) ;
164179
165180 const answer = answerLaunchConfirmation ( device , BUDGET_MS ) ;
166181 await vi . advanceTimersByTimeAsync ( BUDGET_MS ) ;
167182
168183 await expect ( answer ) . resolves . toBeUndefined ( ) ;
169- await vi . advanceTimersByTimeAsync ( BUDGET_MS ) ;
170- expect ( acceptAlert ) . not . toHaveBeenCalled ( ) ;
171184 } finally {
172185 vi . useRealTimers ( ) ;
173186 }
174187} ) ;
175188
176- test ( 'the port reads and accepts the alert for the session app and lists every installed app ' , async ( ) => {
177- const readAlert = vi . fn ( async ( ) => ( { message : 'Open in “Example App”?' } ) ) ;
189+ test ( 'the port reads and accepts the alert for the session app and asks the scheme owner ' , async ( ) => {
190+ const readAlert = vi . fn ( async ( ) => CONFIRMATION ) ;
178191 const acceptAlert = vi . fn ( async ( ) => ( { } ) ) ;
179192 const interactor = { readAlert, acceptAlert } as unknown as Interactor ;
180193
181- const device = createLaunchConfirmationPort ( simulator , 'com.example.app' , interactor ) ;
194+ const device = createLaunchConfirmationPort (
195+ simulator ,
196+ 'com.example.app' ,
197+ 'example://automation' ,
198+ async ( ) => interactor ,
199+ ) ;
182200 await device . readAlert ( ) ;
183201 await device . acceptAlert ( ) ;
184202
185203 const target = { appBundleId : 'com.example.app' , surface : 'app' } ;
186204 expect ( readAlert ) . toHaveBeenCalledWith ( target ) ;
187205 expect ( acceptAlert ) . toHaveBeenCalledWith ( target ) ;
188- await expect ( device . listInstalledApps ( ) ) . resolves . toEqual ( [
189- { bundleId : 'com.example.other' , name : 'Other App' } ,
190- { bundleId : 'com.example.app' , name : 'Example App' } ,
191- ] ) ;
206+ await expect ( device . resolveUrlOwner ( ) ) . resolves . toBe ( 'com.example.app' ) ;
207+ expect ( resolveIosSimulatorDeepLinkBundleId ) . toHaveBeenCalledWith (
208+ simulator ,
209+ 'example://automation' ,
210+ ) ;
192211} ) ;
0 commit comments