From 7ba110ad70704bc88616498beb32e81d7b809d47 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Fri, 6 Nov 2026 15:06:51 +0300 Subject: [PATCH 1/3] feat(limrun): share Android text and performance reads --- packages/platform-android/src/mechanics.ts | 7 ++ .../src/android-perf-runtime.test.ts | 63 +++++++++++++ .../src/android-perf-runtime.ts | 25 ++++++ .../src/app-log-runtime.fixtures.ts | 1 + .../src/app-log-runtime.test.ts | 8 +- .../provider-limrun/src/app-log-runtime.ts | 6 ++ packages/provider-limrun/src/facts-runtime.ts | 8 +- .../src/interaction-operations.ts | 14 +++ .../src/runtime-dependencies.fixtures.ts | 3 + .../src/runtime-dependencies.test.ts | 3 + .../src/runtime-dependencies.ts | 10 ++- packages/provider-limrun/src/runtime.ts | 14 +++ scripts/capability-matrix/annotations.ts | 10 +++ src/platform-runtime-gateway.fixtures.ts | 3 + src/sdk/limrun-android-performance.test.ts | 90 +++++++++++++++++++ src/sdk/limrun-runtime-dependencies.ts | 10 +++ ...limrun-deployment-cancellation.fixtures.ts | 3 + .../stale-provider-runtime-admission.test.ts | 3 + website/docs/docs/capabilities.md | 2 +- website/docs/docs/limrun.md | 17 ++++ 20 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 packages/provider-limrun/src/android-perf-runtime.test.ts create mode 100644 packages/provider-limrun/src/android-perf-runtime.ts create mode 100644 src/sdk/limrun-android-performance.test.ts 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..a41446133b --- /dev/null +++ b/packages/provider-limrun/src/android-perf-runtime.test.ts @@ -0,0 +1,63 @@ +import { expect, test } 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'; + +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 owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + hasLiveSession: () => live, + getInteractor: () => + ({ readTextAtPoint: async () => 'current field value' }) as unknown as Interactor, + bindPerformance: async () => + live + ? { + perfFrames: async () => ({ metric: { available: true, totalFrameCount: 100 } }), + perfMemorySample: async () => ({ metric: { available: true, totalPssKb: 512 } }), + perfMemorySnapshot: async () => ({ + artifact: { available: true, path: '/tmp/heap.hprof' }, + }), + } + : 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 } }); + live = false; + 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](); +}); 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..f698255774 --- /dev/null +++ b/packages/provider-limrun/src/android-perf-runtime.ts @@ -0,0 +1,25 @@ +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(); + 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..fd0a486366 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'; @@ -57,6 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< 'isSessionActive' > & Readonly<{ + bindPerformance(device: DeviceInfo): Promise; host: PlatformRuntimeHost; runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; @@ -206,6 +209,9 @@ function bindLimrunAppLogs( ), ...interaction, ...runnerOperations, + ...(device.platform === 'android' + ? 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. 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..d96f496677 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,3 +1,5 @@ +import { bindElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; +import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { type RuntimeFacts, @@ -138,6 +140,18 @@ export function bindLimrunInteractionOperations( const { device, signal } = params; const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); return Object.freeze({ + ...(device.platform === 'android' + ? bindElementTextRuntime({ + device, + signal, + resolveInteractor: async (_device, runner) => { + const interactor = resolveInteractor(runner); + if (!interactor) + throw invalidRuntimeContract('Limrun Android interactor is unavailable'); + return interactor; + }, + }) + : {}), ...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-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. From 99be56862fdadcbbfa38aebbf48b2dbac7964dcc Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Fri, 6 Nov 2026 17:50:47 +0300 Subject: [PATCH 2/3] fix(limrun): guard Android reads and cover session routing --- .../src/element-text-runtime.test.ts | 20 +++ .../contracts/src/element-text-runtime.ts | 18 +++ .../src/android-perf-runtime.test.ts | 52 +++++++- .../src/android-perf-runtime.ts | 1 + .../provider-limrun/src/app-log-runtime.ts | 7 +- .../src/interaction-operations.ts | 26 ++-- .../limrun-android-performance.test.ts | 116 ++++++++++++++++++ 7 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 test/integration/provider-scenarios/limrun-android-performance.test.ts 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..3597517bad 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,20 @@ 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) => { + const { providerInteractorSource } = await import('./interactor-operation-binding.ts'); + return await providerInteractorSource({ ...params, operation: 'element-text' })(runner); + }, + }); +} diff --git a/packages/provider-limrun/src/android-perf-runtime.test.ts b/packages/provider-limrun/src/android-perf-runtime.test.ts index a41446133b..ec39a1a791 100644 --- a/packages/provider-limrun/src/android-perf-runtime.test.ts +++ b/packages/provider-limrun/src/android-perf-runtime.test.ts @@ -1,8 +1,9 @@ -import { expect, test } from 'vitest'; +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', @@ -15,19 +16,23 @@ const device: DeviceInfo = { 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: () => - ({ readTextAtPoint: async () => 'current field value' }) as unknown as Interactor, + live + ? ({ readTextAtPoint: async () => 'current field value' } as unknown as Interactor) + : undefined, bindPerformance: async () => live ? { - perfFrames: async () => ({ metric: { available: true, totalFrameCount: 100 } }), + perfFrames, perfMemorySample: async () => ({ metric: { available: true, totalPssKb: 512 } }), - perfMemorySnapshot: async () => ({ - artifact: { available: true, path: '/tmp/heap.hprof' }, - }), + perfMemorySnapshot, } : undefined, }), @@ -51,7 +56,26 @@ test('a live Android owner binds reads and rejects a lease lost after binding', 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.perfMemorySample!({ appId: 'com.example.app' }), ).rejects.toMatchObject({ code: 'DEVICE_NOT_FOUND' }); @@ -61,3 +85,19 @@ test('a live Android owner binds reads and rejects a lease lost after binding', ).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 index f698255774..700c61a1ad 100644 --- a/packages/provider-limrun/src/android-perf-runtime.ts +++ b/packages/provider-limrun/src/android-perf-runtime.ts @@ -13,6 +13,7 @@ export function createLimrunAndroidPerfOperations( 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; diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index fd0a486366..56f73adcb3 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -13,6 +13,7 @@ import { type DeviceBinding, providerRuntimeOwner, sameRuntimeOwner, + whenAdmitted, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeHost, @@ -209,9 +210,9 @@ function bindLimrunAppLogs( ), ...interaction, ...runnerOperations, - ...(device.platform === 'android' - ? createLimrunAndroidPerfOperations(() => options.bindPerformance(device), signal) - : {}), + ...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. diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index d96f496677..d95add71ba 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,11 +1,12 @@ -import { bindElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; -import { invalidRuntimeContract } from '@agent-device/contracts/runtime-contract-error'; +import { bindProviderElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; +import { AppError } from '@agent-device/kernel/errors'; 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 { @@ -138,20 +139,15 @@ export function bindLimrunInteractionOperations( }>, ) { const { device, signal } = params; - const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); + const resolveInteractor = (runner: RunnerContext) => { + const interactor = params.getInteractor(device, runner); + if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); + return interactor; + }; return Object.freeze({ - ...(device.platform === 'android' - ? bindElementTextRuntime({ - device, - signal, - resolveInteractor: async (_device, runner) => { - const interactor = resolveInteractor(runner); - if (!interactor) - throw invalidRuntimeContract('Limrun Android interactor is unavailable'); - return interactor; - }, - }) - : {}), + ...whenAdmitted(params.facts.readTextAtPoint, () => + bindProviderElementTextRuntime({ device, signal, resolveInteractor }), + ), ...bindProviderSnapshotInteractor({ device, signal, 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(); + } +}); From 3848d114b36d6de352ee20827fbb8e4cda9996c1 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Fri, 6 Nov 2026 18:57:25 +0300 Subject: [PATCH 3/3] refactor(limrun): centralize required interactor lookup --- .../contracts/src/element-text-runtime.ts | 1 + .../src/android-perf-runtime.test.ts | 3 +++ .../provider-limrun/src/app-log-runtime.ts | 24 ++++++++----------- .../src/interaction-operations.ts | 9 ++----- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/contracts/src/element-text-runtime.ts b/packages/contracts/src/element-text-runtime.ts index 3597517bad..929d7dbdf0 100644 --- a/packages/contracts/src/element-text-runtime.ts +++ b/packages/contracts/src/element-text-runtime.ts @@ -135,6 +135,7 @@ export function bindProviderElementTextRuntime( 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/provider-limrun/src/android-perf-runtime.test.ts b/packages/provider-limrun/src/android-perf-runtime.test.ts index ec39a1a791..e7d3365b1b 100644 --- a/packages/provider-limrun/src/android-perf-runtime.test.ts +++ b/packages/provider-limrun/src/android-perf-runtime.test.ts @@ -76,6 +76,9 @@ test('a live Android owner binds reads and rejects a lease lost after binding', 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' }); diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 56f73adcb3..22f4dfafa7 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -165,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 = { @@ -191,19 +192,14 @@ 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, @@ -223,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/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index d95add71ba..ccaf03840b 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -1,5 +1,4 @@ import { bindProviderElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; -import { AppError } from '@agent-device/kernel/errors'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { type RuntimeFacts, @@ -133,17 +132,13 @@ 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']; }>, ) { const { device, signal } = params; - const resolveInteractor = (runner: RunnerContext) => { - const interactor = params.getInteractor(device, runner); - if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); - return interactor; - }; + const resolveInteractor = (runner: RunnerContext) => params.getInteractor(device, runner); return Object.freeze({ ...whenAdmitted(params.facts.readTextAtPoint, () => bindProviderElementTextRuntime({ device, signal, resolveInteractor }),