Skip to content
24 changes: 23 additions & 1 deletion packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,14 @@ export type {
InstalledAppInfo,
ListAppsInput,
} from '../app-inventory-runtime.ts';
export { appsRuntimeUse, defineUse } from '../platform-runtime-operations.ts';
export {
appsRuntimeUse,
captureSnapshotUse,
defineUse,
resolveSnapshotRuntimePlan,
snapshotRuntimePlanUses,
} from '../platform-runtime-operations.ts';
export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type {
PlatformRuntimeHost,
PlatformRuntimeModule,
Expand All @@ -227,6 +234,21 @@ export {
shutdownTargetUse,
} from '../platform-runtime-operations.ts';
export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations.ts';
export {
bindLocalSnapshotInteractor,
bindProviderSnapshotInteractor,
snapshotRuntimeOperationFacts,
} from '../snapshot-runtime.ts';
export type {
CaptureSnapshotInput,
LocalSnapshotInteractorResolver,
ProviderSnapshotInteractorResolver,
SnapshotRuntimeExecution,
SnapshotRuntimeHost,
SnapshotRuntimeOperations,
SnapshotRuntimeOperationFacts,
SnapshotResult,
} from '../snapshot-runtime.ts';
export type {
AppStateRuntimeCommand,
AppStateRuntimeCommandResult,
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> &
nodes?: RawSnapshotNode[];
backend: Extract<
SnapshotBackend,
'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'web'
'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'macos-helper' | 'web'
>;
};

Expand Down
43 changes: 43 additions & 0 deletions packages/contracts/src/platform-runtime-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { localRuntimeOwner, providerRuntimeOwner } from './platform-runtime.ts';
import {
bootTargetHeadlessUse,
bootTargetUse,
captureSnapshotUse,
resolveDeviceReadinessRuntimePlan,
resolveSnapshotRuntimePlan,
type PlatformRuntimeProviderModule,
} from './platform-runtime-operations.ts';

Expand Down Expand Up @@ -50,3 +52,44 @@ test.each([
});
},
);

test.each([
[false, true, 'active-app', 'captureSnapshot', captureSnapshotUse],
[
true,
true,
'custom-actions-active-app',
'captureSnapshotWithCustomActions',
{ required: ['captureSnapshot', 'captureSnapshotWithCustomActions'], preferred: [] },
],
[
false,
false,
'without-active-app',
'captureSnapshotWithoutActiveApp',
{ required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'], preferred: [] },
],
[
true,
false,
'custom-actions-without-active-app',
'captureSnapshotWithCustomActions',
{
required: [
'captureSnapshot',
'captureSnapshotWithCustomActions',
'captureSnapshotWithoutActiveApp',
],
preferred: [],
},
],
] as const)(
'normalizes snapshot customActions=%s activeApp=%s into %s',
(customActions, hasActiveApp, kind, operation, use) => {
assert.deepEqual(resolveSnapshotRuntimePlan({ customActions, hasActiveApp }), {
kind,
operation,
use,
});
},
);
73 changes: 73 additions & 0 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state
import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts';
import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts';
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
import type {
DeviceReadinessRuntimeHost,
DeviceReadinessRuntimeOperations,
Expand Down Expand Up @@ -41,6 +42,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
AppStateRuntimeOperations &
NetworkRuntimeOperations &
ScreenRecordingRuntimeOperations &
SnapshotRuntimeOperations &
DeviceReadinessRuntimeOperations &
DeviceShutdownRuntimeOperations &
ApplicationLifecycleRuntimeOperations;
Expand All @@ -58,6 +60,76 @@ export const bootTargetHeadlessUse = defineUse({
required: ['bootTargetHeadless'],
});
export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] });
export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] });
const captureSnapshotWithCustomActionsUse = defineUse({
required: ['captureSnapshot', 'captureSnapshotWithCustomActions'],
});
const captureSnapshotWithoutActiveAppUse = defineUse({
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
});
const captureSnapshotWithCustomActionsWithoutActiveAppUse = defineUse({
required: [
'captureSnapshot',
'captureSnapshotWithCustomActions',
'captureSnapshotWithoutActiveApp',
],
});

export const snapshotRuntimePlanUses = Object.freeze([
captureSnapshotUse,
captureSnapshotWithCustomActionsUse,
captureSnapshotWithoutActiveAppUse,
captureSnapshotWithCustomActionsWithoutActiveAppUse,
] as const);

export type SnapshotRuntimePlan =
| Readonly<{
kind: 'active-app';
operation: 'captureSnapshot';
use: typeof captureSnapshotUse;
}>
| Readonly<{
kind: 'custom-actions-active-app';
operation: 'captureSnapshotWithCustomActions';
use: typeof captureSnapshotWithCustomActionsUse;
}>
| Readonly<{
kind: 'custom-actions-without-active-app';
operation: 'captureSnapshotWithCustomActions';
use: typeof captureSnapshotWithCustomActionsWithoutActiveAppUse;
}>
| Readonly<{
kind: 'without-active-app';
operation: 'captureSnapshotWithoutActiveApp';
use: typeof captureSnapshotWithoutActiveAppUse;
}>;

