Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/command-surface/src/capture/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ describe('capture command interface', () => {
});
});

test('reads and writes the screenshot capture backend', () => {
const input = screenshotCliReader(['page.png'], flags({ screenshotCaptureBackend: 'runner' }));
expect(input).toMatchObject({ path: 'page.png', captureBackend: 'runner' });
expect(screenshotDaemonWriter(input)).toMatchObject({
command: 'screenshot',
options: { screenshotCaptureBackend: 'runner' },
});
});

test('reads diff snapshot input only', () => {
expect(
diffCliReader(['snapshot'], flags({ snapshotDepth: 4, out: './diff.json' })),
Expand Down
17 changes: 17 additions & 0 deletions packages/command-surface/src/capture/screenshot-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => {
screenshotScale: 0.3,
screenshotNoStabilize: true,
screenshotNormalizeStatusBar: true,
screenshotCaptureBackend: 'runner',
}),
{
overlayRefs: true,
Expand All @@ -35,6 +36,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => {
scale: 0.3,
stabilize: false,
normalizeStatusBar: true,
captureBackend: 'runner',
},
);
});
Expand All @@ -49,6 +51,7 @@ test('screenshot flag projection maps public options to request flags', () => {
fullscreen: true,
stabilize: false,
normalizeStatusBar: true,
captureBackend: 'runner',
}),
{
overlayRefs: true,
Expand All @@ -58,6 +61,7 @@ test('screenshot flag projection maps public options to request flags', () => {
screenshotFullscreen: true,
screenshotNoStabilize: true,
screenshotNormalizeStatusBar: true,
screenshotCaptureBackend: 'runner',
},
);
});
Expand Down Expand Up @@ -152,6 +156,7 @@ test('screenshot script flags use the shared recorded flag contract', () => {
'screenshotScale',
'screenshotNoStabilize',
'screenshotNormalizeStatusBar',
'screenshotCaptureBackend',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
]);
assert.deepEqual(
SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS.map((definition) => definition.key),
Expand All @@ -167,9 +172,21 @@ test('screenshot script flags use the shared recorded flag contract', () => {
'screenshotScale',
'screenshotNoStabilize',
'screenshotNormalizeStatusBar',
'screenshotCaptureBackend',
]);
});

test('the capture backend stays out of recorded scripts', () => {
const parts: string[] = [];
appendScreenshotScriptFlags(parts, { screenshotCaptureBackend: 'runner' });
assert.deepEqual(parts, []);
assert.equal(
SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS.find(({ key }) => key === 'screenshotCaptureBackend')
?.recorded,
false,
);
});

test('explicit screenshot stabilization survives SDK projection and script recording', () => {
const projected = screenshotFlagsFromOptions({ stabilize: true });
assert.deepEqual(screenshotOptionsFromFlags(projected), { stabilize: true });
Expand Down
5 changes: 5 additions & 0 deletions packages/command-surface/src/capture/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CaptureScreenshotOptions } from '@agent-device/contracts/client';
import { SESSION_SURFACES } from '@agent-device/contracts/session';
import {
RETIRED_SCREENSHOT_MAX_SIZE,
SCREENSHOT_CAPTURE_BACKENDS,
SCREENSHOT_COMMAND_FLAG_KEYS,
SCREENSHOT_SCALE_LIMITS,
SCREENSHOT_SCREEN_MASKS,
Expand Down Expand Up @@ -50,6 +51,10 @@ const screenshotCommandMetadata = defineFieldCommandMetadata(
maxSize: retiredField(RETIRED_SCREENSHOT_MAX_SIZE.migration.screenshot),
stabilize: booleanField(),
normalizeStatusBar: booleanField(),
captureBackend: enumField(
SCREENSHOT_CAPTURE_BACKENDS,
'Capture through the XCTest runner instead of the platform default, which leaves out the simulator-drawn Dynamic Island. Local Apple targets only; refused on provider sessions and on a macOS surface the helper captures, and with pixelDensity, screenMask or normalizeStatusBar.',
),
surface: enumField(SESSION_SURFACES),
},
);
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/client-capture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ScreenshotScreenMask } from './screenshot.ts';
import type { ScreenshotCaptureBackend, ScreenshotScreenMask } from './screenshot.ts';
// The public API vocabulary for snapshot, screenshot and diff capture.

