Skip to content

Commit 328f71f

Browse files
committed
refactor: migrate snapshot to device runtime
1 parent 856ff38 commit 328f71f

121 files changed

Lines changed: 2069 additions & 768 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/contracts/src/facades/platform.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export type {
209209
InstalledAppInfo,
210210
ListAppsInput,
211211
} from '../app-inventory-runtime.ts';
212-
export { appsRuntimeUse, defineUse } from '../platform-runtime-operations.ts';
212+
export { appsRuntimeUse, captureSnapshotUse, defineUse } from '../platform-runtime-operations.ts';
213213
export type {
214214
PlatformRuntimeHost,
215215
PlatformRuntimeModule,
@@ -227,6 +227,19 @@ export {
227227
shutdownTargetUse,
228228
} from '../platform-runtime-operations.ts';
229229
export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations.ts';
230+
export {
231+
bindLocalSnapshotInteractor,
232+
bindProviderSnapshotInteractor,
233+
} from '../snapshot-runtime.ts';
234+
export type {
235+
CaptureSnapshotInput,
236+
LocalSnapshotInteractorResolver,
237+
ProviderSnapshotInteractorResolver,
238+
SnapshotRuntimeExecution,
239+
SnapshotRuntimeHost,
240+
SnapshotRuntimeOperations,
241+
SnapshotResult,
242+
} from '../snapshot-runtime.ts';
230243
export type {
231244
AppStateRuntimeCommand,
232245
AppStateRuntimeCommandResult,

packages/contracts/src/interactor-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> &
173173
nodes?: RawSnapshotNode[];
174174
backend: Extract<
175175
SnapshotBackend,
176-
'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'web'
176+
'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'macos-helper' | 'web'
177177
>;
178178
};
179179

packages/contracts/src/platform-runtime-operations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state
1212
import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts';
1313
import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts';
1414
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
15+
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
1516
import type {
1617
DeviceReadinessRuntimeHost,
1718
DeviceReadinessRuntimeOperations,
@@ -41,6 +42,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
4142
AppStateRuntimeOperations &
4243
NetworkRuntimeOperations &
4344
ScreenRecordingRuntimeOperations &
45+
SnapshotRuntimeOperations &
4446
DeviceReadinessRuntimeOperations &
4547
DeviceShutdownRuntimeOperations &
4648
ApplicationLifecycleRuntimeOperations;
@@ -58,6 +60,7 @@ export const bootTargetHeadlessUse = defineUse({
5860
required: ['bootTargetHeadless'],
5961
});
6062
export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] });
63+
export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] });
6164
export const deviceBootRuntimeUses = Object.freeze([bootTargetUse, bootTargetHeadlessUse] as const);
6265

6366
export type DeviceReadinessRuntimePlan =
@@ -111,6 +114,7 @@ export type PlatformRuntimeHost = AppLogRuntimeHost &
111114
): Promise<import('./platform-runtime-host.ts').HostTemporaryTextFile>;
112115
}>;
113116
screenRecording: ScreenRecordingRuntimeHost;
117+
snapshot: SnapshotRuntimeHost;
114118
deviceReadiness: DeviceReadinessRuntimeHost;
115119
deviceShutdown: DeviceShutdownRuntimeHost;
116120
localInteractors: LocalApplicationInteractorHost;

packages/contracts/src/platform-runtime-unavailable.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
2222
appState?: RuntimeOperationUnavailability;
2323
network: RuntimeOperationUnavailability;
2424
screenRecording?: RuntimeOperationUnavailability;
25+
snapshot?: RuntimeOperationUnavailability;
2526
readiness?: RuntimeOperationUnavailability;
2627
shutdown?: RuntimeOperationUnavailability;
2728
lifecycle: ApplicationLifecycleOperationFacts;
@@ -34,6 +35,7 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<{
3435
appState: RuntimeOperationUnavailability;
3536
network: RuntimeOperationUnavailability;
3637
screenRecording: RuntimeOperationUnavailability;
38+
snapshot: RuntimeOperationUnavailability;
3739
readiness: RuntimeOperationUnavailability;
3840
shutdown: RuntimeOperationUnavailability;
3941
lifecycle: ApplicationLifecycleOperationFacts;
@@ -65,6 +67,7 @@ export function createUnavailablePlatformRuntimeFacts(
6567
appState,
6668
network,
6769
screenRecording,
70+
snapshot,
6871
readiness,
6972
shutdown,
7073
lifecycle,
@@ -90,6 +93,7 @@ export function createUnavailablePlatformRuntimeFacts(
9093
screenRecordingStart: screenRecording,
9194
screenRecordingReattach: screenRecording,
9295
screenRecordingCleanup: screenRecording,
96+
captureSnapshot: snapshot,
9397
ensureReady: readiness,
9498
bootTarget: readiness,
9599
bootTargetHeadless: readiness,
@@ -111,6 +115,7 @@ function freezeUnavailableFacts(
111115
screenRecording: Object.freeze({
112116
...(unavailable.screenRecording ?? unavailable.network),
113117
}),
118+
snapshot: Object.freeze({ ...(unavailable.snapshot ?? unavailable.network) }),
114119
readiness: Object.freeze({ ...(unavailable.readiness ?? unavailable.network) }),
115120
shutdown: Object.freeze({ ...(unavailable.shutdown ?? unavailable.network) }),
116121
lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle),
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { DeviceInfo } from '@agent-device/kernel/device';
4+
import type { Interactor, RunnerContext } from './interactor-types.ts';
5+
import { bindLocalSnapshotInteractor, bindProviderSnapshotInteractor } from './snapshot-runtime.ts';
6+
7+
const device: DeviceInfo = {
8+
id: 'snapshot-device',
9+
name: 'Snapshot Device',
10+
platform: 'android',
11+
kind: 'emulator',
12+
target: 'mobile',
13+
};
14+
15+
test('local snapshot binding injects its signal and resolves only its selected device', async () => {
16+
const controller = new AbortController();
17+
let resolvedDevice: DeviceInfo | undefined;
18+
let resolvedRunner: RunnerContext | undefined;
19+
let capturedOptions: Parameters<Interactor['snapshot']>[0];
20+
const operations = bindLocalSnapshotInteractor({
21+
device,
22+
signal: controller.signal,
23+
resolveInteractor: async (selectedDevice, runner) => {
24+
resolvedDevice = selectedDevice;
25+
resolvedRunner = runner;
26+
return {
27+
snapshot: async (options: Parameters<Interactor['snapshot']>[0]) => {
28+
capturedOptions = options;
29+
return { backend: 'android', nodes: [] };
30+
},
31+
} as unknown as Interactor;
32+
},
33+
});
34+
35+
assert.equal(resolvedDevice, undefined, 'binding must not eagerly construct the interactor');
36+
37+
await operations.captureSnapshot({
38+
options: {
39+
appBundleId: 'com.example.app',
40+
interactiveOnly: true,
41+
preferredBackend: 'private-ax',
42+
},
43+
execution: { requestId: 'request-1', verbose: true },
44+
});
45+
46+
assert.equal(resolvedDevice, device);
47+
assert.equal(resolvedRunner?.requestId, 'request-1');
48+
assert.equal(resolvedRunner?.appBundleId, 'com.example.app');
49+
assert.equal(resolvedRunner?.signal, controller.signal);
50+
assert.equal(capturedOptions?.interactiveOnly, true);
51+
assert.equal(capturedOptions?.preferredBackend, 'private-ax');
52+
assert.equal(capturedOptions?.signal, controller.signal);
53+
});
54+
55+
test('provider snapshot binding fails closed when its selected owner loses the interactor', async () => {
56+
const operations = bindProviderSnapshotInteractor({
57+
device,
58+
signal: new AbortController().signal,
59+
resolveInteractor: () => undefined,
60+
});
61+
62+
await assert.rejects(
63+
operations.captureSnapshot({}),
64+
(error: unknown) =>
65+
error instanceof Error &&
66+
error.message === 'Provider-owned snapshot operation has no bound provider interactor.',
67+
);
68+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { DeviceInfo } from '@agent-device/kernel/device';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
import type {
4+
Interactor,
5+
RunnerContext,
6+
SnapshotOptions,
7+
SnapshotResult,
8+
} from './interactor-types.ts';
9+
10+
export type { SnapshotResult } from './interactor-types.ts';
11+
12+
/** Runner metadata needed by the selected snapshot implementation, without request-owned state. */
13+
export type SnapshotRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundleId' | 'signal'>>;
14+
15+
/** Neutral snapshot intent. The request binding supplies cancellation and exact-owner authority. */
16+
export type CaptureSnapshotInput = Readonly<{
17+
options?: Readonly<Omit<SnapshotOptions, 'signal'>>;
18+
execution?: SnapshotRuntimeExecution;
19+
}>;
20+
21+
export type SnapshotRuntimeOperations = Readonly<{
22+
captureSnapshot(input: CaptureSnapshotInput): Promise<SnapshotResult>;
23+
}>;
24+
25+
/** Existing desktop-surface mechanics injected without exposing daemon/session ownership. */
26+
export type SnapshotRuntimeHost = Readonly<{
27+
apple: Readonly<{
28+
captureSurface(
29+
device: DeviceInfo,
30+
options: CaptureSnapshotInput['options'],
31+
signal: AbortSignal,
32+
): Promise<SnapshotResult>;
33+
}>;
34+
linux: Readonly<{
35+
captureSurface(
36+
device: DeviceInfo,
37+
options: CaptureSnapshotInput['options'],
38+
signal: AbortSignal,
39+
): Promise<SnapshotResult>;
40+
}>;
41+
}>;
42+
43+
export type LocalSnapshotInteractorResolver = (
44+
device: DeviceInfo,
45+
runner: RunnerContext,
46+
) => Promise<Interactor>;
47+
48+
export type ProviderSnapshotInteractorResolver = (runner: RunnerContext) => Interactor | undefined;
49+
50+
type SnapshotInteractorBindingParams =
51+
| Readonly<{
52+
device: DeviceInfo;
53+
signal: AbortSignal;
54+
ownership: 'local';
55+
resolveInteractor: LocalSnapshotInteractorResolver;
56+
}>
57+
| Readonly<{
58+
device: DeviceInfo;
59+
signal: AbortSignal;
60+
ownership: 'provider';
61+
resolveInteractor: ProviderSnapshotInteractorResolver;
62+
}>;
63+
64+
/** Captures one selected owner's interactor authority for the lifetime of a request binding. */
65+
function bindSnapshotInteractor(
66+
params: SnapshotInteractorBindingParams,
67+
): SnapshotRuntimeOperations {
68+
return Object.freeze({
69+
captureSnapshot: async (input) => {
70+
const runner: RunnerContext = {
71+
...input.execution,
72+
appBundleId: input.options?.appBundleId,
73+
signal: params.signal,
74+
};
75+
const interactor =
76+
params.ownership === 'local'
77+
? await params.resolveInteractor(params.device, runner)
78+
: params.resolveInteractor(runner);
79+
if (!interactor) {
80+
throw new AppError(
81+
'UNSUPPORTED_OPERATION',
82+
'Provider-owned snapshot operation has no bound provider interactor.',
83+
{ reason: 'provider-runtime-interactor-missing', deviceId: params.device.id },
84+
);
85+
}
86+
return await interactor.snapshot({ ...input.options, signal: params.signal });
87+
},
88+
});
89+
}
90+
91+
export function bindLocalSnapshotInteractor(
92+
params: Readonly<{
93+
device: DeviceInfo;
94+
signal: AbortSignal;
95+
resolveInteractor: LocalSnapshotInteractorResolver;
96+
}>,
97+
): SnapshotRuntimeOperations {
98+
return bindSnapshotInteractor({ ...params, ownership: 'local' });
99+
}
100+
101+
/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */
102+
export function bindProviderSnapshotInteractor(
103+
params: Readonly<{
104+
device: DeviceInfo;
105+
signal: AbortSignal;
106+
resolveInteractor: ProviderSnapshotInteractorResolver;
107+
}>,
108+
): SnapshotRuntimeOperations {
109+
return bindSnapshotInteractor({ ...params, ownership: 'provider' });
110+
}

packages/platform-android/src/runtime.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ test.each([
9393
expect(facts.operations.ensureReady).toEqual({ available: true });
9494
expect(facts.operations.bootTarget).toEqual({ available: true });
9595
expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator');
96+
expect(facts.operations.captureSnapshot).toEqual({ available: true });
97+
expect(binding.operations.captureSnapshot).toBeTypeOf('function');
9698

9799
await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({
98100
id: runtimeDevice.id,
@@ -259,6 +261,10 @@ test.each([
259261
expect(facts.operations.ensureReady).toEqual({ available: true });
260262
expect(facts.operations.bootTarget).toEqual({ available: true });
261263
expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator');
264+
expect(facts.operations.captureSnapshot.available).toBe(runtimeDevice.kind !== 'simulator');
265+
expect(binding.operations.captureSnapshot).toBeTypeOf(
266+
runtimeDevice.kind === 'simulator' ? 'undefined' : 'function',
267+
);
262268
expectLifecycleFacts(binding, legacy);
263269
},
264270
);

packages/platform-android/src/runtime.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
import {
1010
applicationLifecycleOperationFacts,
1111
availableApplicationLifecycleOperations,
12+
bindLocalSnapshotInteractor,
1213
localRuntimeOwner,
1314
} from '@agent-device/contracts/platform';
1415
import type { DeviceInfo } from '@agent-device/kernel/device';
@@ -65,6 +66,11 @@ const shutdownKindUnavailable = Object.freeze({
6566
reason: 'unsupported-device-kind',
6667
hint: 'shutdown is supported only for Apple simulators and Android emulators.',
6768
} as const);
69+
const snapshotKindUnavailable = Object.freeze({
70+
available: false,
71+
reason: 'unsupported-device-kind',
72+
hint: 'snapshot is supported only for Android emulators and devices.',
73+
} as const);
6874

6975
function androidLifecycleFacts(device: DeviceInfo) {
7076
const openTarget = androidOpenTargetFact(device);
@@ -114,6 +120,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
114120
screenRecordingStart: available,
115121
screenRecordingReattach: available,
116122
screenRecordingCleanup: available,
123+
captureSnapshot: device.kind === 'simulator' ? snapshotKindUnavailable : available,
117124
ensureReady: available,
118125
bootTarget: available,
119126
bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable,
@@ -160,6 +167,13 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
160167
networkDump: async (input: NetworkDumpInput) =>
161168
await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal),
162169
...recording,
170+
...(facts.operations.captureSnapshot.available
171+
? bindLocalSnapshotInteractor({
172+
device: request.device,
173+
signal: request.scope.signal,
174+
resolveInteractor: host.localInteractors.resolve,
175+
})
176+
: {}),
163177
ensureReady: async (input: EnsureReadyInput) =>
164178
await ensureAndroidReady(
165179
host,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type {
2+
CaptureSnapshotInput,
3+
PlatformRuntimeHost,
4+
PlatformRuntimeOperations,
5+
} from '@agent-device/contracts/platform';
6+
import { bindLocalSnapshotInteractor } from '@agent-device/contracts/platform';
7+
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
8+
9+
/** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */
10+
export function bindAppleSnapshotRuntime(
11+
host: PlatformRuntimeHost,
12+
request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>,
13+
): SnapshotRuntimeOperation {
14+
const appSnapshot = bindLocalSnapshotInteractor({
15+
device: request.device,
16+
signal: request.signal,
17+
resolveInteractor: host.localInteractors.resolve,
18+
});
19+
return Object.freeze({
20+
captureSnapshot: async (input: CaptureSnapshotInput) => {
21+
if (
22+
isMacOs(request.device) &&
23+
input.options?.surface !== undefined &&
24+
input.options.surface !== 'app'
25+
) {
26+
return await host.snapshot.apple.captureSurface(
27+
request.device,
28+
input.options,
29+
request.signal,
30+
);
31+
}
32+
return await appSnapshot.captureSnapshot(input);
33+
},
34+
});
35+
}
36+
37+
type SnapshotRuntimeOperation = Pick<PlatformRuntimeOperations, 'captureSnapshot'>;

0 commit comments

Comments
 (0)