/** Selects one owner-fact-backed capture plan from normalized command/session intent. */
export function resolveSnapshotRuntimePlan(input: {
customActions: boolean;
hasActiveApp: boolean;
}): SnapshotRuntimePlan {
if (input.customActions) {
return input.hasActiveApp
? Object.freeze({
kind: 'custom-actions-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsUse,
})
: Object.freeze({
kind: 'custom-actions-without-active-app',
operation: 'captureSnapshotWithCustomActions',
use: captureSnapshotWithCustomActionsWithoutActiveAppUse,
});
}
return input.hasActiveApp
? Object.freeze({ kind: 'active-app', operation: 'captureSnapshot', use: captureSnapshotUse })
: Object.freeze({
kind: 'without-active-app',
operation: 'captureSnapshotWithoutActiveApp',
use: captureSnapshotWithoutActiveAppUse,
});
}
export const deviceBootRuntimeUses = Object.freeze([bootTargetUse, bootTargetHeadlessUse] as const);

export type DeviceReadinessRuntimePlan =
Expand Down Expand Up @@ -111,6 +183,7 @@ export type PlatformRuntimeHost = AppLogRuntimeHost &
): Promise<import('./platform-runtime-host.ts').HostTemporaryTextFile>;
}>;
screenRecording: ScreenRecordingRuntimeHost;
snapshot: SnapshotRuntimeHost;
deviceReadiness: DeviceReadinessRuntimeHost;
deviceShutdown: DeviceShutdownRuntimeHost;
localInteractors: LocalApplicationInteractorHost;
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
RuntimeOperationUnavailability,
RuntimeOwnerRef,
} from './platform-runtime.ts';
import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts';

/**
* A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics:
Expand All @@ -22,6 +23,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
appState?: RuntimeOperationUnavailability;
network: RuntimeOperationUnavailability;
screenRecording?: RuntimeOperationUnavailability;
snapshot?: RuntimeOperationUnavailability;
readiness?: RuntimeOperationUnavailability;
shutdown?: RuntimeOperationUnavailability;
lifecycle: ApplicationLifecycleOperationFacts;
Expand All @@ -34,6 +36,7 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<{
appState: RuntimeOperationUnavailability;
network: RuntimeOperationUnavailability;
screenRecording: RuntimeOperationUnavailability;
snapshot: RuntimeOperationUnavailability;
readiness: RuntimeOperationUnavailability;
shutdown: RuntimeOperationUnavailability;
lifecycle: ApplicationLifecycleOperationFacts;
Expand Down Expand Up @@ -65,6 +68,7 @@ export function createUnavailablePlatformRuntimeFacts(
appState,
network,
screenRecording,
snapshot,
readiness,
shutdown,
lifecycle,
Expand All @@ -90,6 +94,11 @@ export function createUnavailablePlatformRuntimeFacts(
screenRecordingStart: screenRecording,
screenRecordingReattach: screenRecording,
screenRecordingCleanup: screenRecording,
...snapshotRuntimeOperationFacts({
capture: snapshot,
customActions: snapshot,
withoutActiveApp: snapshot,
}),
ensureReady: readiness,
bootTarget: readiness,
bootTargetHeadless: readiness,
Expand All @@ -111,6 +120,7 @@ function freezeUnavailableFacts(
screenRecording: Object.freeze({
...(unavailable.screenRecording ?? unavailable.network),
}),
snapshot: Object.freeze({ ...(unavailable.snapshot ?? unavailable.network) }),
readiness: Object.freeze({ ...(unavailable.readiness ?? unavailable.network) }),
shutdown: Object.freeze({ ...(unavailable.shutdown ?? unavailable.network) }),
lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle),
Expand Down
68 changes: 68 additions & 0 deletions packages/contracts/src/snapshot-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { Interactor, RunnerContext } from './interactor-types.ts';
import { bindLocalSnapshotInteractor, bindProviderSnapshotInteractor } from './snapshot-runtime.ts';

const device: DeviceInfo = {
id: 'snapshot-device',
name: 'Snapshot Device',
platform: 'android',
kind: 'emulator',
target: 'mobile',
};

test('local snapshot binding injects its signal and resolves only its selected device', async () => {
const controller = new AbortController();
let resolvedDevice: DeviceInfo | undefined;
let resolvedRunner: RunnerContext | undefined;
let capturedOptions: Parameters<Interactor['snapshot']>[0];
const operations = bindLocalSnapshotInteractor({
device,
signal: controller.signal,
resolveInteractor: async (selectedDevice, runner) => {
resolvedDevice = selectedDevice;
resolvedRunner = runner;
return {
snapshot: async (options: Parameters<Interactor['snapshot']>[0]) => {
capturedOptions = options;
return { backend: 'android', nodes: [] };
},
} as unknown as Interactor;
},
});

assert.equal(resolvedDevice, undefined, 'binding must not eagerly construct the interactor');

await operations.captureSnapshot({
options: {
appBundleId: 'com.example.app',
interactiveOnly: true,
preferredBackend: 'private-ax',
},
execution: { requestId: 'request-1', verbose: true },
});

assert.equal(resolvedDevice, device);
assert.equal(resolvedRunner?.requestId, 'request-1');
assert.equal(resolvedRunner?.appBundleId, 'com.example.app');
assert.equal(resolvedRunner?.signal, controller.signal);
assert.equal(capturedOptions?.interactiveOnly, true);
assert.equal(capturedOptions?.preferredBackend, 'private-ax');
assert.equal(capturedOptions?.signal, controller.signal);
});

test('provider snapshot binding fails closed when its selected owner loses the interactor', async () => {
const operations = bindProviderSnapshotInteractor({
device,
signal: new AbortController().signal,
resolveInteractor: () => undefined,
});

await assert.rejects(
operations.captureSnapshot({}),
(error: unknown) =>
error instanceof Error &&
error.message === 'Provider-owned snapshot operation has no bound provider interactor.',
);
});
Loading
Loading