diff --git a/packages/contracts/src/element-text-runtime.test.ts b/packages/contracts/src/element-text-runtime.test.ts index 197cbc1d93..dd1abb7230 100644 --- a/packages/contracts/src/element-text-runtime.test.ts +++ b/packages/contracts/src/element-text-runtime.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { bindElementTextRuntime, + bindProviderElementTextRuntime, elementTextRead, type ElementTextReadOutcome, type ElementTextUnreadableReason, @@ -44,6 +45,25 @@ test('the outcome union is closed: every value is a read or a classified unreada } }); +test('provider text reads resolve their owner for every call and reject a lost interactor', async () => { + let interactor: Interactor | undefined = { + readTextAtPoint: async () => 'live provider value', + } as unknown as Interactor; + const runtime = bindProviderElementTextRuntime({ + device: { platform: 'android', id: 'provider-device' } as DeviceInfo, + signal: new AbortController().signal, + resolveInteractor: () => interactor, + }); + assert.deepEqual(await runtime.readTextAtPoint({ point: { x: 1, y: 2 } }), { + status: 'read', + text: 'live provider value', + }); + interactor = undefined; + await assert.rejects(runtime.readTextAtPoint({ point: { x: 1, y: 2 } }), { + details: { reason: 'provider-runtime-interactor-missing', deviceId: 'provider-device' }, + }); +}); + test('a non-blank owner answer is a read that preserves the exact text', () => { const outcome = elementTextRead(' padded value '); assert.deepEqual(outcome, { status: 'read', text: ' padded value ' }); diff --git a/packages/contracts/src/element-text-runtime.ts b/packages/contracts/src/element-text-runtime.ts index dce162cb80..929d7dbdf0 100644 --- a/packages/contracts/src/element-text-runtime.ts +++ b/packages/contracts/src/element-text-runtime.ts @@ -7,6 +7,7 @@ import { invalidRuntimeContract } from './runtime-contract-error.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SessionSurface } from './session-surface.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; +import type { ProviderInteractorOperationResolver } from './interactor-operation-binding.ts'; /** A live text read at a resolved point, with optional identity to preserve the selected control. */ export type ReadTextAtPointInput = Readonly<{ @@ -122,3 +123,21 @@ export function bindElementTextRuntime( }, }); } + +/** Provider text reads use the same interactor ownership check as other provider operations. */ +export function bindProviderElementTextRuntime( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderInteractorOperationResolver; + }>, +): ElementTextRuntimeOperations { + return bindElementTextRuntime({ + ...params, + resolveInteractor: async (_device, runner) => { + // Keep provider binding dependencies out of local text reads' eager module closure. + const { providerInteractorSource } = await import('./interactor-operation-binding.ts'); + return await providerInteractorSource({ ...params, operation: 'element-text' })(runner); + }, + }); +} diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 40d6cba9d4..e950569625 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -381,3 +381,10 @@ export async function createAndroidAppDeploymentOperations( const { createAndroidAppDeploymentOperations: bind } = await import('./deployment/runtime.ts'); return bind(params); } + +export async function createAndroidPerfOperations( + params: Parameters[0], +) { + const { createAndroidPerfOperations: bind } = await import('./perf/runtime.ts'); + return bind(params); +} diff --git a/packages/provider-limrun/src/android-perf-runtime.test.ts b/packages/provider-limrun/src/android-perf-runtime.test.ts new file mode 100644 index 0000000000..e7d3365b1b --- /dev/null +++ b/packages/provider-limrun/src/android-perf-runtime.test.ts @@ -0,0 +1,106 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { createLimrunPlatformRuntimeOwner } from './app-log-runtime.ts'; +import { limrunOwnerOptions, limrunScope } from './app-log-runtime.fixtures.ts'; +import { createLimrunAndroidPerfOperations } from './android-perf-runtime.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'limrun:android:reads', + name: 'Limrun Android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('a live Android owner binds reads and rejects a lease lost after binding', async () => { + let live = true; + const perfFrames = vi.fn(async () => ({ metric: { available: true, totalFrameCount: 100 } })); + const perfMemorySnapshot = vi.fn(async () => ({ + artifact: { available: true, path: '/tmp/artifacts/heap.hprof' }, + })); + const owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + hasLiveSession: () => live, + getInteractor: () => + live + ? ({ readTextAtPoint: async () => 'current field value' } as unknown as Interactor) + : undefined, + bindPerformance: async () => + live + ? { + perfFrames, + perfMemorySample: async () => ({ metric: { available: true, totalPssKb: 512 } }), + perfMemorySnapshot, + } + : undefined, + }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope: limrunScope }); + for (const operation of [ + 'readTextAtPoint', + 'perfFrames', + 'perfMemorySample', + 'perfMemorySnapshot', + ] as const) { + expect(binding.facts.operations[operation]).toEqual({ available: true }); + expect(binding.operations[operation]).toBeTypeOf('function'); + } + expect(binding.facts.operations.perfNativeCaptureStart.available).toBe(false); + expect(binding.operations.perfNativeCaptureStart).toBeUndefined(); + await expect(binding.operations.readTextAtPoint!({ point: { x: 10, y: 20 } })).resolves.toEqual({ + status: 'read', + text: 'current field value', + }); + await expect( + binding.operations.perfMemorySample!({ appId: 'com.example.app' }), + ).resolves.toMatchObject({ metric: { totalPssKb: 512 } }); + await expect(binding.operations.perfFrames!({ appId: 'com.example.app' })).resolves.toMatchObject( + { + metric: { available: true, totalFrameCount: 100 }, + }, + ); + expect(perfFrames).toHaveBeenCalledWith({ appId: 'com.example.app' }); + const snapshotInput = { + appId: 'com.example.app', + kind: 'android-hprof' as const, + artifactsDir: '/tmp/artifacts', + outPath: '/tmp/artifacts/heap.hprof', + }; + await expect(binding.operations.perfMemorySnapshot!(snapshotInput)).resolves.toMatchObject({ + artifact: { available: true, path: '/tmp/artifacts/heap.hprof' }, + }); + expect(perfMemorySnapshot).toHaveBeenCalledWith(snapshotInput); + live = false; + await expect( + binding.operations.readTextAtPoint!({ point: { x: 10, y: 20 } }), + ).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); + await expect(binding.operations.readClipboard!({})).rejects.toMatchObject({ + code: 'DEVICE_NOT_FOUND', + }); + await expect( + binding.operations.perfMemorySample!({ appId: 'com.example.app' }), + ).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); + expect((await owner.inspectFacts(device)).operations.perfMemorySample.available).toBe(false); + await expect( + owner.bind({ device, intent: { kind: 'ordinary' }, scope: limrunScope }), + ).rejects.toMatchObject({ details: { reason: 'provider-session-unavailable' } }); + await binding[Symbol.asyncDispose](); +}); + +test('cancellation while binding prevents a performance operation from starting', async () => { + const controller = new AbortController(); + const perfMemorySnapshot = vi.fn(); + const reason = new Error('Canceled during binding'); + const runtime = createLimrunAndroidPerfOperations(async () => { + controller.abort(reason); + return { perfFrames: vi.fn(), perfMemorySample: vi.fn(), perfMemorySnapshot }; + }, controller.signal); + const capture = runtime.perfMemorySnapshot({ + appId: 'com.example.app', + artifactsDir: '/tmp/artifacts', + }); + await expect(capture).rejects.toBe(reason); + expect(perfMemorySnapshot).not.toHaveBeenCalled(); +}); diff --git a/packages/provider-limrun/src/android-perf-runtime.ts b/packages/provider-limrun/src/android-perf-runtime.ts new file mode 100644 index 0000000000..700c61a1ad --- /dev/null +++ b/packages/provider-limrun/src/android-perf-runtime.ts @@ -0,0 +1,26 @@ +import type { PerfRuntimeOperations } from '@agent-device/contracts/perf-runtime'; +import { AppError } from '@agent-device/kernel/errors'; + +export type LimrunAndroidPerfOperations = Pick< + PerfRuntimeOperations, + 'perfFrames' | 'perfMemorySample' | 'perfMemorySnapshot' +>; + +export function createLimrunAndroidPerfOperations( + bind: () => Promise, + signal: AbortSignal, +): LimrunAndroidPerfOperations { + const load = async () => { + signal.throwIfAborted(); + const operations = await bind(); + signal.throwIfAborted(); + if (!operations) + throw new AppError('DEVICE_NOT_FOUND', 'Limrun Android session is unavailable'); + return operations; + }; + return { + perfFrames: async (input) => (await load()).perfFrames(input), + perfMemorySample: async (input) => (await load()).perfMemorySample(input), + perfMemorySnapshot: async (input) => (await load()).perfMemorySnapshot(input), + }; +} diff --git a/packages/provider-limrun/src/app-log-runtime.fixtures.ts b/packages/provider-limrun/src/app-log-runtime.fixtures.ts index 71e9ae2c88..8d14bcd4de 100644 --- a/packages/provider-limrun/src/app-log-runtime.fixtures.ts +++ b/packages/provider-limrun/src/app-log-runtime.fixtures.ts @@ -29,6 +29,7 @@ export function limrunOwnerOptions( overrides: Partial = {}, ): LimrunPlatformRuntimeOwnerOptions { return { + bindPerformance: async () => undefined, host: unusedLimrunHost(), runtimeInstance: 'default', ownsDevice: () => true, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 58f94fbbdd..8d0e9f72b8 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -322,12 +322,8 @@ test.each([ expect(binding.operations.focusPoint).toBeTypeOf('function'); expect(binding.operations.typeText).toBeTypeOf('function'); expect(binding.operations.captureSnapshot).toBeTypeOf('function'); - expect(binding.facts.operations.readTextAtPoint.available).toBe( - runtimeDevice.platform === 'apple', - ); - expect(binding.operations.readTextAtPoint).toBeTypeOf( - runtimeDevice.platform === 'apple' ? 'function' : 'undefined', - ); + expect(binding.facts.operations.readTextAtPoint.available).toBe(true); + expect(binding.operations.readTextAtPoint).toBeTypeOf('function'); expectLimrunNavigationAndKeyboardFacts(binding, runtimeDevice); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 9e94a80764..22f4dfafa7 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -1,3 +1,5 @@ +import { createLimrunAndroidPerfOperations } from './android-perf-runtime.ts'; +import type { LimrunAndroidPerfOperations } from './android-perf-runtime.ts'; import type { LimrunIosRunner } from './runtime-dependencies.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contracts/device'; @@ -11,6 +13,7 @@ import { type DeviceBinding, providerRuntimeOwner, sameRuntimeOwner, + whenAdmitted, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeHost, @@ -57,6 +60,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< 'isSessionActive' > & Readonly<{ + bindPerformance(device: DeviceInfo): Promise; host: PlatformRuntimeHost; runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; @@ -161,20 +165,21 @@ function bindLimrunAppLogs( reconnect: async (descriptor, reconnectSignal) => await options.reconnect(descriptor, reconnectSignal), }); + const requireLimrunInteractor = (target: DeviceInfo, runner?: RunnerContext): Interactor => { + const interactor = options.getInteractor(target, runner); + if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); + return interactor; + }; const interaction = bindLimrunInteractionOperations({ device, signal, - getInteractor: options.getInteractor, + getInteractor: requireLimrunInteractor, presentIosAcquisition: options.host.snapshot.presentIosAcquisition, facts: runtimeFacts.operations, }); const runnerOperations = options.getIosRunner(device)?.bindRunnerOperations({ signal, - resolveInteractor: async (target, context) => { - const interactor = options.getInteractor(target, context); - if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); - return interactor; - }, + resolveInteractor: async (target, context) => requireLimrunInteractor(target, context), snapshot: { capture: interaction, present: options.host.snapshot.presentIosAcquisition }, }); const operations = { @@ -187,25 +192,23 @@ function bindLimrunAppLogs( { device, signal, - getInteractor: options.getInteractor, + getInteractor: requireLimrunInteractor, resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }, options.getIosRunner(device)?.bindApplicationLifecycle({ signal, clock: options.host.clock, - resolveInteractor: async (target, context) => { - const interactor = options.getInteractor(target, context); - if (!interactor) - throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); - return interactor; - }, + resolveInteractor: async (target, context) => requireLimrunInteractor(target, context), }), ), runtimeFacts.operations, ), ...interaction, ...runnerOperations, + ...whenAdmitted(runtimeFacts.operations.perfFrames, () => + createLimrunAndroidPerfOperations(() => options.bindPerformance(device), signal), + ), appState: async (input) => { const state = await runnerOperations?.appState(input); // A stopped runner returns no state; still prove the remote instance can answer. @@ -216,7 +219,7 @@ function bindLimrunAppLogs( ...bindAdmittedProviderInteractorOperations({ device, signal, - resolveInteractor: (runner) => options.getInteractor(device, runner), + resolveInteractor: (runner) => requireLimrunInteractor(device, runner), facts: runtimeFacts.operations, }), ...createLimrunAppDeploymentOperations( diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 9a843f341f..1f40840dd5 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -163,7 +163,7 @@ export function limrunAppLogFacts( findText: customSnapshotUnavailable, querySelector: customSnapshotUnavailable, }), - ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), + ...elementTextRuntimeOperationFacts({ readTextAtPoint: available }), } : {}), ...screenshotRuntimeOperationFacts({ capture: available }), @@ -178,9 +178,9 @@ export function limrunAppLogFacts( query: audioProbeUnavailable, }), ...perfRuntimeOperationFacts({ - frames: elementTextUnavailable, - memorySample: elementTextUnavailable, - memorySnapshot: elementTextUnavailable, + frames: isAndroid ? available : elementTextUnavailable, + memorySample: isAndroid ? available : elementTextUnavailable, + memorySnapshot: isAndroid ? available : elementTextUnavailable, nativeCapture: elementTextUnavailable, profileReport: elementTextUnavailable, }), diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 09e5657e6f..ccaf03840b 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,9 +1,11 @@ +import { bindProviderElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { type RuntimeFacts, type RuntimeOperationFact, type RuntimeOperationUnavailability, unavailableFact, + whenAdmitted, } from '@agent-device/contracts/platform-runtime'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { @@ -130,7 +132,7 @@ export function bindLimrunInteractionOperations( params: Readonly<{ device: DeviceInfo; signal: AbortSignal; - getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor; presentIosAcquisition: SnapshotRuntimePresenter; facts: RuntimeFacts['operations']; }>, @@ -138,6 +140,9 @@ export function bindLimrunInteractionOperations( const { device, signal } = params; const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); return Object.freeze({ + ...whenAdmitted(params.facts.readTextAtPoint, () => + bindProviderElementTextRuntime({ device, signal, resolveInteractor }), + ), ...bindProviderSnapshotInteractor({ device, signal, diff --git a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts index e87542e1f0..dcd31a15ab 100644 --- a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts +++ b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts @@ -26,6 +26,9 @@ export function stubLimrunRuntimeDependencies(): LimrunRuntimeDependencies { return { clientVersion: 'test-version', android: { + bindPerformance: async () => { + throw new Error('Unexpected performance binding'); + }, bindDeployment: async () => { throw new Error('Unexpected deployment binding'); }, diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 9847b6fbd7..9814d2ac8e 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -415,6 +415,9 @@ function createContractFixture() { const dependencies = { clientVersion: 'test-version', android: { + bindPerformance: async () => { + throw new Error('Unexpected performance binding'); + }, bindDeployment: async () => { throw new Error('Unexpected deployment binding'); }, diff --git a/packages/provider-limrun/src/runtime-dependencies.ts b/packages/provider-limrun/src/runtime-dependencies.ts index 75e700e6b5..fa81df773f 100644 --- a/packages/provider-limrun/src/runtime-dependencies.ts +++ b/packages/provider-limrun/src/runtime-dependencies.ts @@ -1,3 +1,5 @@ +import type { AndroidPerfHost } from '@agent-device/contracts/perf-runtime-host'; +import type { LimrunAndroidPerfOperations } from './android-perf-runtime.ts'; import type { AppDeploymentInstaller, AppDeploymentRuntimeOperations, @@ -20,7 +22,7 @@ import type { PlatformRuntimeHost, PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import type { RuntimeFacts } from '@agent-device/contracts/platform-runtime'; +import type { RuntimeFacts, RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import type { SnapshotRuntimeOperations, SnapshotRuntimePresenter, @@ -95,6 +97,12 @@ export type LimrunDeploymentRequest = { }; export type LimrunAndroidRuntimeAdapter = { + bindPerformance( + device: DeviceInfo, + adb: LimrunAdbProvider, + host: AndroidPerfHost, + owner: RuntimeOwnerRef, + ): Promise; bindDeployment( device: DeviceInfo, adb: LimrunAdbProvider, diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 40f673e163..99f2a56628 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -1,3 +1,4 @@ +import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import Limrun from '@limrun/api'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { @@ -233,6 +234,17 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session ? createLimrunDeviceSession(session) : undefined; } + async bindPerformance(host: PlatformRuntimeHost, device: DeviceInfo, owner: RuntimeOwnerRef) { + const session = this.getSessionForDevice(device); + if (!session || session.platform !== 'android') return undefined; + return await this.dependencies.android.bindPerformance( + device, + session.adbProvider, + host.perf.android, + owner, + ); + } + async bindDeployment( host: PlatformRuntimeHost, device: DeviceInfo, @@ -891,6 +903,8 @@ async function loadLimrunPlatformRuntime( const { readLimrunAppState } = await import('./app-state-read.ts'); return await readLimrunAppState(runtime.getDeviceSession(device), signal); }, + bindPerformance: (device) => + runtime.bindPerformance(host, device, providerRuntimeOwner(LIMRUN_PROVIDER, runtimeInstance)), bindDeployment: (device, signal, operationDrain) => runtime.bindDeployment(host, device, signal, operationDrain), configurePortReverse: async (options) => await runtime.configurePortReverse(options), diff --git a/scripts/capability-matrix/annotations.ts b/scripts/capability-matrix/annotations.ts index 8b58ed5da3..481a23ca91 100644 --- a/scripts/capability-matrix/annotations.ts +++ b/scripts/capability-matrix/annotations.ts @@ -326,6 +326,16 @@ export const MATRIX_SECTIONS: readonly MatrixSection[] = [ commands: ['perf'], link: docs('debugging-profiling', 'performance-snapshots'), cells: { + 'limrun-android': { + text: 'Frame and memory samples and HPROF heap snapshots; native captures and CPU profiles are unavailable', + admits: ['perfFrames', 'perfMemorySample', 'perfMemorySnapshot'], + refuses: [ + 'perfNativeCaptureStart', + 'perfNativeCaptureReattach', + 'perfNativeCaptureCleanup', + 'perfProfileReport', + ], + }, 'harmonyos-emulator': { text: 'Process RSS memory sample only; frames, memory-snapshot artifacts, CPU profiles, and native captures report unavailable', admits: ['perfFrames', 'perfMemorySample', 'perfMemorySnapshot'], diff --git a/src/platform-runtime-gateway.fixtures.ts b/src/platform-runtime-gateway.fixtures.ts index c9ae9dd81a..0e8095eff5 100644 --- a/src/platform-runtime-gateway.fixtures.ts +++ b/src/platform-runtime-gateway.fixtures.ts @@ -212,6 +212,9 @@ function lifecycleOperations(): ApplicationLifecycleRuntimeOperations { export const limrunTestDependencies: LimrunRuntimeDependencies = { clientVersion: 'test', android: { + bindPerformance: async () => { + throw new Error('Unexpected performance binding'); + }, bindDeployment: async () => { throw new Error('Unexpected deployment binding'); }, diff --git a/src/sdk/limrun-android-performance.test.ts b/src/sdk/limrun-android-performance.test.ts new file mode 100644 index 0000000000..a7a54becd3 --- /dev/null +++ b/src/sdk/limrun-android-performance.test.ts @@ -0,0 +1,90 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import { expect, test, vi } from 'vitest'; +import { createLimrunRuntimeDependencies } from './limrun-runtime-dependencies.ts'; +import { createPerfRuntimeHost } from '../platform-runtime-perf-host.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; + +const device: DeviceInfo = { + platform: 'android', + id: 'limrun:android:perf-test', + name: 'Limrun Android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +// Any unscoped operation must fail instead of accidentally reaching a local device. +vi.mock('@agent-device/host-kit/command', async (original) => ({ + ...(await original()), + runCmd: async () => { + throw new Error('Local ADB must not run'); + }, +})); + +test('Limrun performance reads use the provider ADB and collect heap artifacts', async () => { + const dependencies = createLimrunRuntimeDependencies({ env: process.env, cwd: process.cwd() }); + const host = createPerfRuntimeHost().android; + const dir = await mkdtempForTest('limrun-perf-'); + let remoteHeap: string | undefined; + let frameWindowCleared = false; + const operations = await dependencies.android.bindPerformance( + device, + { + exec: async (args) => { + const ok = { exitCode: 0, stdout: '', stderr: '' }; + switch (args.join(' ')) { + case 'shell dumpsys meminfo com.example.app': + return { + ...ok, + stdout: 'TOTAL PSS: 52000 TOTAL RSS: 100112 TOTAL SWAP PSS: 0', + }; + case 'shell dumpsys gfxinfo com.example.app framestats': + return { + ...ok, + stdout: 'Total frames rendered: 100\nJanky frames: 5 (5.0%)', + }; + case 'shell dumpsys gfxinfo com.example.app reset': + frameWindowCleared = true; + return ok; + case 'shell pidof com.example.app': + return { ...ok, stdout: '123' }; + } + if (args.slice(0, 4).join(' ') === 'shell am dumpheap com.example.app') { + remoteHeap = args[4]; + return ok; + } + if (args[0] === 'pull' && args[1] === remoteHeap) { + await writeFile(args[2]!, 'JAVA PROFILE 1.0.3\0'); + return ok; + } + if (args.slice(0, 3).join(' ') === 'shell rm -f' && args[3] === remoteHeap) { + remoteHeap = undefined; + return ok; + } + throw new Error(`Unexpected remote call: ${args.join(' ')}`); + }, + }, + host, + providerRuntimeOwner('limrun', 'default'), + ); + await expect(operations.perfMemorySample!({ appId: 'com.example.app' })).resolves.toMatchObject({ + metric: { available: true, totalPssKb: 52000, totalRssKb: 100112 }, + sampling: { method: 'adb-shell-dumpsys-meminfo' }, + }); + await expect(operations.perfFrames!({ appId: 'com.example.app' })).resolves.toMatchObject({ + metric: { available: true, totalFrameCount: 100, droppedFrameCount: 5 }, + }); + expect(frameWindowCleared).toBe(true); + const heap = `${dir}/heap.hprof`; + await expect( + operations.perfMemorySnapshot!({ + appId: 'com.example.app', + artifactsDir: dir, + outPath: heap, + }), + ).resolves.toMatchObject({ artifact: { available: true, path: heap, pid: 123 } }); + expect(await readFile(heap, 'utf8')).toBe('JAVA PROFILE 1.0.3\0'); + expect(remoteHeap).toBeUndefined(); +}); diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index 4eeddc54bf..5436c33fa6 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -21,6 +21,16 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu return { clientVersion: readVersion(), android: { + bindPerformance: async (device, adb, runtimeHost, owner) => { + const { createAndroidPerfOperations, withAndroidAdbProvider } = + await import('@agent-device/platform-android/mechanics'); + const scopedHost = withMethodScope({ ...runtimeHost }, (task) => + runOnHost(() => withAndroidAdbProvider(adb, { serial: device.id }, task)), + ); + const { perfFrames, perfMemorySample, perfMemorySnapshot } = + await createAndroidPerfOperations({ device, owner, resolveHost: () => scopedHost }); + return { perfFrames, perfMemorySample, perfMemorySnapshot }; + }, bindDeployment: async (device, adb, request) => { const { createAndroidAppDeploymentOperations, withAndroidAdbProvider } = await import('@agent-device/platform-android/mechanics'); diff --git a/test/integration/provider-scenarios/limrun-android-performance.test.ts b/test/integration/provider-scenarios/limrun-android-performance.test.ts new file mode 100644 index 0000000000..23a59370f8 --- /dev/null +++ b/test/integration/provider-scenarios/limrun-android-performance.test.ts @@ -0,0 +1,116 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { createLimrunRuntime } from '@agent-device/provider-limrun'; +import { createLimrunRuntimeDependencies } from '../../../src/sdk/limrun-runtime-dependencies.ts'; +import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; +import { assertRpcOk } from './assertions.ts'; +import { createProviderScenarioHarness } from './harness.ts'; +import { respondToAndroidSettingsAdbCommand } from './android-world.ts'; + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly androidInstances = { + create: async () => ({ + metadata: { id: 'limrun-perf-instance' }, + status: { + apiUrl: 'https://limrun.example.test', + token: 'test-instance-token', + adbWebSocketUrl: 'wss://limrun.example.test/adb', + }, + }), + delete: async () => undefined, + }; + }, +})); + +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: async () => ({ + disconnect: async () => undefined, + startAdbTunnel: async () => ({ + address: { address: '127.0.0.1', port: 62001 }, + close: async () => undefined, + }), + }), +})); + +vi.mock('@agent-device/host-kit/command', async (original) => ({ + ...(await original()), + runCmd: async () => { + throw new Error('Unscoped host commands must not run'); + }, +})); + +test('Limrun Android memory sampling reaches the allocated session ADB through daemon dispatch', async () => { + const dependencies = createLimrunRuntimeDependencies({ env: {}, cwd: process.cwd() }); + const sessionExec = vi.fn(async (invocation) => { + const args = [...invocation.command]; + const ok = { exitCode: 0, stdout: '', stderr: '' }; + if (args[0] === 'disconnect') return ok; + expect(invocation.target.selector).toEqual({ kind: 'serial', serial: '127.0.0.1:62001' }); + if (args.join(' ') === 'shell dumpsys meminfo com.android.settings') { + return { ...ok, stdout: 'TOTAL PSS: 52000 TOTAL RSS: 100112 TOTAL SWAP PSS: 0' }; + } + if (args.join(' ') === 'reverse --list') return ok; + if ( + args.join(' ') === + 'shell am start -W -a android.intent.action.MAIN -c android.intent.category.DEFAULT -c android.intent.category.LAUNCHER -p com.android.settings' + ) { + return { ...ok, stdout: 'Status: ok\nActivity: com.android.settings/.Settings\n' }; + } + return respondToAndroidSettingsAdbCommand(args, '', '', {}); + }); + const registration = createLimrunRuntime( + { apiKey: 'test-api-key' }, + { ...dependencies, host: { ...dependencies.host, runAdb: sessionExec } }, + { includePlatformModule: true }, + ); + const runtime = registration.runtime; + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventorySource: providers.deviceInventorySource!, + platformRuntime: { + providerRuntimes: [runtime], + providerModules: [{ runtime, module: registration.platformModule }], + }, + }); + try { + const flags = { + platform: 'android' as const, + tenant: 'team-a', + runId: 'run-a', + leaseProvider: 'limrun', + }; + const meta = { + tenantId: 'team-a', + runId: 'run-a', + leaseBackend: 'android-instance' as const, + leaseProvider: 'limrun', + deviceKey: 'android-a', + clientId: 'client-a', + }; + const { lease } = assertRpcOk<{ lease: DeviceLease }>( + await daemon.callCommand('lease_allocate', [], flags, { meta }), + ); + const scope = { ...flags, leaseId: lease.leaseId }; + const options = { meta: { ...meta, leaseId: lease.leaseId } }; + assertRpcOk(await daemon.callCommand('open', ['com.android.settings'], scope, options)); + sessionExec.mockClear(); + const sampled = assertRpcOk( + await daemon.callCommand('perf', ['memory', 'sample'], scope, options), + ); + expect(sampled).toMatchObject({ + metrics: { memory: { available: true, totalPssKb: 52000, totalRssKb: 100112 } }, + }); + expect(sessionExec.mock.calls.map(([invocation]) => invocation.command)).toContainEqual([ + 'shell', + 'dumpsys', + 'meminfo', + 'com.android.settings', + ]); + assertRpcOk(await daemon.callCommand('close', [], scope, options)); + } finally { + await daemon.close(); + await runtime.shutdown(); + } +}); diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts index 7298f8a705..66a597827b 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts @@ -165,6 +165,9 @@ function limrunDependencies(ios: LimrunRuntimeDependencies['ios']): LimrunRuntim return { clientVersion: 'test', android: { + bindPerformance: async () => { + throw new Error('Unexpected performance binding'); + }, bindDeployment: async (device, _adb, request) => { const { createAndroidAppDeploymentOperations } = await import('@agent-device/platform-android/mechanics'); diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index 6d3412f55a..5dca13a202 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -301,6 +301,9 @@ function limrunDependencies(): LimrunRuntimeDependencies { return { clientVersion: 'test', android: { + bindPerformance: async () => { + throw new Error('Unexpected performance binding'); + }, bindDeployment: async () => { throw new Error('Unexpected deployment binding'); }, diff --git a/website/docs/docs/capabilities.md b/website/docs/docs/capabilities.md index a39e37ed74..0d21a92c96 100644 --- a/website/docs/docs/capabilities.md +++ b/website/docs/docs/capabilities.md @@ -233,7 +233,7 @@ What a **Yes** does not claim: | [Screen recording](/docs/commands#media-and-logs) | No | No | No | No | Yes | Yes | No | No | | [App logs](/docs/debugging-profiling#logs) | No | No | No | No | Yes | Yes | No | No | | [HTTP(S) request inspection](/docs/debugging-profiling#network-inspection) | No | No | No | No | Yes | Yes | No | No | -| [Performance samples (frames, memory, CPU profiles)](/docs/debugging-profiling#performance-snapshots) | No | No | No | No | No | No | No | No | +| [Performance samples (frames, memory, CPU profiles)](/docs/debugging-profiling#performance-snapshots) | No | No | No | No | No | Frame and memory samples and HPROF heap snapshots; native captures and CPU profiles are unavailable | No | No | | [Audio probe](/docs/debugging-profiling#audio-probes) | No | No | No | No | No | No | No | No | What a **Yes** does not claim: diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 883aff2bd1..66ce7dbec6 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -119,6 +119,23 @@ An installed Expo dev client can open with the same automatic launch URL discove For a local Metro server, connect with `--provider-network-logs` and include `localhost:8081`, or your Metro port, in `--provider-network-hosts`. While the session is open, the app can reach that port on the machine running agent-device. See [Capture session network traffic](#capture-session-network-traffic) for the network changes this option makes. Otherwise, use a publicly reachable HTTPS Metro endpoint. `port reverse` is Android-only. +## Read Android performance data + +On an open Android session, use the same commands as on a local emulator: + +```bash +agent-device perf frames --json +agent-device perf memory sample --json +agent-device perf memory snapshot --kind android-hprof --out ./heap.hprof +``` + +Frame statistics and memory samples describe the running app. A heap snapshot downloads an +HPROF file to your machine; the app must permit heap dumps. Reading frame statistics resets +the app's frame counters, as it does locally. Native Simpleperf and Perfetto captures remain +unavailable through the Limrun provider. + +Android `get text` also uses the shared live text reader when the selected element supports it. + ## Read iOS app logs After opening your app, use `logs start` to collect its stdout, stderr, and matching simulator logs. Use `open --launch-console ` to capture its initial launch output. Runner logs contain the test results Limrun provides, but native XCTest stdout is unavailable.