mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
3e4828d68d
* 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>
82 lines
2.8 KiB
TypeScript
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}`,
|
|
);
|
|
}
|