From 0c8227e9b7f9faa253156f4f4a2016f03aa0aba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 5 Sep 2026 22:40:42 +0200 Subject: [PATCH] refactor(runtime): let platform runtimes list apps and read app state directly (#2295) * 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. * 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. * 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. --- .../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 | 50 +++++----- packages/platform-android/src/mechanics.ts | 1 - .../src/network/runtime.test.ts | 14 --- .../src/runtime-facade.test.ts | 27 ++++- .../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 -- .../layering/platform-composition-policy.ts | 1 - scripts/layering/platform-package-policy.ts | 2 - ...atform-runtime-android-adb-binding.test.ts | 97 ++++++++++++++++++ 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 | 26 +++-- src/sdk/android-adb.ts | 3 +- src/sdk/limrun-runtime-dependencies.ts | 20 +--- 33 files changed, 286 insertions(+), 516 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 1b4179a89..df8ba0e5c 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 41c998ae3..a19dac5d3 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 55ca69d27..3b9cff9f8 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 8601abe6f..737cb335e 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 741b719fa..a3ca401ad 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 1ca8a52e1..aa8f5e5ed 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,29 +40,35 @@ 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({ - ...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/mechanics.ts b/packages/platform-android/src/mechanics.ts index 7c68ee358..6e1899dc3 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 acf0ecb72..f56623eb7 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-facade.test.ts b/packages/platform-android/src/runtime-facade.test.ts index 64a1686ce..4e4ad92c6 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/packages/platform-android/src/runtime.fixtures.ts b/packages/platform-android/src/runtime.fixtures.ts index 8c762f862..b781270f4 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 e283cc929..61adafcdd 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 b1d0cb2b9..682677370 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 a0bacaa0b..702daeab4 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 af241afa0..0523dd04f 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 2efe5d48b..efb332a54 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 a2e59c7f0..e1cea0e10 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 1ba682404..ab9746a18 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 3fe85f6c5..f9f995eea 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 bd04074ff..0250b1f9b 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 8b69c3d5d..e59d16238 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 1507aab86..3ed3eed31 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 de14d3b04..30841b731 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 325a4ff49..37801f3e2 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/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts index 0ca24960b..222413834 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 bffa1956f..0bda0c6c6 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', 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 000000000..eb7190993 --- /dev/null +++ b/src/platform-runtime-android-adb-binding.test.ts @@ -0,0 +1,97 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { 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]); + 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 }; + }, + }; +}); + +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; + +const sessionArtifacts = { + sessionsDir: '/sessions', + resolveSessionArtifacts: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), +}; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; + +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 }); + + 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(); +}); + +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 }); + + 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', + ]); + + 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 b9921051f..000000000 --- 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 e639fe9f2..000000000 --- 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 923854250..000000000 --- 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 18b38edc9..eb6ea1f39 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 2004a70cb..20076c51a 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, @@ -31,10 +28,9 @@ import { import { createAndroidObservationAdapter as createPackageAndroidObservationAdapter, createAndroidInventoryModule, + createAndroidRuntimeModule, readAndroidAppStateWithExecutor, - readAndroidAppState as readAndroidPackageAppState, loadShutdownRuntime as loadAndroidShutdownRuntime, - runtimeModule as androidRuntimeModule, } from '@agent-device/platform-android'; import { createHarmonyInventoryModule, @@ -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,6 +114,13 @@ export function createPlatformDeviceInventoryGateways( }); } +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'); + }, +}); + /** The root composition registry shared by the gateway and bounded host-contract fixtures. */ export const platformRuntimeModules: ReadonlyMap = new Map< Platform, diff --git a/src/sdk/android-adb.ts b/src/sdk/android-adb.ts index 84b6c75f7..ff180fad7 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 6db3a5d04..66d1a3289 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; },