@@ -9,6 +9,9 @@ import type { Interactor, SnapshotOptions } from '@agent-device/contracts/intera
99import type { DeviceInfo } from '@agent-device/kernel/device' ;
1010import { AppError , createRequestCanceledError } from '@agent-device/kernel/errors' ;
1111import { bindAndroidApplicationLifecycle } from './lifecycle.ts' ;
12+ import { closeAndroidApp } from './app-lifecycle.ts' ;
13+ import { withAndroidAdbProvider } from './adb-executor.ts' ;
14+ import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts' ;
1215
1316const device : DeviceInfo = {
1417 platform : 'android' ,
@@ -20,6 +23,7 @@ const device: DeviceInfo = {
2023} ;
2124
2225afterEach ( ( ) => {
26+ vi . useRealTimers ( ) ;
2327 vi . restoreAllMocks ( ) ;
2428} ) ;
2529
@@ -39,6 +43,7 @@ function createLifecycle(
3943 input : Readonly < { stateDir : string ; shutdownTarget : boolean ; signal : AbortSignal } > ,
4044 ) => Promise < void > ;
4145 shutdownTarget ?: ( ) => Promise < unknown > ;
46+ close ?: Interactor [ 'close' ] ;
4247 } > = { } ,
4348) : LifecycleFixture {
4449 const calls : string [ ] = [ ] ;
@@ -53,6 +58,7 @@ function createLifecycle(
5358 openDevice : async ( ) => { } ,
5459 close : async ( app : string ) => {
5560 calls . push ( `close:${ app } ` ) ;
61+ await params . close ?.( app ) ;
5662 } ,
5763 setSetting : async ( ) => { } ,
5864 snapshot : async ( options : SnapshotOptions ) => {
@@ -164,6 +170,43 @@ test.each([
164170 expect ( outcome . appBundleId ) . toBe ( appBundleId ) ;
165171} ) ;
166172
173+ test ( 'relaunch refuses to open or observe after the Android process close fence expires' , async ( ) => {
174+ vi . useFakeTimers ( ) ;
175+ bindAndroidAdbHostStub ( { } ) ;
176+ const { calls, lifecycle } = createLifecycle ( {
177+ close : async ( app ) => await closeAndroidApp ( device , app ) ,
178+ } ) ;
179+ await withAndroidAdbProvider (
180+ {
181+ exec : async ( args ) => ( {
182+ stdout :
183+ args [ 1 ] === 'pidof'
184+ ? '12345\n'
185+ : args [ 1 ] === 'dumpsys'
186+ ? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
187+ : '' ,
188+ stderr : '' ,
189+ exitCode : 0 ,
190+ } ) ,
191+ } ,
192+ { serial : device . id } ,
193+ async ( ) => {
194+ const rejection = expect (
195+ lifecycle . openApplication ( openInput ( { relaunch : true } ) ) ,
196+ ) . rejects . toMatchObject ( {
197+ code : 'COMMAND_FAILED' ,
198+ details : {
199+ reason : 'android-app-close-timeout' ,
200+ phase : 'process' ,
201+ packageName : 'com.example.app' ,
202+ } ,
203+ } ) ;
204+ await Promise . all ( [ rejection , vi . runAllTimersAsync ( ) ] ) ;
205+ } ,
206+ ) ;
207+ expect ( calls ) . toEqual ( [ 'close:com.example.app' ] ) ;
208+ } ) ;
209+
167210test ( 'preserves a runtime launch URL duration after the admitted Android follow-up open' , async ( ) => {
168211 const { calls, lifecycle } = createLifecycle ( ) ;
169212 vi . spyOn ( Date , 'now' )
0 commit comments