11import { afterEach , expect , test , vi } from 'vitest' ;
2- import type { LocalApplicationInteractorHost } from '@agent-device/contracts/application-lifecycle-runtime' ;
2+ import type {
3+ LocalApplicationInteractorHost ,
4+ OpenApplicationInput ,
5+ } from '@agent-device/contracts/application-lifecycle-runtime' ;
36import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations' ;
4- import type { Interactor } from '@agent-device/contracts/interactor-types' ;
7+ import type { Interactor , SnapshotOptions } from '@agent-device/contracts/interactor-types' ;
58import type { DeviceInfo } from '@agent-device/kernel/device' ;
9+ import { AppError } from '@agent-device/kernel/errors' ;
610import { bindAndroidApplicationLifecycle } from './lifecycle.ts' ;
711
812const device : DeviceInfo = {
@@ -18,17 +22,32 @@ afterEach(() => {
1822 vi . restoreAllMocks ( ) ;
1923} ) ;
2024
21- test ( 'preserves a runtime launch URL duration after the admitted Android follow-up open' , async ( ) => {
22- const opens : string [ ] = [ ] ;
25+ type LifecycleFixture = Readonly < {
26+ calls : string [ ] ;
27+ lifecycle : ReturnType < typeof bindAndroidApplicationLifecycle > ;
28+ } > ;
29+
30+ function createLifecycle (
31+ params : Readonly < {
32+ snapshot ?: ( options : SnapshotOptions | undefined ) => Promise < unknown > ;
33+ openedAppBundleId ?: string ;
34+ signal ?: AbortSignal ;
35+ } > = { } ,
36+ ) : LifecycleFixture {
37+ const calls : string [ ] = [ ] ;
2338 const localInteractors : LocalApplicationInteractorHost = {
2439 resolve : async ( ) =>
2540 ( {
2641 open : async ( app : string ) => {
27- opens . push ( app ) ;
42+ calls . push ( `open: ${ app } ` ) ;
2843 } ,
2944 openDevice : async ( ) => { } ,
3045 close : async ( ) => { } ,
3146 setSetting : async ( ) => { } ,
47+ snapshot : async ( options ?: SnapshotOptions ) => {
48+ calls . push ( `snapshot:${ options ?. appBundleId } ` ) ;
49+ return await ( params . snapshot ?.( options ) ?? Promise . resolve ( { nodes : [ ] } ) ) ;
50+ } ,
3251 } ) as unknown as Interactor ,
3352 } ;
3453 const host = {
@@ -41,7 +60,8 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
4160 } ,
4261 androidApplications : {
4362 resolveOpenTarget : async ( ) => ( { } ) ,
44- inferOpenedAppBundleId : async ( ) => 'com.example.app' ,
63+ inferOpenedAppBundleId : async ( ) =>
64+ 'openedAppBundleId' in params ? params . openedAppBundleId : 'com.example.app' ,
4565 resetFramePerfStats : async ( ) => { } ,
4666 applyRuntimeHints : async ( ) => { } ,
4767 clearRuntimeHints : async ( ) => { } ,
@@ -60,21 +80,18 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
6080 | 'localInteractors'
6181 | 'toolchains'
6282 > ;
63- vi . spyOn ( Date , 'now' )
64- . mockReturnValueOnce ( 10 )
65- . mockReturnValueOnce ( 20 )
66- . mockReturnValueOnce ( 30 )
67- . mockReturnValueOnce ( 50 ) ;
6883 const lifecycle = bindAndroidApplicationLifecycle ( {
6984 host,
7085 device,
71- signal : new AbortController ( ) . signal ,
86+ signal : params . signal ?? new AbortController ( ) . signal ,
7287 } ) ;
88+ return { calls, lifecycle } ;
89+ }
7390
74- const outcome = await lifecycle . openApplication ( {
91+ function openInput ( overrides : Partial < OpenApplicationInput > = { } ) : OpenApplicationInput {
92+ return {
7593 target : 'com.example.app' ,
7694 positionals : [ 'com.example.app' ] ,
77- runtimeLaunchUrl : 'example://after-open' ,
7895 appBundleId : 'com.example.app' ,
7996 surface : 'app' ,
8097 hasExistingSession : false ,
@@ -84,8 +101,106 @@ test('preserves a runtime launch URL duration after the admitted Android follow-
84101 stateDir : '/state' ,
85102 runtimeHints : { } ,
86103 execution : { } ,
87- } ) ;
104+ ...overrides ,
105+ } ;
106+ }
107+
108+ /** What the Android capture throws once its bounded re-capture still sees an unmounted app. */
109+ function unreadableLaunchContentError ( ) : AppError {
110+ return new AppError (
111+ 'COMMAND_FAILED' ,
112+ 'Android snapshot helper returned insufficient foreground app content' ,
113+ {
114+ androidSnapshotHelperFailureReason : 'content-poor-app-window' ,
115+ attempts : 3 ,
116+ retriable : true ,
117+ } ,
118+ ) ;
119+ }
88120
89- expect ( opens ) . toEqual ( [ 'com.example.app' , 'example://after-open' ] ) ;
121+ test ( 'preserves a runtime launch URL duration after the admitted Android follow-up open' , async ( ) => {
122+ const { calls, lifecycle } = createLifecycle ( ) ;
123+ vi . spyOn ( Date , 'now' )
124+ . mockReturnValueOnce ( 10 )
125+ . mockReturnValueOnce ( 20 )
126+ . mockReturnValueOnce ( 30 )
127+ . mockReturnValueOnce ( 50 ) ;
128+
129+ const outcome = await lifecycle . openApplication (
130+ openInput ( { runtimeLaunchUrl : 'example://after-open' } ) ,
131+ ) ;
132+
133+ expect ( calls . filter ( ( call ) => call . startsWith ( 'open:' ) ) ) . toEqual ( [
134+ 'open:com.example.app' ,
135+ 'open:example://after-open' ,
136+ ] ) ;
90137 expect ( outcome . timing . launchUrlDurationMs ) . toBe ( 20 ) ;
91138} ) ;
139+
140+ test ( 'an Android app open returns only after a capture of the launched app is readable' , async ( ) => {
141+ let releaseCapture : ( ) => void = ( ) => { } ;
142+ const captureReleased = new Promise < void > ( ( resolve ) => {
143+ releaseCapture = resolve ;
144+ } ) ;
145+ const { calls, lifecycle } = createLifecycle ( {
146+ openedAppBundleId : 'com.example.opened' ,
147+ snapshot : async ( ) => {
148+ await captureReleased ;
149+ return { nodes : [ ] } ;
150+ } ,
151+ } ) ;
152+
153+ let settled = false ;
154+ const opening = lifecycle . openApplication ( openInput ( ) ) . then ( ( outcome ) => {
155+ settled = true ;
156+ return outcome ;
157+ } ) ;
158+ await vi . waitFor ( ( ) => expect ( calls ) . toContain ( 'snapshot:com.example.opened' ) ) ;
159+ await Promise . resolve ( ) ;
160+ expect ( settled ) . toBe ( false ) ;
161+ releaseCapture ( ) ;
162+ const outcome = await opening ;
163+
164+ expect ( calls ) . toEqual ( [ 'open:com.example.app' , 'snapshot:com.example.opened' ] ) ;
165+ expect ( outcome . appBundleId ) . toBe ( 'com.example.opened' ) ;
166+ expect ( outcome . timing . postOpenObservation ) . toBe ( 'observable' ) ;
167+ expect ( outcome . timing . postOpenSettleDurationMs ) . toEqual ( expect . any ( Number ) ) ;
168+ } ) ;
169+
170+ test ( 'an app still unreadable after the capture re-captures opens as unobservable' , async ( ) => {
171+ const { calls, lifecycle } = createLifecycle ( {
172+ snapshot : async ( ) => {
173+ throw unreadableLaunchContentError ( ) ;
174+ } ,
175+ } ) ;
176+
177+ const outcome = await lifecycle . openApplication ( openInput ( { relaunch : true } ) ) ;
178+
179+ expect ( calls ) . toEqual ( [ 'open:com.example.app' , 'snapshot:com.example.app' ] ) ;
180+ expect ( outcome . appBundleId ) . toBe ( 'com.example.app' ) ;
181+ expect ( outcome . timing . postOpenObservation ) . toBe ( 'unobservable' ) ;
182+ } ) ;
183+
184+ test ( 'a cancelled open does not report the launch observation as unobservable' , async ( ) => {
185+ const controller = new AbortController ( ) ;
186+ const { lifecycle } = createLifecycle ( {
187+ signal : controller . signal ,
188+ snapshot : async ( ) => {
189+ controller . abort ( new Error ( 'request cancelled' ) ) ;
190+ throw unreadableLaunchContentError ( ) ;
191+ } ,
192+ } ) ;
193+
194+ await expect ( lifecycle . openApplication ( openInput ( ) ) ) . rejects . toThrow ( 'request cancelled' ) ;
195+ } ) ;
196+
197+ test ( 'an open whose launched app package is unknown does not observe the launch' , async ( ) => {
198+ const { calls, lifecycle } = createLifecycle ( { openedAppBundleId : undefined } ) ;
199+
200+ const outcome = await lifecycle . openApplication (
201+ openInput ( { target : 'https://example.com' , positionals : [ 'https://example.com' ] } ) ,
202+ ) ;
203+
204+ expect ( calls ) . toEqual ( [ 'open:https://example.com' ] ) ;
205+ expect ( outcome . timing . postOpenObservation ) . toBe ( 'not-eligible' ) ;
206+ } ) ;
0 commit comments