Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/contracts/src/element-text-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
bindElementTextRuntime,
bindProviderElementTextRuntime,
elementTextRead,
type ElementTextReadOutcome,
type ElementTextUnreadableReason,
Expand Down Expand Up @@ -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 ' });
Expand Down
19 changes: 19 additions & 0 deletions packages/contracts/src/element-text-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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);
},
});
}
7 changes: 7 additions & 0 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('./perf/runtime.ts').createAndroidPerfOperations>[0],
) {
const { createAndroidPerfOperations: bind } = await import('./perf/runtime.ts');
return bind(params);
}
106 changes: 106 additions & 0 deletions packages/provider-limrun/src/android-perf-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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: () =>
Comment thread
muvaf marked this conversation as resolved.
Resolved
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' }),
Comment thread
muvaf marked this conversation as resolved.
Resolved
).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();
});
26 changes: 26 additions & 0 deletions packages/provider-limrun/src/android-perf-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<LimrunAndroidPerfOperations | undefined>,
signal: AbortSignal,
): LimrunAndroidPerfOperations {
const load = async () => {
signal.throwIfAborted();
const operations = await bind();
Comment thread
muvaf marked this conversation as resolved.
Resolved
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),
};
}
1 change: 1 addition & 0 deletions packages/provider-limrun/src/app-log-runtime.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function limrunOwnerOptions(
overrides: Partial<LimrunPlatformRuntimeOwnerOptions> = {},
): LimrunPlatformRuntimeOwnerOptions {
return {
bindPerformance: async () => undefined,
host: unusedLimrunHost(),
runtimeInstance: 'default',
ownsDevice: () => true,
Expand Down
8 changes: 2 additions & 6 deletions packages/provider-limrun/src/app-log-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
31 changes: 17 additions & 14 deletions packages/provider-limrun/src/app-log-runtime.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +13,7 @@ import {
type DeviceBinding,
providerRuntimeOwner,
sameRuntimeOwner,
whenAdmitted,
} from '@agent-device/contracts/platform-runtime';
import type {
PlatformRuntimeHost,
Expand Down Expand Up @@ -57,6 +60,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit<
'isSessionActive'
> &
Readonly<{
bindPerformance(device: DeviceInfo): Promise<LimrunAndroidPerfOperations | undefined>;
host: PlatformRuntimeHost;
runtimeInstance: string;
ownsDevice(device: DeviceInfo): boolean;
Expand Down Expand Up @@ -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 = {
Expand All @@ -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.
Expand All @@ -216,7 +219,7 @@ function bindLimrunAppLogs(
...bindAdmittedProviderInteractorOperations({
device,
signal,
resolveInteractor: (runner) => options.getInteractor(device, runner),
resolveInteractor: (runner) => requireLimrunInteractor(device, runner),
facts: runtimeFacts.operations,
}),
...createLimrunAppDeploymentOperations(
Expand Down
8 changes: 4 additions & 4 deletions packages/provider-limrun/src/facts-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function limrunAppLogFacts(
findText: customSnapshotUnavailable,
querySelector: customSnapshotUnavailable,
}),
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }),
...elementTextRuntimeOperationFacts({ readTextAtPoint: available }),
}
: {}),
...screenshotRuntimeOperationFacts({ capture: available }),
Expand All @@ -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,
}),
Expand Down
7 changes: 6 additions & 1 deletion packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -130,14 +132,17 @@ 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<PlatformRuntimeOperations>['operations'];
}>,
) {
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,
Expand Down
3 changes: 3 additions & 0 deletions packages/provider-limrun/src/runtime-dependencies.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
Expand Down
3 changes: 3 additions & 0 deletions packages/provider-limrun/src/runtime-dependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
Expand Down
Loading
Loading