diff --git a/packages/platform-android/src/runtime.fixtures.ts b/packages/platform-android/src/runtime.fixtures.ts new file mode 100644 index 0000000000..8c762f862f --- /dev/null +++ b/packages/platform-android/src/runtime.fixtures.ts @@ -0,0 +1,110 @@ +import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; +import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export const ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +export const UNKNOWN_KIND_DEVICE = { + ...ANDROID_EMULATOR, + kind: 'unknown', +} as unknown as DeviceInfo; + +const audioProbeHost: PlatformRuntimeHost['audioProbe'] = { + hostCapture: { + info: { + source: 'system-audio', + backend: 'fixture', + sourceCount: 0, + notes: () => [], + }, + start: async () => { + throw new Error('Audio probe is outside this runtime fixture.'); + }, + inspectProcess: async () => 'missing', + terminateProcess: async () => 'already-missing', + }, + web: { resolve: async () => undefined }, + 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, + start: async () => { + throw new Error('unused'); + }, + signal: async () => true, + isRunning: async () => false, + exists: async () => false, + pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + remove: async () => true, + readManifest: async () => undefined, + writeManifest: async () => {}, + removeManifest: async () => {}, + }; +} + +/** The smallest host the Android runtime binds against; `overrides` replace whole facets. */ +export function androidRuntimeHost(overrides: Record = {}): PlatformRuntimeHost { + 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() } }, + ...overrides, + } as unknown as PlatformRuntimeHost; +} + +/** A host with app state and device readiness, so navigation and clipboard cells can bind. */ +export function androidNavigationHost( + probeClipboardShellSupport: () => Promise = async () => 'supported', +): PlatformRuntimeHost { + return androidRuntimeHost({ + androidTools: { + probeClipboardShellSupport, + runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + appState: emptyAppState, + deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } }, + }); +} + +export async function bindOrdinary( + runtime: PlatformRuntimeOwner, + device: DeviceInfo, +): Promise> { + return await runtime.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 9807f731a1..e283cc9293 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -7,62 +7,38 @@ import type { } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidPlatformRuntime } from './runtime.ts'; +import { + ANDROID_EMULATOR, + UNKNOWN_KIND_DEVICE, + androidNavigationHost, + androidRuntimeHost, + bindOrdinary, + emptyAppInventory, +} from './runtime.fixtures.ts'; -const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Android', - kind: 'emulator', - target: 'mobile', - booted: true, -}; const appStateUnavailable = { available: false, reason: 'unsupported-device-kind', hint: 'Android appstate is supported only for Android emulators and devices.', } as const; -const unknownKindDevice = { ...device, kind: 'unknown' } as unknown as DeviceInfo; -const audioProbeHost: PlatformRuntimeHost['audioProbe'] = { - hostCapture: { - info: { - source: 'system-audio', - backend: 'fixture', - sourceCount: 0, - notes: () => [], - }, - start: async () => { - throw new Error('Audio probe is outside this runtime fixture.'); - }, - inspectProcess: async () => 'missing', - terminateProcess: async () => 'already-missing', - }, - web: { resolve: async () => undefined }, - ownedProcesses: { replace: () => {}, clear: () => {} }, -}; test.each([ - ['emulator', device], - ['device', { ...device, kind: 'device' as const }], - ['unknown', unknownKindDevice], + ['emulator', ANDROID_EMULATOR], + ['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 host = { - androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, + const host = androidRuntimeHost({ commands: { which: async () => 'tool', run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), }, toolchains: { prepare: async () => {} }, clock: { now: () => 1, sleep: async () => {} }, - processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps }, - harmonyos: { listApps: async () => [] }, - }, + appInventory: { ...emptyAppInventory, android: { listApps } }, appState: { android: { run: appState }, harmonyos: { run: async () => ({ stdout: '' }) }, @@ -76,36 +52,8 @@ test.each([ }, androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, }, - localInteractors: { resolve: async () => ({}) }, - audioProbe: audioProbeHost, - screenRecording: { - android: { - resolve: async () => ({ - mode: 'local' as const, - start: async () => { - throw new Error('unused'); - }, - signal: async () => true, - isRunning: async () => false, - exists: async () => false, - pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - remove: async () => true, - readManifest: async () => undefined, - writeManifest: async () => {}, - removeManifest: async () => {}, - }), - }, - }, - } as unknown as PlatformRuntimeHost; - const binding = await createAndroidPlatformRuntime(host).bind({ - device: runtimeDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, }); + const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice); const { facts } = binding; expect(facts.device.providerMode).toBe('local'); expect(facts.operations.networkDump).toEqual({ available: true }); @@ -174,108 +122,31 @@ test.each([ }); test('rejects the non-discovered Android simulator cell for appstate', async () => { - const runtimeDevice = { ...device, kind: 'simulator' as const }; - const host = { - androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, - processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - localInteractors: { resolve: async () => ({}) }, - audioProbe: audioProbeHost, + 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 } }, - screenRecording: { - android: { - resolve: async () => ({ - mode: 'local' as const, - start: async () => { - throw new Error('unused'); - }, - signal: async () => true, - isRunning: async () => false, - exists: async () => false, - pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - remove: async () => true, - readManifest: async () => undefined, - writeManifest: async () => {}, - removeManifest: async () => {}, - }), - }, - }, - } as unknown as PlatformRuntimeHost; - const binding = await createAndroidPlatformRuntime(host).bind({ - device: runtimeDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, }); + const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice); expect(binding.facts.operations.appState).toEqual(appStateUnavailable); expect(binding.operations.appState).toBeUndefined(); }); -function androidNavigationHostFixture( - probeClipboardShellSupport: () => Promise = async () => 'supported', -) { - return { - androidTools: { - probeClipboardShellSupport, - runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - }, - processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, - appState: { - android: { run: async () => ({ stdout: '' }) }, - harmonyos: { run: async () => ({ stdout: '' }) }, - }, - deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } }, - localInteractors: { resolve: async () => ({}) }, - audioProbe: audioProbeHost, - screenRecording: { - android: { - resolve: async () => ({ - mode: 'local' as const, - start: async () => { - throw new Error('unused'); - }, - signal: async () => true, - isRunning: async () => false, - exists: async () => false, - pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - remove: async () => true, - readManifest: async () => undefined, - writeManifest: async () => {}, - removeManifest: async () => {}, - }), - }, - }, - } as unknown as PlatformRuntimeHost; -} - test.each([ - ['emulator', device], - ['device', { ...device, kind: 'device' as const }], - ['unknown', unknownKindDevice], + ['emulator', ANDROID_EMULATOR], + ['device', { ...ANDROID_EMULATOR, kind: 'device' as const }], + ['unknown', UNKNOWN_KIND_DEVICE], ])( 'classifies Android %s back/home/orientation/keyboard facts through the shared touch gate', async (_name, runtimeDevice) => { - const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ - device: runtimeDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + runtimeDevice, + ); const { facts } = binding; // back/home/orientation/keyboard status+dismiss+enter all ride the same adb-driven touch @@ -303,16 +174,11 @@ test.each([ ); test('admits Android tv-remote only for a real TV target', async () => { - const tvDevice = { ...device, kind: 'emulator' as const, target: 'tv' as const }; - const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ - device: tvDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const tvDevice = { ...ANDROID_EMULATOR, kind: 'emulator' as const, target: 'tv' as const }; + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + tvDevice, + ); expect(binding.facts.operations.tvRemote).toEqual({ available: true }); expect(binding.operations.tvRemote).toBeTypeOf('function'); @@ -324,14 +190,10 @@ test('admits Android tv-remote only for a real TV target', async () => { // bucket's name for a device with no declared kind, which `DeviceKind` cannot express.) test('admits both clipboard halves and the app switcher on every real Android kind', async () => { for (const kind of ['emulator', 'device'] as const) { - const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ - device: { ...device, id: `android-${kind}`, kind }, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, + const binding = await bindOrdinary(createAndroidPlatformRuntime(androidNavigationHost()), { + ...ANDROID_EMULATOR, + id: `android-${kind}`, + kind, }); expect(binding.facts.operations.readClipboard).toEqual({ available: true }); expect(binding.facts.operations.writeClipboard).toEqual({ available: true }); @@ -355,16 +217,15 @@ test('admits both clipboard halves and the app switcher on every real Android ki }); test('the synthetic Android simulator cell refuses back/home/orientation/keyboard like every other touch operation', async () => { - const simulatorDevice = { ...device, id: 'android-simulator', kind: 'simulator' as const }; - const binding = await createAndroidPlatformRuntime(androidNavigationHostFixture()).bind({ - device: simulatorDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const simulatorDevice = { + ...ANDROID_EMULATOR, + id: 'android-simulator', + kind: 'simulator' as const, + }; + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + simulatorDevice, + ); const { facts } = binding; for (const operation of [ @@ -401,7 +262,7 @@ type LegacyLifecycleCell = Readonly<{ test.each([ [ 'emulator', - device, + ANDROID_EMULATOR, { openTarget: true, prepareAppleRunner: false, @@ -412,7 +273,7 @@ test.each([ ], [ 'device', - { ...device, kind: 'device' as const }, + { ...ANDROID_EMULATOR, kind: 'device' as const }, { openTarget: true, prepareAppleRunner: false, @@ -423,7 +284,7 @@ test.each([ ], [ 'synthetic simulator', - { ...device, id: 'android-simulator', kind: 'simulator' as const }, + { ...ANDROID_EMULATOR, id: 'android-simulator', kind: 'simulator' as const }, { openTarget: false, prepareAppleRunner: false, @@ -435,44 +296,10 @@ test.each([ ] satisfies ReadonlyArray)( 'classifies the Android %s lifecycle denominator against the legacy dispatch cell', async (_name, runtimeDevice, legacy) => { - const host = { - androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, - processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, - localInteractors: { resolve: async () => ({}) }, - audioProbe: audioProbeHost, - screenRecording: { - android: { - resolve: async () => ({ - mode: 'local' as const, - start: async () => { - throw new Error('unused'); - }, - signal: async () => true, - isRunning: async () => false, - exists: async () => false, - pull: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - remove: async () => true, - readManifest: async () => undefined, - writeManifest: async () => {}, - removeManifest: async () => {}, - }), - }, - }, - } as unknown as PlatformRuntimeHost; - const binding = await createAndroidPlatformRuntime(host).bind({ - device: runtimeDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidRuntimeHost()), + runtimeDevice, + ); const { facts } = binding; expect(facts.device.providerMode).toBe('local'); expect(facts.operations.networkDump).toEqual({ available: true }); @@ -525,13 +352,13 @@ function expectLifecycleFacts( // synthesis and to target-authored drag but never to a plain one-contact fling or pan. test.each([ // name, device, plan, multiTouch, drag, scroll - ['emulator', device, true, true, true, true], - ['physical device', { ...device, kind: 'device' as const }, true, true, true, true], - ['unknown kind', unknownKindDevice, true, true, true, true], - ['TV target', { ...device, target: 'tv' as const }, true, false, false, true], + ['emulator', ANDROID_EMULATOR, true, true, true, true], + ['physical device', { ...ANDROID_EMULATOR, kind: 'device' as const }, true, true, true, true], + ['unknown kind', UNKNOWN_KIND_DEVICE, true, true, true, true], + ['TV target', { ...ANDROID_EMULATOR, target: 'tv' as const }, true, false, false, true], [ 'synthetic simulator row', - { ...device, kind: 'simulator' as const }, + { ...ANDROID_EMULATOR, kind: 'simulator' as const }, false, false, false, @@ -553,7 +380,7 @@ test.each([ test('carries the retired Android TV hints verbatim', async () => { const facts = await createAndroidPlatformRuntime(gestureHost()).inspectFacts({ - ...device, + ...ANDROID_EMULATOR, target: 'tv', }); expect(facts.operations.performMultiTouchGesturePlan).toEqual({ @@ -569,37 +396,20 @@ test('carries the retired Android TV hints verbatim', async () => { test('binds only the Android gesture tiers the target admitted', async () => { const bind = async (runtimeDevice: DeviceInfo) => - await createAndroidPlatformRuntime(gestureHost()).bind({ - device: runtimeDevice, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); - const phone = await bind(device); + await bindOrdinary(createAndroidPlatformRuntime(gestureHost()), runtimeDevice); + const phone = await bind(ANDROID_EMULATOR); expect(phone.operations.performMultiTouchGesturePlan).toBeTypeOf('function'); expect(phone.operations.scrollDirection).toBeTypeOf('function'); - const tv = await bind({ ...device, target: 'tv' }); + const tv = await bind({ ...ANDROID_EMULATOR, target: 'tv' }); expect(tv.operations.performGesturePlan).toBeTypeOf('function'); expect(tv.operations.performMultiTouchGesturePlan).toBeUndefined(); expect(tv.operations.performTargetAuthoredDrag).toBeUndefined(); }); function gestureHost(): PlatformRuntimeHost { - return { - androidTools: { probeClipboardShellSupport: async () => 'supported' as const }, - processTransports: { resolve: async () => ({ mode: 'local' as const }) }, - appInventory: { - apple: { listApps: async () => [] }, - android: { listApps: async () => [] }, - harmonyos: { listApps: async () => [] }, - }, - localInteractors: { resolve: async () => ({}) }, - audioProbe: audioProbeHost, + return androidRuntimeHost({ screenRecording: { android: { resolve: async () => ({ mode: 'local' as const }) } }, - } as unknown as PlatformRuntimeHost; + }); } // R55 defect, found on a Pixel 9 Pro XL / Android 36 emulator: the retired bucket admitted both @@ -607,17 +417,10 @@ function gestureHost(): PlatformRuntimeHost { // `clipboard read` then failed with `UNSUPPORTED_OPERATION` from the leaf. Admission now probes // the same condition the leaf checks, so a build with no clipboard shell command refuses up front. test('refuses both clipboard halves when the build reports no clipboard shell', async () => { - const binding = await createAndroidPlatformRuntime( - androidNavigationHostFixture(async () => 'unsupported'), - ).bind({ - device: { ...device, id: 'android-no-clipboard-shell', kind: 'device' }, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost(async () => 'unsupported')), + { ...ANDROID_EMULATOR, id: 'android-no-clipboard-shell', kind: 'device' }, + ); for (const key of ['readClipboard', 'writeClipboard'] as const) { expect(binding.facts.operations[key]).toMatchObject({ @@ -635,8 +438,12 @@ test.each([['supported'], ['unsupported']] as const)( 'caches a definitive %s verdict instead of re-probing per inspection', async (verdict) => { const probe = vi.fn(async () => verdict); - const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); - const target = { ...device, id: `android-probe-cache-${verdict}`, kind: 'device' as const }; + const runtime = createAndroidPlatformRuntime(androidNavigationHost(probe)); + const target = { + ...ANDROID_EMULATOR, + id: `android-probe-cache-${verdict}`, + kind: 'device' as const, + }; await runtime.inspectFacts(target); await runtime.inspectFacts(target); @@ -650,17 +457,10 @@ test.each([['supported'], ['unsupported']] as const)( // `capabilities` advertised — and must not be remembered, or one transport blip decides the // question for the owner's whole life. test('a failed probe refuses rather than fabricating availability', async () => { - const binding = await createAndroidPlatformRuntime( - androidNavigationHostFixture(async () => 'probe-failed'), - ).bind({ - device: { ...device, id: 'android-probe-failed', kind: 'device' }, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost(async () => 'probe-failed')), + { ...ANDROID_EMULATOR, id: 'android-probe-failed', kind: 'device' }, + ); for (const key of ['readClipboard', 'writeClipboard'] as const) { expect(binding.facts.operations[key]).toMatchObject({ available: false }); @@ -676,8 +476,8 @@ test('a failed probe is not cached, so the next inspection asks again', async () .fn<() => Promise>() .mockResolvedValueOnce('probe-failed') .mockResolvedValue('supported'); - const runtime = createAndroidPlatformRuntime(androidNavigationHostFixture(probe)); - const target = { ...device, id: 'android-probe-retry', kind: 'device' as const }; + const runtime = createAndroidPlatformRuntime(androidNavigationHost(probe)); + const target = { ...ANDROID_EMULATOR, id: 'android-probe-retry', kind: 'device' as const }; const first = await runtime.inspectFacts(target); const second = await runtime.inspectFacts(target); @@ -688,11 +488,11 @@ test('a failed probe is not cached, so the next inspection asks again', async () }); test('a host with no clipboard probe refuses rather than assuming support', async () => { - const host = androidNavigationHostFixture(); + const host = androidNavigationHost(); const withoutProbe = { ...host, androidTools: {} } as unknown as PlatformRuntimeHost; const facts = await createAndroidPlatformRuntime(withoutProbe).inspectFacts({ - ...device, + ...ANDROID_EMULATOR, id: 'android-no-probe', kind: 'device', }); diff --git a/packages/platform-apple/src/core/__tests__/perf.fixtures.ts b/packages/platform-apple/src/core/__tests__/perf.fixtures.ts new file mode 100644 index 0000000000..7cc3eec449 --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/perf.fixtures.ts @@ -0,0 +1,98 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { vi } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; + +export type MockRunCmdResult = Awaited>; +export type HostCommandHandler = ( + cmd: string, + args: string[], + options: Parameters[2], +) => Promise; + +/** Routes every `runCmd` call through `handlers` in order; the first non-null result wins. */ +export function mockHostCommands(handlers: HostCommandHandler[]): void { + vi.mocked(runCmd).mockImplementation(async (cmd, args, options) => { + for (const handler of handlers) { + const result = await handler(cmd, args, options); + if (result) return result; + } + throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); + }); +} + +/** Writes `Example.app/Contents/Info.plist` under `tmpDir` and returns the bundle path. */ +export async function writeMacosAppBundle(tmpDir: string, executable: string): Promise { + const bundlePath = path.join(tmpDir, 'Example.app'); + await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true }); + await fs.writeFile( + path.join(bundlePath, 'Contents', 'Info.plist'), + infoPlistXml(executable), + 'utf8', + ); + return bundlePath; +} + +/** Writes a flat `Example.app/Info.plist` under `tmpDir` and returns the app path. */ +export async function writeIosSimulatorApp(tmpDir: string, executable: string): Promise { + const appPath = path.join(tmpDir, 'Example.app'); + await fs.mkdir(appPath, { recursive: true }); + await fs.writeFile(path.join(appPath, 'Info.plist'), infoPlistXml(executable), 'utf8'); + return appPath; +} + +/** `mdfind` locates the bundle; `plutil` fails so the executable is read from Info.plist. */ +export function macosBundleLookup(bundlePath: string): HostCommandHandler { + return async (cmd) => { + if (cmd === 'mdfind') return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 }; + if (cmd === 'plutil') return plutilFallback(); + return null; + }; +} + +/** `simctl get_app_container` locates the app; `plutil` fails like `macosBundleLookup`. */ +export function iosSimulatorAppContainer(appPath: string): HostCommandHandler { + return async (cmd, args) => { + if (cmd === 'xcrun' && args.includes('get_app_container')) { + return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; + } + if (cmd === 'plutil') return plutilFallback(); + return null; + }; +} + +export function hostPs(rows: string[]): HostCommandHandler { + return async (cmd) => (cmd === 'ps' ? psOutput(rows) : null); +} + +export function simulatorPs(rows: string[]): HostCommandHandler { + return async (cmd, args) => (isSimulatorPs(cmd, args) ? psOutput(rows) : null); +} + +export function simulatorPsUnavailable(): HostCommandHandler { + return async (cmd, args) => + isSimulatorPs(cmd, args) + ? { stdout: '', stderr: 'No such file or directory', exitCode: 2 } + : null; +} + +export function isSimulatorPs(cmd: string, args: string[]): boolean { + return cmd === 'xcrun' && args.includes('spawn') && args.includes('ps'); +} + +function infoPlistXml(executable: string): string { + return [ + '', + '', + `CFBundleExecutable${executable}`, + '', + ].join(''); +} + +function plutilFallback(): MockRunCmdResult { + return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; +} + +function psOutput(rows: string[]): MockRunCmdResult { + return { stdout: rows.join('\n'), stderr: '', exitCode: 0 }; +} diff --git a/packages/platform-apple/src/core/__tests__/perf.test.ts b/packages/platform-apple/src/core/__tests__/perf.test.ts index cd2315741a..459d3d2d7f 100644 --- a/packages/platform-apple/src/core/__tests__/perf.test.ts +++ b/packages/platform-apple/src/core/__tests__/perf.test.ts @@ -30,10 +30,21 @@ import { parseAppleFramePerfSample } from '../perf-frame.ts'; import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { + hostPs, + iosSimulatorAppContainer, + isSimulatorPs, + macosBundleLookup, + mockHostCommands, + simulatorPs, + simulatorPsUnavailable, + writeIosSimulatorApp, + writeMacosAppBundle, + type MockRunCmdResult, +} from './perf.fixtures.ts'; const mockRunCmd = vi.mocked(runCmd); const mockRunCmdBackground = vi.mocked(runCmdBackground); -type MockRunCmdResult = Awaited>; type XcrunMockHandler = (args: string[]) => Promise; const IOS_SIMULATOR: DeviceInfo = { @@ -160,39 +171,15 @@ test('parseAppleFramePerfSample summarizes app hitches and worst windows', () => test('sampleAppleMemoryPerf aggregates host ps memory for macOS app bundle', async () => { const tmpDir = await mkdtempForTest('agent-device-macos-perf-'); - const bundlePath = path.join(tmpDir, 'Example.app'); - await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true }); - await fs.writeFile( - path.join(bundlePath, 'Contents', 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'mdfind') { - return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'ps') { - return { - stdout: [ - `111 8.5 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`, - `222 1.5 5000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')} --helper`, - '333 9.0 9999 /Applications/Other.app/Contents/MacOS/Other', - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + const bundlePath = await writeMacosAppBundle(tmpDir, 'ExampleExec'); + mockHostCommands([ + macosBundleLookup(bundlePath), + hostPs([ + `111 8.5 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`, + `222 1.5 5000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')} --helper`, + '333 9.0 9999 /Applications/Other.app/Contents/MacOS/Other', + ]), + ]); try { const memory = await sampleAppleMemoryPerf(MACOS_DEVICE, 'com.example.app'); @@ -205,38 +192,14 @@ test('sampleAppleMemoryPerf aggregates host ps memory for macOS app bundle', asy test('sampleAppleMemoryPerf uses simctl spawn ps for iOS simulators', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-sim-perf-'); - const appPath = path.join(tmpDir, 'Example.app'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExample Sim Exec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { - return { - stdout: [ - `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, - '222 4.0 1024 SpringBoard', - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec'); + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPs([ + `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, + '222 4.0 1024 SpringBoard', + ]), + ]); try { const memory = await sampleAppleMemoryPerf(IOS_SIMULATOR, 'com.example.sim'); @@ -249,38 +212,16 @@ test('sampleAppleMemoryPerf uses simctl spawn ps for iOS simulators', async () = test('captureAppleMemorySnapshot records memgraph for iOS simulator processes', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-sim-memgraph-'); - const appPath = path.join(tmpDir, 'Example.app'); + const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec'); const outPath = path.join(tmpDir, 'app.memgraph'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleSimExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args, options) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('ps')) { - return { - stdout: [ - `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, - `222 1.0 16384 ${path.join(appPath, 'ExampleSimExec')} --helper`, - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - if (cmd === 'xcrun' && args.includes('leaks')) { + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPs([ + `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, + `222 1.0 16384 ${path.join(appPath, 'ExampleSimExec')} --helper`, + ]), + async (cmd, args, options) => { + if (cmd !== 'xcrun' || !args.includes('leaks')) return null; assert.equal(options?.timeoutMs, 120_000); assert.deepEqual(args, [ 'simctl', @@ -291,10 +232,9 @@ test('captureAppleMemorySnapshot records memgraph for iOS simulator processes', '222', ]); await fs.writeFile(outPath, 'memgraph-bytes', 'utf8'); - return { stdout: '', stderr: '', exitCode: 0 }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + return emptyRunResult(); + }, + ]); try { const snapshot = await captureAppleMemorySnapshot(IOS_SIMULATOR, 'com.example.sim', outPath); @@ -311,41 +251,18 @@ test('captureAppleMemorySnapshot records memgraph for iOS simulator processes', test('captureAppleMemorySnapshot records memgraph for macOS app processes', async () => { const tmpDir = await mkdtempForTest('agent-device-macos-memgraph-'); - const bundlePath = path.join(tmpDir, 'Example.app'); + const bundlePath = await writeMacosAppBundle(tmpDir, 'ExampleExec'); const outPath = path.join(tmpDir, 'app.memgraph'); - await fs.mkdir(path.join(bundlePath, 'Contents'), { recursive: true }); - await fs.writeFile( - path.join(bundlePath, 'Contents', 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'mdfind') { - return { stdout: `${bundlePath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'ps') { - return { - stdout: `111 1.0 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`, - stderr: '', - exitCode: 0, - }; - } - if (cmd === 'leaks') { + mockHostCommands([ + macosBundleLookup(bundlePath), + hostPs([`111 1.0 12000 ${path.join(bundlePath, 'Contents', 'MacOS', 'ExampleExec')}`]), + async (cmd, args) => { + if (cmd !== 'leaks') return null; assert.deepEqual(args, [`--outputGraph=${outPath}`, '111']); await fs.writeFile(outPath, 'mac-memgraph-bytes', 'utf8'); - return { stdout: '', stderr: '', exitCode: 0 }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + return emptyRunResult(); + }, + ]); try { const snapshot = await captureAppleMemorySnapshot(MACOS_DEVICE, 'com.example.app', outPath); @@ -361,40 +278,17 @@ test('captureAppleMemorySnapshot records memgraph for macOS app processes', asyn test('captureAppleMemorySnapshot removes partial memgraph when leaks exits nonzero', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-memgraph-fail-'); - const appPath = path.join(tmpDir, 'Example.app'); + const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec'); const outPath = path.join(tmpDir, 'app.memgraph'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleSimExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('ps')) { - return { - stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, - stderr: '', - exitCode: 0, - }; - } - if (cmd === 'xcrun' && args.includes('leaks')) { + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPs([`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`]), + async (cmd, args) => { + if (cmd !== 'xcrun' || !args.includes('leaks')) return null; await fs.writeFile(outPath, 'partial-memgraph', 'utf8'); return { stdout: '', stderr: 'permission denied', exitCode: 1 }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + }, + ]); try { await assert.rejects( @@ -409,35 +303,13 @@ test('captureAppleMemorySnapshot removes partial memgraph when leaks exits nonze test('captureAppleMemorySnapshot removes partial memgraph and hints when leaks times out', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-memgraph-timeout-'); - const appPath = path.join(tmpDir, 'Example.app'); + const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec'); const outPath = path.join(tmpDir, 'app.memgraph'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleSimExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('ps')) { - return { - stdout: `111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`, - stderr: '', - exitCode: 0, - }; - } - if (cmd === 'xcrun' && args.includes('leaks')) { + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPs([`111 1.0 8192 ${path.join(appPath, 'ExampleSimExec')}`]), + async (cmd, args) => { + if (cmd !== 'xcrun' || !args.includes('leaks')) return null; await fs.writeFile(outPath, 'partial-memgraph', 'utf8'); throw new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms', { cmd, @@ -447,9 +319,8 @@ test('captureAppleMemorySnapshot removes partial memgraph and hints when leaks t exitCode: -1, timeoutMs: 120_000, }); - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + }, + ]); try { await assert.rejects( @@ -484,27 +355,11 @@ test('captureAppleMemorySnapshot reports physical iOS as unavailable', async () test('captureAppleMemorySnapshot reports iOS simulator without process tools as unavailable', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-sim-no-ps-'); - const appPath = path.join(tmpDir, 'Example.app'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExampleSimExec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('ps')) { + const appPath = await writeIosSimulatorApp(tmpDir, 'ExampleSimExec'); + mockHostCommands([ + iosSimulatorAppContainer(appPath), + async (cmd, args) => { + if (!isSimulatorPs(cmd, args)) return null; throw new AppError( 'COMMAND_FAILED', 'The operation couldn’t be completed. No such file or directory', @@ -517,9 +372,8 @@ test('captureAppleMemorySnapshot reports iOS simulator without process tools as processExitError: true, }, ); - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + }, + ]); try { const snapshot = await captureAppleMemorySnapshot( @@ -538,41 +392,12 @@ test('captureAppleMemorySnapshot reports iOS simulator without process tools as test('sampleAppleMemoryPerf falls back to host ps when simulator ps is unavailable', async () => { const tmpDir = await mkdtempForTest('agent-device-ios-sim-perf-'); - const appPath = path.join(tmpDir, 'Example.app'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExample Sim Exec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { - return { stdout: '', stderr: 'No such file or directory', exitCode: 2 }; - } - if (cmd === 'ps') { - return { - stdout: [ - `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, - '222 4.0 1024 SpringBoard', - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec'); + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPsUnavailable(), + hostPs([`111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, '222 4.0 1024 SpringBoard']), + ]); try { const memory = await sampleAppleMemoryPerf(IOS_SIMULATOR, 'com.example.sim'); @@ -659,39 +484,15 @@ test('sampleAppleFramePerf retries transient kperf lock failures', async () => { test('startAppleXctracePerfCapture attaches to an active iOS simulator app process', async () => { const tmpDir = await mkdtempForTest('agent-device-xctrace-sim-'); - const appPath = path.join(tmpDir, 'Example.app'); + const appPath = await writeIosSimulatorApp(tmpDir, 'Example Sim Exec'); const tracePath = path.join(tmpDir, 'app.trace'); - await fs.mkdir(appPath, { recursive: true }); - await fs.writeFile( - path.join(appPath, 'Info.plist'), - [ - '', - '', - 'CFBundleExecutableExample Sim Exec', - '', - ].join(''), - 'utf8', - ); - - mockRunCmd.mockImplementation(async (cmd, args) => { - if (cmd === 'xcrun' && args.includes('get_app_container')) { - return { stdout: `${appPath}\n`, stderr: '', exitCode: 0 }; - } - if (cmd === 'plutil') { - return { stdout: '', stderr: 'mock fallback', exitCode: 1 }; - } - if (cmd === 'xcrun' && args.includes('spawn') && args.includes('ps')) { - return { - stdout: [ - `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, - '222 4.0 1024 SpringBoard', - ].join('\n'), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`); - }); + mockHostCommands([ + iosSimulatorAppContainer(appPath), + simulatorPs([ + `111 12.0 8192 ${path.join(appPath, 'Example Sim Exec')}`, + '222 4.0 1024 SpringBoard', + ]), + ]); try { const capture = await startAppleXctracePerfCapture({ diff --git a/vitest.config.ts b/vitest.config.ts index 433f80f76d..c8e591d918 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -242,6 +242,7 @@ export default defineConfig({ exclude: [ 'src/**/*.test.ts', 'src/**/__tests__/**', + '**/*.fixtures.ts', 'src/**/*-types.ts', 'src/**/types.ts', 'src/sdk/**',