import type { SessionSurface } from './session-surface.ts';
Expand Down Expand Up @@ -118,6 +118,8 @@ export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & {
scale?: number;
stabilize?: boolean;
normalizeStatusBar?: boolean;
/** `runner`: capture an Apple target through the XCTest runner instead of `simctl`. */
captureBackend?: ScreenshotCaptureBackend;
surface?: SessionSurface;
};

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/facades/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type { DiffSnapshotCommandResult, SnapshotDiffLine, SnapshotDiffSummary }
export {
RETIRED_SCREENSHOT_MAX_SIZE,
SCREENSHOT_ACTION_FLAG_KEYS,
SCREENSHOT_CAPTURE_BACKENDS,
SCREENSHOT_COMMAND_FLAG_KEYS,
SCREENSHOT_CROP_REASONS,
SCREENSHOT_FULLSCREEN_REASONS,
Expand All @@ -19,6 +20,7 @@ export {
validateScreenshotScale,
} from '../screenshot.ts';
export type {
ScreenshotCaptureBackend,
ScreenshotCropReason,
ScreenshotScreenMask,
ScreenshotDispatchFlags,
Expand Down
12 changes: 9 additions & 3 deletions packages/contracts/src/local-interactor-operation-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { bindLocalTypeTextInteractor } from './type-text-runtime.ts';

/**
* The whole local interactor-backed operation set a pointer-driving family binds: the uniform
* one-fact-one-bind leaves the interactor catalog walks, plus the four that carry extra input of their own — gesture and touch read the
* fact map, and touch needs a pause clock. Android, Linux, and HarmonyOS bind this one list, so
* one-fact-one-bind leaves the interactor catalog walks, plus the ones that carry extra input of their own — gesture and touch read the
* fact map, touch needs a pause clock, and screenshot takes the owner's capture declaration. Android, Linux, and HarmonyOS bind this one list, so
* no pointer-driving family can drift from another by forgetting a member. Capture stays out: an
* owner's snapshot mechanics are its own (Linux captures a surface through its host, Android
* drives the interactor), so there is no shared reading to make.
Expand All @@ -26,6 +26,7 @@ export function bindLocalInteractorOperationSet(
resolveInteractor: LocalInteractorOperationResolver;
facts: RuntimeFacts<PlatformRuntimeOperations>['operations'];
pause: (milliseconds: number) => Promise<void>;
captureCapabilities: Parameters<typeof bindLocalScreenshotInteractor>[0]['captureCapabilities'];
Comment thread
vitalyiegorov marked this conversation as resolved.
Resolved
}>,
): Partial<PlatformRuntimeOperations> {
const { facts, pause } = params;
Expand All @@ -35,7 +36,12 @@ export function bindLocalInteractorOperationSet(
resolveInteractor: params.resolveInteractor,
};
return {
...(facts.captureScreenshot.available ? bindLocalScreenshotInteractor(resolver) : {}),
...(facts.captureScreenshot.available
? bindLocalScreenshotInteractor({
...resolver,
captureCapabilities: params.captureCapabilities,
})
: {}),
...(facts.focusPoint.available ? bindLocalFocusInteractor(resolver) : {}),
...bindLocalGestureInteractor({ ...resolver, facts }),
...(facts.scrollDirection.available ? bindLocalScrollInteractor(resolver) : {}),
Expand Down
55 changes: 54 additions & 1 deletion packages/contracts/src/screenshot-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Interactor } from './interactor-types.ts';
import {
bindLocalScreenshotInteractor,
bindProviderScreenshotInteractor,
defaultCaptureOnly,
screenshotRuntimeOperationFacts,
} from './screenshot-runtime.ts';

Expand All @@ -24,7 +25,12 @@ test('a local binding hands the interactor the destination, options, and the req
const resolveInteractor = vi.fn(async () => ({ screenshot }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindLocalScreenshotInteractor({ device, signal, resolveInteractor });
const operations = bindLocalScreenshotInteractor({
device,
signal,
resolveInteractor,
captureCapabilities: defaultCaptureOnly('test'),
});
await operations.captureScreenshot({
outPath: '/tmp/out.png',
options: { appBundleId: 'com.example.app', fullscreen: true },
Expand All @@ -47,6 +53,7 @@ test('a provider binding fails closed when its exact owner exposes no interactor
device,
signal: new AbortController().signal,
resolveInteractor: () => undefined,
captureCapabilities: defaultCaptureOnly('test'),
});

await expect(operations.captureScreenshot({ outPath: '/tmp/out.png' })).rejects.toMatchObject({
Expand All @@ -63,6 +70,7 @@ test.each([bindLocalScreenshotInteractor, bindProviderScreenshotInteractor])(
device: { ...device, platform: 'apple', appleOs: 'ios', kind: 'simulator' },
signal: new AbortController().signal,
resolveInteractor,
captureCapabilities: defaultCaptureOnly('test'),
});
await expect(
operations.captureScreenshot({
Expand Down Expand Up @@ -92,3 +100,48 @@ test('the selected capture capability admits a mask for one invocation without c
expect(screenshot.mock.lastCall).toEqual(['/tmp/capture.png', { screenMask }]);
}
});

test.each([bindLocalScreenshotInteractor, bindProviderScreenshotInteractor])(
'an owner declaring no capture backend refuses an explicit one with its own explanation',
async (bind) => {
const resolveInteractor = vi.fn();
const operations = bind({
device,
signal: new AbortController().signal,
resolveInteractor,
captureCapabilities: defaultCaptureOnly('this owner has one capture path'),
});
await expect(
operations.captureScreenshot({
outPath: '/tmp/capture.png',
options: { captureBackend: 'runner' },
}),
).rejects.toMatchObject({
code: 'UNSUPPORTED_OPERATION',
message: expect.stringContaining('this owner has one capture path'),
details: { reason: 'screenshot-capture-backend-unsupported' },
});
expect(resolveInteractor).not.toHaveBeenCalled();
},
);

test.each([
[['runner'] as const, { captureBackend: 'runner' }],
[[] as const, {}],
])(
'a preferred backend selects a declared one and otherwise keeps the default capture: %j',
async (captureBackends, expected) => {
const screenshot = vi.fn(async (_outPath: string, _options: unknown) => ({}));
const operations = bindLocalScreenshotInteractor({
device,
signal: new AbortController().signal,
resolveInteractor: async () => ({ screenshot }) as unknown as Interactor,
captureCapabilities: () => ({ captureBackends }),
});
await operations.captureScreenshot({
outPath: '/tmp/capture.png',
options: { captureBackend: undefined, preferredCaptureBackend: 'runner' },
});
expect(screenshot).toHaveBeenLastCalledWith('/tmp/capture.png', expected);
},
);
44 changes: 37 additions & 7 deletions packages/contracts/src/screenshot-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ScreenshotOptions,
} from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { ScreenshotCaptureBackend } from './screenshot.ts';

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

Expand All @@ -26,7 +27,7 @@ export type ScreenshotRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundle
*/
export type CaptureScreenshotInput = Readonly<{
outPath: string;
options?: Readonly<ScreenshotOptions>;
options?: Readonly<ScreenshotOptions & { preferredCaptureBackend?: ScreenshotCaptureBackend }>;
execution?: ScreenshotRuntimeExecution;
}>;

Expand All @@ -36,17 +37,36 @@ export type ScreenshotRuntimeOperations = Readonly<{

export type ScreenshotCaptureCapabilities = Readonly<{
screenMask?: boolean;
/** The backends a request can select on this owner; an absent list selects none. */
captureBackends?: readonly ScreenshotCaptureBackend[];
/** Why this owner cannot serve a selected backend. */
captureBackendRefusal?: string;
}>;

type ScreenshotCaptureBindingOptions = Readonly<{
signal: AbortSignal;
captureCapabilities?: (options: Readonly<ScreenshotOptions>) => ScreenshotCaptureCapabilities;
captureCapabilities: (options: Readonly<ScreenshotOptions>) => ScreenshotCaptureCapabilities;
}>;

/** The declaration of an owner that captures one way only, and why. */
export function defaultCaptureOnly(
captureBackendRefusal: string,
): (options: Readonly<ScreenshotOptions>) => ScreenshotCaptureCapabilities {
return () => ({ captureBackendRefusal });
}

export async function assertScreenshotCaptureSupport(
options: Readonly<ScreenshotOptions> | undefined,
capabilities: ScreenshotCaptureCapabilities = {},
): Promise<void> {
const backend = options?.captureBackend;
if (backend !== undefined && !capabilities.captureBackends?.includes(backend)) {
throw new AppError(
'UNSUPPORTED_OPERATION',
`--capture-backend ${backend} is not supported: ${capabilities.captureBackendRefusal ?? 'this capture owner declares no such backend'}`,
{ reason: 'screenshot-capture-backend-unsupported' },
);
}
if (options?.screenMask === undefined) return;
const { readScreenshotScreenMask } = await import('./screenshot.ts');
const screenMask = readScreenshotScreenMask(options?.screenMask);
Expand All @@ -70,6 +90,18 @@ export function screenshotRuntimeOperationFacts(
return Object.freeze({ captureScreenshot: input.capture });
}

/** A preference, unlike `captureBackend`, keeps the owner's default where it declares no such backend. Maestro's comparison capture asks for one. */
function resolveCaptureOptions(
input: CaptureScreenshotInput['options'],
binding: ScreenshotCaptureBindingOptions,
): Readonly<ScreenshotOptions> {
const { captureBackend: explicit, preferredCaptureBackend: preferred, ...options } = input ?? {};
if (explicit) return { ...options, captureBackend: explicit };
return preferred && binding.captureCapabilities(options).captureBackends?.includes(preferred)
? { ...options, captureBackend: preferred }
: options;
}

/**
* 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
Expand All @@ -81,16 +113,14 @@ function bindScreenshotCapture(
): ScreenshotRuntimeOperations {
return Object.freeze({
captureScreenshot: async (input: CaptureScreenshotInput) => {
await assertScreenshotCaptureSupport(
input.options,
binding.captureCapabilities?.(input.options ?? {}),
);
const options = resolveCaptureOptions(input.options, binding);
await assertScreenshotCaptureSupport(options, binding.captureCapabilities(options));
const interactor = await resolveInteractor({
...input.execution,
appBundleId: input.options?.appBundleId,
signal: binding.signal,
});
return (await interactor.screenshot(input.outPath, input.options)) ?? {};
return (await interactor.screenshot(input.outPath, options)) ?? {};
},
});
}
Expand Down
Loading