From cce3edd0c6430207202c58c53a0dd15c21ddff67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:07:32 +0200 Subject: [PATCH 1/3] refactor(runtime): let platform runtimes list apps and read app state directly The root host carried two adapters, appInventory and appState, that only forwarded a platform call back into that platform's own package. Each platform runtime now performs its own listApps and appState call through a lazy import inside its package, keeping the deferred load, the AbortSignal threading, and the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so Android, Apple and Harmony fixtures no longer stub the two platforms they do not own. Android is the one platform runtime whose package now reaches adb directly. The adb host that adb mechanics require is bound by a module side effect that only the root can perform, so the Android runtime-module registration binds it before the module loads. loadAndroidMechanics keeps its own binding import for the root host ports that reach mechanics without binding a runtime; neither binder subsumes the other. Android appstate now runs one foreground-focus loop instead of two. The host shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter already closes over its own adb executor, so it calls the executor variant directly, and that variant took the per-attempt abort check the host variant had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the deleted host port and go with it. Tests: the new ordering test in src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the binding import from that registration (order came back ["android-runtime", "adb-host"]); the composed-gateway listApps test in the same file was seen red by reverting the Android runtime's inlined listApps to a host.appInventory lookup (TypeError reading 'android'); the new abort test in packages/platform-android/src/app-state.test.ts was seen red by removing both signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second dumpsys was issued and the call resolved). All green after. --- .../contracts/src/app-inventory-runtime.ts | 24 ---- packages/contracts/src/app-state-runtime.ts | 29 ----- .../src/platform-runtime-operations.ts | 9 +- .../platform-android/src/app-state.test.ts | 17 ++- packages/platform-android/src/app-state.ts | 52 ++------- packages/platform-android/src/index.ts | 22 +--- packages/platform-android/src/mechanics.ts | 1 - .../src/network/runtime.test.ts | 14 --- .../platform-android/src/runtime.fixtures.ts | 13 --- packages/platform-android/src/runtime.test.ts | 43 +++---- packages/platform-android/src/runtime.ts | 32 ++++-- .../src/app-resolution-facade.ts | 1 - .../src/network/runtime.test.ts | 14 --- .../platform-apple/src/runtime.fixtures.ts | 9 -- packages/platform-apple/src/runtime.test.ts | 23 ++-- packages/platform-apple/src/runtime.ts | 14 ++- packages/platform-harmonyos/src/app-state.ts | 23 +--- packages/platform-harmonyos/src/index.ts | 3 - .../platform-harmonyos/src/runtime.test.ts | 37 +++--- packages/platform-harmonyos/src/runtime.ts | 19 ++-- packages/platform-web/src/runtime.test.ts | 9 -- .../src/platform-runtime.test.ts | 9 -- ...atform-runtime-android-adb-binding.test.ts | 106 ++++++++++++++++++ src/platform-runtime-app-inventory-host.ts | 42 ------- src/platform-runtime-app-state-host.test.ts | 98 ---------------- src/platform-runtime-app-state-host.ts | 37 ------ src/platform-runtime-operation-host.ts | 4 - src/platform-runtime.ts | 33 +++--- src/sdk/android-adb.ts | 3 +- src/sdk/limrun-runtime-dependencies.ts | 20 +--- 30 files changed, 255 insertions(+), 505 deletions(-) create mode 100644 src/platform-runtime-android-adb-binding.test.ts delete mode 100644 src/platform-runtime-app-inventory-host.ts delete mode 100644 src/platform-runtime-app-state-host.test.ts delete mode 100644 src/platform-runtime-app-state-host.ts diff --git a/packages/contracts/src/app-inventory-runtime.ts b/packages/contracts/src/app-inventory-runtime.ts index 1b4179a894..df8ba0e5ce 100644 --- a/packages/contracts/src/app-inventory-runtime.ts +++ b/packages/contracts/src/app-inventory-runtime.ts @@ -14,27 +14,3 @@ export type ListAppsInput = Readonly<{ export type AppInventoryRuntimeOperations = Readonly<{ listApps(input: ListAppsInput): Promise; }>; - -export type AppInventoryRuntimeHost = Readonly<{ - apple: Readonly<{ - listApps( - device: DeviceInfo, - filter: AppsFilter, - signal: AbortSignal, - ): Promise; - }>; - android: Readonly<{ - listApps( - device: DeviceInfo, - filter: AppsFilter, - signal: AbortSignal, - ): Promise; - }>; - harmonyos: Readonly<{ - listApps( - device: DeviceInfo, - filter: AppsFilter, - signal: AbortSignal, - ): Promise; - }>; -}>; diff --git a/packages/contracts/src/app-state-runtime.ts b/packages/contracts/src/app-state-runtime.ts index 41c998ae35..a19dac5d32 100644 --- a/packages/contracts/src/app-state-runtime.ts +++ b/packages/contracts/src/app-state-runtime.ts @@ -1,38 +1,9 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; - /** Neutral foreground identity returned by a selected platform/provider runtime. */ export type AppStateRuntimeResult = Readonly<{ package?: string; activity?: string; }>; -export type AppStateRuntimeCommand = Readonly<{ - args: readonly string[]; - allowFailure?: boolean; - timeoutMs?: number; -}>; - -export type AppStateRuntimeCommandResult = Readonly<{ - stdout: string; -}>; - export type AppStateRuntimeOperations = Readonly<{ appState(): Promise; }>; - -export type AppStateRuntimeHost = Readonly<{ - android: Readonly<{ - run( - device: DeviceInfo, - command: AppStateRuntimeCommand, - signal: AbortSignal, - ): Promise; - }>; - harmonyos: Readonly<{ - run( - device: DeviceInfo, - command: AppStateRuntimeCommand, - signal: AbortSignal, - ): Promise; - }>; -}>; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 55ca69d272..3b9cff9f81 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -1,14 +1,11 @@ import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts'; -import type { - AppInventoryRuntimeHost, - AppInventoryRuntimeOperations, -} from './app-inventory-runtime.ts'; +import type { AppInventoryRuntimeOperations } from './app-inventory-runtime.ts'; import type { AndroidAppDeploymentExecutor, AppDeploymentRuntimeOperations, AppleAppDeploymentExecutor, } from './app-deployment-runtime.ts'; -import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state-runtime.ts'; +import type { AppStateRuntimeOperations } from './app-state-runtime.ts'; import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts'; import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts'; import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts'; @@ -721,8 +718,6 @@ export const keyboardRuntimePlanUses = Object.freeze([ export type PlatformRuntimeHost = AppLogRuntimeHost & NetworkRuntimeHost & Readonly<{ - appInventory: AppInventoryRuntimeHost; - appState: AppStateRuntimeHost; /** Focused native ports; deployment semantics remain in the owning family packages. */ appleDeployment: AppleAppDeploymentExecutor; androidDeployment: AndroidAppDeploymentExecutor; diff --git a/packages/platform-android/src/app-state.test.ts b/packages/platform-android/src/app-state.test.ts index 8601abe6f9..737cb335e4 100644 --- a/packages/platform-android/src/app-state.test.ts +++ b/packages/platform-android/src/app-state.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { parseAndroidForegroundApp } from './app-state.ts'; +import { parseAndroidForegroundApp, readAndroidAppStateWithExecutor } from './app-state.ts'; test('parses Android window and activity foreground markers', () => { expect( @@ -28,3 +28,18 @@ test('scans repeated uncontrolled focus text without regular-expression backtrac parseAndroidForegroundApp(`ResumedActivity:${'ResumedActivity:a'.repeat(20_000)}`), ).toBeNull(); }); + +test('stops between dumpsys attempts once the request is aborted', async () => { + const controller = new AbortController(); + const issued: string[][] = []; + const run = async (args: string[]) => { + issued.push(args); + controller.abort(new Error('request canceled')); + return { exitCode: 0, stdout: 'mCurrentFocus=Window{1 u0 StatusBar}', stderr: '' }; + }; + + await expect(readAndroidAppStateWithExecutor(run, controller.signal)).rejects.toThrow( + 'request canceled', + ); + expect(issued).toEqual([['shell', 'dumpsys', 'window', 'windows']]); +}); diff --git a/packages/platform-android/src/app-state.ts b/packages/platform-android/src/app-state.ts index 741b719faf..a3ca401ad7 100644 --- a/packages/platform-android/src/app-state.ts +++ b/packages/platform-android/src/app-state.ts @@ -1,19 +1,6 @@ -import type { - AppStateRuntimeCommand, - AppStateRuntimeCommandResult, - AppStateRuntimeResult, -} from '@agent-device/contracts/app-state-runtime'; -import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import { parseAndroidFocusSegment } from './app-parsers.ts'; -export type AndroidAppStateHost = Readonly<{ - run( - device: DeviceInfo, - command: AppStateRuntimeCommand, - signal: AbortSignal, - ): Promise; -}>; - const FOCUS_COMMANDS = [ ['shell', 'dumpsys', 'window', 'windows'], ['shell', 'dumpsys', 'window'], @@ -29,11 +16,12 @@ export type AndroidCommandExecutor = ( export async function readAndroidAppStateWithExecutor( run: AndroidCommandExecutor, + signal?: AbortSignal, ): Promise { - const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS); + const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS, signal); if (windowFocus) return windowFocus; - const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS); + const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS, signal); if (activityFocus) return activityFocus; return {}; } @@ -41,48 +29,22 @@ export async function readAndroidAppStateWithExecutor( async function readAndroidFocusWithExecutor( run: AndroidCommandExecutor, commands: readonly (readonly string[])[], + signal?: AbortSignal, ): Promise { for (const args of commands) { + signal?.throwIfAborted(); const result = await run([...args], { allowFailure: true }); + signal?.throwIfAborted(); const parsed = parseAndroidForegroundApp(result.stdout ?? ''); if (parsed) return parsed; } return null; } -export async function readAndroidAppState( - host: AndroidAppStateHost, - device: DeviceInfo, - signal: AbortSignal, -): Promise { - const windowFocus = await readAndroidFocus(host, device, FOCUS_COMMANDS, signal); - if (windowFocus) return windowFocus; - - const activityFocus = await readAndroidFocus(host, device, ACTIVITY_COMMANDS, signal); - if (activityFocus) return activityFocus; - return {}; -} - export function parseAndroidForegroundApp(text: string): AppStateRuntimeResult | null { return parseAndroidFocusSegment(text, (segment) => parseAndroidComponentFromSegment(segment)); } -async function readAndroidFocus( - host: AndroidAppStateHost, - device: DeviceInfo, - commands: readonly (readonly string[])[], - signal: AbortSignal, -): Promise { - for (const args of commands) { - signal.throwIfAborted(); - const result = await host.run(device, { args, allowFailure: true }, signal); - signal.throwIfAborted(); - const parsed = parseAndroidForegroundApp(result.stdout); - if (parsed) return parsed; - } - return null; -} - function parseAndroidComponentFromSegment(segment: string): AppStateRuntimeResult | null { const match = segment.match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/); return match?.[1] && match[2] ? { package: match[1], activity: match[2] } : null; diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index 1ca8a52e19..c88027b66e 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -1,16 +1,11 @@ -import type { - AppStateRuntimeHost, - AppStateRuntimeResult, -} from '@agent-device/contracts/app-state-runtime'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { InventoryPlatformModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform-module'; import type { PlatformRuntimeModule } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/device-shutdown-runtime'; -import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidInventoryConfig } from './inventory-config.ts'; -import type { AndroidAppStateHost } from './app-state.ts'; import type { AndroidObservationAdapter, AndroidObservationHost, @@ -21,7 +16,6 @@ const metadata = Object.freeze({ } satisfies PlatformModuleMetadata); export type { AndroidInventoryConfig } from './inventory-config.ts'; -export type { AndroidAppStateHost } from './app-state.ts'; /** Package-owned Android observation policy, loaded only when a daemon request needs it. */ export function createAndroidObservationAdapter( @@ -46,20 +40,12 @@ export function createAndroidObservationAdapter( }); } -export async function readAndroidAppState( - host: AndroidAppStateHost | AppStateRuntimeHost['android'], - device: DeviceInfo, - signal: AbortSignal, -): Promise { - const { readAndroidAppState: read } = await import('./app-state.ts'); - return await read(host, device, signal); -} - export async function readAndroidAppStateWithExecutor( run: import('./app-state.ts').AndroidCommandExecutor, -): Promise { + signal?: AbortSignal, +): Promise { const { readAndroidAppStateWithExecutor: read } = await import('./app-state.ts'); - return await read(run); + return await read(run, signal); } export const runtimeModule = Object.freeze({ diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 7c68ee358b..6e1899dc3e 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -72,7 +72,6 @@ export async function listAndroidAppsWithAdb( export { closeAndroidApp, isAmStartError, - listAndroidApps, openAndroidApp, openAndroidDevice, parseAndroidLaunchComponent, diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index acf0ecb72f..f56623eb78 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -150,11 +150,6 @@ function host(options: { readProcessMarker: async () => options.marker, }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, }; } @@ -192,16 +187,7 @@ function unusedAppLogHost(): Omit< terminate: async () => 'already-missing', }, processTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, clock: { now: () => 1, sleep: async () => {} }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { applePhysical: { ensureConnected: async () => {} }, appleAutomation: { diff --git a/packages/platform-android/src/runtime.fixtures.ts b/packages/platform-android/src/runtime.fixtures.ts index 8c762f862f..b781270f40 100644 --- a/packages/platform-android/src/runtime.fixtures.ts +++ b/packages/platform-android/src/runtime.fixtures.ts @@ -39,17 +39,6 @@ const audioProbeHost: PlatformRuntimeHost['audioProbe'] = { ownedProcesses: { replace: () => {}, clear: () => {} }, }; -export const emptyAppInventory = { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, -}; - -const emptyAppState = { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, -}; - function localAndroidScreenRecording() { return { mode: 'local' as const, @@ -72,7 +61,6 @@ export function androidRuntimeHost(overrides: Record = {}): Pla return { androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: emptyAppInventory, localInteractors: { resolve: async () => ({}) }, audioProbe: audioProbeHost, screenRecording: { android: { resolve: async () => localAndroidScreenRecording() } }, @@ -89,7 +77,6 @@ export function androidNavigationHost( probeClipboardShellSupport, runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), }, - appState: emptyAppState, deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } }, }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index e283cc9293..61adafcdda 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -7,13 +7,13 @@ import type { } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidPlatformRuntime } from './runtime.ts'; +import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; import { ANDROID_EMULATOR, UNKNOWN_KIND_DEVICE, androidNavigationHost, androidRuntimeHost, bindOrdinary, - emptyAppInventory, } from './runtime.fixtures.ts'; const appStateUnavailable = { @@ -27,10 +27,20 @@ test.each([ ['device', { ...ANDROID_EMULATOR, kind: 'device' as const }], ['unknown', UNKNOWN_KIND_DEVICE], ])('classifies the Android %s runtime denominator', async (_name, runtimeDevice) => { - const listApps = vi.fn(async () => [{ id: 'com.example.app', name: 'Example' }]); - const appState = vi.fn(async () => ({ - stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}', - })); + const execSerialAdb = vi.fn(async (_serial: string, args: string[]) => { + if (args.includes('query-activities')) { + return { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 }; + } + if (args.includes('dumpsys')) { + return { + stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + bindAndroidAdbHostStub({ execSerialAdb }); const host = androidRuntimeHost({ commands: { which: async () => 'tool', @@ -38,11 +48,6 @@ test.each([ }, toolchains: { prepare: async () => {} }, clock: { now: () => 1, sleep: async () => {} }, - appInventory: { ...emptyAppInventory, android: { listApps } }, - appState: { - android: { run: appState }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { applePhysical: { ensureConnected: async () => {} }, appleAutomation: { @@ -95,7 +100,11 @@ test.each([ await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), ).resolves.toEqual([{ id: 'com.example.app', name: 'Example' }]); - expect(listApps).toHaveBeenCalledWith(runtimeDevice, 'all', expect.any(AbortSignal)); + expect(execSerialAdb).toHaveBeenCalledWith( + runtimeDevice.id, + expect.arrayContaining(['query-activities']), + expect.objectContaining({ allowFailure: true }), + ); await expect(binding.operations.bootTarget?.({})).resolves.toMatchObject({ id: runtimeDevice.id, @@ -105,10 +114,10 @@ test.each([ package: 'com.example.app', activity: '.MainActivity', }); - expect(appState).toHaveBeenCalledWith( - runtimeDevice, - { args: ['shell', 'dumpsys', 'window', 'windows'], allowFailure: true }, - expect.any(AbortSignal), + expect(execSerialAdb).toHaveBeenCalledWith( + runtimeDevice.id, + ['shell', 'dumpsys', 'window', 'windows'], + expect.objectContaining({ allowFailure: true }), ); if (runtimeDevice.kind === 'emulator') { @@ -124,10 +133,6 @@ test.each([ test('rejects the non-discovered Android simulator cell for appstate', async () => { const runtimeDevice = { ...ANDROID_EMULATOR, kind: 'simulator' as const }; const host = androidRuntimeHost({ - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } }, }); const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index b1d0cb2b9e..682677370a 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -53,7 +53,7 @@ import { createAndroidAppLogRuntime } from './logs/runtime.ts'; import { dumpAndroidNetworkTraffic } from './network/runtime.ts'; import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; -import { readAndroidAppState } from './app-state.ts'; +import { readAndroidAppStateWithExecutor } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import { @@ -419,12 +419,18 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...(facts.operations.appState.available ? { - appState: async () => - await readAndroidAppState( - host.appState.android, - request.device, + appState: async () => { + request.scope.signal.throwIfAborted(); + const { runAndroidAdb } = await import('./adb.ts'); + return await readAndroidAppStateWithExecutor( + async (args, options) => + await runAndroidAdb(request.device, args, { + ...options, + signal: request.scope.signal, + }), request.scope.signal, - ), + ); + }, } : {}), networkDump: async (input: NetworkDumpInput) => @@ -470,12 +476,14 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor ), } : {}), - listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => - await host.appInventory.android.listApps( - input.device, - input.filter, - request.scope.signal, - ), + listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => { + request.scope.signal.throwIfAborted(); + const { listAndroidApps } = await import('./app-lifecycle.ts'); + return (await listAndroidApps(input.device, input.filter)).map((app) => ({ + id: app.package, + name: app.name, + })); + }, ...availableApplicationLifecycleOperations( bindAndroidApplicationLifecycle({ host, diff --git a/packages/platform-apple/src/app-resolution-facade.ts b/packages/platform-apple/src/app-resolution-facade.ts index a0bacaa0bb..702daeab4a 100644 --- a/packages/platform-apple/src/app-resolution-facade.ts +++ b/packages/platform-apple/src/app-resolution-facade.ts @@ -3,7 +3,6 @@ export { detectSoleRunningIosSimulatorApp, findIosSimulatorInstalledApp, invalidateIosAppResolutionCache, - listIosApps, resolveIosApp, resolveIosAppAlias, resolveIosSimulatorDeepLinkBundleId, diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index af241afa03..0523dd04fd 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -123,11 +123,6 @@ function host(options: { readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, }; } @@ -159,16 +154,7 @@ function unusedAppLogHost(): Omit< terminate: async () => 'already-missing', }, processTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, clock: { now: () => 1, sleep: async () => {} }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { applePhysical: { ensureConnected: async () => {} }, appleAutomation: { diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index 2efe5d48bd..efb332a54b 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -15,15 +15,6 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { applePhysical: { ensureConnected: async () => {} }, appleAutomation: { diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index a2e59c7f0d..e1cea0e10d 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -1,4 +1,11 @@ import { expect, test, vi } from 'vitest'; + +vi.mock('./core/app-resolution.ts', async (importOriginal) => ({ + ...(await importOriginal()), + listIosApps: vi.fn(async () => [{ bundleId: 'com.example.app', name: 'Example' }]), +})); + +import { listIosApps } from './core/app-resolution.ts'; import type { DeviceBinding, RuntimeFacts } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import type { SnapshotRuntimeHost } from '@agent-device/contracts/snapshot-runtime'; @@ -398,16 +405,10 @@ test('macOS readiness is a no-op while boot remains unavailable', async () => { expect(binding.operations.bootTarget).toBeUndefined(); }); -test('routes Apple app inventory through the injected host facet', async () => { - const host = platformRuntimeHostFixture(); - const listApps = vi.fn(async () => [{ id: 'com.example.app', name: 'Example' }]); - const runtime = createApplePlatformRuntime({ - ...host, - appInventory: { - ...host.appInventory, - apple: { listApps }, - }, - }); +test('lists Apple apps through the package-owned resolver', async () => { + const listApps = vi.mocked(listIosApps); + listApps.mockClear(); + const runtime = createApplePlatformRuntime(platformRuntimeHostFixture()); const device = appleDevice(); const binding = await runtime.bind({ device, @@ -422,7 +423,7 @@ test('routes Apple app inventory through the injected host facet', async () => { await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ { id: 'com.example.app', name: 'Example' }, ]); - expect(listApps).toHaveBeenCalledWith(device, 'all', expect.any(AbortSignal)); + expect(listApps).toHaveBeenCalledWith(device, 'all'); }); type LegacyLifecycleCell = Readonly<{ diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 1ba6824045..ab9746a182 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -462,12 +462,14 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR await ensureAppleReady(host, request.device, request.scope.signal), })), ...whenAdmitted(facts.operations.listApps, () => ({ - listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => - await host.appInventory.apple.listApps( - input.device, - input.filter, - request.scope.signal, - ), + listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => { + request.scope.signal.throwIfAborted(); + const { listIosApps } = await import('./core/app-resolution.ts'); + return (await listIosApps(input.device, input.filter)).map((app) => ({ + id: app.bundleId, + name: app.name, + })); + }, })), ...availableApplicationLifecycleOperations( bindAppleApplicationLifecycle({ diff --git a/packages/platform-harmonyos/src/app-state.ts b/packages/platform-harmonyos/src/app-state.ts index 3fe85f6c58..f9f995eea0 100644 --- a/packages/platform-harmonyos/src/app-state.ts +++ b/packages/platform-harmonyos/src/app-state.ts @@ -1,30 +1,17 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { - AppStateRuntimeCommand, - AppStateRuntimeCommandResult, - AppStateRuntimeResult, -} from '@agent-device/contracts/app-state-runtime'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -export type HarmonyAppStateHost = Readonly<{ - run( - device: DeviceInfo, - command: AppStateRuntimeCommand, - signal: AbortSignal, - ): Promise; -}>; - export async function readHarmonyAppState( - host: HarmonyAppStateHost, device: DeviceInfo, signal: AbortSignal, ): Promise { signal.throwIfAborted(); - const result = await host.run( - device, - { args: ['shell', 'aa', 'dump', '-l'], timeoutMs: 15_000 }, + const { runHarmonyHdc } = await import('./hdc.ts'); + const result = await runHarmonyHdc(device, ['shell', 'aa', 'dump', '-l'], { + timeoutMs: 15_000, signal, - ); + }); signal.throwIfAborted(); const foreground = parseHarmonyForegroundApp(result.stdout); if (!foreground) { diff --git a/packages/platform-harmonyos/src/index.ts b/packages/platform-harmonyos/src/index.ts index bd04074ff3..0250b1f9b8 100644 --- a/packages/platform-harmonyos/src/index.ts +++ b/packages/platform-harmonyos/src/index.ts @@ -32,9 +32,6 @@ export const runtimeModule = Object.freeze({ export type { HarmonyInventoryConfig } from './inventory-config.ts'; -export const listHarmonyApps = deferred<(typeof import('./app-lifecycle.ts'))['listHarmonyApps']>( - async () => (await import('./app-lifecycle.ts')).listHarmonyApps, -); export const openHarmonyApp = deferred<(typeof import('./app-lifecycle.ts'))['openHarmonyApp']>( async () => (await import('./app-lifecycle.ts')).openHarmonyApp, ); diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 8b69c3d5da..e59d162389 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -1,4 +1,8 @@ import { expect, test, vi } from 'vitest'; + +vi.mock('./hdc.ts', () => ({ runHarmonyHdc: vi.fn() })); + +import { runHarmonyHdc } from './hdc.ts'; import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeHost, @@ -25,19 +29,17 @@ test.each([ ['device', device], ['emulator', { ...device, kind: 'emulator' as const }], ])('classifies the HarmonyOS %s runtime denominator', async (_name, runtimeDevice) => { - const listApps = vi.fn(async () => [{ id: 'com.example.application', name: 'application' }]); + const hdc = vi.mocked(runHarmonyHdc); + hdc.mockReset(); + hdc.mockImplementation(async (_device, args) => ({ + exitCode: 0, + stderr: '', + stdout: args.includes('bm') + ? 'com.example.application\n' + : 'Mission ID #76 mission name #[#com.example.harmony:entry:MainAbility]\nstate #FOREGROUND', + })); const host = { processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { harmonyos: { listApps } }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { - run: async () => ({ - stdout: - 'Mission ID #76 mission name #[#com.example.harmony:entry:MainAbility]\nstate #FOREGROUND', - }), - }, - }, localInteractors: { resolve: async () => ({}) }, } as unknown as PlatformRuntimeHost; const binding = await createHarmonyPlatformRuntime(host).bind({ @@ -134,7 +136,11 @@ test.each([ await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), ).resolves.toEqual([{ id: 'com.example.application', name: 'application' }]); - expect(listApps).toHaveBeenCalledWith(runtimeDevice, 'all', expect.any(AbortSignal)); + expect(hdc).toHaveBeenCalledWith( + runtimeDevice, + ['shell', 'bm', 'dump', '-a'], + expect.objectContaining({ timeoutMs: 15_000 }), + ); await expect(binding.operations.appState?.()).resolves.toEqual({ package: 'com.example.harmony', activity: 'MainAbility', @@ -146,10 +152,6 @@ test('rejects the non-discovered HarmonyOS simulator cell for appstate', async ( const host = { processTransports: { resolve: async () => ({ mode: 'local' as const }) }, localInteractors: { resolve: async () => ({}) }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, } as unknown as PlatformRuntimeHost; const binding = await createHarmonyPlatformRuntime(host).bind({ device: runtimeDevice, @@ -241,7 +243,6 @@ test.each([ async ({ device: runtimeDevice, legacy }) => { const host = { processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { harmonyos: { listApps: async () => [] } }, localInteractors: { resolve: async () => ({}) }, } as unknown as PlatformRuntimeHost; const binding = await createHarmonyPlatformRuntime(host).bind({ @@ -344,8 +345,6 @@ test('binds the HarmonyOS gesture tiers it admitted and omits the rest', async ( function gestureHost(): PlatformRuntimeHost { return { processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { harmonyos: { listApps: async () => [] } }, - appState: { harmonyos: { run: async () => ({ stdout: '' }) } }, localInteractors: { resolve: async () => ({}) }, } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 1507aab868..3ed3eed31c 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -344,11 +344,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor ...(facts.operations.appState.available ? { appState: async () => - await readHarmonyAppState( - host.appState.harmonyos, - request.device, - request.scope.signal, - ), + await readHarmonyAppState(request.device, request.scope.signal), } : {}), ensureReady: async () => ({ ...request.device, booted: true }), @@ -423,12 +419,13 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor await host.clock.sleep(milliseconds, request.scope.signal), }), ), - listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => - await host.appInventory.harmonyos.listApps( - input.device, - input.filter, - request.scope.signal, - ), + listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => { + request.scope.signal.throwIfAborted(); + const { listHarmonyApps } = await import('./app-lifecycle.ts'); + return ( + await listHarmonyApps(input.device, input.filter, { signal: request.scope.signal }) + ).map((app) => ({ id: app.package, name: app.name })); + }, ...availableApplicationLifecycleOperations( bindHarmonyApplicationLifecycle({ host: host.localInteractors, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index de14d3b04e..30841b7314 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -385,15 +385,6 @@ function host( readProcessMarker: async () => ({ status: 'missing' }), }, networkTransports: { resolve: async () => transport }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceReadiness: { applePhysical: { ensureConnected: async () => {} }, appleAutomation: { diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 325a4ff494..37801f3e23 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -407,10 +407,6 @@ function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost }, androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, deviceShutdown: { apple: { shutdownTarget: async () => ({ success: true, exitCode: 0, stdout: '', stderr: '' }), @@ -470,11 +466,6 @@ function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost }), }, networkTransports: { resolve: async () => ({ mode: 'local' }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, screenRecording: { outputs: { prepare: async () => {} }, apple: { diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts new file mode 100644 index 0000000000..2ab68c1d2b --- /dev/null +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -0,0 +1,106 @@ +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { afterEach, expect, test, vi } from 'vitest'; + +const adb = vi.hoisted(() => ({ calls: [] as string[][] })); + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + whichCmd: async (executable: string) => `/usr/bin/${executable}`, + runCmd: async (cmd: string, args: string[]) => { + adb.calls.push([cmd, ...args]); + return args.includes('query-activities') + ? { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }; + }, + }; +}); + +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; + +const sessionArtifacts = { + sessionsDir: '/sessions', + resolveSessionArtifacts: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), +}; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('the composed gateway lists Android apps from inside the platform package', async () => { + const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const binding = await gateway.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ + { id: 'com.example.app', name: 'Example' }, + ]); + expect(adb.calls).toContainEqual([ + 'adb', + '-s', + device.id, + 'shell', + 'cmd', + 'package', + 'query-activities', + '--brief', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + ]); + + await gateway.shutdown(); +}); + +afterEach(() => { + vi.doUnmock('./platform-runtime-android-adb-host.ts'); + vi.doUnmock('@agent-device/platform-android'); + vi.resetModules(); +}); + +test('the root binds the adb host before it loads the Android runtime module', async () => { + const order: string[] = []; + vi.resetModules(); + vi.doMock('./platform-runtime-android-adb-host.ts', () => { + order.push('adb-host'); + return {}; + }); + vi.doMock('@agent-device/platform-android', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + runtimeModule: Object.freeze({ + ...original.runtimeModule, + loadRuntime: async (host: PlatformRuntimeHost) => { + order.push('android-runtime'); + return await original.runtimeModule.loadRuntime(host); + }, + }), + }; + }); + + const { createPlatformRuntimeGateway: create } = await import('./platform-runtime.ts'); + const gateway = create(sessionArtifacts); + await gateway.inspectFacts(device).catch(() => {}); + + expect(order).toEqual(['adb-host', 'android-runtime']); + await gateway.shutdown(); +}); diff --git a/src/platform-runtime-app-inventory-host.ts b/src/platform-runtime-app-inventory-host.ts deleted file mode 100644 index b9921051ff..0000000000 --- a/src/platform-runtime-app-inventory-host.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { - AppInventoryRuntimeHost, - InstalledAppInfo, -} from '@agent-device/contracts/app-inventory-runtime'; -import type { AppsFilter } from '@agent-device/contracts/device'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; - -export function createAppInventoryRuntimeHost(): AppInventoryRuntimeHost { - return Object.freeze({ - apple: Object.freeze({ - listApps: async (device: DeviceInfo, filter: AppsFilter) => { - const { listIosApps } = await import('@agent-device/platform-apple/app-resolution'); - return mapAppleApps(await listIosApps(device, filter)); - }, - }), - android: Object.freeze({ - listApps: async (device: DeviceInfo, filter: AppsFilter) => { - const { listAndroidApps } = await loadAndroidMechanics(); - return (await listAndroidApps(device, filter)).map((app) => ({ - id: app.package, - name: app.name, - })); - }, - }), - harmonyos: Object.freeze({ - listApps: async (device: DeviceInfo, filter: AppsFilter, signal: AbortSignal) => { - const { listHarmonyApps } = await import('@agent-device/platform-harmonyos'); - return (await listHarmonyApps(device, filter, { signal })).map((app) => ({ - id: app.package, - name: app.name, - })); - }, - }), - }); -} - -function mapAppleApps( - apps: readonly { bundleId: string; name: string }[], -): readonly InstalledAppInfo[] { - return apps.map((app) => ({ id: app.bundleId, name: app.name })); -} diff --git a/src/platform-runtime-app-state-host.test.ts b/src/platform-runtime-app-state-host.test.ts deleted file mode 100644 index e639fe9f2e..0000000000 --- a/src/platform-runtime-app-state-host.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { expect, beforeEach, test, vi } from 'vitest'; - -const executors = vi.hoisted(() => ({ - android: vi.fn(), - harmonyos: vi.fn(), -})); - -vi.mock('@agent-device/platform-android/mechanics', () => ({ - runAndroidAdb: executors.android, -})); - -vi.mock('@agent-device/platform-harmonyos', () => ({ - runHarmonyHdc: executors.harmonyos, -})); - -import { createAppStateRuntimeHost } from './platform-runtime-app-state-host.ts'; - -const android = { - platform: 'android' as const, - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator' as const, -}; -const harmony = { - platform: 'harmonyos' as const, - id: 'harmony-1', - name: 'Harmony', - kind: 'device' as const, -}; - -beforeEach(() => { - executors.android.mockReset(); - executors.harmonyos.mockReset(); - executors.android.mockResolvedValue({ - stdout: 'mCurrentFocus=Window{1 u0 com.example.android/.MainActivity}', - }); - executors.harmonyos.mockResolvedValue({ - stdout: - 'Mission ID #76 mission name #[#com.example.harmony:entry:MainAbility]\nstate #FOREGROUND', - }); -}); - -test('keeps only focused cancellable command bridges in the root host', async () => { - const host = createAppStateRuntimeHost(); - const signal = new AbortController().signal; - - await expect( - host.android.run(android, { args: ['shell', 'dumpsys', 'window'], allowFailure: true }, signal), - ).resolves.toEqual({ - stdout: 'mCurrentFocus=Window{1 u0 com.example.android/.MainActivity}', - }); - expect(executors.android).toHaveBeenCalledWith( - android, - ['shell', 'dumpsys', 'window'], - expect.objectContaining({ allowFailure: true, signal }), - ); - - await expect( - host.harmonyos.run(harmony, { args: ['shell', 'aa', 'dump', '-l'], timeoutMs: 15_000 }, signal), - ).resolves.toEqual({ - stdout: - 'Mission ID #76 mission name #[#com.example.harmony:entry:MainAbility]\nstate #FOREGROUND', - }); - expect(executors.harmonyos).toHaveBeenCalledWith( - harmony, - ['shell', 'aa', 'dump', '-l'], - expect.objectContaining({ timeoutMs: 15_000, signal }), - ); -}); - -test('forwards an in-flight abort to the underlying Android executor', async () => { - const controller = new AbortController(); - let observedSignal: AbortSignal | undefined; - executors.android.mockImplementationOnce( - async (_device, _args, options: { signal?: AbortSignal }) => { - observedSignal = options.signal; - await new Promise((_resolve, reject) => { - options.signal?.addEventListener( - 'abort', - () => reject(options.signal?.reason ?? new DOMException('Aborted', 'AbortError')), - { once: true }, - ); - }); - }, - ); - - const pending = createAppStateRuntimeHost().android.run( - android, - { args: ['shell', 'dumpsys', 'window'], allowFailure: true }, - controller.signal, - ); - await vi.waitFor(() => expect(executors.android).toHaveBeenCalledTimes(1)); - - controller.abort(); - - await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); - expect(observedSignal).toBe(controller.signal); -}); diff --git a/src/platform-runtime-app-state-host.ts b/src/platform-runtime-app-state-host.ts deleted file mode 100644 index 9238542500..0000000000 --- a/src/platform-runtime-app-state-host.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { - AppStateRuntimeCommand, - AppStateRuntimeHost, -} from '@agent-device/contracts/app-state-runtime'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; - -export function createAppStateRuntimeHost(): AppStateRuntimeHost { - return Object.freeze({ - android: Object.freeze({ - run: async (device: DeviceInfo, command: AppStateRuntimeCommand, signal: AbortSignal) => { - signal.throwIfAborted(); - const { runAndroidAdb } = await loadAndroidMechanics(); - const result = await runAndroidAdb(device, [...command.args], { - allowFailure: command.allowFailure, - timeoutMs: command.timeoutMs, - signal, - }); - signal.throwIfAborted(); - return { stdout: result.stdout }; - }, - }), - harmonyos: Object.freeze({ - run: async (device: DeviceInfo, command: AppStateRuntimeCommand, signal: AbortSignal) => { - signal.throwIfAborted(); - const { runHarmonyHdc } = await import('@agent-device/platform-harmonyos'); - const result = await runHarmonyHdc(device, [...command.args], { - allowFailure: command.allowFailure, - timeoutMs: command.timeoutMs, - signal, - }); - signal.throwIfAborted(); - return { stdout: result.stdout }; - }, - }), - }); -} diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 18b38edc99..eb6ea1f396 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -24,8 +24,6 @@ import { createPerfRuntimeHost } from './platform-runtime-perf-host.ts'; import { createApplePhysicalReadinessHost } from './platform-runtime-apple-physical-readiness.ts'; import { createAppleAutomationKeepHotHost } from './platform-runtime-apple-automation-keep-hot.ts'; import { createAndroidEmulatorHost } from './platform-runtime-android-emulator-host.ts'; -import { createAppInventoryRuntimeHost } from './platform-runtime-app-inventory-host.ts'; -import { createAppStateRuntimeHost } from './platform-runtime-app-state-host.ts'; import { createDeviceShutdownRuntimeHost } from './platform-runtime-device-shutdown-host.ts'; import { createAppleAppDeploymentExecutor } from './platform-runtime-apple-deployment-executor.ts'; import { createAndroidAppDeploymentExecutor } from './platform-runtime-android-deployment-executor.ts'; @@ -105,8 +103,6 @@ export function createPlatformRuntimeHost(options: { }, }), ...network, - appInventory: createAppInventoryRuntimeHost(), - appState: createAppStateRuntimeHost(), appleDeployment: createAppleAppDeploymentExecutor(), androidDeployment: createAndroidAppDeploymentExecutor(), androidTools: createAndroidToolHost(), diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index 2004a70cbc..f02aeb0df5 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -4,10 +4,7 @@ import type { } from '@agent-device/contracts/device'; import type { AppLogSessionArtifacts } from '@agent-device/contracts/app-log-runtime'; import type { OwnedProcessRecordWriter } from '@agent-device/contracts/platform-runtime-host'; -import type { - AppStateRuntimeHost, - AppStateRuntimeResult, -} from '@agent-device/contracts/app-state-runtime'; +import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/device-shutdown-runtime'; import { type ComposedDeviceInventoryGateways, @@ -32,7 +29,6 @@ import { createAndroidObservationAdapter as createPackageAndroidObservationAdapter, createAndroidInventoryModule, readAndroidAppStateWithExecutor, - readAndroidAppState as readAndroidPackageAppState, loadShutdownRuntime as loadAndroidShutdownRuntime, runtimeModule as androidRuntimeModule, } from '@agent-device/platform-android'; @@ -67,18 +63,11 @@ export type { PlatformProviderResolvers, } from './platform-runtime/request-providers.ts'; -export async function readAndroidAppStateWithHost( - host: AppStateRuntimeHost['android'], - device: Parameters[0], - signal: AbortSignal, -): Promise { - return await readAndroidPackageAppState(host, device, signal); -} - export async function getAndroidAppStateWithAdb( adb: Parameters[0], + signal?: AbortSignal, ): Promise { - return await readAndroidAppStateWithExecutor(adb); + return await readAndroidAppStateWithExecutor(adb, signal); } const androidInventoryModule = createAndroidInventoryModule({ @@ -125,13 +114,27 @@ export function createPlatformDeviceInventoryGateways( }); } +/** + * Android mechanics call adb through a process-wide host that only this root can bind. Root binds it + * from two places: this registry entry, which covers everything the Android runtime reaches from + * inside its own package, and `loadAndroidMechanics`, which covers the root host ports that call + * into mechanics without ever binding a runtime. Neither one subsumes the other. + */ +const androidRuntimeModuleWithBoundAdbHost: PlatformRuntimeModule = Object.freeze({ + ...androidRuntimeModule, + loadRuntime: async (host) => { + await import('./platform-runtime-android-adb-host.ts'); + return await androidRuntimeModule.loadRuntime(host); + }, +}); + /** The root composition registry shared by the gateway and bounded host-contract fixtures. */ export const platformRuntimeModules: ReadonlyMap = new Map< Platform, PlatformRuntimeModule >([ ['apple', appleRuntimeModule], - ['android', androidRuntimeModule], + ['android', androidRuntimeModuleWithBoundAdbHost], ['harmonyos', harmonyosRuntimeModule], ['vega', vegaRuntimeModule], ['linux', linuxRuntimeModule], diff --git a/src/sdk/android-adb.ts b/src/sdk/android-adb.ts index 84b6c75f7e..ff180fad7b 100644 --- a/src/sdk/android-adb.ts +++ b/src/sdk/android-adb.ts @@ -13,9 +13,10 @@ import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-ru export async function getAndroidAppStateWithAdb( adb: AndroidAdbExecutor, + signal?: AbortSignal, ): Promise { const { getAndroidAppStateWithAdb: read } = await import('../platform-runtime.ts'); - return await read(adb); + return await read(adb, signal); } export { diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index 6db3a5d04d..66d1a32896 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -31,21 +31,11 @@ export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }) ).map((app) => ({ id: app.package, name: app.name })); }, - getForegroundApp: async (device, adb, signal) => { - const { readAndroidAppStateWithHost } = await import('../platform-runtime.ts'); - const app = await readAndroidAppStateWithHost( - { - run: async (_device, command, commandSignal) => { - const result = await adb([...command.args], { - allowFailure: command.allowFailure, - timeoutMs: command.timeoutMs, - signal: commandSignal, - }); - return { stdout: result.stdout }; - }, - }, - device, - signal ?? new AbortController().signal, + getForegroundApp: async (_device, adb, signal) => { + const { getAndroidAppStateWithAdb } = await import('../platform-runtime.ts'); + const app = await getAndroidAppStateWithAdb( + async (args, options) => await adb(args, { ...options, signal }), + signal, ); return app.package ? { appId: app.package, activity: app.activity } : undefined; }, From 558560ab7bf31a11775290ffc842e3c5a886cb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 09:07:32 +0200 Subject: [PATCH 2/3] chore(gates): drop the retired app-inventory/app-state host allowances The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change deletes, and the ./platform-runtime-app-state-host.ts composition allowance has no importer left. --- scripts/layering/platform-composition-policy.ts | 1 - scripts/layering/platform-package-policy.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts index 0ca24960b9..2224138347 100644 --- a/scripts/layering/platform-composition-policy.ts +++ b/scripts/layering/platform-composition-policy.ts @@ -76,7 +76,6 @@ function isAllowedCompositionImport(specifier: string): boolean { specifier === './platform-runtime-android-adb-host.ts' || specifier === './platform-runtime-android-observation-host.ts' || specifier === './platform-runtime-operation-host.ts' || - specifier === './platform-runtime-app-state-host.ts' || specifier === './platform-runtime-device-inventory.ts' || specifier === './platform-runtime-host.ts' || specifier === './platform-runtime/request-providers.ts' || diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index bffa1956f7..0bda0c6c6e 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -36,8 +36,6 @@ const COMPOSITION_FILES = new Set([COMPOSITION_FILE, REQUEST_PROVIDER_COMPOSITIO const RULE = 'R13 platform-package-substrate'; const RAW_PROCESS_SPECIFIERS = new Set(['child_process', 'node:child_process']); const PLATFORM_RUNTIME_HOST_FILES = new Set([ - 'src/platform-runtime-app-inventory-host.ts', - 'src/platform-runtime-app-state-host.ts', 'src/platform-runtime-audio-probe-host.ts', 'src/platform-runtime-host-diagnostics.ts', 'src/platform-runtime-managed-web-backend.ts', From 6df2d9362746078949e88ba9259954d9f20d4d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:00:40 +0200 Subject: [PATCH 3/3] refactor(runtime): construct the Android runtime module with its adb host binding The Android runtime now calls adb from inside its package for listApps and appState, which needs the process-wide adb host port bound. That dependency was hidden in a registry wrapper doing a side-effect import, with a paragraph explaining why it and loadAndroidMechanics did not subsume each other and an import-order test pinning the ordering. The package now declares the dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding before the runtime loads, and the composition root supplies the one binding implementation (evaluating its adb host module). The wrapper, the paragraph and the import-order test are gone; the routed listApps test stays and a routed appState test joins it. --- packages/platform-android/src/index.ts | 28 +++++-- .../src/runtime-facade.test.ts | 27 ++++++- ...atform-runtime-android-adb-binding.test.ts | 77 ++++++++----------- src/platform-runtime.ts | 17 ++-- 4 files changed, 86 insertions(+), 63 deletions(-) diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index c88027b66e..aa8f5e5ede 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -48,13 +48,27 @@ export async function readAndroidAppStateWithExecutor( return await read(run, signal); } -export const runtimeModule = Object.freeze({ - ...metadata, - loadRuntime: async (host) => { - const { createAndroidPlatformRuntime } = await import('./runtime.ts'); - return createAndroidPlatformRuntime(host); - }, -} satisfies PlatformRuntimeModule); +/** What the composition root supplies before this package's runtime can reach a device. */ +export type AndroidRuntimeModuleDependencies = Readonly<{ + /** + * Binds the process-wide adb host port (`bindAndroidAdbHost`) the runtime's mechanics run + * through. Awaited before the runtime loads, so no caller has to import anything first. + */ + bindAdbHost(): Promise; +}>; + +export function createAndroidRuntimeModule( + dependencies: AndroidRuntimeModuleDependencies, +): PlatformRuntimeModule { + return Object.freeze({ + ...metadata, + loadRuntime: async (host) => { + await dependencies.bindAdbHost(); + const { createAndroidPlatformRuntime } = await import('./runtime.ts'); + return createAndroidPlatformRuntime(host); + }, + } satisfies PlatformRuntimeModule); +} export function createAndroidInventoryModule( config: AndroidInventoryConfig, diff --git a/packages/platform-android/src/runtime-facade.test.ts b/packages/platform-android/src/runtime-facade.test.ts index 64a1686ce4..4e4ad92c66 100644 --- a/packages/platform-android/src/runtime-facade.test.ts +++ b/packages/platform-android/src/runtime-facade.test.ts @@ -7,11 +7,36 @@ vi.mock('./logs/runtime.ts', async (loadOriginal) => { return await loadOriginal(); }); -import { runtimeModule } from './index.ts'; +import { createAndroidRuntimeModule } from './index.ts'; test('defers Android app-log mechanics until runtime load', async () => { + const runtimeModule = createAndroidRuntimeModule({ bindAdbHost: async () => {} }); expect(mechanics.evaluations).toBe(0); expect(runtimeModule.family).toBe('android'); await runtimeModule.loadRuntime({} as never); expect(mechanics.evaluations).toBe(1); }); + +test('binds the adb host it was constructed with before the runtime loads', async () => { + const order: string[] = []; + const bindAdbHost = vi.fn(async () => { + order.push('bind-adb-host'); + }); + const runtimeModule = createAndroidRuntimeModule({ bindAdbHost }); + + expect(bindAdbHost).not.toHaveBeenCalled(); + await runtimeModule.loadRuntime({} as never); + order.push('runtime-loaded'); + + expect(order).toEqual(['bind-adb-host', 'runtime-loaded']); +}); + +test('a binding that fails keeps the runtime unloaded', async () => { + const runtimeModule = createAndroidRuntimeModule({ + bindAdbHost: async () => { + throw new Error('adb host unavailable'); + }, + }); + + await expect(runtimeModule.loadRuntime({} as never)).rejects.toThrow('adb host unavailable'); +}); diff --git a/src/platform-runtime-android-adb-binding.test.ts b/src/platform-runtime-android-adb-binding.test.ts index 2ab68c1d2b..eb7190993f 100644 --- a/src/platform-runtime-android-adb-binding.test.ts +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -1,6 +1,5 @@ -import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { afterEach, expect, test, vi } from 'vitest'; +import { expect, test, vi } from 'vitest'; const adb = vi.hoisted(() => ({ calls: [] as string[][] })); @@ -11,9 +10,17 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { whichCmd: async (executable: string) => `/usr/bin/${executable}`, runCmd: async (cmd: string, args: string[]) => { adb.calls.push([cmd, ...args]); - return args.includes('query-activities') - ? { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 } - : { stdout: '', stderr: '', exitCode: 0 }; + if (args.includes('query-activities')) { + return { stdout: 'com.example.app/.MainActivity\n', stderr: '', exitCode: 0 }; + } + if (args.includes('dumpsys')) { + return { + stdout: 'mCurrentFocus=Window{1 u0 com.example.app/.MainActivity}\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; }, }; }); @@ -28,6 +35,12 @@ const sessionArtifacts = { }), }; +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + const device: DeviceInfo = { platform: 'android', id: 'emulator-5554', @@ -39,15 +52,7 @@ const device: DeviceInfo = { test('the composed gateway lists Android apps from inside the platform package', async () => { const gateway = createPlatformRuntimeGateway(sessionArtifacts); - const binding = await gateway.bind({ - device, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); await expect(binding.operations.listApps?.({ device, filter: 'all' })).resolves.toEqual([ { id: 'com.example.app', name: 'Example' }, @@ -70,37 +75,23 @@ test('the composed gateway lists Android apps from inside the platform package', await gateway.shutdown(); }); -afterEach(() => { - vi.doUnmock('./platform-runtime-android-adb-host.ts'); - vi.doUnmock('@agent-device/platform-android'); - vi.resetModules(); -}); +test('the composed gateway reads Android app state from inside the platform package', async () => { + const gateway = createPlatformRuntimeGateway(sessionArtifacts); + const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); -test('the root binds the adb host before it loads the Android runtime module', async () => { - const order: string[] = []; - vi.resetModules(); - vi.doMock('./platform-runtime-android-adb-host.ts', () => { - order.push('adb-host'); - return {}; - }); - vi.doMock('@agent-device/platform-android', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - runtimeModule: Object.freeze({ - ...original.runtimeModule, - loadRuntime: async (host: PlatformRuntimeHost) => { - order.push('android-runtime'); - return await original.runtimeModule.loadRuntime(host); - }, - }), - }; + await expect(binding.operations.appState?.()).resolves.toEqual({ + package: 'com.example.app', + activity: '.MainActivity', }); + expect(adb.calls).toContainEqual([ + 'adb', + '-s', + device.id, + 'shell', + 'dumpsys', + 'window', + 'windows', + ]); - const { createPlatformRuntimeGateway: create } = await import('./platform-runtime.ts'); - const gateway = create(sessionArtifacts); - await gateway.inspectFacts(device).catch(() => {}); - - expect(order).toEqual(['adb-host', 'android-runtime']); await gateway.shutdown(); }); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index f02aeb0df5..20076c51aa 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -28,9 +28,9 @@ import { import { createAndroidObservationAdapter as createPackageAndroidObservationAdapter, createAndroidInventoryModule, + createAndroidRuntimeModule, readAndroidAppStateWithExecutor, loadShutdownRuntime as loadAndroidShutdownRuntime, - runtimeModule as androidRuntimeModule, } from '@agent-device/platform-android'; import { createHarmonyInventoryModule, @@ -114,17 +114,10 @@ export function createPlatformDeviceInventoryGateways( }); } -/** - * Android mechanics call adb through a process-wide host that only this root can bind. Root binds it - * from two places: this registry entry, which covers everything the Android runtime reaches from - * inside its own package, and `loadAndroidMechanics`, which covers the root host ports that call - * into mechanics without ever binding a runtime. Neither one subsumes the other. - */ -const androidRuntimeModuleWithBoundAdbHost: PlatformRuntimeModule = Object.freeze({ - ...androidRuntimeModule, - loadRuntime: async (host) => { +const androidRuntimeModule = createAndroidRuntimeModule({ + // Evaluating the root's adb host module binds the process-wide port exactly once. + bindAdbHost: async () => { await import('./platform-runtime-android-adb-host.ts'); - return await androidRuntimeModule.loadRuntime(host); }, }); @@ -134,7 +127,7 @@ export const platformRuntimeModules: ReadonlyMap([ ['apple', appleRuntimeModule], - ['android', androidRuntimeModuleWithBoundAdbHost], + ['android', androidRuntimeModule], ['harmonyos', harmonyosRuntimeModule], ['vega', vegaRuntimeModule], ['linux', linuxRuntimeModule],