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
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ test('runtime diff screenshot enforces the normalized comparison pixel budget',
}
});

test('runtime diff screenshot attaches overlay refs to live mismatch regions', async () => {
test('runtime diff screenshot keeps the screen mask on current and overlay captures', async () => {
const dir = mkdtempForTestSync('runtime-diff-screenshot-');
const baseline = path.join(dir, 'baseline.png');
const diffOut = path.join(dir, 'diff.png');
Expand All @@ -153,6 +153,7 @@ test('runtime diff screenshot attaches overlay refs to live mismatch regions', a
try {
const device = createAgentDevice({
backend: createScreenshotBackend((outPath, options) => {
assert.equal(options?.screenMask, 'black');
fs.writeFileSync(outPath, solidPngBuffer(20, 20, { r: 255, g: 255, b: 255 }));
return {
path: outPath,
Expand Down Expand Up @@ -182,6 +183,7 @@ test('runtime diff screenshot attaches overlay refs to live mismatch regions', a
out: { kind: 'path', path: diffOut },
threshold: 0,
overlayRefs: true,
screenMask: 'black',
});

assert.equal(result.currentOverlayPath, overlayOut);
Expand Down Expand Up @@ -230,3 +232,22 @@ function solidPngBuffer(
}
return PNG.sync.write(png);
}

test('runtime diff refuses a screen mask for saved images before reading or capturing', async () => {
const device = createAgentDevice({
backend: createScreenshotBackend(() => {
throw new Error('must not capture');
}),
artifacts: createLocalArtifactAdapter(),
sessions,
policy: localCommandPolicy(),
});
await assert.rejects(
device.capture.diffScreenshot({
baseline: { kind: 'path', path: '/missing/baseline.png' },
current: { kind: 'path', path: '/missing/current.png' },
screenMask: 'black',
}),
{ code: 'INVALID_ARGS', message: /requires a live screenshot/ },
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import type {
ResolvedInputFile,
} from '@agent-device/contracts/artifact-adapter';
import type { AgentDeviceRuntime, CommandContext } from '@agent-device/contracts/runtime-contract';
import {
readScreenshotScreenMask,
type ScreenshotScreenMask,
} from '@agent-device/contracts/capture';
import { AppError } from '@agent-device/kernel/errors';
import {
compareScreenshots,
Expand All @@ -37,6 +41,7 @@ export type DiffScreenshotCommandOptions = CommandContext & {
threshold?: number;
overlayRefs?: boolean;
normalizeStatusBar?: boolean;
screenMask?: ScreenshotScreenMask;
surface?: BackendScreenshotOptions['surface'];
};

Expand All @@ -56,12 +61,7 @@ export const diffScreenshotCommand: RuntimeCommand<

const threshold = normalizeThreshold(options.threshold);
const currentRef = options.current ?? { kind: 'live' };
if (options.overlayRefs && !isLiveCurrentRef(currentRef)) {
throw new AppError(
'INVALID_ARGS',
'diff screenshot <current.png> cannot use --overlay-refs because saved-image comparisons have no live accessibility refs',
);
}
validateLiveCaptureOptions(options, currentRef);

const baseline = await resolveCommandInput(runtime, options.baseline, {
usage: 'diff screenshot baseline',
Expand Down Expand Up @@ -121,6 +121,26 @@ export const diffScreenshotCommand: RuntimeCommand<
}
};

function validateLiveCaptureOptions(
options: DiffScreenshotCommandOptions,
currentRef: FileInputRef | LiveScreenshotInputRef,
): void {
readScreenshotScreenMask(options.screenMask);
if (isLiveCurrentRef(currentRef)) return;
if (options.overlayRefs) {
throw new AppError(
'INVALID_ARGS',
'diff screenshot <current.png> cannot use --overlay-refs because saved-image comparisons have no live accessibility refs',
);
}
if (options.screenMask !== undefined) {
throw new AppError(
'INVALID_ARGS',
'--screen-mask requires a live screenshot, not a saved current image',
);
}
}

function normalizeThreshold(threshold: unknown): number {
if (threshold == null || threshold === '') return DEFAULT_SCREENSHOT_DIFF_THRESHOLD;
const value = Number(threshold);
Expand Down Expand Up @@ -222,10 +242,11 @@ async function captureScreenshot(
}

function liveDiffScreenshotOptions(
options: Pick<DiffScreenshotCommandOptions, 'normalizeStatusBar' | 'surface'>,
options: Pick<DiffScreenshotCommandOptions, 'normalizeStatusBar' | 'surface' | 'screenMask'>,
): BackendScreenshotOptions {
return {
normalizeStatusBar: options.normalizeStatusBar ?? true,
...(options.screenMask !== undefined ? { screenMask: options.screenMask } : {}),
Comment thread
sync marked this conversation as resolved.
Resolved
...(options.surface ? { surface: options.surface } : {}),
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AppError } from '@agent-device/kernel/errors';
import { validateScreenshotScale } from '@agent-device/contracts/capture';
import { validateScreenshotScale, readScreenshotScreenMask } from '@agent-device/contracts/capture';
import { successText } from '@agent-device/kernel/success-text';
import { resizePngFileToScale } from '@agent-device/capture-kit/png-resize';
import type { DeviceRotation } from '@agent-device/contracts/device';
Expand Down Expand Up @@ -46,6 +46,7 @@ export const screenshotCommand: RuntimeCommand<
fullscreen: options.fullscreen,
overlayRefs: options.overlayRefs,
pixelDensity: options.pixelDensity,
screenMask: readScreenshotScreenMask(options.screenMask),
stabilize: options.stabilize,
normalizeStatusBar: options.normalizeStatusBar,
surface: options.surface,
Expand Down
2 changes: 2 additions & 0 deletions packages/command-runtime/src/commands/runtime-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ScreenshotScreenMask } from '@agent-device/contracts/capture';
import type { FileOutputRef } from '@agent-device/contracts/artifact-adapter';
import type { AgentDeviceRuntime, CommandContext } from '@agent-device/contracts/runtime-contract';
import type { SessionSurface } from '@agent-device/contracts/session';
Expand Down Expand Up @@ -50,6 +51,7 @@ export type ScreenshotCommandOptions = CommandContext & {
fullscreen?: boolean;
overlayRefs?: boolean;
pixelDensity?: number;
screenMask?: ScreenshotScreenMask;
scale?: number;
stabilize?: boolean;
normalizeStatusBar?: boolean;
Expand Down
14 changes: 11 additions & 3 deletions packages/command-surface/src/capture/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,18 @@ const diffCommandMetadata = defineFieldCommandMetadata(DIFF_COMMAND_NAME, diffCo

const diffCliSchema = {
usageOverride:
'diff snapshot | diff screenshot --baseline <path> [current.png] [--out <diff.png>] [--threshold <0-1>] [--overlay-refs]',
'diff snapshot | diff screenshot --baseline <path> [current.png] [--out <diff.png>] [--threshold <0-1>] [--overlay-refs] [--screen-mask <black>]',
usageFlags: [],
positionalArgs: ['kind', 'current?'],
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out', 'overlayRefs'],
flagsByAction: { snapshot: [], screenshot: ['screenshotScreenMask'] },
allowedFlags: [
...SNAPSHOT_FLAGS,
'baseline',
'threshold',
'out',
'overlayRefs',
'screenshotScreenMask',
],
} as const;

export const diffCliReader: CliReader = (positionals, flags) => {
Expand All @@ -106,7 +114,7 @@ export const diffCommandFacet = defineCommandFacet({
text: {
summary: 'Diff snapshot or screenshot',
cliDetail:
'Screenshot --threshold is a per-pixel RGB tolerance: 0 requires exact colors and 1 ignores color differences; the current image scales proportionally to the baseline width and aligns top-left. Scaled height rounds to the nearest pixel (minimum 1). Extra or missing bottom rows always count as differences, even at threshold 1. The originals remain unchanged. When dimensions differ, normalization metadata reports original, scaled and compared sizes; region coordinates use comparison pixels. Both screenshot inputs are decoded from their bytes, so a baseline or current image may be PNG or JPEG, and the diff image is always PNG. JPEG is lossy, so keep --threshold above 0 whenever either input is JPEG. Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines.',
'Screenshot --threshold is a per-pixel RGB tolerance: 0 requires exact colors and 1 ignores color differences; the current image scales proportionally to the baseline width and aligns top-left. Scaled height rounds to the nearest pixel (minimum 1). Extra or missing bottom rows always count as differences, even at threshold 1. The originals remain unchanged. When dimensions differ, normalization metadata reports original, scaled and compared sizes; region coordinates use comparison pixels. Both screenshot inputs are decoded from their bytes, so a baseline or current image may be PNG or JPEG, and the diff image is always PNG. JPEG is lossy, so keep --threshold above 0 whenever either input is JPEG. Live iOS simulator screenshot diffs normalize status-bar chrome by default; use screenshot --normalize-status-bar when capturing reusable baselines. Use --screen-mask black for a live iOS simulator capture with a black screen outline, and use the same option when creating its baseline. --screen-mask is refused when current.png is supplied; saved images are compared unchanged.',
},
metadata: diffCommandMetadata,
run: (client, input) => client.capture.diff(input),
Expand Down
29 changes: 27 additions & 2 deletions packages/command-surface/src/capture/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, expect, test } from 'vitest';
import type { CliFlags } from '@agent-device/contracts/command';
import { alertCliReader, alertDaemonWriter } from './alert.ts';
import { diffCliReader } from './diff.ts';
import { screenshotCliReader, screenshotDaemonWriter } from './screenshot.ts';
import {
screenshotCliReader,
screenshotDaemonWriter,
screenshotCommandFacet,
} from './screenshot.ts';
import { snapshotCliReader } from './snapshot.ts';
import { waitCliReader, waitDaemonWriter } from './wait.ts';

Expand Down Expand Up @@ -48,11 +52,17 @@ describe('capture command interface', () => {
test('reads screenshot path and writes screenshot flags', () => {
const input = screenshotCliReader(
['page.png'],
flags({ screenshotPixelDensity: 2, screenshotFullscreen: true, screenshotScale: 0.3 }),
flags({
screenshotScreenMask: 'black',
screenshotPixelDensity: 2,
screenshotFullscreen: true,
screenshotScale: 0.3,
}),
);
expect(input).toMatchObject({
path: 'page.png',
pixelDensity: 2,
screenMask: 'black',
fullscreen: true,
scale: 0.3,
});
Expand All @@ -61,6 +71,7 @@ describe('capture command interface', () => {
positionals: ['page.png'],
options: {
screenshotPixelDensity: 2,
screenshotScreenMask: 'black',
screenshotFullscreen: true,
screenshotScale: 0.3,
},
Expand Down Expand Up @@ -173,3 +184,17 @@ describe('capture command interface', () => {
});
});
});

test('refuses invalid screen-mask inputs from structured clients', () => {
expectInvalidArgs(
() => screenshotDaemonWriter({ screenMask: 'alpha' }),
'--screen-mask must be black',
);
});

test('screenshot exposes the screen mask enum in the shared Node and MCP input schema', () => {
expect(screenshotCommandFacet.metadata.inputSchema.properties?.screenMask).toMatchObject({
type: 'string',
enum: ['black'],
});
});
18 changes: 18 additions & 0 deletions packages/command-surface/src/capture/screenshot-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => {
overlayRefs: true,
screenshotCropOn: 'role=button label=Save',
screenshotPixelDensity: 2,
screenshotScreenMask: 'black',
screenshotFullscreen: true,
screenshotScale: 0.3,
screenshotNoStabilize: true,
Expand All @@ -29,6 +30,7 @@ test('screenshot flag projection maps CLI flags to runtime options', () => {
overlayRefs: true,
cropOn: 'role=button label=Save',
pixelDensity: 2,
screenMask: 'black',
fullscreen: true,
scale: 0.3,
stabilize: false,
Expand All @@ -43,6 +45,7 @@ test('screenshot flag projection maps public options to request flags', () => {
overlayRefs: true,
cropOn: 'label="Network & internet"',
pixelDensity: 3,
screenMask: 'black',
fullscreen: true,
stabilize: false,
normalizeStatusBar: true,
Expand All @@ -51,6 +54,7 @@ test('screenshot flag projection maps public options to request flags', () => {
overlayRefs: true,
screenshotCropOn: 'label="Network & internet"',
screenshotPixelDensity: 3,
screenshotScreenMask: 'black',
screenshotFullscreen: true,
screenshotNoStabilize: true,
screenshotNormalizeStatusBar: true,
Expand Down Expand Up @@ -117,11 +121,23 @@ test('screenshot script flags use the shared recorded flag contract', () => {
message: /requires a selector expression/,
});

result = readScreenshotScriptFlag({ args: ['--screen-mask', 'black'], index: 0, flags });
assert.deepEqual(result, { handled: true, nextIndex: 1 });
assert.equal(flags.screenshotScreenMask, 'black');
assert.throws(
() => readScreenshotScriptFlag({ args: ['--screen-mask', 'alpha'], index: 0, flags: {} }),
{ code: 'INVALID_ARGS' },
);
assert.throws(() => readScreenshotScriptFlag({ args: ['--screen-mask'], index: 0, flags: {} }), {
code: 'INVALID_ARGS',
});
appendScreenshotScriptFlags(parts, flags);

assert.deepEqual(parts, [
'--pixel-density',
'3',
'--screen-mask',
'black',
'--fullscreen',
'--scale',
'0.3',
Expand All @@ -131,6 +147,7 @@ test('screenshot script flags use the shared recorded flag contract', () => {
assert.deepEqual(SCREENSHOT_ACTION_FLAG_KEYS, [
'screenshotCropOn',
'screenshotPixelDensity',
'screenshotScreenMask',
'screenshotFullscreen',
'screenshotScale',
'screenshotNoStabilize',
Expand All @@ -145,6 +162,7 @@ test('screenshot script flags use the shared recorded flag contract', () => {
'overlayRefs',
'screenshotCropOn',
'screenshotPixelDensity',
'screenshotScreenMask',
'screenshotFullscreen',
'screenshotScale',
'screenshotNoStabilize',
Expand Down
7 changes: 6 additions & 1 deletion packages/command-surface/src/capture/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
RETIRED_SCREENSHOT_MAX_SIZE,
SCREENSHOT_COMMAND_FLAG_KEYS,
SCREENSHOT_SCALE_LIMITS,
SCREENSHOT_SCREEN_MASKS,
screenshotFlagsFromPublicOptions,
screenshotOptionsFromFlags,
validateNoRetiredScreenshotMaxSize,
Expand Down Expand Up @@ -40,6 +41,10 @@ const screenshotCommandMetadata = defineFieldCommandMetadata(
pixelDensity: integerField('Output screenshot pixel density in pixels per logical point.', {
min: 1,
}),
screenMask: enumField(
SCREENSHOT_SCREEN_MASKS,
'Fill the iOS simulator screen outline with black for this capture. Requires simctl; omit for the default image.',
),
fullscreen: booleanField(),
scale: numberField('Screenshot scale factor.', SCREENSHOT_SCALE_LIMITS),
maxSize: retiredField(RETIRED_SCREENSHOT_MAX_SIZE.migration.screenshot),
Expand Down Expand Up @@ -74,7 +79,7 @@ export const screenshotCommandFacet = defineCommandFacet({
text: {
summary: 'Capture a screenshot',
cliDetail:
"Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on <selector> to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing.",
"Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. Use --screen-mask black to fill the iOS simulator screen outline, including rounded corners, with black for this capture. It requires simctl: physical devices, tvOS and visionOS simulators, other platforms, provider captures and XCTest runner captures are refused. A masked capture fails if simctl fails instead of falling back to an unmasked runner capture. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on <selector> to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing.",
},
metadata: screenshotCommandMetadata,
run: (client, input) => client.capture.screenshot(input),
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ScreenshotScreenMask } from './screenshot.ts';
import type { ScreenshotResultData } from './snapshot-types.ts';
import type { SnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts';
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
Expand Down Expand Up @@ -71,6 +72,7 @@ export type BackendScreenshotOptions = {
fullscreen?: boolean;
overlayRefs?: boolean;
pixelDensity?: number;
screenMask?: ScreenshotScreenMask;
stabilize?: boolean;
normalizeStatusBar?: boolean;
surface?: SessionSurface;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-capture.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { 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 @@ -112,6 +113,7 @@ export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & {
/** Crop the capture to the frame of the selector resolved on the same screen. */
cropOn?: string;
pixelDensity?: number;
screenMask?: ScreenshotScreenMask;
fullscreen?: boolean;
scale?: number;
stabilize?: boolean;
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/facades/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export {
SCREENSHOT_CROP_REASONS,
SCREENSHOT_FULLSCREEN_REASONS,
SCREENSHOT_SCALE_LIMITS,
SCREENSHOT_SCREEN_MASKS,
readScreenshotScreenMask,
SCREENSHOT_SPECIFIC_FLAG_DEFINITIONS,
appendScreenshotScriptFlags,
readScreenshotScriptFlag,
Expand All @@ -18,6 +20,7 @@ export {
} from '../screenshot.ts';
export type {
ScreenshotCropReason,
ScreenshotScreenMask,
ScreenshotDispatchFlags,
ScreenshotFullscreenReason,
ScreenshotPublicOptions,
Expand Down
Loading
Loading