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. Apple targets only; refused 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
1 change: 0 additions & 1 deletion packages/contracts/src/command-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export type MaestroRuntimeFlags = {
allowNonHittableCoordinateFallback?: boolean;
expectedTapPoint?: Point;
prewarmRunnerBeforeOpen?: boolean;
screenshotCaptureBackend?: 'runner';
};

export type CommandFlags = Omit<CliFlags, DaemonExcludedCliFlag> & {
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
8 changes: 8 additions & 0 deletions packages/contracts/src/screenshot-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type ScreenshotRuntimeOperations = Readonly<{

export type ScreenshotCaptureCapabilities = Readonly<{
screenMask?: boolean;
runner?: boolean;
}>;

type ScreenshotCaptureBindingOptions = Readonly<{
Expand All @@ -47,6 +48,13 @@ export async function assertScreenshotCaptureSupport(
options: Readonly<ScreenshotOptions> | undefined,
capabilities: ScreenshotCaptureCapabilities = {},
): Promise<void> {
if (options?.captureBackend === 'runner' && capabilities.runner === false) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'--capture-backend runner cannot capture this surface: the macOS helper owns it',
{ reason: 'screenshot-capture-backend-unsupported' },
);
}
if (options?.screenMask === undefined) return;
const { readScreenshotScreenMask } = await import('./screenshot.ts');
const screenMask = readScreenshotScreenMask(options?.screenMask);
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export const SCREENSHOT_FULLSCREEN_REASONS = {
export type ScreenshotFullscreenReason =
(typeof SCREENSHOT_FULLSCREEN_REASONS)[keyof typeof SCREENSHOT_FULLSCREEN_REASONS];

/** The capture paths a screenshot can name instead of the platform default. */
export const SCREENSHOT_CAPTURE_BACKENDS = ['runner'] as const;

export type ScreenshotCaptureBackend = (typeof SCREENSHOT_CAPTURE_BACKENDS)[number];

export const SCREENSHOT_COMMAND_FLAG_KEYS = [
'out',
'overlayRefs',
Expand All @@ -98,6 +103,7 @@ export const SCREENSHOT_COMMAND_FLAG_KEYS = [
'screenshotScale',
'screenshotNoStabilize',
'screenshotNormalizeStatusBar',
'screenshotCaptureBackend',
] as const;

export const SCREENSHOT_ACTION_FLAG_KEYS = [
Expand All @@ -108,6 +114,7 @@ export const SCREENSHOT_ACTION_FLAG_KEYS = [
'screenshotScale',
'screenshotNoStabilize',
'screenshotNormalizeStatusBar',
'screenshotCaptureBackend',
] as const;

type ScreenshotSpecificFlagKey = (typeof SCREENSHOT_ACTION_FLAG_KEYS)[number];
Expand Down Expand Up @@ -200,6 +207,17 @@ export const SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS: readonly ScreenshotSpecificFl
projectConfig: true,
recorded: true,
},
{
key: 'screenshotCaptureBackend',
names: ['--capture-backend'],
type: 'enum',
enumValues: SCREENSHOT_CAPTURE_BACKENDS,
usageLabel: '--capture-backend runner',
usageDescription:
'Screenshot: on Apple targets capture through the XCTest runner (XCUIScreen), which leaves out the simulator-drawn Dynamic Island, instead of simctl; refused on macOS surfaces the helper captures, and with --pixel-density, --screen-mask and --normalize-status-bar',
projectConfig: true,
recorded: false,
},
];

const SCREENSHOT_SCRIPT_BOOLEAN_FLAGS = [
Expand Down Expand Up @@ -244,6 +262,7 @@ export type ScreenshotRequestFlags = {
screenshotScale?: number;
screenshotNoStabilize?: boolean;
screenshotNormalizeStatusBar?: boolean;
screenshotCaptureBackend?: ScreenshotCaptureBackend;
};

export type ScreenshotDispatchFlags = Pick<
Expand All @@ -253,6 +272,7 @@ export type ScreenshotDispatchFlags = Pick<
| 'screenshotFullscreen'
| 'screenshotNoStabilize'
| 'screenshotNormalizeStatusBar'
| 'screenshotCaptureBackend'
>;

export type ScreenshotRuntimeFlags = Pick<
Expand All @@ -264,6 +284,7 @@ export type ScreenshotRuntimeFlags = Pick<
| 'screenshotScale'
| 'screenshotNoStabilize'
| 'screenshotNormalizeStatusBar'
| 'screenshotCaptureBackend'
>;

export type ScreenshotPublicOptions = {
Expand All @@ -275,6 +296,7 @@ export type ScreenshotPublicOptions = {
scale?: number;
stabilize?: boolean;
normalizeStatusBar?: boolean;
captureBackend?: ScreenshotCaptureBackend;
};

export type ScreenshotRuntimeOptions = {
Expand All @@ -286,6 +308,7 @@ export type ScreenshotRuntimeOptions = {
scale?: number;
stabilize?: boolean;
normalizeStatusBar?: boolean;
captureBackend?: ScreenshotCaptureBackend;
};

export function screenshotOptionsFromFlags(
Expand All @@ -301,6 +324,7 @@ export function screenshotOptionsFromFlags(
stabilize:
flags?.screenshotNoStabilize === undefined ? undefined : !flags.screenshotNoStabilize,
normalizeStatusBar: flags?.screenshotNormalizeStatusBar,
captureBackend: flags?.screenshotCaptureBackend,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
});
}

Expand All @@ -321,6 +345,7 @@ export function screenshotFlagsFromOptions(
(options.stabilize === undefined ? undefined : !options.stabilize),
screenshotNormalizeStatusBar:
options.screenshotNormalizeStatusBar ?? options.normalizeStatusBar,
screenshotCaptureBackend: options.screenshotCaptureBackend ?? options.captureBackend,
});
}

Expand Down
4 changes: 2 additions & 2 deletions packages/daemon/src/core/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ test('contextFromFlags propagates back mode into the dispatch context', () => {
assert.equal(context.backMode, 'system');
});

test('contextFromFlags forwards the Maestro runner screenshot backend', () => {
test('contextFromFlags forwards the runner screenshot capture backend', () => {
const context = contextFromFlags('/tmp/agent-device.log', {
maestro: { screenshotCaptureBackend: 'runner' },
screenshotCaptureBackend: 'runner',
});
assert.equal(context.screenshotCaptureBackend, 'runner');
});
Expand Down
1 change: 0 additions & 1 deletion packages/daemon/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export function contextFromFlags(
logPath,
traceLogPath,
...dispatchContextFlags(flags),
screenshotCaptureBackend: flags?.maestro?.screenshotCaptureBackend,
...screenshotFlagsFromOptions(flags),
clickButton: resolveClickButton(flags),
};
Expand Down
6 changes: 3 additions & 3 deletions packages/daemon/src/core/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import type { Point } from '@agent-device/kernel/snapshot';
* anything this list does not.
*
* The flags that DO change on the way through stay hand-written at the mapper,
* where the decision lives: `clickButton` (resolved from several flags), the
* screenshot family (`screenshotFlagsFromOptions`), and the maestro-nested
* capture backend. Order follows the flag order in `CliFlags`.
* where the decision lives: `clickButton` (resolved from several flags) and the
* screenshot family (`screenshotFlagsFromOptions`), which includes the capture
* backend. Order follows the flag order in `CliFlags`.
*/
// Exported only because `DispatchContextFlags` says `typeof` it.
// fallow-ignore-next-line unused-export
Expand Down
62 changes: 62 additions & 0 deletions packages/daemon/src/execution/screenshot-runtime-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
mkdtempForTestSync,
} from '@agent-device/test-kit';
import { makeSession } from '../__tests__/support/session-factories.ts';
import { contextFromFlags } from '../core/context.ts';
import type { GenericPlatformExecutionParams } from '../core/generic-leaf-execution.ts';
import { resolveScreenshotGenericExecution } from './screenshot-runtime.ts';
import {
Expand Down Expand Up @@ -251,6 +252,67 @@ test('rejects --pixel-density outside iOS-family simulators before inspecting ow
expect(fixture.binds).toHaveLength(0);
});

test('refuses --capture-backend runner outside Apple targets, with --pixel-density, and any other backend, before inspecting owner facts', async () => {
const refused = async (
device: SessionState['device'],
// The wire flags are untyped, so a request can name a backend the type does not list.
flags: Record<string, unknown>,
code: string,
): Promise<void> => {
const fixture = screenshotRuntimeFixture();
await expect(
resolveScreenshotGenericExecution({
hostCwd: process.cwd(),
createCommandSurface: testCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: screenshotRequest({
positionals: ['/tmp/runner.png'],
flags: flags as DaemonRequest['flags'],
}),
session: makeSession('default', { device }),
inspectFacts: fixture.inspectFacts,
bindDevice: fixture.bindDevice,
}),
).rejects.toMatchObject({ code });
expect(fixture.binds).toHaveLength(0);
};

await refused(ANDROID_EMULATOR, { screenshotCaptureBackend: 'runner' }, 'UNSUPPORTED_OPERATION');
await refused(
IOS_SIMULATOR,
{ screenshotCaptureBackend: 'runner', screenshotPixelDensity: 3 },
'INVALID_ARGS',
);
await refused(
IOS_SIMULATOR,
{ screenshotCaptureBackend: 'runner', screenshotNormalizeStatusBar: true },
'INVALID_ARGS',
);
await refused(IOS_SIMULATOR, { screenshotCaptureBackend: 'simctl' }, 'INVALID_ARGS');
});

test('a --capture-backend runner request reaches the bound capture', async () => {
const fixture = screenshotRuntimeFixture();
const session = makeSession('ios', { device: IOS_SIMULATOR });
const req = screenshotRequest({
positionals: [path.join(mkdtempForTestSync('agent-device-runner-capture'), 'runner.png')],
flags: { screenshotCaptureBackend: 'runner' },
});

const resolved = await resolveScreenshotGenericExecution({
hostCwd: process.cwd(),
createCommandSurface: testCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req,
session,
inspectFacts: fixture.inspectFacts,
bindDevice: fixture.bindDevice,
});

await executeResult(resolved, session, req, contextFromFlags('/tmp/daemon.log', req.flags));
expect(fixture.captureScreenshot.mock.calls[0]?.[0]?.options?.captureBackend).toBe('runner');
});

const CROP_TREE_ROOT = {
index: 0,
depth: 0,
Expand Down
Loading