diff --git a/packages/platform-android/src/__tests__/app-lifecycle-close.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-close.test.ts new file mode 100644 index 0000000000..a33e4305fa --- /dev/null +++ b/packages/platform-android/src/__tests__/app-lifecycle-close.test.ts @@ -0,0 +1,294 @@ +import { test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; +import './test-utils/android-host-test-setup.ts'; + +test('closeAndroidApp waits until package is no longer foreground', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + const calls: (readonly string[])[] = []; + let focusPolls = 0; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + if (args.join(' ') === 'shell dumpsys window windows') { + focusPolls += 1; + return { + stdout: + focusPolls === 1 + ? 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n' + : 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: { + ensure: async () => {}, + remove: async () => {}, + removeAllOwned: async () => {}, + }, + }, + { serial: 'emulator-5554' }, + async () => await closeAndroidApp(device, 'com.example.app'), + ); + + assert.deepEqual(calls, [ + ['shell', 'am', 'force-stop', 'com.example.app'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ]); +}); + +test('closeAndroidApp returns after force-stop when package is already not foreground', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + const calls: (readonly string[])[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + if (args.join(' ') === 'shell dumpsys window windows') { + return { + stdout: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: { + ensure: async () => {}, + remove: async () => {}, + removeAllOwned: async () => {}, + }, + }, + { serial: 'emulator-5554' }, + async () => await closeAndroidApp(device, 'com.example.app'), + ); + + assert.deepEqual(calls, [ + ['shell', 'am', 'force-stop', 'com.example.app'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ]); +}); + +test('closeAndroidApp waits until package process exits after force-stop', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + const calls: (readonly string[])[] = []; + let processPolls = 0; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + if (args.join(' ') === 'shell dumpsys window windows') { + return { + stdout: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', + stderr: '', + exitCode: 0, + }; + } + if (args.join(' ') === 'shell pidof com.example.app') { + processPolls += 1; + return { + stdout: processPolls === 1 ? '12345\n' : '', + stderr: '', + exitCode: processPolls === 1 ? 0 : 1, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: { + ensure: async () => {}, + remove: async () => {}, + removeAllOwned: async () => {}, + }, + }, + { serial: 'emulator-5554' }, + async () => await closeAndroidApp(device, 'com.example.app'), + ); + + assert.deepEqual(calls, [ + ['shell', 'am', 'force-stop', 'com.example.app'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ]); +}); + +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 refuses to relaunch a package whose stop fence expires', async () => { + vi.useFakeTimers(); + const calls: (readonly string[])[] = []; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const stdout = + args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n' + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const rejection = assert.rejects( + openAndroidApp(ANDROID_EMULATOR, 'com.example.app', { terminateRunningApp: true }), + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'android-app-close-timeout' && + error.details?.phase === 'foreground', + ); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']); + assert.equal(calls.filter((args) => args[2] === 'start').length, 0, 'no launch after refusal'); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts index 93d684ab53..ea36416c24 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, test } from 'vitest'; +import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { killAndroidApp } from '../app-lifecycle.ts'; import { withAndroidAdbProvider } from '../adb-executor.ts'; @@ -149,6 +149,39 @@ test('killAndroidApp proceeds when focus still names the target but the resumed ]); }); +test('killAndroidApp waits out a slow exit even while stale focus still names the target', async () => { + vi.useFakeTimers(); + let pidofCalls = 0; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + const command = args.join(' '); + if (command === dumpsysActivityActivities) { + return { stdout: RESUMED_LAUNCHER_DUMP, stderr: '', exitCode: 0 }; + } + if (command === dumpsysWindowWindows) { + return { stdout: TARGET_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + if (command === pidofTarget) { + pidofCalls += 1; + return { stdout: pidofCalls <= 2 ? '12345\n' : '', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => { + await Promise.all([killAndroidApp(DEVICE, 'com.example.app'), vi.runAllTimersAsync()]); + }, + ); + assert.ok(pidofCalls > 2, `the kill polled the process until it exited (${pidofCalls})`); + } finally { + vi.useRealTimers(); + } +}); + test('killAndroidApp fails when the process survives the kill', async () => { await withAndroidAdbProvider( { 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 9c1ce01128..a1d77aeb70 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts'; +import { openAndroidApp } from '../app-lifecycle.ts'; import { withAndroidAdbProvider } from '../adb-executor.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -23,145 +23,6 @@ test('openAndroidApp rejects activity override for deep link URLs', async () => ); }); -test('closeAndroidApp waits until package is no longer foreground', async () => { - const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }; - const calls: (readonly string[])[] = []; - let focusPolls = 0; - - await withAndroidAdbProvider( - { - exec: async (args) => { - calls.push(args); - if (args.join(' ') === 'shell dumpsys window windows') { - focusPolls += 1; - return { - stdout: - focusPolls === 1 - ? 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n' - : 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }, - reverse: { - ensure: async () => {}, - remove: async () => {}, - removeAllOwned: async () => {}, - }, - }, - { serial: 'emulator-5554' }, - async () => await closeAndroidApp(device, 'com.example.app'), - ); - - assert.deepEqual(calls, [ - ['shell', 'am', 'force-stop', 'com.example.app'], - ['shell', 'dumpsys', 'window', 'windows'], - ['shell', 'dumpsys', 'window', 'windows'], - ['shell', 'pidof', 'com.example.app'], - ['shell', 'pidof', 'com.example.app'], - ]); -}); - -test('closeAndroidApp returns after force-stop when package is already not foreground', async () => { - const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }; - const calls: (readonly string[])[] = []; - - await withAndroidAdbProvider( - { - exec: async (args) => { - calls.push(args); - if (args.join(' ') === 'shell dumpsys window windows') { - return { - stdout: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }, - reverse: { - ensure: async () => {}, - remove: async () => {}, - removeAllOwned: async () => {}, - }, - }, - { serial: 'emulator-5554' }, - async () => await closeAndroidApp(device, 'com.example.app'), - ); - - assert.deepEqual(calls, [ - ['shell', 'am', 'force-stop', 'com.example.app'], - ['shell', 'dumpsys', 'window', 'windows'], - ['shell', 'pidof', 'com.example.app'], - ['shell', 'pidof', 'com.example.app'], - ]); -}); - -test('closeAndroidApp waits until package process exits after force-stop', async () => { - const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }; - const calls: (readonly string[])[] = []; - let processPolls = 0; - - await withAndroidAdbProvider( - { - exec: async (args) => { - calls.push(args); - if (args.join(' ') === 'shell dumpsys window windows') { - return { - stdout: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n', - stderr: '', - exitCode: 0, - }; - } - if (args.join(' ') === 'shell pidof com.example.app') { - processPolls += 1; - return { - stdout: processPolls === 1 ? '12345\n' : '', - stderr: '', - exitCode: processPolls === 1 ? 0 : 1, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }, - reverse: { - ensure: async () => {}, - remove: async () => {}, - removeAllOwned: async () => {}, - }, - }, - { serial: 'emulator-5554' }, - async () => await closeAndroidApp(device, 'com.example.app'), - ); - - assert.deepEqual(calls, [ - ['shell', 'am', 'force-stop', 'com.example.app'], - ['shell', 'dumpsys', 'window', 'windows'], - ['shell', 'pidof', 'com.example.app'], - ['shell', 'pidof', 'com.example.app'], - ['shell', 'pidof', 'com.example.app'], - ]); -}); - 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 fe6cfad1a4..7000caa0a4 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'; @@ -559,7 +559,8 @@ async function resolveAndroidPackageToEnd( async function stopAndroidPackage(device: DeviceInfo, packageName: string): Promise { await runAndroidShell(device, ['am', 'force-stop', packageName]); - await waitForAndroidPackageStopped(device, packageName); + const expired = await waitForAndroidPackageStopped(device, packageName); + if (expired) throw androidCloseTimeoutError(packageName, expired); } /** @@ -590,7 +591,10 @@ async function killAndroidPackage(device: DeviceInfo, packageName: string): Prom ); } await runAndroidShell(device, ['am', 'kill', packageName]); - await waitForAndroidPackageStopped(device, packageName); + // Both fences run as on close, but a survivor is classified below rather than refused as a + // close timeout. + await waitForAndroidPackageNotForeground(device, packageName); + await waitForAndroidPackageProcessGone(device, packageName); const process = await readAndroidPackageProcessState(device, packageName); if (process === 'running') { throw new AppError('COMMAND_FAILED', `am kill did not stop ${packageName}`, { @@ -606,24 +610,52 @@ async function killAndroidPackage(device: DeviceInfo, packageName: string): Prom } } +type AndroidStopFencePhase = 'foreground' | 'process'; + +/** The fence that expired before the package stopped, or undefined once it stopped. */ async function waitForAndroidPackageStopped( device: DeviceInfo, packageName: string, -): Promise { - await waitForAndroidPackageNotForeground(device, packageName); - await waitForAndroidPackageProcessGone(device, packageName); +): Promise { + if (!(await waitForAndroidPackageNotForeground(device, packageName))) return 'foreground'; + if (!(await waitForAndroidPackageProcessGone(device, packageName))) return 'process'; + return undefined; +} + +function androidCloseTimeoutError(packageName: string, phase: AndroidStopFencePhase): AppError { + const message = + phase === 'foreground' + ? `Android app ${packageName} remained foreground after close` + : `Android app ${packageName} process remained running after close`; + return new AppError('COMMAND_FAILED', message, { + reason: 'android-app-close-timeout', + phase, + packageName, + timeoutMs: + phase === 'foreground' ? ANDROID_CLOSE_FOCUS_TIMEOUT_MS : ANDROID_CLOSE_PROCESS_TIMEOUT_MS, + }); } 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; +): Promise { + 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 true; await sleep(ANDROID_CLOSE_FOCUS_POLL_MS); } + return false; +} + +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 { @@ -634,15 +666,26 @@ async function readAndroidForegroundApp(device: DeviceInfo): Promise { - const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS; - while (Date.now() < deadline) { - if ((await readAndroidPackageProcessState(device, packageName)) !== 'running') { +): Promise { + const deadline = Deadline.fromTimeoutMs(ANDROID_CLOSE_PROCESS_TIMEOUT_MS); + const probeProcessRunning = () => + probeBeforeDeadline( + deadline, + async () => (await readAndroidPackageProcessState(device, packageName)) === 'running', + ); + while (!deadline.isExpired()) { + const processRunning = await probeProcessRunning(); + if (!processRunning) break; + if (!processRunning.value) { await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS); - if ((await readAndroidPackageProcessState(device, packageName)) !== 'running') return; + if (deadline.isExpired()) break; + const processStillRunning = await probeProcessRunning(); + if (!processStillRunning) break; + if (!processStillRunning.value) return true; } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } + return false; } async function readAndroidPackageProcessState( diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index cbbc493513..ed37e49762 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -10,9 +10,12 @@ 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 { killAndroidApp } from './app-lifecycle.ts'; +import { closeAndroidApp, killAndroidApp } from './app-lifecycle.ts'; +import { withAndroidAdbProvider } from './adb-executor.ts'; +import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; -vi.mock('./app-lifecycle.ts', () => ({ +vi.mock('./app-lifecycle.ts', async (importOriginal) => ({ + ...(await importOriginal()), killAndroidApp: vi.fn(async () => {}), })); @@ -28,6 +31,7 @@ const device: DeviceInfo = { }; afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); mockKillAndroidApp.mockClear(); }); @@ -50,6 +54,7 @@ function createLifecycle( input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>, ) => Promise; shutdownTarget?: () => Promise; + close?: Interactor['close']; }> = {}, ): LifecycleFixture { const calls: string[] = []; @@ -69,6 +74,7 @@ function createLifecycle( openDevice: async () => {}, close: async (app: string) => { calls.push(`close:${app}`); + await params.close?.(app); }, setSetting: async () => {}, snapshot: async (options: SnapshotOptions) => { @@ -143,6 +149,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 266a32b6f5..47f95e63e8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -91,6 +91,7 @@ Open apps and links: - `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): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails. +- 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: