Files
Szymon Dziedzic 3e4828d68d feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing

* fix: refuse retired --max-size inputs on every released surface

Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:

- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
  as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
  --max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
  the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
  option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
  AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
  (other commands keep working)

Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.

* test: drop retired maxSize entries from the MCP undocumented-input allowlist

* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses

- readFieldInput silently dropped undeclared keys before the daemon writers
  could refuse them, so an MCP call carrying { maxSize } reached transport and
  returned native-size success. New retiredField() combinator declares the
  removed key in the field map: the projection seam refuses it with the
  canonical migration message and the JSON schema no longer advertises it.
  Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
  and record --max-size forms (SHA-256 pinned, new retired-capture-size
  coverage surface) so check:replay-compat proves the shipped syntax refuses
  with migration guidance instead of degrading silently.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-06 15:11:00 +02:00

82 lines
2.8 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import { assertPngFile } from '../provider-scenarios/assertions.ts';
import { isPlayableVideo } from '../../../src/utils/video.ts';
import type { CliJsonResult } from '../cli-json.ts';
import type { LiveDeviceContext } from './runtime.ts';
type RunStep<Context> = (
context: Context,
step: string,
args: string[],
options?: { allowFailure?: boolean },
) => Promise<CliJsonResult>;
export function createLiveDeviceAssertions<
BehaviorId extends string,
Context extends LiveDeviceContext<BehaviorId>,
>(
runStep: RunStep<Context>,
verifyCommand: (context: Context, command: string, evidence: string) => void,
waitCommand: string,
) {
async function assertWaitText(context: Context, expected: string): Promise<void> {
const result = await runStep(context, `wait for ${expected}`, [
'wait',
'text',
expected,
'10000',
]);
assertJsonContains(result, expected, `wait should observe ${expected}`);
verifyCommand(context, waitCommand, `wait observes durable text: ${expected}`);
}
async function assertWaitSelector(context: Context, selector: string): Promise<void> {
await runStep(context, `wait for ${selector}`, ['wait', selector, '10000']);
verifyCommand(context, waitCommand, `wait observes durable selector: ${selector}`);
}
async function assertElementText(
context: Context,
selector: string,
expected: string,
): Promise<void> {
const result = await runStep(context, `read ${selector}`, ['get', 'text', selector]);
assert.equal(
result.json?.data?.text,
expected,
`${selector} should expose ${expected}: ${JSON.stringify(result.json)}`,
);
}
async function capturePng(context: Context, step: string, outputPath: string): Promise<void> {
await runStep(context, step, ['screenshot', outputPath, '--scale', '0.5']);
assertPngFile(outputPath);
}
return { assertElementText, assertWaitSelector, assertWaitText, capturePng };
}
export function assertJsonContains(result: CliJsonResult, expected: string, message: string): void {
const serialized = JSON.stringify(result.json?.data ?? result.json);
assert.ok(serialized.includes(expected), `${message}\nreceived: ${serialized}`);
}
export function assertFilesDiffer(first: string, second: string, message: string): void {
assert.notDeepEqual(fs.readFileSync(first), fs.readFileSync(second), message);
}
export function assertNonEmptyFile(filePath: string, name: string): void {
assert.ok(fs.statSync(filePath).size > 0, `${name} artifact is empty: ${filePath}`);
}
export async function assertMp4File(filePath: string): Promise<void> {
assertNonEmptyFile(filePath, 'recording');
assert.equal(
await isPlayableVideo(filePath),
true,
`recording is not a finalized playable video: ${filePath}`,
);
}