diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index 0dbfdc4fdf..8db2fb2807 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -195,6 +195,11 @@ export type CloseApplicationInput = Readonly<{ surface: SessionSurface; /** A selector-only close establishes readiness inside its admitted lifecycle binding. */ ensureReady?: boolean; + /** + * `kill` asks for system-initiated process death of the positional target (Maestro `killApp`); + * absent means an ordinary close. A backend without such a primitive closes normally. + */ + mode?: 'kill'; execution: ApplicationLifecycleExecution; }>; diff --git a/packages/contracts/src/replay.ts b/packages/contracts/src/replay.ts index 64da7630c7..db5c97368b 100644 --- a/packages/contracts/src/replay.ts +++ b/packages/contracts/src/replay.ts @@ -277,6 +277,8 @@ export type ReplayDispatchOptions = Readonly<{ openLifecycle?: ReplayOpenLifecycle; /** Terminate the targeted app without ending the owning daemon session. */ closeAppOnly?: boolean; + /** With `closeAppOnly`: system-initiated process death (Maestro `killApp`) instead of a close. */ + killApp?: boolean; /** * Daemon-composed hierarchy capture used as operational evidence only. It must not issue or * replace client ref authority. diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 5adc2475d9..3f371f5f31 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { SessionStore } from '../../../../core/session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../../../core/daemon-request.ts'; import type { SessionState } from '../../../../core/session-state.ts'; +import type { CloseApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime'; import { activateCompleteRefFrame, readSessionRuntimeRevision, @@ -53,6 +54,7 @@ async function close(params: { sessionStore: SessionStore; positionals?: string[]; internal?: DaemonRequest['internal']; + bindDevice?: Parameters[0]['bindDevice']; }) { return await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, @@ -62,6 +64,7 @@ async function close(params: { logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), sessionStore: params.sessionStore, invoke: noopInvoke, + bindDevice: params.bindDevice, }); } @@ -172,6 +175,54 @@ test('close expires the ref frame immediately before its admitted platform mutat expect(mockDispatch).toHaveBeenCalledOnce(); }); +test('an app-only kill close carries mode kill while a stop close carries none', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-app-only-kill-carries-mode'; + const device = { + platform: 'android' as const, + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }; + const session = makeSession(sessionName, device); + session.appBundleId = 'com.example.app'; + sessionStore.publish(sessionName, session); + const seenModes: Array = []; + const bindDevice: Parameters[0]['bindDevice'] = async (boundDevice, use) => { + const binding = await mockBindDeviceRuntime(boundDevice, use); + return { + ...binding, + operations: { + ...binding.operations, + closeApplication: async (input: CloseApplicationInput) => { + seenModes.push(input.mode); + }, + }, + }; + }; + + const killed = await close({ + sessionName, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true, killApp: true }, + bindDevice, + }); + const stopped = await close({ + sessionName, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + bindDevice, + }); + + expect(killed?.ok).toBe(true); + expect(stopped?.ok).toBe(true); + expect(seenModes).toEqual(['kill', undefined]); +}); + test('app-only close expires the rebuilt record after admission, preserving the captured snapshot', async () => { const sessionStore = makeSessionStore(); const address = 'cwd:close:default'; diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts new file mode 100644 index 0000000000..b440292745 --- /dev/null +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts @@ -0,0 +1,87 @@ +import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; +import { test, expect, vi } from 'vitest'; + +import path from 'node:path'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { CloseApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { DaemonRequest, DaemonResponse } from '../../../../core/daemon-request.ts'; +import { SessionStore } from '../../../../core/session-store.ts'; +import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir'; + +const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); + +vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { + const actual = + await importOriginal(); + const { selectionFromResolveTargetDevice } = + await import('../../../../__tests__/support/device-selection-stub.ts'); + return { + ...actual, + resolveTargetDevice: mockResolveTargetDevice, + resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)), + }; +}); + +import { + handleSessionCommands, + mockBindDeviceRuntime, +} from '../../../../__tests__/support/handlers/session-command-harness.ts'; + +const noopInvoke = async (_req: DaemonRequest): Promise => ({ ok: true, data: {} }); + +const ANDROID_DEVICE: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('an app-only kill close with no stored session carries mode kill to the platform close', async () => { + const sessionStore = new SessionStore( + path.join(mkdtempForTestSync('agent-device-session-close-without-session-'), 'sessions'), + ); + mockResolveTargetDevice.mockResolvedValue(ANDROID_DEVICE); + const seenInputs: CloseApplicationInput[] = []; + + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'no-such-session', + command: 'close', + positionals: ['com.example.app'], + flags: { serial: 'emulator-5554' }, + internal: { closeAppOnly: true, killApp: true }, + }, + sessionName: 'no-such-session', + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + bindDevice: async (device, use) => { + const binding = await mockBindDeviceRuntime(device, use); + return { + ...binding, + operations: { + ...binding.operations, + closeApplication: async (input: CloseApplicationInput) => { + seenInputs.push(input); + }, + }, + }; + }, + }); + + expect(response?.ok).toBe(true); + expect(seenInputs).toEqual([ + expect.objectContaining({ + mode: 'kill', + positionals: ['com.example.app'], + surface: 'app', + ensureReady: true, + }), + ]); +}); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts index bf97d646b7..ca7f0ed6db 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts @@ -156,6 +156,7 @@ async function dispatchTargetedPlatformClose(params: { outPath: req.flags?.out, appBundleId: session.appBundleId, surface: session.surface ?? 'app', + ...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}), execution: applicationLifecycleExecutionFromRequest(req, logPath, session.trace?.outPath), }); return undefined; @@ -449,6 +450,7 @@ async function closeWithoutSession(params: { outPath: req.flags?.out, surface: 'app', ensureReady: true, + ...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}), execution: applicationLifecycleExecutionFromRequest(req, logPath), }); return { diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts index e396f7efd9..2fad6af325 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-port.test.ts @@ -179,6 +179,47 @@ test('projects standalone clearState to settings without opening the app', async ]); }); +test('projects standalone killApp to an app-only close carrying the kill mode', async () => { + const requests: MaestroDaemonOperationRequest[] = []; + const invoke: MaestroDaemonOperationInvoke = async (request) => { + requests.push(request); + return { ok: true, data: {} }; + }; + const port = createDaemonMaestroRuntimePort({ + ...makeRuntimeEnvelope({ flags: { platform: 'android', replayBackend: 'maestro' } }), + invoke, + dependencies: makeDependencies(), + platform: 'android', + }); + + await port.execute({ + command: { kind: 'killApp', source: { line: 2 }, appId: 'com.example.app' }, + generation: 0, + env: {}, + invalidateObservation() {}, + }); + await port.execute({ + command: { kind: 'killApp', source: { line: 3 } }, + generation: 1, + env: {}, + appId: 'com.example.session', + invalidateObservation() {}, + }); + + expect(requests).toEqual([ + expect.objectContaining({ + command: 'close', + positionals: ['com.example.app'], + dispatch: { closeAppOnly: true, killApp: true }, + }), + expect.objectContaining({ + command: 'close', + positionals: ['com.example.session'], + dispatch: { closeAppOnly: true, killApp: true }, + }), + ]); +}); + test('uses the direct viewport without snapshot and pairs it with the nested gesture request', async () => { const requests: MaestroDaemonOperationRequest[] = []; const viewport = { x: 10, y: 20, width: 400, height: 800 }; diff --git a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts index ad9409bf02..1f45660a6a 100644 --- a/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts +++ b/packages/maestro/src/daemon-port/__tests__/daemon-runtime-public-operation.test.ts @@ -49,6 +49,22 @@ describe('Maestro public operation projection', () => { operation: { kind: 'stopApp' }, expected: { command: 'close', positionals: [], dispatch: { closeAppOnly: true } }, }, + { + operation: { kind: 'killApp', appId: 'com.example' }, + expected: { + command: 'close', + positionals: ['com.example'], + dispatch: { closeAppOnly: true, killApp: true }, + }, + }, + { + operation: { kind: 'killApp' }, + expected: { + command: 'close', + positionals: [], + dispatch: { closeAppOnly: true, killApp: true }, + }, + }, { operation: { kind: 'clearState', appId: 'com.example' }, expected: { command: 'settings', positionals: ['clear-app-state', 'com.example'] }, diff --git a/packages/maestro/src/daemon-port/daemon-runtime-port.ts b/packages/maestro/src/daemon-port/daemon-runtime-port.ts index d2f8917c6a..65788203ff 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-port.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-port.ts @@ -189,6 +189,10 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper const appId = input.appId ?? context.appId; await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context); }, + killApp: async (input, context) => { + const appId = input.appId ?? context.appId; + await invokeMutation({ kind: 'killApp', ...(appId ? { appId } : {}) }, context); + }, setPermissions: async (input, context) => { await applyPermissionMutations( input.appId ?? context.appId, diff --git a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts index 9016723661..d30915bf52 100644 --- a/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts +++ b/packages/maestro/src/daemon-port/daemon-runtime-public-operation.ts @@ -21,6 +21,7 @@ export type MaestroPublicOperation = launchArgs: string[]; } | { kind: 'stopApp'; appId?: string } + | { kind: 'killApp'; appId?: string } | { kind: 'clearState'; appId?: string } | { kind: 'settingsPermission'; @@ -53,6 +54,8 @@ export type MaestroPublicOperation = export type MaestroDaemonDispatchOptions = Readonly<{ /** Terminate the targeted app without ending the owning daemon session. */ closeAppOnly?: true; + /** With `closeAppOnly`: Maestro `killApp` process death (`am kill` on Android) instead of `stop`. */ + killApp?: true; /** A hierarchy capture used as operational evidence only; it issues no client ref authority. */ observationOnly?: true; /** Provider-owned viewport already resolved for a nested gesture command. */ @@ -88,12 +91,15 @@ export function projectMaestroPublicOperation( type MaestroAppOperation = Extract< MaestroPublicOperation, - { kind: 'launchApp' | 'stopApp' | 'openLink' } + { kind: 'launchApp' | 'stopApp' | 'killApp' | 'openLink' } >; function isAppOperation(operation: MaestroPublicOperation): operation is MaestroAppOperation { return ( - operation.kind === 'launchApp' || operation.kind === 'stopApp' || operation.kind === 'openLink' + operation.kind === 'launchApp' || + operation.kind === 'stopApp' || + operation.kind === 'killApp' || + operation.kind === 'openLink' ); } @@ -103,6 +109,8 @@ function projectAppOperation(operation: MaestroAppOperation): MaestroDaemonOpera return projectLaunchApp(operation); case 'stopApp': return projectStopApp(operation); + case 'killApp': + return projectKillApp(operation); case 'openLink': return projectOpenLink(operation); } @@ -132,6 +140,16 @@ function projectStopApp( }; } +function projectKillApp( + operation: Extract, +): MaestroDaemonOperationRequest { + return { + command: 'close', + positionals: operation.appId ? [operation.appId] : [], + dispatch: { closeAppOnly: true, killApp: true }, + }; +} + function projectClearState( operation: Extract, ): MaestroDaemonOperationRequest { diff --git a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts index a802935c71..640620b61d 100644 --- a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts +++ b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts @@ -671,6 +671,27 @@ describe('parseMaestroProgram', () => { }); }); + test('parses standalone killApp with an explicit or config app id', () => { + const program = parseMaestroProgram( + `appId: example.app +--- +- killApp: example.app +- killApp +`, + { sourcePath: '/flows/kill.yaml' }, + ); + + assert.deepEqual(program.commands[0], { + kind: 'killApp', + source: { path: '/flows/kill.yaml', line: 3 }, + appId: 'example.app', + }); + assert.deepEqual(program.commands[1], { + kind: 'killApp', + source: { path: '/flows/kill.yaml', line: 4 }, + }); + }); + test('preserves source paths for unsupported and malformed flows', () => { const sourcePath = '/flows/includes/child.yaml'; assert.throws( diff --git a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts index 8bc902a47a..7cec3054c4 100644 --- a/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts +++ b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts @@ -79,6 +79,7 @@ export function makeOperations( resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }), launchApp: noOp, stopApp: noOp, + killApp: noOp, setPermissions: noOp, clearState: noOp, openLink: noOp, diff --git a/packages/maestro/src/internal/__tests__/runtime-port.test.ts b/packages/maestro/src/internal/__tests__/runtime-port.test.ts index 582978de93..de7d4a0004 100644 --- a/packages/maestro/src/internal/__tests__/runtime-port.test.ts +++ b/packages/maestro/src/internal/__tests__/runtime-port.test.ts @@ -178,6 +178,25 @@ describe('MaestroRuntimePort', () => { expect(calls[1]).toMatchObject({ input: { appId: 'com.example.checkout' } }); }); + test('dispatches standalone killApp with an explicit or config app id', async () => { + const calls: RecordedCall[] = []; + const operations = makeOperations({ + killApp: vi.fn(async (input, context) => record(calls, 'killApp', input, context)), + }); + const program = parseMaestroProgram( + ['appId: com.example.checkout', '---', '- killApp: com.example.other', '- killApp'].join( + '\n', + ), + ); + + const result = await executeMaestroProgram(program, createMaestroRuntimePort(operations)); + + expect(result).toMatchObject({ executed: 2, skipped: 0 }); + expect(calls.map(({ kind }) => kind)).toEqual(['killApp', 'killApp']); + expect(calls[0]).toMatchObject({ input: { appId: 'com.example.other' } }); + expect(calls[1]).toMatchObject({ input: { appId: 'com.example.checkout' } }); + }); + test('preserves observation validity after visual waits and scripts', async () => { const waitInvalidation = vi.fn(); const scriptInvalidation = vi.fn(); diff --git a/packages/maestro/src/internal/program-ir-command-parser.ts b/packages/maestro/src/internal/program-ir-command-parser.ts index f1d797c950..a2b6a3dc4d 100644 --- a/packages/maestro/src/internal/program-ir-command-parser.ts +++ b/packages/maestro/src/internal/program-ir-command-parser.ts @@ -10,6 +10,7 @@ import type { MaestroExtendedWaitUntilCommand, MaestroHideKeyboardCommand, MaestroInputTextCommand, + MaestroKillAppCommand, MaestroLaunchAppCommand, MaestroLaunchArguments, MaestroOpenLinkCommand, @@ -127,6 +128,7 @@ const COMMAND_VALUE_PARSERS: Readonly> = { back: parseBack, waitForAnimationToEnd: parseWaitForAnimationToEnd, stopApp: parseStopApp, + killApp: parseKillApp, setPermissions: parseSetPermissions, clearState: parseClearState, runScript: parseMaestroRunScriptCommand, @@ -459,6 +461,16 @@ function parseStopApp( return { kind: 'stopApp', source, appId: readRequiredString(value, 'stopApp', context) }; } +function parseKillApp( + value: Node | null, + commandNode: Node, + context: MaestroProgramParseContext, +): MaestroKillAppCommand { + const source = sourceAt(commandNode, context); + if (isNullNode(value)) return { kind: 'killApp', source }; + return { kind: 'killApp', source, appId: readRequiredString(value, 'killApp', context) }; +} + function parseSetPermissions( value: Node | null, commandNode: Node, diff --git a/packages/maestro/src/internal/program-ir.ts b/packages/maestro/src/internal/program-ir.ts index 783008e47a..51e27c3c77 100644 --- a/packages/maestro/src/internal/program-ir.ts +++ b/packages/maestro/src/internal/program-ir.ts @@ -208,6 +208,12 @@ export type MaestroStopAppCommand = { appId?: string; }; +export type MaestroKillAppCommand = { + kind: 'killApp'; + source: MaestroSourceLocation; + appId?: string; +}; + export type MaestroSetPermissionsCommand = MaestroOptionalCommand & { kind: 'setPermissions'; source: MaestroSourceLocation; @@ -287,6 +293,7 @@ export type MaestroCommand = | MaestroBackCommand | MaestroWaitForAnimationToEndCommand | MaestroStopAppCommand + | MaestroKillAppCommand | MaestroSetPermissionsCommand | MaestroClearStateCommand | MaestroRunScriptCommand diff --git a/packages/maestro/src/internal/runtime-port-commands.ts b/packages/maestro/src/internal/runtime-port-commands.ts index cb4fafb422..e398600ee1 100644 --- a/packages/maestro/src/internal/runtime-port-commands.ts +++ b/packages/maestro/src/internal/runtime-port-commands.ts @@ -30,7 +30,7 @@ type MaestroCommandOf = Extract< >; type MaestroLifecycleCommand = MaestroCommandOf< - 'launchApp' | 'stopApp' | 'setPermissions' | 'clearState' | 'openLink' + 'launchApp' | 'stopApp' | 'killApp' | 'setPermissions' | 'clearState' | 'openLink' >; type MaestroTargetCommand = MaestroCommandOf<'tapOn' | 'doubleTapOn' | 'longPressOn'>; type MaestroTextCommand = MaestroCommandOf<'inputText' | 'eraseText'>; @@ -55,6 +55,7 @@ type MaestroRuntimeCommandHandlers = { const MAESTRO_RUNTIME_COMMAND_HANDLERS = { launchApp: executeLifecycleCommand, stopApp: executeLifecycleCommand, + killApp: executeLifecycleCommand, setPermissions: executeLifecycleCommand, clearState: executeLifecycleCommand, openLink: executeLifecycleCommand, @@ -82,6 +83,7 @@ const MAESTRO_RUNTIME_COMMAND_HANDLERS = { const MAESTRO_COMMAND_REQUIRES_SETTLED_PREDECESSOR = { launchApp: true, stopApp: true, + killApp: true, setPermissions: true, clearState: true, openLink: true, @@ -150,6 +152,13 @@ async function executeLifecycleCommand( context, 'invalidate', ); + case 'killApp': + return await invokeOperation( + operations.killApp, + { appId: command.appId ?? request.appId }, + context, + 'invalidate', + ); case 'setPermissions': return await invokeOperation( operations.setPermissions, diff --git a/packages/maestro/src/internal/runtime-port-types.ts b/packages/maestro/src/internal/runtime-port-types.ts index 1cbc56dea0..c9477663e3 100644 --- a/packages/maestro/src/internal/runtime-port-types.ts +++ b/packages/maestro/src/internal/runtime-port-types.ts @@ -132,6 +132,7 @@ export type MaestroRuntimeOperations = { readonly launchArguments?: MaestroLaunchArguments; }>; readonly stopApp: MaestroRuntimeOperation<{ readonly appId?: string }>; + readonly killApp: MaestroRuntimeOperation<{ readonly appId?: string }>; readonly setPermissions: MaestroRuntimeOperation<{ readonly appId?: string; readonly permissions: Readonly>; diff --git a/packages/maestro/src/internal/support-matrix.ts b/packages/maestro/src/internal/support-matrix.ts index df98349fad..dd5c234476 100644 --- a/packages/maestro/src/internal/support-matrix.ts +++ b/packages/maestro/src/internal/support-matrix.ts @@ -1,7 +1,7 @@ export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [ 'Flows: launchApp (with clearState, permissions, and Apple-only launch arguments; permissions apply after state clearing but before launch, and a launchApp without permissions touches nothing — there is no silent all: allow default); setPermissions (mid-flow permission grants, denials, and resets; all resolves in the backend — one simctl call on iOS, the declared permissions on Android — with specific entries overriding after it); runFlow file/inline; runFlow.when and repeat.while conditions (platform, visible, notVisible, and true, all re-evaluated before every repeat iteration); onFlowStart/onFlowComplete; repeat with times, while, or both; and retry.', 'Interactions: tapOn, doubleTapOn, longPressOn, inputText on the focused element, eraseText, openLink, hideKeyboard, basic pressKey, and back; selector targets poll until available and support recursive index, childOf, above, below, leftOf, rightOf, containsChild, containsDescendants, points, and optional; outer command labels are metadata, not target selectors.', - 'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, clearState, and stopApp.', + 'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, clearState, stopApp, and killApp (system-initiated process death: am kill on ADB-backed Android devices, which refuse a foreground target with android-kill-requires-background-app — background the app first, for example pressKey: Home — and fail a target whose process survives with android-kill-process-survived; iOS, Limrun, and WebDriver-provider backends run it as stopApp).', 'Scripts: ordered runScript file/env scripts with http.post, json, and output variables; evalScript inline expressions run flow-scoped JavaScript and write output.* leaves for later steps.', ] as const; diff --git a/packages/maestro/test/conformance/conformance-normalize.ts b/packages/maestro/test/conformance/conformance-normalize.ts index 8309f73038..94ac210a1f 100644 --- a/packages/maestro/test/conformance/conformance-normalize.ts +++ b/packages/maestro/test/conformance/conformance-normalize.ts @@ -95,6 +95,7 @@ export type CanonicalCommand = | { kind: 'takeScreenshot' } | { kind: 'waitForAnimationToEnd'; timeout?: number | string } | { kind: 'stopApp' } + | { kind: 'killApp' } | { kind: 'setPermissions'; appId?: string; permissions?: Record } | { kind: 'clearState'; appId?: string } | { @@ -132,6 +133,8 @@ function canonicalizeUpstreamLifecycleCommand( }); case 'StopAppCommand': return { kind: 'stopApp' }; + case 'KillAppCommand': + return { kind: 'killApp' }; case 'ClearStateCommand': return dropUndefined({ kind: 'clearState' as const, appId: str(f.appId) }); default: @@ -400,7 +403,7 @@ type AgentLifecycleCommand = Extract< { kind: (typeof AGENT_LIFECYCLE_KINDS)[number] } >; -const AGENT_LIFECYCLE_KINDS = ['launchApp', 'stopApp', 'clearState'] as const; +const AGENT_LIFECYCLE_KINDS = ['launchApp', 'stopApp', 'killApp', 'clearState'] as const; function isAgentLifecycleCommand(command: MaestroCommand): command is AgentLifecycleCommand { return (AGENT_LIFECYCLE_KINDS as readonly string[]).includes(command.kind); @@ -421,6 +424,8 @@ function canonicalizeAgentLifecycleCommand( }); case 'stopApp': return { kind: 'stopApp' }; + case 'killApp': + return { kind: 'killApp' }; case 'clearState': return dropUndefined({ kind: 'clearState', appId: command.appId ?? config.appId }); } diff --git a/packages/maestro/test/conformance/expected-divergence.ts b/packages/maestro/test/conformance/expected-divergence.ts index 52f96e4fc1..00ed530f28 100644 --- a/packages/maestro/test/conformance/expected-divergence.ts +++ b/packages/maestro/test/conformance/expected-divergence.ts @@ -48,11 +48,6 @@ export const FLOW_DIVERGENCES: Record = { reason: 'travel is outside the supported subset.', unsupported: ['travel'], }, - 'upstream/116_kill_app': { - classification: 'we-reject', - reason: 'Standalone killApp is outside the supported subset.', - unsupported: ['killApp'], - }, // --- Deliberately stricter than upstream --- 'invalid/duplicate-keys': { classification: 'we-reject', @@ -138,6 +133,12 @@ export type DocumentedDeviation = { }; export const DOCUMENTED_DEVIATIONS: DocumentedDeviation[] = [ + { + id: 'kill-app-preconditions', + area: 'killApp', + description: + 'Upstream killApp runs am kill unconditionally. On ADB-backed Android agent-device refuses a resumed target (android-kill-requires-background-app) and fails when the process survives (android-kill-process-survived), so a kill that would silently do nothing upstream fails loudly here.', + }, { id: 'animation-wait-mechanism', area: 'waitForAnimationToEnd', diff --git a/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts new file mode 100644 index 0000000000..93d684ab53 --- /dev/null +++ b/packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, test } from 'vitest'; +import assert from 'node:assert/strict'; +import { killAndroidApp } from '../app-lifecycle.ts'; +import { withAndroidAdbProvider } from '../adb-executor.ts'; +import { resetAndroidWindowDumpFocusMemoForTests } from '../window-state.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { assertRejectsAppError } from './test-utils/app-error.ts'; +import './test-utils/android-host-test-setup.ts'; + +// The kill precondition reads the AMS resumed activity, never WMS focus: input-focus transfer +// can lag activity resume after `launchApp`, a system window can own the focus while the target +// stays resumed (#592), and a transient window (IME, dialog) can own it for one sample. These +// tests pin that authority against AMS fixtures paired with a disagreeing WMS focus dump. + +const RESUMED_TARGET_DUMP = + ' mResumedActivity: ActivityRecord{99 u0 com.example.app/.MainActivity t7}\n'; +const RESUMED_LAUNCHER_DUMP = + ' mResumedActivity: ActivityRecord{98 u0 com.android.launcher/.Launcher t1}\n'; +const STALE_LAUNCHER_FOCUS_DUMP = 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'; +const TARGET_FOCUS_DUMP = 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n'; + +const dumpsysActivityActivities = ['shell', 'dumpsys', 'activity', 'activities'].join(' '); +const dumpsysWindowWindows = ['shell', 'dumpsys', 'window', 'windows'].join(' '); +const pidofTarget = ['shell', 'pidof', 'com.example.app'].join(' '); + +const DEVICE: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +const noReverse = { + ensure: async () => {}, + remove: async () => {}, + removeAllOwned: async () => {}, +}; + +beforeEach(() => { + resetAndroidWindowDumpFocusMemoForTests(); +}); + +test('killAndroidApp dispatches am kill rather than am force-stop', async () => { + const calls: (readonly string[])[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const command = args.join(' '); + if (command === dumpsysActivityActivities) { + return { stdout: RESUMED_LAUNCHER_DUMP, stderr: '', exitCode: 0 }; + } + if (command === dumpsysWindowWindows) { + return { stdout: STALE_LAUNCHER_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => await killAndroidApp(DEVICE, 'com.example.app'), + ); + + assert.deepEqual(calls, [ + ['shell', 'dumpsys', 'activity', 'activities'], + ['shell', 'am', 'kill', 'com.example.app'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ]); +}); + +test('killAndroidApp refuses when the resumed activity names the target behind stale launcher focus', async () => { + const calls: (readonly string[])[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const command = args.join(' '); + if (command === dumpsysWindowWindows) { + return { stdout: STALE_LAUNCHER_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + if (command === dumpsysActivityActivities) { + return { stdout: RESUMED_TARGET_DUMP, stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => { + await assertRejectsAppError(() => killAndroidApp(DEVICE, 'com.example.app'), { + code: 'COMMAND_FAILED', + hint: /Background the app before killApp/, + reason: 'android-kill-requires-background-app', + }); + }, + ); + + // The precondition never consults WMS focus: one AMS read decides, and no kill is dispatched. + assert.deepEqual(calls, [['shell', 'dumpsys', 'activity', 'activities']]); +}); + +test('killAndroidApp proceeds when focus still names the target but the resumed activity moved on', async () => { + const calls: (readonly string[])[] = []; + let windowReads = 0; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const command = args.join(' '); + if (command === dumpsysActivityActivities) { + return { stdout: RESUMED_LAUNCHER_DUMP, stderr: '', exitCode: 0 }; + } + if (command === dumpsysWindowWindows) { + windowReads += 1; + // WMS focus still names the target (lag after `pressKey: Home`, or a transient + // window); the first window read happens after the kill, in the stop wait. + return { + stdout: windowReads === 1 ? TARGET_FOCUS_DUMP : STALE_LAUNCHER_FOCUS_DUMP, + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => await killAndroidApp(DEVICE, 'com.example.app'), + ); + + // The AMS read comes first and the kill is dispatched even though WMS named the target. + // The full sequence pins that no WMS read happens before the kill: the two window reads are + // the post-kill stop wait (TARGET focus once, then STALE). + assert.deepEqual(calls, [ + ['shell', 'dumpsys', 'activity', 'activities'], + ['shell', 'am', 'kill', 'com.example.app'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'dumpsys', 'window', 'windows'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ['shell', 'pidof', 'com.example.app'], + ]); +}); + +test('killAndroidApp fails when the process survives the kill', async () => { + 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: STALE_LAUNCHER_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + if (command === pidofTarget) { + return { stdout: '12345\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => { + await assertRejectsAppError(() => killAndroidApp(DEVICE, 'com.example.app'), { + code: 'COMMAND_FAILED', + hint: /foreground service|force-stop/, + reason: 'android-kill-process-survived', + }); + }, + ); +}); + +test('killAndroidApp fails when the liveness probe itself cannot answer', async () => { + 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: STALE_LAUNCHER_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + if (command === pidofTarget) { + return { stdout: '', stderr: 'error: device offline\n', exitCode: 1 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => { + await assertRejectsAppError(() => killAndroidApp(DEVICE, 'com.example.app'), { + code: 'COMMAND_FAILED', + hint: /pidof did not answer/, + reason: 'android-process-probe-unavailable', + }); + }, + ); +}); + +test('killAndroidApp fails closed when the resumed-activity probe cannot answer', async () => { + const calls: (readonly string[])[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => { + await assertRejectsAppError(() => killAndroidApp(DEVICE, 'com.example.app'), { + code: 'COMMAND_FAILED', + hint: /dumpsys did not answer/, + reason: 'android-process-probe-unavailable', + }); + }, + ); + + // Both AMS variants are asked before failing closed, and no kill is dispatched. + assert.deepEqual(calls, [ + ['shell', 'dumpsys', 'activity', 'activities'], + ['shell', 'dumpsys', 'activity'], + ]); +}); + +test('killAndroidApp proceeds when an activity dump answers with nothing resumed', async () => { + const calls: (readonly string[])[] = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const command = args.join(' '); + if (command === dumpsysActivityActivities) { + return { + stdout: 'ACTIVITY MANAGER ACTIVITIES (dumpsys activity activities)\n', + stderr: '', + exitCode: 0, + }; + } + if (command === dumpsysWindowWindows) { + return { stdout: STALE_LAUNCHER_FOCUS_DUMP, stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + reverse: noReverse, + }, + { serial: 'emulator-5554' }, + async () => await killAndroidApp(DEVICE, 'com.example.app'), + ); + + assert.ok(calls.some((args) => args.join(' ') === 'shell am kill com.example.app')); +}); diff --git a/packages/platform-android/src/__tests__/app-parsers.test.ts b/packages/platform-android/src/__tests__/app-parsers.test.ts index fe5c1d7277..c643203cc2 100644 --- a/packages/platform-android/src/__tests__/app-parsers.test.ts +++ b/packages/platform-android/src/__tests__/app-parsers.test.ts @@ -1,6 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseAndroidLaunchablePackages, readAndroidBlockingDialogFocus } from '../app-parsers.ts'; +import { + parseAndroidLaunchablePackages, + parseAndroidResumedActivity, + readAndroidBlockingDialogFocus, +} from '../app-parsers.ts'; test('parseAndroidLaunchablePackages ignores cmd package query metadata lines', () => { assert.deepEqual( @@ -32,3 +36,35 @@ test('readAndroidBlockingDialogFocus preserves responding window text without re }, ); }); + +test('parseAndroidResumedActivity reads every resumed-marker shape', () => { + assert.deepEqual( + parseAndroidResumedActivity( + ' mResumedActivity: ActivityRecord{99 u0 com.example.app/.MainActivity t7}\n', + ), + { package: 'com.example.app', activity: '.MainActivity' }, + ); + assert.deepEqual( + parseAndroidResumedActivity( + ' ResumedActivity: ActivityRecord{99 u0 com.example.app/.MainActivity t7}\n', + ), + { package: 'com.example.app', activity: '.MainActivity' }, + ); + assert.deepEqual( + parseAndroidResumedActivity( + ' topResumedActivity=ActivityRecord{99 u0 com.example.app/.MainActivity t7}\n', + ), + { package: 'com.example.app', activity: '.MainActivity' }, + ); +}); + +test('parseAndroidResumedActivity ignores a stale last-resumed record', () => { + // `mLastResumedActivity:` contains `ResumedActivity:` as a substring. Matching it would let a + // stale record refuse a kill whose current resumed field is null or names another app. + assert.equal( + parseAndroidResumedActivity( + ' mLastResumedActivity: ActivityRecord{98 u0 com.example.app/.MainActivity t7}\n', + ), + null, + ); +}); diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 0986d9f7b0..fe6cfad1a4 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -23,7 +23,7 @@ import { parseAndroidLaunchablePackages, parseAndroidUserInstalledPackages, } from './app-parsers.ts'; -import { getAndroidAppState } from './window-state.ts'; +import { getAndroidAppState, getAndroidResumedActivity } from './window-state.ts'; const ANDROID_LAUNCHER_CATEGORY = 'android.intent.category.LAUNCHER'; const ANDROID_LEANBACK_CATEGORY = 'android.intent.category.LEANBACK_LAUNCHER'; @@ -541,17 +541,20 @@ export async function openAndroidDevice(device: DeviceInfo): Promise { } export async function closeAndroidApp(device: DeviceInfo, app: string): Promise { - const trimmed = app.trim(); - if (trimmed.toLowerCase() === 'settings') { - await runAndroidShell(device, ['am', 'force-stop', 'com.android.settings']); - await waitForAndroidPackageStopped(device, 'com.android.settings'); - return; - } + await stopAndroidPackage(device, await resolveAndroidPackageToEnd(device, app, 'Close')); +} + +async function resolveAndroidPackageToEnd( + device: DeviceInfo, + app: string, + verb: 'Close' | 'Kill', +): Promise { + if (app.trim().toLowerCase() === 'settings') return 'com.android.settings'; const resolved = await resolveAndroidApp(device, app); if (resolved.type === 'intent') { - throw new AppError('INVALID_ARGS', 'Close requires a package name, not an intent'); + throw new AppError('INVALID_ARGS', `${verb} requires a package name, not an intent`); } - await stopAndroidPackage(device, resolved.value); + return resolved.value; } async function stopAndroidPackage(device: DeviceInfo, packageName: string): Promise { @@ -559,6 +562,50 @@ async function stopAndroidPackage(device: DeviceInfo, packageName: string): Prom await waitForAndroidPackageStopped(device, packageName); } +/** + * System-initiated process death (`am kill`), which only reaps a background process, unlike + * `closeAndroidApp`'s `am force-stop`. A resumed target is refused, and a process that survives + * the kill fails rather than reading as success. + */ +export async function killAndroidApp(device: DeviceInfo, app: string): Promise { + await killAndroidPackage(device, await resolveAndroidPackageToEnd(device, app, 'Kill')); +} + +async function killAndroidPackage(device: DeviceInfo, packageName: string): Promise { + const resumed = await getAndroidResumedActivity(device); + if (resumed.status === 'resumed' && resumed.package === packageName) { + throw new AppError('COMMAND_FAILED', `Cannot kill foreground app ${packageName}`, { + reason: 'android-kill-requires-background-app', + hint: 'Background the app before killApp (for example pressKey: Home): am kill only reaps background processes.', + }); + } + if (resumed.status === 'unknown') { + throw new AppError( + 'COMMAND_FAILED', + `Could not read resumed activity state before killing ${packageName}`, + { + reason: 'android-process-probe-unavailable', + hint: 'adb dumpsys did not answer; retry once the device is reachable.', + }, + ); + } + await runAndroidShell(device, ['am', 'kill', packageName]); + await waitForAndroidPackageStopped(device, packageName); + const process = await readAndroidPackageProcessState(device, packageName); + if (process === 'running') { + throw new AppError('COMMAND_FAILED', `am kill did not stop ${packageName}`, { + reason: 'android-kill-process-survived', + hint: 'The process survived am kill (for example a foreground service keeps it alive): use stopApp (am force-stop) to terminate it.', + }); + } + if (process === 'unknown') { + throw new AppError('COMMAND_FAILED', `Could not read process state for ${packageName}`, { + reason: 'android-process-probe-unavailable', + hint: 'adb pidof did not answer; retry once the device is reachable.', + }); + } +} + async function waitForAndroidPackageStopped( device: DeviceInfo, packageName: string, @@ -590,20 +637,25 @@ async function waitForAndroidPackageProcessGone( ): Promise { const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS; while (Date.now() < deadline) { - if (!(await isAndroidPackageProcessRunning(device, packageName))) { + if ((await readAndroidPackageProcessState(device, packageName)) !== 'running') { await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS); - if (!(await isAndroidPackageProcessRunning(device, packageName))) return; + if ((await readAndroidPackageProcessState(device, packageName)) !== 'running') return; } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } } -async function isAndroidPackageProcessRunning( +async function readAndroidPackageProcessState( device: DeviceInfo, packageName: string, -): Promise { +): Promise<'running' | 'gone' | 'unknown'> { const result = await runAndroidShell(device, ['pidof', packageName], { allowFailure: true, }); - return (result.stdout ?? '').trim().length > 0; + if ((result.stdout ?? '').trim().length > 0) return 'running'; + // `pidof` exits 1 with empty streams when no process matches. + if ((result.exitCode === 0 || result.exitCode === 1) && (result.stderr ?? '').trim() === '') { + return 'gone'; + } + return 'unknown'; } diff --git a/packages/platform-android/src/app-parsers.ts b/packages/platform-android/src/app-parsers.ts index 6dcfdd09bc..ff1eead1e3 100644 --- a/packages/platform-android/src/app-parsers.ts +++ b/packages/platform-android/src/app-parsers.ts @@ -12,6 +12,13 @@ export type AndroidBlockingDialogFocus = { */ export const ANDROID_FOCUSED_WINDOW_MARKER = 'mCurrentFocus=Window{'; +/** Markers that name the ActivityManager resumed activity in a `dumpsys activity` dump. */ +export const ANDROID_RESUMED_ACTIVITY_MARKERS = [ + 'mResumedActivity:', + 'ResumedActivity:', + 'topResumedActivity=', +] as const; + /** The line prefixes a `dumpsys` dump uses to name the focused window or resumed activity. */ export const ANDROID_FOCUS_MARKERS = [ ANDROID_FOCUSED_WINDOW_MARKER, @@ -90,6 +97,32 @@ export function parseAndroidFocusSegment( return null; } +/** + * Reads the resumed activity out of an AMS dump (`dumpsys activity …`). Only a + * resumed-marker line counts: an activity dump lists the whole back stack, so any + * other `package/Activity` token is not evidence about what is resumed. + */ +export function parseAndroidResumedActivity( + text: string, +): { package: string; activity: string } | null { + for (const line of text.split('\n')) { + for (const marker of ANDROID_RESUMED_ACTIVITY_MARKERS) { + const markerIndex = line.indexOf(marker); + // Unanchored match would also hit stale fields such as `mLastResumedActivity:`: a + // marker only counts at the start of a field, i.e. at line start or after whitespace. + if (markerIndex === -1 || (markerIndex > 0 && /\S/.test(line[markerIndex - 1] ?? ''))) + continue; + const component = line + .slice(markerIndex + marker.length) + .match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/); + if (component?.[1] && component[2]) { + return { package: component[1], activity: component[2] }; + } + } + } + return null; +} + function parseAndroidBlockingDialogFromSegment( segment: string, raw: string, diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 6d19f02761..cbbc493513 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import type { CloseApplicationFinalizationInput, + CloseApplicationInput, LocalApplicationInteractorHost, OpenApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; @@ -9,6 +10,13 @@ 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'; + +vi.mock('./app-lifecycle.ts', () => ({ + killAndroidApp: vi.fn(async () => {}), +})); + +const mockKillAndroidApp = vi.mocked(killAndroidApp); const device: DeviceInfo = { platform: 'android', @@ -21,6 +29,7 @@ const device: DeviceInfo = { afterEach(() => { vi.restoreAllMocks(); + mockKillAndroidApp.mockClear(); }); type LifecycleFixture = Readonly<{ @@ -255,6 +264,16 @@ function finalizationInput( }; } +function closeInput(overrides: Partial = {}): CloseApplicationInput { + return { + positionals: ['com.example.app'], + appBundleId: 'com.example.app', + surface: 'app', + execution: {}, + ...overrides, + }; +} + test('an Android relaunch closes the requested package before opening it', async () => { const { calls, lifecycle } = createLifecycle({ openedAppBundleId: 'com.android.settings' }); @@ -376,3 +395,30 @@ test('an ordinary close tells the restore no emulator kill is coming', async () expect(sequence).toEqual(['restore:false']); expect(result).toEqual({}); }); + +test('kill mode kills the target without resolving an interactor', async () => { + const { calls, lifecycle } = createLifecycle(); + + await lifecycle.closeApplication(closeInput({ mode: 'kill' })); + + expect(calls).toEqual([]); + expect(mockKillAndroidApp).toHaveBeenCalledExactlyOnceWith(device, 'com.example.app'); +}); + +test('a close without a mode force-stops through the interactor', async () => { + const { calls, lifecycle } = createLifecycle(); + + await lifecycle.closeApplication(closeInput()); + + expect(calls).toEqual(['close:com.example.app']); + expect(mockKillAndroidApp).not.toHaveBeenCalled(); +}); + +test('kill mode without any target fails instead of reading as success', async () => { + const { lifecycle } = createLifecycle(); + + await expect( + lifecycle.closeApplication(closeInput({ mode: 'kill', positionals: [] })), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(mockKillAndroidApp).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-android/src/lifecycle.ts b/packages/platform-android/src/lifecycle.ts index f25fa78259..e3c8595784 100644 --- a/packages/platform-android/src/lifecycle.ts +++ b/packages/platform-android/src/lifecycle.ts @@ -70,6 +70,13 @@ export function bindAndroidApplicationLifecycle( if (input.ensureReady) { await ensureAndroidReady(host, device, { headless: false }, signal); } + if (input.mode === 'kill') { + const [target] = input.positionals; + if (!target) throw new AppError('INVALID_ARGS', 'Kill requires an app target'); + const { killAndroidApp } = await import('./app-lifecycle.ts'); + await killAndroidApp(device, target); + return; + } await invokeApplicationClose({ device, interactor: await binding.resolveInteractor(input.execution, input.appBundleId), diff --git a/packages/platform-android/src/window-state.ts b/packages/platform-android/src/window-state.ts index f41d290221..d59fec2b16 100644 --- a/packages/platform-android/src/window-state.ts +++ b/packages/platform-android/src/window-state.ts @@ -4,14 +4,17 @@ import { runAndroidShell } from './adb.ts'; import { ANDROID_FOCUSED_WINDOW_MARKER, ANDROID_FOCUS_MARKERS, + ANDROID_RESUMED_ACTIVITY_MARKERS, + parseAndroidResumedActivity, readAndroidBlockingDialogFocus, type AndroidBlockingDialogFocus, } from './app-parsers.ts'; /** - * What is on the Android screen right now: which app owns the foreground, and whether a blocking - * system dialog owns the focus. Both answers come out of the same `dumpsys` text, so the dump - * sequence is owned here once instead of being repeated per question. + * What is on the Android screen right now: which app owns the foreground, which activity AMS + * reports resumed, and whether a blocking system dialog owns the focus. Every answer comes out + * of the same `dumpsys` text, so the dump sequence is owned here once instead of being repeated + * per question. */ // What WMS says owns the focus. Ranked ahead of the activity dumps because that is what the user @@ -49,6 +52,14 @@ const ANDROID_WINDOW_QUESTIONS = { tiers: [ANDROID_WINDOW_FOCUS_DUMPS, ANDROID_RESUMED_ACTIVITY_DUMPS], section: markerSectionPattern(ANDROID_FOCUS_MARKERS), }, + // Which activity AMS reports resumed: the authority for `am kill`, which reaps on process + // state rather than input focus. WMS focus can lag activity resume after `launchApp`, name a + // system window holding focus instead of the resumed app (#592), or name a transient window + // (IME, dialog), so the kill precondition asks this rather than `foreground`. + resumedActivity: { + tiers: [ANDROID_RESUMED_ACTIVITY_DUMPS], + section: markerSectionPattern(ANDROID_RESUMED_ACTIVITY_MARKERS), + }, // Whether a blocking dialog owns the focus: only the focused-window line can carry that title, // and only the window dumps print it. blockingDialog: { @@ -135,6 +146,33 @@ export async function getAndroidAppState( return {}; } +/** + * What AMS reports resumed. `none` means an activity dump answered without a resumed record (a + * transition, or the keyguard up); `unknown` means no dump answered at all. + */ +export type AndroidResumedActivityObservation = + | { status: 'resumed'; package: string; activity: string } + | { status: 'none' } + | { status: 'unknown' }; + +/** + * Which app AMS reports resumed, without consulting WMS focus. Backed only by the activity + * dumps: the kill precondition's source of truth for whether `am kill` can reap the target. + */ +export async function getAndroidResumedActivity( + device: DeviceInfo, + read: AndroidWindowDumpReader = createAndroidWindowDumpReader(device), +): Promise { + let answered = false; + for (const args of orderAndroidWindowDumps(device, 'resumedActivity')) { + const text = await read(args); + const resumed = parseAndroidResumedActivity(text); + if (resumed) return { status: 'resumed', ...resumed }; + answered ||= text.trim().length > 0; + } + return { status: answered ? 'none' : 'unknown' }; +} + /** * A dump that shows the focused window has answered the blocking-dialog question, whether or not * the answer is a dialog. The remaining variants exist for devices whose earlier one says nothing diff --git a/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts b/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts index 0e45f0c04d..234c2770e1 100644 --- a/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts +++ b/packages/replay-port/src/daemon-port/__tests__/session-replay-maestro-request.test.ts @@ -102,6 +102,19 @@ test('folds Maestro settings app targeting into dispatch', () => { expect(request.dispatch).toEqual({ settingsAppBundleId: 'com.example.app' }); }); +test('folds Maestro killApp targeting into dispatch', () => { + const request = maestroOperationDispatchRequest( + { token: 'token', session: 'session', command: 'replay', positionals: [] }, + { + command: 'close', + positionals: ['com.example.app'], + dispatch: { closeAppOnly: true, killApp: true }, + }, + ); + + expect(request.dispatch).toEqual({ closeAppOnly: true, killApp: true }); +}); + test('leaves dispatch options the operation does not set untouched', () => { const viewport = { x: 0, y: 0, width: 402, height: 874 }; const request = maestroOperationDispatchRequest( diff --git a/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts b/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts index 9c4ffcd615..51854e9221 100644 --- a/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts +++ b/packages/replay-port/src/daemon-port/session-replay-maestro-request.ts @@ -39,6 +39,7 @@ function maestroDispatchOptions( ): Pick { return stripUndefined({ closeAppOnly: dispatch?.closeAppOnly, + killApp: dispatch?.killApp, observationOnly: dispatch?.observationOnly, gestureViewport: dispatch?.gestureViewport, gestureExecutionProfile: dispatch?.gestureExecutionProfile, diff --git a/scripts/fuzz/validation-arbitraries-maestro.ts b/scripts/fuzz/validation-arbitraries-maestro.ts index 1507debbcc..6b47745bb7 100644 --- a/scripts/fuzz/validation-arbitraries-maestro.ts +++ b/scripts/fuzz/validation-arbitraries-maestro.ts @@ -22,6 +22,8 @@ function validMaestroCommand(pick: number, salt: number): string[] { () => ['- back'], () => ['- hideKeyboard'], () => ['- stopApp'], + () => ['- killApp'], + () => [`- killApp: ${text}`], () => ['- clearState'], () => [`- clearState: ${text}`], () => ['- scroll'], diff --git a/website/docs/docs/run-maestro-flows.md b/website/docs/docs/run-maestro-flows.md index 7145d3a632..4b1edd96f2 100644 --- a/website/docs/docs/run-maestro-flows.md +++ b/website/docs/docs/run-maestro-flows.md @@ -27,7 +27,7 @@ Keep the target binding, such as `--platform ios`, on the `replay` or `test` com - Flows: `launchApp` (with `clearState`, `permissions`, and Apple-only launch arguments; `permissions` apply after state clearing but before launch, and a `launchApp` without `permissions` touches nothing — there is no silent `all: allow` default); `setPermissions` (mid-flow permission grants, denials, and resets; `all` resolves in the backend — one simctl call on iOS, the declared permissions on Android — with specific entries overriding after it); `runFlow` file/inline; `runFlow.when` and `repeat.while` conditions (`platform`, `visible`, `notVisible`, and `true`, all re-evaluated before every `repeat` iteration); `onFlowStart`/`onFlowComplete`; `repeat` with `times`, `while`, or both; and retry. - Interactions: `tapOn`, `doubleTapOn`, `longPressOn`, `inputText` on the focused element, `eraseText`, `openLink`, `hideKeyboard`, basic `pressKey`, and `back`; selector targets poll until available and support recursive `index`, `childOf`, `above`, `below`, `leftOf`, `rightOf`, `containsChild`, `containsDescendants`, points, and `optional`; outer command labels are metadata, not target selectors. -- Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, `clearState`, and `stopApp`. +- Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, `clearState`, `stopApp`, and `killApp` (system-initiated process death: `am kill` on ADB-backed Android devices, which refuse a foreground target with `android-kill-requires-background-app` — background the app first, for example `pressKey: Home` — and fail a target whose process survives with `android-kill-process-survived`; iOS, Limrun, and WebDriver-provider backends run it as `stopApp`). - Scripts: ordered `runScript` file/env scripts with `http.post`, `json`, and `output` variables; `evalScript` inline expressions run flow-scoped JavaScript and write `output.*` leaves for later steps. ## Boundaries