diff --git a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts index 8401bcca93..4856cee252 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts'; import { withAndroidAdbProvider } from '../adb-executor.ts'; @@ -162,6 +162,119 @@ test('closeAndroidApp waits until package process exits after force-stop', async ]); }); +test.each(['foreground', 'process'] as const)( + 'closeAndroidApp refuses an exhausted %s fence', + async (phase) => { + vi.useFakeTimers(); + const calls: (readonly string[])[] = []; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const stdout = + args[1] === 'pidof' + ? '12345\n' + : args[1] === 'dumpsys' + ? `mCurrentFocus=Window{42 u0 ${phase === 'foreground' ? 'com.example.app/.MainActivity' : 'com.android.launcher/.Launcher'}}\n` + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const rejection = assert.rejects( + closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { + reason: 'android-app-close-timeout', + phase, + packageName: 'com.example.app', + timeoutMs: 2_000, + }); + return true; + }, + ); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']); + assert.equal( + calls.filter((args) => args[1] === 'pidof').length, + phase === 'process' ? 40 : 0, + ); + } finally { + vi.useRealTimers(); + } + }, +); + +test.each([ + ['foreground', 1, 1_999, true, 2], + ['foreground', 1, 2_000, false, 0], + ['foreground', 1, 2_001, false, 0], + ['process', 1, 1_849, true, 2], + ['process', 1, 1_850, false, 1], + ['process', 1, 2_000, false, 1], + ['process', 1, 2_001, false, 1], + ['process', 2, 1_999, true, 2], + ['process', 2, 2_000, false, 2], + ['process', 2, 2_001, false, 2], +] as const)( + 'closeAndroidApp admits %s probe %i completing at %ims only within its fence (success=%s)', + async (phase, probe, completionMs, succeeds, expectedProcessPolls) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + let processPolls = 0; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + if (args[1] === 'pidof') processPolls += 1; + if ( + (phase === 'foreground' && args[1] === 'dumpsys') || + (phase === 'process' && args[1] === 'pidof' && processPolls === probe) + ) { + await new Promise((resolve) => setTimeout(resolve, completionMs - Date.now())); + } + return { + stdout: + args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n' + : '', + stderr: '', + exitCode: args[1] === 'pidof' ? 1 : 0, + }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const close = closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'); + const result = succeeds + ? close + : assert.rejects(close, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { + reason: 'android-app-close-timeout', + phase, + packageName: 'com.example.app', + timeoutMs: 2_000, + }); + return true; + }); + await Promise.all([result, vi.runAllTimersAsync()]); + }, + ); + assert.equal(processPolls, expectedProcessPolls); + } finally { + vi.useRealTimers(); + } + }, +); + test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => { const device: DeviceInfo = { platform: 'android', diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index c3ae10b6e8..2874dbf4e4 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -1,6 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; -import { sleep } from '@agent-device/host-kit/retry'; +import { Deadline, sleep } from '@agent-device/host-kit/retry'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; @@ -534,12 +534,31 @@ async function waitForAndroidPackageNotForeground( device: DeviceInfo, packageName: string, ): Promise { - const deadline = Date.now() + ANDROID_CLOSE_FOCUS_TIMEOUT_MS; - while (Date.now() < deadline) { - const foreground = await readAndroidForegroundApp(device); - if (foreground?.package !== packageName) return; + const deadline = Deadline.fromTimeoutMs(ANDROID_CLOSE_FOCUS_TIMEOUT_MS); + while (!deadline.isExpired()) { + const foreground = await probeBeforeDeadline(deadline, () => readAndroidForegroundApp(device)); + if (!foreground) break; + if (foreground.value?.package !== packageName) return; await sleep(ANDROID_CLOSE_FOCUS_POLL_MS); } + throw new AppError( + 'COMMAND_FAILED', + `Android app ${packageName} remained foreground after close`, + { + reason: 'android-app-close-timeout', + phase: 'foreground', + packageName, + timeoutMs: ANDROID_CLOSE_FOCUS_TIMEOUT_MS, + }, + ); +} + +async function probeBeforeDeadline( + deadline: Deadline, + probe: () => Promise, +): Promise<{ value: T } | undefined> { + const value = await probe(); + return deadline.isExpired() ? undefined : { value }; } async function readAndroidForegroundApp(device: DeviceInfo): Promise { @@ -551,14 +570,31 @@ async function waitForAndroidPackageProcessGone( device: DeviceInfo, packageName: string, ): Promise { - const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS; - while (Date.now() < deadline) { - if (!(await isAndroidPackageProcessRunning(device, packageName))) { + const deadline = Deadline.fromTimeoutMs(ANDROID_CLOSE_PROCESS_TIMEOUT_MS); + const probeProcessRunning = () => + probeBeforeDeadline(deadline, () => isAndroidPackageProcessRunning(device, packageName)); + while (!deadline.isExpired()) { + const processRunning = await probeProcessRunning(); + if (!processRunning) break; + if (!processRunning.value) { await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS); - if (!(await isAndroidPackageProcessRunning(device, packageName))) return; + if (deadline.isExpired()) break; + const processStillRunning = await probeProcessRunning(); + if (!processStillRunning) break; + if (!processStillRunning.value) return; } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } + throw new AppError( + 'COMMAND_FAILED', + `Android app ${packageName} process remained running after close`, + { + reason: 'android-app-close-timeout', + phase: 'process', + packageName, + timeoutMs: ANDROID_CLOSE_PROCESS_TIMEOUT_MS, + }, + ); } async function isAndroidPackageProcessRunning( diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 0a417adbfc..34fc896bd4 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -9,6 +9,9 @@ import type { Interactor, SnapshotOptions } from '@agent-device/contracts/intera import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; +import { closeAndroidApp } from './app-lifecycle.ts'; +import { withAndroidAdbProvider } from './adb-executor.ts'; +import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; const device: DeviceInfo = { platform: 'android', @@ -20,6 +23,7 @@ const device: DeviceInfo = { }; afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -38,6 +42,7 @@ function createLifecycle( input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>, ) => Promise; shutdownTarget?: () => Promise; + close?: Interactor['close']; }> = {}, ): LifecycleFixture { const calls: string[] = []; @@ -48,7 +53,10 @@ function createLifecycle( calls.push(`open:${app}`); }, openDevice: async () => {}, - close: async () => {}, + close: async (app: string) => { + calls.push(`close:${app}`); + await params.close?.(app); + }, setSetting: async () => {}, snapshot: async (options: SnapshotOptions) => { calls.push(`observe:${options.appBundleId}`); @@ -113,6 +121,43 @@ function openInput(overrides: Partial = {}): OpenApplicati }; } +test('relaunch refuses to open or observe after the Android process close fence expires', async () => { + vi.useFakeTimers(); + bindAndroidAdbHostStub({}); + const { calls, lifecycle } = createLifecycle({ + close: async (app) => await closeAndroidApp(device, app), + }); + await withAndroidAdbProvider( + { + exec: async (args) => ({ + stdout: + args[1] === 'pidof' + ? '12345\n' + : args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n' + : '', + stderr: '', + exitCode: 0, + }), + }, + { serial: device.id }, + async () => { + const rejection = expect( + lifecycle.openApplication(openInput({ relaunch: true })), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'android-app-close-timeout', + phase: 'process', + packageName: 'com.example.app', + }, + }); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + expect(calls).toEqual(['close:com.example.app']); +}); + test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { const { calls, lifecycle } = createLifecycle(); vi.spyOn(Date, 'now') diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 2b1367079c..17f4e9fbce 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -82,7 +82,7 @@ const openCommandMetadata = defineFieldCommandMetadata( const closeCommandMetadata = defineFieldCommandMetadata( 'close', - 'Close the named app, or close the active session app when app is omitted. Use shutdown only when the selected simulator or emulator should also stop.', + 'Close the named app, or close the active session app when app is omitted. Use shutdown only when the selected simulator or emulator should also stop. Android ADB close fails with android-app-close-timeout if the app stays foreground, or its process stays running, past its 2s check window.', { app: stringField('Optional app to close.'), shutdown: booleanField('Shutdown the session/device where supported.'), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1cc09b6a7c..c433fe854c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -90,6 +90,7 @@ Open apps and links: - `open --launch-console ` captures launch-time stdout/stderr for iOS simulator app launches. It is not valid for URL opens or other targets. - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session. - Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open --relaunch`. +- Android (ADB): `close` force-stops the app, then checks for 2 seconds that it has left the foreground and for 2 more that its process has exited. If either check window ends first, `close` fails with `details.reason: android-app-close-timeout` and `details.phase` set to `foreground` or `process`; apps Android restarts at once, such as the launcher, fail this way. iOS Simulator launch behavior: