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
16 changes: 15 additions & 1 deletion packages/contracts/src/facades/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,13 @@ export {
appsRuntimeUse,
captureSnapshotUse,
defineUse,
resolveScreenshotRuntimePlan,
resolveSnapshotRuntimePlan,
screenshotRuntimePlanUses,
snapshotRuntimePlanUses,
viewportRuntimeUse,
} from '../platform-runtime-operations.ts';
export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type { ScreenshotRuntimePlan, SnapshotRuntimePlan } from '../platform-runtime-operations.ts';
export type {
PlatformRuntimeHost,
PlatformRuntimeModule,
Expand All @@ -235,6 +237,18 @@ export {
shutdownTargetUse,
} from '../platform-runtime-operations.ts';
export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations.ts';
export {
bindLocalScreenshotInteractor,
bindProviderScreenshotInteractor,
screenshotRuntimeOperationFacts,
} from '../screenshot-runtime.ts';
export type {
CaptureScreenshotInput,
ScreenshotOptions,
ScreenshotRuntimeExecution,
ScreenshotRuntimeOperations,
ScreenshotRuntimeOperationFacts,
} from '../screenshot-runtime.ts';
export {
bindLocalSnapshotInteractor,
bindProviderSnapshotInteractor,
Expand Down
37 changes: 37 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 { ScreenshotRuntimeOperations } from './screenshot-runtime.ts';
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
import type { ViewportRuntimeOperations } from './viewport-runtime.ts';
import type {
Expand Down Expand Up @@ -43,6 +44,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
AppStateRuntimeOperations &
NetworkRuntimeOperations &
ScreenRecordingRuntimeOperations &
ScreenshotRuntimeOperations &
SnapshotRuntimeOperations &
ViewportRuntimeOperations &
DeviceReadinessRuntimeOperations &
Expand Down Expand Up @@ -133,6 +135,41 @@ export function resolveSnapshotRuntimePlan(input: {
use: captureSnapshotWithoutActiveAppUse,
});
}

const captureScreenshotUse = defineUse({ required: ['captureScreenshot'] });
/**
* `--overlay-refs` annotates the capture with the refs of a snapshot taken in the same request, so
* the snapshot is part of what the command requires — not something to discover after the PNG is
* already on disk. Declaring it in the use is what lets admission refuse the whole request up
* front on a target that can capture pixels but not a tree.
*/
const captureScreenshotWithOverlayRefsUse = defineUse({
required: ['captureScreenshot', 'captureSnapshot'],
});

export const screenshotRuntimePlanUses = Object.freeze([
captureScreenshotUse,
captureScreenshotWithOverlayRefsUse,
] as const);

export type ScreenshotRuntimePlan =
| Readonly<{ kind: 'capture'; use: typeof captureScreenshotUse }>
| Readonly<{
kind: 'capture-with-overlay-refs';
use: typeof captureScreenshotWithOverlayRefsUse;
}>;

/** Selects one owner-fact-backed capture plan from normalized command intent. */
export function resolveScreenshotRuntimePlan(
input: Readonly<{ overlayRefs: boolean }>,
): ScreenshotRuntimePlan {
return input.overlayRefs
? Object.freeze({
kind: 'capture-with-overlay-refs',
use: captureScreenshotWithOverlayRefsUse,
})
: Object.freeze({ kind: 'capture', use: captureScreenshotUse });
}
export const deviceBootRuntimeUses = Object.freeze([bootTargetUse, bootTargetHeadlessUse] as const);

export type DeviceReadinessRuntimePlan =
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test('generic unavailable binding preserves exact provider ownership and mode',
const binding = createUnavailablePlatformRuntimeBinding(device, owner, {
appLog: { available: false, reason: 'unsupported-provider-mode' },
network: { available: false, reason: 'owner-capability-missing' },
screenshot: { available: false, reason: 'unsupported-device-kind' },
viewport: { available: false, reason: 'unsupported-platform-leaf' },
lifecycle,
});
Expand All @@ -39,6 +40,11 @@ test('generic unavailable binding preserves exact provider ownership and mode',
available: false,
reason: 'unsupported-platform-leaf',
});
// Both capture cells are owner-stated, so neither inherits the network gap's reason.
assert.deepEqual(binding.facts.operations.captureScreenshot, {
available: false,
reason: 'unsupported-device-kind',
});
assert.deepEqual(binding.operations, {});
await binding[Symbol.asyncDispose]();
});
27 changes: 18 additions & 9 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 { screenshotRuntimeOperationFacts } from './screenshot-runtime.ts';
import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts';
import { viewportRuntimeOperationFacts } from './viewport-runtime.ts';

Expand All @@ -24,6 +25,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
appState?: RuntimeOperationUnavailability;
network: RuntimeOperationUnavailability;
screenRecording?: RuntimeOperationUnavailability;
screenshot: RuntimeOperationUnavailability;
snapshot?: RuntimeOperationUnavailability;
viewport: RuntimeOperationUnavailability;
readiness?: RuntimeOperationUnavailability;
Expand All @@ -38,6 +40,7 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<{
appState: RuntimeOperationUnavailability;
network: RuntimeOperationUnavailability;
screenRecording: RuntimeOperationUnavailability;
screenshot: RuntimeOperationUnavailability;
snapshot: RuntimeOperationUnavailability;
viewport: RuntimeOperationUnavailability;
readiness: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -71,6 +74,7 @@ export function createUnavailablePlatformRuntimeFacts(
appState,
network,
screenRecording,
screenshot,
snapshot,
viewport,
readiness,
Expand Down Expand Up @@ -98,6 +102,7 @@ export function createUnavailablePlatformRuntimeFacts(
screenRecordingStart: screenRecording,
screenRecordingReattach: screenRecording,
screenRecordingCleanup: screenRecording,
...screenshotRuntimeOperationFacts({ capture: screenshot }),
...snapshotRuntimeOperationFacts({
capture: snapshot,
customActions: snapshot,
Expand All @@ -116,19 +121,23 @@ export function createUnavailablePlatformRuntimeFacts(
function freezeUnavailableFacts(
unavailable: UnavailablePlatformRuntimeFacts,
): FrozenUnavailablePlatformRuntimeFacts {
// Every optional cell falls back to the caller's network gap: an owner that did not classify a
// family has, by construction, the same reason its transport does.
const orNetwork = (fact: RuntimeOperationUnavailability | undefined) =>
Object.freeze({ ...(fact ?? unavailable.network) });
return Object.freeze({
appLog: Object.freeze({ ...unavailable.appLog }),
apps: Object.freeze({ ...(unavailable.apps ?? unavailable.network) }),
appDeployment: Object.freeze({ ...(unavailable.appDeployment ?? unavailable.network) }),
appState: Object.freeze({ ...(unavailable.appState ?? unavailable.network) }),
apps: orNetwork(unavailable.apps),
appDeployment: orNetwork(unavailable.appDeployment),
appState: orNetwork(unavailable.appState),
network: Object.freeze({ ...unavailable.network }),
screenRecording: Object.freeze({
...(unavailable.screenRecording ?? unavailable.network),
}),
snapshot: Object.freeze({ ...(unavailable.snapshot ?? unavailable.network) }),
screenRecording: orNetwork(unavailable.screenRecording),
// Capture cells are stated by their owner, never inherited from the transport gap (#1873).
screenshot: Object.freeze({ ...unavailable.screenshot }),
snapshot: orNetwork(unavailable.snapshot),
viewport: Object.freeze({ ...unavailable.viewport }),
readiness: Object.freeze({ ...(unavailable.readiness ?? unavailable.network) }),
shutdown: Object.freeze({ ...(unavailable.shutdown ?? unavailable.network) }),
readiness: orNetwork(unavailable.readiness),
shutdown: orNetwork(unavailable.shutdown),
lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle),
});
}
56 changes: 56 additions & 0 deletions packages/contracts/src/screenshot-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test, vi } from 'vitest';
import type { Interactor } from './interactor-types.ts';
import {
bindLocalScreenshotInteractor,
bindProviderScreenshotInteractor,
screenshotRuntimeOperationFacts,
} from './screenshot-runtime.ts';

const device = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
} as const;

test('builds the exact screenshot operation fact catalog', () => {
const capture = { available: true } as const;
expect(screenshotRuntimeOperationFacts({ capture })).toEqual({ captureScreenshot: capture });
});

test('a local binding hands the interactor the destination, options, and the request signal', async () => {
const screenshot = vi.fn(async () => {});
const resolveInteractor = vi.fn(async () => ({ screenshot }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindLocalScreenshotInteractor({ device, signal, resolveInteractor });
await operations.captureScreenshot({
outPath: '/tmp/out.png',
options: { appBundleId: 'com.example.app', fullscreen: true },
execution: { logPath: '/tmp/daemon.log' },
});

expect(resolveInteractor).toHaveBeenCalledWith(device, {
logPath: '/tmp/daemon.log',
appBundleId: 'com.example.app',
signal,
});
expect(screenshot).toHaveBeenCalledWith('/tmp/out.png', {
appBundleId: 'com.example.app',
fullscreen: true,
});
});

test('a provider binding fails closed when its exact owner exposes no interactor', async () => {
const operations = bindProviderScreenshotInteractor({
device,
signal: new AbortController().signal,
resolveInteractor: () => undefined,
});

await expect(operations.captureScreenshot({ outPath: '/tmp/out.png' })).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
details: { reason: 'provider-runtime-interactor-missing' },
});
});
88 changes: 88 additions & 0 deletions packages/contracts/src/screenshot-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import type { Interactor, RunnerContext, ScreenshotOptions } from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';

export type { ScreenshotOptions } from './interactor-types.ts';

/** Runner metadata needed by the selected capture implementation, without request-owned state. */
export type ScreenshotRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundleId' | 'signal'>>;

/**
* Neutral capture intent. The destination is the caller's already-reserved artifact path — the
* runtime never invents one — and the request binding supplies cancellation and exact-owner
* authority.
*/
export type CaptureScreenshotInput = Readonly<{
outPath: string;
options?: Readonly<ScreenshotOptions>;
execution?: ScreenshotRuntimeExecution;
}>;

export type ScreenshotRuntimeOperations = Readonly<{
captureScreenshot(input: CaptureScreenshotInput): Promise<void>;
}>;

export type ScreenshotRuntimeOperationFacts = Readonly<{
captureScreenshot: RuntimeOperationFact;
}>;

/** Builds the owner claim for the single capture requirement. */
export function screenshotRuntimeOperationFacts(
input: Readonly<{ capture: RuntimeOperationFact }>,
): ScreenshotRuntimeOperationFacts {
return Object.freeze({ captureScreenshot: input.capture });
}

/**
* Captures one selected owner's interactor authority for the lifetime of a request binding. The
* owner is already chosen by the time a binder is called, so each entry point supplies its own
* resolution and this holds only what both share: the runner context and the capture itself.
*/
function bindScreenshotCapture(
signal: AbortSignal,
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
): ScreenshotRuntimeOperations {
return Object.freeze({
captureScreenshot: async (input: CaptureScreenshotInput) => {
const interactor = await resolveInteractor({
...input.execution,
appBundleId: input.options?.appBundleId,
signal,
});
await interactor.screenshot(input.outPath, input.options);
},
});
}

export function bindLocalScreenshotInteractor(
params: Readonly<{
device: DeviceInfo;
signal: AbortSignal;
resolveInteractor: (device: DeviceInfo, runner: RunnerContext) => Promise<Interactor>;
}>,
): ScreenshotRuntimeOperations {
return bindScreenshotCapture(
params.signal,
async (runner) => await params.resolveInteractor(params.device, runner),
);
}

/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */
export function bindProviderScreenshotInteractor(
params: Readonly<{
device: DeviceInfo;
signal: AbortSignal;
resolveInteractor: (runner: RunnerContext) => Interactor | undefined;
}>,
): ScreenshotRuntimeOperations {
return bindScreenshotCapture(params.signal, async (runner) => {
const interactor = params.resolveInteractor(runner);
if (interactor) return interactor;
throw new AppError(
'UNSUPPORTED_OPERATION',
'Provider-owned screenshot operation has no bound provider interactor.',
{ reason: 'provider-runtime-interactor-missing', deviceId: params.device.id },
);
});
}
2 changes: 2 additions & 0 deletions packages/platform-android/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ test.each([
expect(facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true });
expect(facts.operations.setViewport).toMatchObject({ available: false });
expect(binding.operations.setViewport).toBeUndefined();
expect(facts.operations.captureScreenshot).toEqual({ available: true });
expect(binding.operations.captureScreenshot).toBeTypeOf('function');
expect(binding.operations.captureSnapshot).toBeTypeOf('function');

await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({
Expand Down
17 changes: 17 additions & 0 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import type {
import {
applicationLifecycleOperationFacts,
availableApplicationLifecycleOperations,
bindLocalScreenshotInteractor,
bindLocalSnapshotInteractor,
localRuntimeOwner,
screenshotRuntimeOperationFacts,
snapshotRuntimeOperationFacts,
viewportRuntimeOperationFacts,
} from '@agent-device/contracts/platform';
Expand Down Expand Up @@ -68,6 +70,11 @@ const shutdownKindUnavailable = Object.freeze({
reason: 'unsupported-device-kind',
hint: 'shutdown is supported only for Apple simulators and Android emulators.',
} as const);
const screenshotKindUnavailable = Object.freeze({
available: false,
reason: 'unsupported-device-kind',
hint: 'screenshot is supported only for Android emulators and devices.',
} as const);
const snapshotKindUnavailable = Object.freeze({
available: false,
reason: 'unsupported-device-kind',
Expand Down Expand Up @@ -137,6 +144,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
customActions: snapshotCustomActionsUnavailable,
withoutActiveApp: device.kind === 'simulator' ? snapshotKindUnavailable : available,
}),
...screenshotRuntimeOperationFacts({
capture: device.kind === 'simulator' ? screenshotKindUnavailable : available,
}),
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
ensureReady: available,
bootTarget: available,
Expand Down Expand Up @@ -191,6 +201,13 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
resolveInteractor: host.localInteractors.resolve,
})
: {}),
...(facts.operations.captureScreenshot.available
? bindLocalScreenshotInteractor({
device: request.device,
signal: request.scope.signal,
resolveInteractor: host.localInteractors.resolve,
})
: {}),
ensureReady: async (input: EnsureReadyInput) =>
await ensureAndroidReady(
host,
Expand Down
Loading
Loading