From 697f30044707154afca64592c6d73ca1183089b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 22:26:14 +0200 Subject: [PATCH 1/3] feat(diff): accept JPEG inputs for screenshot comparison Both diff screenshot inputs had to be PNG, so a capture from another tool had to be converted first and a HarmonyOS artifact, which the platform serves as JPEG, never compared. Each input is now decoded from its own bytes. png-transcode.ts became screenshot-image.ts, the one owner of container sniffing for both the decode and the provider transcode path, and the PNG worker gained a decode-image job that answers pixels instead of PNG bytes. --- CHANGELOG.md | 9 ++ packages/capture-kit/package.json | 4 + .../capture-kit/src/png-worker-client.test.ts | 44 ++++++++- packages/capture-kit/src/png-worker-client.ts | 27 +++++- .../capture-kit/src/png-worker-contract.ts | 3 + packages/capture-kit/src/png-worker.ts | 11 ++- ...scode.test.ts => screenshot-image.test.ts} | 61 +++++++++++- .../{png-transcode.ts => screenshot-image.ts} | 38 ++++++-- scripts/layering/package-boundaries.test.ts | 1 + src/commands/capture/diff.ts | 2 +- .../schema/cli-help-command-usage.test.ts | 8 ++ .../__tests__/screenshot-diff.test.ts | 96 ++++++++++++++++++- src/screenshot-diff/screenshot-diff.ts | 11 ++- website/docs/docs/commands.md | 5 +- 14 files changed, 295 insertions(+), 25 deletions(-) rename packages/capture-kit/src/{png-transcode.test.ts => screenshot-image.test.ts} (54%) rename packages/capture-kit/src/{png-transcode.ts => screenshot-image.ts} (56%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd138dec7..349f555fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Added (diff): `diff screenshot` accepts a JPEG baseline or current image. Both inputs had to be PNG, + so a capture exported by another tool had to be converted first and a HarmonyOS capture — which the + platform serves as JPEG — could never be compared. Each input is now decoded from its own bytes, so + the container is sniffed and a `.png` name holding JPEG decodes as JPEG. `png-transcode.ts` became + `screenshot-image.ts`, the one owner of that sniffing for both the decode and the provider transcode + path, and the PNG worker gained a `decode-image` job that answers pixels instead of PNG bytes. + `screenshot` still writes PNG and so does the `--out` diff image: crop, overlay, and resize rewrite a + screenshot in place, which a lossy container could not survive. + - Added (limrun): `record start` and `record stop` on Limrun iOS and Android direct sessions. The runtime declared recording unavailable although the Limrun SDK exposes a server-side recorder. Start asks the instance to record (`--quality medium` maps to Limrun quality 5, `high` to 8); diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 05346e4968..f5ae3da11c 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -202,6 +202,10 @@ "types": "./src/screenshot-diff-pixels.ts", "default": "./src/screenshot-diff-pixels.ts" }, + "./screenshot-image": { + "types": "./src/screenshot-image.ts", + "default": "./src/screenshot-image.ts" + }, "./screenshot-overlay": { "types": "./src/screenshot-overlay.ts", "default": "./src/screenshot-overlay.ts" diff --git a/packages/capture-kit/src/png-worker-client.test.ts b/packages/capture-kit/src/png-worker-client.test.ts index 6a09d8efe5..942f50328a 100644 --- a/packages/capture-kit/src/png-worker-client.test.ts +++ b/packages/capture-kit/src/png-worker-client.test.ts @@ -6,6 +6,7 @@ import { computePngRgbDifferenceAsync, computeScreenshotDiffPixelsAsync, decodePngAsync, + decodeScreenshotImageAsync, encodePngAsync, terminatePngWorker, transcodeScreenshotToPngAsync, @@ -110,7 +111,7 @@ test('decodePngAsync rejects invalid PNG data with the canonical decode AppError test('transcodeScreenshotToPngAsync matches the synchronous transcoder byte for byte', async () => { const { encode } = await import('jpeg-js'); - const { transcodeScreenshotToPng } = await import('./png-transcode.ts'); + const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); const rgba = Buffer.alloc(9 * 7 * 4, 0x66); const jpeg = encode({ width: 9, height: 7, data: rgba }, 90).data; @@ -138,3 +139,44 @@ test('transcodeScreenshotToPngAsync rejects a corrupt JPEG with the canonical de return true; }); }); + +test('decodeScreenshotImageAsync answers the synchronous decoder for both containers', async () => { + const { encode } = await import('jpeg-js'); + const { decodeScreenshotImage } = await import('./screenshot-image.ts'); + const png = PNG.sync.write(buildPatternPng(11, 8, 4)); + const jpeg = encode({ width: 11, height: 8, data: buildPatternPng(11, 8, 4).data }, 90).data; + + for (const bytes of [png, jpeg]) { + const fromWorker = await decodeScreenshotImageAsync(bytes, 'fixture'); + const fromSync = decodeScreenshotImage(bytes, 'fixture'); + + assert.deepEqual([fromWorker.width, fromWorker.height], [fromSync.width, fromSync.height]); + assert.deepEqual(fromWorker.data, fromSync.data); + } +}); + +test('decodeScreenshotImageAsync decodes a JPEG by its bytes, not its label', async () => { + const { encode } = await import('jpeg-js'); + const jpeg = encode({ width: 9, height: 7, data: buildPatternPng(9, 7, 5).data }, 90).data; + + const decoded = await decodeScreenshotImageAsync(jpeg, 'fixture.png'); + + assert.deepEqual([decoded.width, decoded.height], [9, 7]); + assert.equal(decoded.data.length, 9 * 7 * 4); +}); + +test('decodeScreenshotImageAsync rejects a container it cannot read with the canonical AppError', async () => { + await assert.rejects(decodeScreenshotImageAsync(Buffer.from('GIF89a'), 'fixture'), (error) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'COMMAND_FAILED'); + assert.equal((error as AppError).message, 'fixture is neither PNG nor JPEG'); + assert.equal((error as AppError).details?.label, 'fixture'); + return true; + }); + + const corrupt = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(16, 0)]); + await assert.rejects(decodeScreenshotImageAsync(corrupt, 'fixture'), (error) => { + assert.match((error as AppError).message, /Failed to decode fixture as JPEG/); + return true; + }); +}); diff --git a/packages/capture-kit/src/png-worker-client.ts b/packages/capture-kit/src/png-worker-client.ts index 1111259af7..994e1cedf0 100644 --- a/packages/capture-kit/src/png-worker-client.ts +++ b/packages/capture-kit/src/png-worker-client.ts @@ -201,6 +201,31 @@ export async function decodePngAsync(buffer: Buffer, label: string): Promise { + const result = await runPngJob({ kind: 'decode-image', image: bytes, label }, async () => { + // Read on demand so the JPEG decoder stays out of the import closure of every entry that only + // needs the worker's other jobs. + const { decodeScreenshotImage } = await import('./screenshot-image.ts'); + const image = decodeScreenshotImage(bytes, label); + return { kind: 'decode-image', width: image.width, height: image.height, data: image.data }; + }); + return toDecodedPng(result); +} + +/** + * Rebuilds the decoded-image handle on this side of the worker boundary. `PNG` is the shape every + * pixel reader here consumes: size plus RGBA rows. + */ +function toDecodedPng(result: { width: number; height: number; data: Uint8Array }): PNG { const png = new PNG({ width: result.width, height: result.height }); png.data = toBuffer(result.data); return png; @@ -267,7 +292,7 @@ export async function transcodeScreenshotToPngAsync(bytes: Buffer, label: string const result = await runPngJob({ kind: 'jpeg-to-png', image: bytes, label }, async () => { // Read on demand so the JPEG decoder stays out of the import closure of every entry that only // needs the worker's other jobs. - const { transcodeScreenshotToPng } = await import('./png-transcode.ts'); + const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); return { kind: 'jpeg-to-png', png: transcodeScreenshotToPng(bytes, label) }; }); return toBuffer(result.png); diff --git a/packages/capture-kit/src/png-worker-contract.ts b/packages/capture-kit/src/png-worker-contract.ts index 0511b11541..a24767da8f 100644 --- a/packages/capture-kit/src/png-worker-contract.ts +++ b/packages/capture-kit/src/png-worker-contract.ts @@ -16,6 +16,8 @@ import type { PngRgbDifferenceResult } from './png-rgb-difference.ts'; export type PngWorkerJob = | { kind: 'decode'; png: Uint8Array; label: string } + // A screenshot in whatever container it arrived in; answers decoded pixels, not PNG bytes. + | { kind: 'decode-image'; image: Uint8Array; label: string } | { kind: 'encode'; width: number; height: number; data: Uint8Array } | { kind: 'crop'; png: Uint8Array; label: string; box: Rect } | { kind: 'rgb-difference'; firstPng: Uint8Array; secondPng: Uint8Array; label: string } @@ -25,6 +27,7 @@ export type PngWorkerJob = export type PngWorkerJobResult = | { kind: 'decode'; width: number; height: number; data: Uint8Array } + | { kind: 'decode-image'; width: number; height: number; data: Uint8Array } | { kind: 'encode'; png: Uint8Array } // A crop answers `null` when the box already covers the image, so the caller keeps the file. | { kind: 'crop'; png: Uint8Array | null } diff --git a/packages/capture-kit/src/png-worker.ts b/packages/capture-kit/src/png-worker.ts index 68a66bf06d..9508d73f9a 100644 --- a/packages/capture-kit/src/png-worker.ts +++ b/packages/capture-kit/src/png-worker.ts @@ -17,13 +17,19 @@ import { * `png-worker-client.ts`; published as the `internal/png-worker` build entry. */ -// The daemon prewarms this worker at startup, so the JPEG decoder loads only for a transcode job. +// The daemon prewarms this worker at startup, so the JPEG decoder loads only for a job that can +// carry JPEG bytes. async function runJob(request: PngWorkerRequest): Promise { switch (request.kind) { case 'decode': { const png = decodePng(toBuffer(request.png), request.label); return { kind: 'decode', width: png.width, height: png.height, data: png.data }; } + case 'decode-image': { + const { decodeScreenshotImage } = await import('./screenshot-image.ts'); + const image = decodeScreenshotImage(toBuffer(request.image), request.label); + return { kind: 'decode-image', width: image.width, height: image.height, data: image.data }; + } case 'encode': { const png = new PNG({ width: request.width, height: request.height }); png.data = toBuffer(request.data); @@ -44,7 +50,7 @@ async function runJob(request: PngWorkerRequest): Promise { return { kind: 'diff-pixels', ...computeScreenshotDiffPixels(request) }; } case 'jpeg-to-png': { - const { transcodeScreenshotToPng } = await import('./png-transcode.ts'); + const { transcodeScreenshotToPng } = await import('./screenshot-image.ts'); return { kind: 'jpeg-to-png', png: transcodeScreenshotToPng(toBuffer(request.image), request.label), @@ -80,6 +86,7 @@ export function resultTransferList(result: PngWorkerJobResult): ArrayBuffer[] { function resultBufferViews(result: PngWorkerJobResult): Uint8Array[] { switch (result.kind) { case 'decode': + case 'decode-image': return [result.data]; case 'encode': return [result.png]; diff --git a/packages/capture-kit/src/png-transcode.test.ts b/packages/capture-kit/src/screenshot-image.test.ts similarity index 54% rename from packages/capture-kit/src/png-transcode.test.ts rename to packages/capture-kit/src/screenshot-image.test.ts index 91872ce16d..3d1baa94eb 100644 --- a/packages/capture-kit/src/png-transcode.test.ts +++ b/packages/capture-kit/src/screenshot-image.test.ts @@ -1,7 +1,11 @@ import { expect, test } from 'vitest'; import { encode as encodeJpeg } from 'jpeg-js'; import { PNG } from './png.ts'; -import { detectScreenshotImageFormat, transcodeScreenshotToPng } from './png-transcode.ts'; +import { + decodeScreenshotImage, + detectScreenshotImageFormat, + transcodeScreenshotToPng, +} from './screenshot-image.ts'; function solidRgba(width: number, height: number, rgba: readonly [number, number, number, number]) { const data = Buffer.alloc(width * height * 4); @@ -74,3 +78,58 @@ test('a truncated JPEG body is refused with the same typed decode error', () => message: 'Failed to decode Limrun iOS screenshot as JPEG', }); }); + +test('decoding a PNG answers its pixels without re-encoding the container', () => { + const png = new PNG({ width: 3, height: 2 }); + png.data = solidRgba(3, 2, [12, 240, 60, 255]); + + const decoded = decodeScreenshotImage(PNG.sync.write(png), 'test screenshot'); + + expect([decoded.width, decoded.height]).toEqual([3, 2]); + expect([...decoded.data.subarray(0, 4)]).toEqual([12, 240, 60, 255]); +}); + +test('decoding a JPEG answers RGBA rows, with opaque alpha where the container has none', () => { + const jpeg = encodeJpeg({ width: 5, height: 3, data: solidRgba(5, 3, [12, 240, 60, 255]) }, 100); + + const decoded = decodeScreenshotImage(jpeg.data, 'test screenshot'); + + expect([decoded.width, decoded.height]).toEqual([5, 3]); + expect(decoded.data.length).toBe(5 * 3 * 4); + const [r = -1, g = -1, b = -1, a = -1] = decoded.data.subarray(0, 4); + // JPEG is lossy; a flat field survives within a few levels per channel. + expect(Math.abs(r - 12)).toBeLessThanOrEqual(4); + expect(Math.abs(g - 240)).toBeLessThanOrEqual(4); + expect(Math.abs(b - 60)).toBeLessThanOrEqual(4); + expect(a).toBe(255); +}); + +test('decoding sniffs the container, so JPEG bytes answer JPEG pixels whatever they are called', () => { + const jpeg = encodeJpeg({ width: 4, height: 4, data: solidRgba(4, 4, [7, 9, 11, 255]) }, 90); + + const fromJpeg = decodeScreenshotImage(jpeg.data, 'baseline.png'); + const fromPng = decodeScreenshotImage(PNG.sync.write(toPng(fromJpeg)), 'baseline.png'); + + expect([...fromPng.data]).toEqual([...fromJpeg.data]); +}); + +test('decoding refuses bytes in neither container and a JPEG body that cannot be decoded', () => { + expect(thrownBy(() => decodeScreenshotImage(Buffer.from('GIF89a'), 'fixture'))).toMatchObject({ + code: 'COMMAND_FAILED', + message: 'fixture is neither PNG nor JPEG', + details: { label: 'fixture', leadingBytes: '47494638' }, + }); + + const corrupt = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 0x41)]); + expect(thrownBy(() => decodeScreenshotImage(corrupt, 'fixture'))).toMatchObject({ + code: 'COMMAND_FAILED', + message: 'Failed to decode fixture as JPEG', + details: { label: 'fixture', reason: expect.any(String) }, + }); +}); + +function toPng(image: { width: number; height: number; data: Buffer }): PNG { + const png = new PNG({ width: image.width, height: image.height }); + png.data = Buffer.from(image.data); + return png; +} diff --git a/packages/capture-kit/src/png-transcode.ts b/packages/capture-kit/src/screenshot-image.ts similarity index 56% rename from packages/capture-kit/src/png-transcode.ts rename to packages/capture-kit/src/screenshot-image.ts index 121360b95a..687ac566cb 100644 --- a/packages/capture-kit/src/png-transcode.ts +++ b/packages/capture-kit/src/screenshot-image.ts @@ -1,6 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { decode as decodeJpeg } from 'jpeg-js'; -import { hasPngSignature, PNG } from './png.ts'; +import { decodePng, hasPngSignature, PNG } from './png.ts'; const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]); @@ -13,10 +13,26 @@ export function detectScreenshotImageFormat(bytes: Buffer): ScreenshotImageForma return undefined; } +/** + * Decodes a screenshot in whatever container it arrived in into the RGBA rows every pixel reader in + * this repository consumes. The container is sniffed, never trusted from a file name or a caller's + * guess, so a JPEG stored under a `.png` name still decodes. + * + * Synchronous and CPU-bound: daemon request paths reach it through the PNG worker + * (`decodeScreenshotImageAsync` in `png-worker-client.ts`), like every other codec job. + */ +export function decodeScreenshotImage(bytes: Buffer, label: string): PNG { + const format = detectScreenshotImageFormat(bytes); + if (format === 'png') return decodePng(bytes, label); + if (format === 'jpeg') return decodeJpegImage(bytes, label); + throw unsupportedContainer(label, bytes); +} + /** * Returns PNG bytes for a screenshot a provider handed back in whatever container it prefers. PNG * passes through untouched; JPEG is decoded and re-encoded losslessly from the decoded pixels, so - * every PNG-only reader downstream (size, crop, overlay, diff) sees the format the path promises. + * every PNG-only reader downstream (size, crop, overlay) sees the format the path promises. Those + * readers rewrite the file in place, which is why a JPEG cannot be handed to them undecoded. * * Synchronous and CPU-bound: daemon request paths reach it through the PNG worker * (`transcodeScreenshotToPngAsync` in `png-worker-client.ts`), like every other codec job. @@ -24,20 +40,17 @@ export function detectScreenshotImageFormat(bytes: Buffer): ScreenshotImageForma export function transcodeScreenshotToPng(bytes: Buffer, label: string): Buffer { const format = detectScreenshotImageFormat(bytes); if (format === 'png') return bytes; - if (format === 'jpeg') return jpegToPng(bytes, label); - throw new AppError('COMMAND_FAILED', `${label} is neither PNG nor JPEG`, { - label, - leadingBytes: bytes.subarray(0, 4).toString('hex'), - }); + if (format === 'jpeg') return PNG.sync.write(decodeJpegImage(bytes, label)); + throw unsupportedContainer(label, bytes); } /** A JPEG signature does not prove a decodable body; a failure keeps the label and the decoder's reason. */ -function jpegToPng(bytes: Buffer, label: string): Buffer { +function decodeJpegImage(bytes: Buffer, label: string): PNG { try { const decoded = decodeJpeg(bytes, { useTArray: true, formatAsRGBA: true }); const png = new PNG({ width: decoded.width, height: decoded.height }); png.data = Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength); - return PNG.sync.write(png); + return png; } catch (error) { throw new AppError('COMMAND_FAILED', `Failed to decode ${label} as JPEG`, { label, @@ -45,3 +58,10 @@ function jpegToPng(bytes: Buffer, label: string): Buffer { }); } } + +function unsupportedContainer(label: string, bytes: Buffer): AppError { + return new AppError('COMMAND_FAILED', `${label} is neither PNG nor JPEG`, { + label, + leadingBytes: bytes.subarray(0, 4).toString('hex'), + }); +} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index f2f423170b..f0076707c7 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -439,6 +439,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/screen-recording-transport', '@agent-device/capture-kit/screenshot-density', '@agent-device/capture-kit/screenshot-diff-pixels', + '@agent-device/capture-kit/screenshot-image', '@agent-device/capture-kit/screenshot-overlay', '@agent-device/capture-kit/scroll-edge-state', '@agent-device/capture-kit/snapshot-chrome', diff --git a/src/commands/capture/diff.ts b/src/commands/capture/diff.ts index 4e50a78596..20c9194e79 100644 --- a/src/commands/capture/diff.ts +++ b/src/commands/capture/diff.ts @@ -59,7 +59,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; image dimensions must still match. 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; image dimensions must still match. 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.', }, metadata: diffCommandMetadata, run: (client, input) => client.capture.diff(input), diff --git a/src/commands/schema/cli-help-command-usage.test.ts b/src/commands/schema/cli-help-command-usage.test.ts index ae965b9697..1d25e03127 100644 --- a/src/commands/schema/cli-help-command-usage.test.ts +++ b/src/commands/schema/cli-help-command-usage.test.ts @@ -39,6 +39,14 @@ test('usageForCommand documents screenshot diff normalization', async () => { assert.match(help, /screenshot --normalize-status-bar/); }); +test('usageForCommand documents the screenshot diff input containers', async () => { + const help = await usageForCommand('diff'); + if (help === null) throw new Error('Expected diff help text'); + assert.match(help, /a baseline or current image may be PNG or JPEG/); + assert.match(help, /the diff image is always PNG/); + assert.match(help, /keep --threshold above 0 whenever either input is JPEG/); +}); + test('usageForCommand resolves longpress help', async () => { const help = await usageForCommand('longpress'); assert.equal(help === null, false); diff --git a/src/screenshot-diff/__tests__/screenshot-diff.test.ts b/src/screenshot-diff/__tests__/screenshot-diff.test.ts index db52ce175a..629dda206d 100644 --- a/src/screenshot-diff/__tests__/screenshot-diff.test.ts +++ b/src/screenshot-diff/__tests__/screenshot-diff.test.ts @@ -5,13 +5,14 @@ import path from 'node:path'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; vi.mock('@agent-device/capture-kit/png-worker-client', async () => { - const [{ PNG }, { decodePng }, { computeScreenshotDiffPixels }] = await Promise.all([ - import('@agent-device/capture-kit/png'), + const [{ PNG }, { decodeScreenshotImage }, { computeScreenshotDiffPixels }] = await Promise.all([ import('@agent-device/capture-kit/png'), + import('@agent-device/capture-kit/screenshot-image'), import('@agent-device/capture-kit/screenshot-diff-pixels'), ]); return { - decodePngAsync: async (buffer: Buffer, label: string) => decodePng(buffer, label), + decodeScreenshotImageAsync: async (buffer: Buffer, label: string) => + decodeScreenshotImage(buffer, label), encodePngAsync: async (png: InstanceType) => PNG.sync.write(png), computeScreenshotDiffPixelsAsync: async ( job: Parameters[0], @@ -20,6 +21,7 @@ vi.mock('@agent-device/capture-kit/png-worker-client', async () => { }); import { PNG } from '@agent-device/capture-kit/png'; +import { decodeScreenshotImage } from '@agent-device/capture-kit/screenshot-image'; import { compareScreenshots } from '../screenshot-diff.ts'; function tmpDir(): string { @@ -43,6 +45,36 @@ function writeSolidPng( fs.writeFileSync(filePath, PNG.sync.write(png)); } +/** Write decoded pixels as a PNG, so a test can pair a JPEG input with its lossless twin. */ +function writePixels( + filePath: string, + image: { width: number; height: number; data: Buffer }, +): void { + const png = new PNG({ width: image.width, height: image.height }); + png.data = Buffer.from(image.data); + fs.writeFileSync(filePath, PNG.sync.write(png)); +} + +/** Encode a solid-color JPEG, so a test can hand the comparison a lossy container. */ +async function writeSolidJpeg( + filePath: string, + width: number, + height: number, + color: { r: number; g: number; b: number }, +): Promise { + const { encode } = await import('jpeg-js'); + const data = Buffer.alloc(width * height * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = color.r; + data[i + 1] = color.g; + data[i + 2] = color.b; + data[i + 3] = 255; + } + const jpeg = Buffer.from(encode({ width, height, data }, 90).data); + fs.writeFileSync(filePath, jpeg); + return jpeg; +} + function paintRect( png: PNG, rect: { x: number; y: number; width: number; height: number }, @@ -416,7 +448,7 @@ test('throws INVALID_ARGS when current file does not exist', async () => { ); }); -test('throws COMMAND_FAILED for invalid PNG data', async () => { +test('throws COMMAND_FAILED for bytes in neither supported container', async () => { const dir = tmpDir(); const baseline = path.join(dir, 'baseline.png'); const current = path.join(dir, 'current.png'); @@ -428,7 +460,61 @@ test('throws COMMAND_FAILED for invalid PNG data', async () => { () => compareScreenshots(baseline, current), (err: any) => { assert.equal(err.code, 'COMMAND_FAILED'); - assert.match(err.message, /Failed to decode baseline screenshot/); + assert.match(err.message, /baseline screenshot is neither PNG nor JPEG/); + return true; + }, + ); +}); + +test('a JPEG input compares as a match against a PNG holding the same pixels', async () => { + const dir = tmpDir(); + // The `.png` name holds JPEG bytes on purpose: the container is sniffed from the bytes. + const baseline = path.join(dir, 'baseline.png'); + const current = path.join(dir, 'current.png'); + + const jpeg = await writeSolidJpeg(baseline, 8, 6, { r: 20, g: 200, b: 40 }); + writePixels(current, decodeScreenshotImage(jpeg, 'fixture')); + + const result = await compareScreenshots(baseline, current); + + assert.equal(result.match, true); + assert.equal(result.differentPixels, 0); + assert.equal(result.totalPixels, 48); +}); + +test('a JPEG input is measured at its decoded dimensions', async () => { + const dir = tmpDir(); + const baseline = path.join(dir, 'baseline.jpg'); + const current = path.join(dir, 'current.png'); + + await writeSolidJpeg(baseline, 8, 6, { r: 20, g: 200, b: 40 }); + writeSolidPng(current, 5, 5, { r: 20, g: 200, b: 40 }); + + const result = await compareScreenshots(baseline, current); + + assert.equal(result.match, false); + assert.deepEqual(result.dimensionMismatch, { + expected: { width: 8, height: 6 }, + actual: { width: 5, height: 5 }, + }); +}); + +test('JPEG bytes that do not decode are refused with the JPEG decode error', async () => { + const dir = tmpDir(); + const baseline = path.join(dir, 'baseline.jpg'); + const current = path.join(dir, 'current.png'); + + fs.writeFileSync( + baseline, + Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 0x41)]), + ); + writeSolidPng(current, 5, 5, { r: 0, g: 0, b: 0 }); + + await assert.rejects( + () => compareScreenshots(baseline, current), + (err: any) => { + assert.equal(err.code, 'COMMAND_FAILED'); + assert.match(err.message, /Failed to decode baseline screenshot as JPEG/); return true; }, ); diff --git a/src/screenshot-diff/screenshot-diff.ts b/src/screenshot-diff/screenshot-diff.ts index 1c2d034197..657d104591 100644 --- a/src/screenshot-diff/screenshot-diff.ts +++ b/src/screenshot-diff/screenshot-diff.ts @@ -5,7 +5,7 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import { PNG } from '@agent-device/capture-kit/png'; import { computeScreenshotDiffPixelsAsync, - decodePngAsync, + decodeScreenshotImageAsync, encodePngAsync, } from '@agent-device/capture-kit/png-worker-client'; import { annotateDiffRegions } from './screenshot-diff-region-overlay.ts'; @@ -75,6 +75,11 @@ export type ScreenshotDiffOptions = { // Match the per-pixel square-root rounding so the maximum stays inclusive. const COLOR_DISTANCE_SCALE = Math.sqrt(3 * 255 ** 2); +/** + * Compares two screenshots pixel by pixel. Each input may be PNG or JPEG: the container is sniffed + * from the bytes rather than the file name, so a baseline exported by any tool that writes JPEG + * still compares. The diff image itself is always PNG. + */ export async function compareScreenshots( baselinePath: string, currentPath: string, @@ -91,8 +96,8 @@ export async function compareScreenshots( ]); const [baseline, current] = await Promise.all([ - decodePngAsync(baselineBuffer, 'baseline screenshot'), - decodePngAsync(currentBuffer, 'current screenshot'), + decodeScreenshotImageAsync(baselineBuffer, 'baseline screenshot'), + decodeScreenshotImageAsync(currentBuffer, 'current screenshot'), ]); validateMaxPixels(baseline.width, baseline.height, 'baseline screenshot', options.maxPixels); validateMaxPixels(current.width, current.height, 'current screenshot', options.maxPixels); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index c44dc890a3..3f6c50844f 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1008,6 +1008,7 @@ agent-device screenshot --fullscreen # Force full-screen capture on macOS app agent-device open --platform macos --surface desktop && agent-device screenshot desktop.png agent-device diff screenshot --baseline baseline.png --out diff.png agent-device diff screenshot --baseline baseline.png current.png --out diff.png +agent-device diff screenshot --baseline baseline.jpg --out diff.png # JPEG inputs are accepted too agent-device diff screenshot --baseline baseline.png --out diff.png --overlay-refs agent-device record start # Start app-scoped recording after open agent-device record start session.mp4 # Start app-scoped recording to explicit path @@ -1025,9 +1026,9 @@ agent-device record stop # Stop active recording - `screenshot --crop-on ` captures a fresh full snapshot of the same screen and crops the saved PNG to the frame the selector resolves to. The crop is re-encoded, so byte-comparing it against an older crop of the same frame is unreliable; a crop whose pixels are all opaque is written as truecolor RGB, while one containing transparency keeps RGBA. The selector must resolve to exactly one framed node; the result carries a `warnings` entry when the frame is clipped to the image. Currently accepted on iOS simulators and Android emulators — every other target is refused before any device work, and the flag cannot be combined with `--overlay-refs` or `--fullscreen` because both move the captured frame away from the snapshot viewport the crop is measured against. - `screenshot --normalize-status-bar` temporarily normalizes iOS simulator status-bar chrome for deterministic screenshot baselines; ordinary screenshots leave the simulator's current chrome visible. - `screenshot --scale --overlay-refs` writes a smaller image and draws refs for that final image size; avoid very small scales when text, icons, or labels need to remain readable. -- `diff screenshot` compares the current live screenshot to `--baseline`, or compares `--baseline` to an optional saved `current.png` path without requiring an active session. Its text output reports ranked changed regions with screen-space rectangles, changed-pixel counts, and each region's share of the diff; JSON also includes normalized rectangles. The earlier best-effort `ocr` and `nonTextDeltas` analyzers are retired; their optional result fields remain for source compatibility but are no longer emitted, so use the baseline/current images and diff artifact with vision for qualitative interpretation. It writes a diff PNG with a light grayscale current-screen context, red-tinted changed pixels, and outlined changed regions when `--out` is provided. Live iOS simulator diffs normalize status-bar chrome by default; use `screenshot --normalize-status-bar` when capturing reusable baselines. +- `diff screenshot` compares the current live screenshot to `--baseline`, or compares `--baseline` to an optional saved `current.png` path without requiring an active session. Each input is decoded from its own bytes, so `--baseline` and a saved current image may be PNG or JPEG whatever their extension says; `agent-device screenshot` itself always writes PNG. Its text output reports ranked changed regions with screen-space rectangles, changed-pixel counts, and each region's share of the diff; JSON also includes normalized rectangles. The earlier best-effort `ocr` and `nonTextDeltas` analyzers are retired; their optional result fields remain for source compatibility but are no longer emitted, so use the baseline/current images and diff artifact with vision for qualitative interpretation. It writes a diff PNG with a light grayscale current-screen context, red-tinted changed pixels, and outlined changed regions when `--out` is provided. Live iOS simulator diffs normalize status-bar chrome by default; use `screenshot --normalize-status-bar` when capturing reusable baselines. - `diff screenshot --overlay-refs` additionally writes a separate current-screen overlay guide for live captures without using that annotated image for the pixel comparison. If current-screen refs intersect changed regions, the output lists the best ref matches under those regions. Saved-image comparisons do not have live accessibility refs, so `--overlay-refs` is unavailable when a `current.png` path is provided. -- `diff screenshot --threshold <0-1>` sets the per-pixel RGB tolerance (default `0.1`): `0` requires exact colors and `1` ignores all color differences. Image dimensions must still match at every threshold. +- `diff screenshot --threshold <0-1>` sets the per-pixel RGB tolerance (default `0.1`): `0` requires exact colors and `1` ignores all color differences. Image dimensions must still match at every threshold. JPEG is lossy and shifts pixels around hard edges, so keep the threshold above `0` whenever either input is JPEG; `0` is only meaningful between two PNG files. - In `--json` mode, each overlay ref also includes a screenshot-space `center` point for coordinate fallback like `press `. - Burned-in touch overlays are exported only on macOS hosts, because the overlay pipeline depends on Swift + AVFoundation helpers. - On Linux or other non-macOS hosts, `record stop` still succeeds and returns the raw video plus telemetry sidecar, and includes `overlayWarning` when burn-in overlays were skipped. From 46b0079dafaaffeba6172ecd8c20addd5b8bf7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 22:46:18 +0200 Subject: [PATCH 2/3] test(diff): compare JPEG inputs through the shipped PNG worker Drops the screenshot-diff module mock and the package export that existed only to feed it, so the JPEG cases run the same worker client production uses; pins the diff artifact container and the decode-image transfer arm, and corrects two doc claims the first commit overreached on: HarmonyOS still serves JPEG, and a stored JPEG compares exactly against itself at threshold 0. --- .../attempt-1/replay-timing.ndjson | 12 ++-- .../01-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 ++-- .../02-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 ++-- .../01-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 ++-- .../02-flow.yaml/attempt-1/result.txt | 2 +- CHANGELOG.md | 12 ++-- packages/capture-kit/package.json | 4 -- .../capture-kit/src/png-worker-client.test.ts | 28 ++++++---- packages/capture-kit/src/png-worker.test.ts | 12 ++++ .../capture-kit/src/screenshot-image.test.ts | 15 ----- scripts/layering/package-boundaries.test.ts | 1 - .../__tests__/screenshot-diff.test.ts | 56 ++++++++----------- website/docs/docs/commands.md | 4 +- 16 files changed, 88 insertions(+), 100 deletions(-) diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson index c3de3c93c6..44b19f42ca 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.818Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":1} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.178Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} +{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt index 6cb55621a0..1c8af6a457 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml +file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/01-flow.yaml session: default:test:req-maestro-test-wire-true:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson index 5d0e648b70..358a21145c 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.820Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.181Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":1} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt index e634dd45f4..081503fdc4 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml +file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/02-flow.yaml session: default:test:req-maestro-test-wire-true:2-02-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson index 4ae708cda6..8e165964ab 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.823Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.184Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt index 69b4b78571..51884ddd6a 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml +file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/01-flow.yaml session: default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson index 9107c923bb..77eb79cb2e 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.824Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.185Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.185Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt index a3bc90a611..c7a299954b 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml +file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/02-flow.yaml session: default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1 attempt: 1/1 status: passed diff --git a/CHANGELOG.md b/CHANGELOG.md index 349f555fa7..fc8e46e1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ - Added (diff): `diff screenshot` accepts a JPEG baseline or current image. Both inputs had to be PNG, so a capture exported by another tool had to be converted first and a HarmonyOS capture — which the - platform serves as JPEG — could never be compared. Each input is now decoded from its own bytes, so - the container is sniffed and a `.png` name holding JPEG decodes as JPEG. `png-transcode.ts` became - `screenshot-image.ts`, the one owner of that sniffing for both the decode and the provider transcode - path, and the PNG worker gained a `decode-image` job that answers pixels instead of PNG bytes. - `screenshot` still writes PNG and so does the `--out` diff image: crop, overlay, and resize rewrite a - screenshot in place, which a lossy container could not survive. + platform serves as JPEG under whatever name the command was given — could never be compared. Each + input is now decoded from its own bytes, so the container is sniffed and a `.png` name holding JPEG + decodes as JPEG. `png-transcode.ts` became `screenshot-image.ts`, the one owner of that sniffing for + both the decode and the provider transcode path, and the PNG worker gained a `decode-image` job that + answers pixels instead of PNG bytes. The `--out` diff image stays PNG, as do the crop, overlay, and + resize passes that rewrite a screenshot in place and could not survive a lossy container. - Added (limrun): `record start` and `record stop` on Limrun iOS and Android direct sessions. The runtime declared recording unavailable although the Limrun SDK exposes a server-side recorder. diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index f5ae3da11c..05346e4968 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -202,10 +202,6 @@ "types": "./src/screenshot-diff-pixels.ts", "default": "./src/screenshot-diff-pixels.ts" }, - "./screenshot-image": { - "types": "./src/screenshot-image.ts", - "default": "./src/screenshot-image.ts" - }, "./screenshot-overlay": { "types": "./src/screenshot-overlay.ts", "default": "./src/screenshot-overlay.ts" diff --git a/packages/capture-kit/src/png-worker-client.test.ts b/packages/capture-kit/src/png-worker-client.test.ts index 942f50328a..b24bedb577 100644 --- a/packages/capture-kit/src/png-worker-client.test.ts +++ b/packages/capture-kit/src/png-worker-client.test.ts @@ -166,17 +166,23 @@ test('decodeScreenshotImageAsync decodes a JPEG by its bytes, not its label', as }); test('decodeScreenshotImageAsync rejects a container it cannot read with the canonical AppError', async () => { - await assert.rejects(decodeScreenshotImageAsync(Buffer.from('GIF89a'), 'fixture'), (error) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'COMMAND_FAILED'); - assert.equal((error as AppError).message, 'fixture is neither PNG nor JPEG'); - assert.equal((error as AppError).details?.label, 'fixture'); - return true; - }); + await assert.rejects( + () => decodeScreenshotImageAsync(Buffer.from('GIF89a'), 'fixture'), + (error) => { + assert.equal(error instanceof AppError, true); + assert.equal((error as AppError).code, 'COMMAND_FAILED'); + assert.equal((error as AppError).message, 'fixture is neither PNG nor JPEG'); + assert.equal((error as AppError).details?.label, 'fixture'); + return true; + }, + ); const corrupt = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(16, 0)]); - await assert.rejects(decodeScreenshotImageAsync(corrupt, 'fixture'), (error) => { - assert.match((error as AppError).message, /Failed to decode fixture as JPEG/); - return true; - }); + await assert.rejects( + () => decodeScreenshotImageAsync(corrupt, 'fixture'), + (error) => { + assert.match((error as AppError).message, /Failed to decode fixture as JPEG/); + return true; + }, + ); }); diff --git a/packages/capture-kit/src/png-worker.test.ts b/packages/capture-kit/src/png-worker.test.ts index 9bc7f3b901..be597d4bef 100644 --- a/packages/capture-kit/src/png-worker.test.ts +++ b/packages/capture-kit/src/png-worker.test.ts @@ -28,6 +28,18 @@ test('resultTransferList transfers a cropped encoding and skips an untouched fil assert.deepEqual(resultTransferList({ kind: 'crop', png: null }), []); }); +test('resultTransferList transfers decoded pixels for both screenshot decode jobs', () => { + const owned = Buffer.alloc(16); // Buffer.alloc never uses the shared pool + const pooled = new Uint8Array(new ArrayBuffer(32), 4, 8); // offset view, pooled-Buffer shape + const decoded = { width: 2, height: 2 }; + + assert.deepEqual(resultTransferList({ kind: 'decode', ...decoded, data: owned }), [owned.buffer]); + assert.deepEqual(resultTransferList({ kind: 'decode-image', ...decoded, data: owned }), [ + owned.buffer, + ]); + assert.deepEqual(resultTransferList({ kind: 'decode-image', ...decoded, data: pooled }), []); +}); + test('resultTransferList transfers only the fully-owned views of a mixed result', () => { const ownedDiffData = Buffer.alloc(16); // Buffer.alloc never uses the shared pool const pooledMask = new Uint8Array(new ArrayBuffer(32), 4, 8); // offset view, pooled-Buffer shape diff --git a/packages/capture-kit/src/screenshot-image.test.ts b/packages/capture-kit/src/screenshot-image.test.ts index 3d1baa94eb..172f56b363 100644 --- a/packages/capture-kit/src/screenshot-image.test.ts +++ b/packages/capture-kit/src/screenshot-image.test.ts @@ -104,15 +104,6 @@ test('decoding a JPEG answers RGBA rows, with opaque alpha where the container h expect(a).toBe(255); }); -test('decoding sniffs the container, so JPEG bytes answer JPEG pixels whatever they are called', () => { - const jpeg = encodeJpeg({ width: 4, height: 4, data: solidRgba(4, 4, [7, 9, 11, 255]) }, 90); - - const fromJpeg = decodeScreenshotImage(jpeg.data, 'baseline.png'); - const fromPng = decodeScreenshotImage(PNG.sync.write(toPng(fromJpeg)), 'baseline.png'); - - expect([...fromPng.data]).toEqual([...fromJpeg.data]); -}); - test('decoding refuses bytes in neither container and a JPEG body that cannot be decoded', () => { expect(thrownBy(() => decodeScreenshotImage(Buffer.from('GIF89a'), 'fixture'))).toMatchObject({ code: 'COMMAND_FAILED', @@ -127,9 +118,3 @@ test('decoding refuses bytes in neither container and a JPEG body that cannot be details: { label: 'fixture', reason: expect.any(String) }, }); }); - -function toPng(image: { width: number; height: number; data: Buffer }): PNG { - const png = new PNG({ width: image.width, height: image.height }); - png.data = Buffer.from(image.data); - return png; -} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index f0076707c7..f2f423170b 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -439,7 +439,6 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/screen-recording-transport', '@agent-device/capture-kit/screenshot-density', '@agent-device/capture-kit/screenshot-diff-pixels', - '@agent-device/capture-kit/screenshot-image', '@agent-device/capture-kit/screenshot-overlay', '@agent-device/capture-kit/scroll-edge-state', '@agent-device/capture-kit/snapshot-chrome', diff --git a/src/screenshot-diff/__tests__/screenshot-diff.test.ts b/src/screenshot-diff/__tests__/screenshot-diff.test.ts index 629dda206d..ad3a447f6f 100644 --- a/src/screenshot-diff/__tests__/screenshot-diff.test.ts +++ b/src/screenshot-diff/__tests__/screenshot-diff.test.ts @@ -1,27 +1,10 @@ -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('@agent-device/capture-kit/png-worker-client', async () => { - const [{ PNG }, { decodeScreenshotImage }, { computeScreenshotDiffPixels }] = await Promise.all([ - import('@agent-device/capture-kit/png'), - import('@agent-device/capture-kit/screenshot-image'), - import('@agent-device/capture-kit/screenshot-diff-pixels'), - ]); - return { - decodeScreenshotImageAsync: async (buffer: Buffer, label: string) => - decodeScreenshotImage(buffer, label), - encodePngAsync: async (png: InstanceType) => PNG.sync.write(png), - computeScreenshotDiffPixelsAsync: async ( - job: Parameters[0], - ) => computeScreenshotDiffPixels(job), - }; -}); - -import { PNG } from '@agent-device/capture-kit/png'; -import { decodeScreenshotImage } from '@agent-device/capture-kit/screenshot-image'; +import { hasPngSignature, PNG } from '@agent-device/capture-kit/png'; import { compareScreenshots } from '../screenshot-diff.ts'; function tmpDir(): string { @@ -45,16 +28,6 @@ function writeSolidPng( fs.writeFileSync(filePath, PNG.sync.write(png)); } -/** Write decoded pixels as a PNG, so a test can pair a JPEG input with its lossless twin. */ -function writePixels( - filePath: string, - image: { width: number; height: number; data: Buffer }, -): void { - const png = new PNG({ width: image.width, height: image.height }); - png.data = Buffer.from(image.data); - fs.writeFileSync(filePath, PNG.sync.write(png)); -} - /** Encode a solid-color JPEG, so a test can hand the comparison a lossy container. */ async function writeSolidJpeg( filePath: string, @@ -466,22 +439,39 @@ test('throws COMMAND_FAILED for bytes in neither supported container', async () ); }); -test('a JPEG input compares as a match against a PNG holding the same pixels', async () => { +test('a JPEG stored under a .png name compares as a match', async () => { const dir = tmpDir(); - // The `.png` name holds JPEG bytes on purpose: the container is sniffed from the bytes. const baseline = path.join(dir, 'baseline.png'); const current = path.join(dir, 'current.png'); + // The `.png` names hold JPEG bytes on purpose: the container is sniffed from the bytes. const jpeg = await writeSolidJpeg(baseline, 8, 6, { r: 20, g: 200, b: 40 }); - writePixels(current, decodeScreenshotImage(jpeg, 'fixture')); + fs.writeFileSync(current, jpeg); - const result = await compareScreenshots(baseline, current); + // The same stored bytes decode to the same pixels, so this pins an exact comparison. + const result = await compareScreenshots(baseline, current, { threshold: 0 }); assert.equal(result.match, true); assert.equal(result.differentPixels, 0); assert.equal(result.totalPixels, 48); }); +test('a JPEG comparison writes the diff artifact as PNG', async () => { + const dir = tmpDir(); + const baseline = path.join(dir, 'baseline.jpg'); + const current = path.join(dir, 'current.jpg'); + const diffPath = path.join(dir, 'diff.png'); + + await writeSolidJpeg(baseline, 8, 6, { r: 20, g: 200, b: 40 }); + await writeSolidJpeg(current, 8, 6, { r: 200, g: 20, b: 40 }); + + const result = await compareScreenshots(baseline, current, { outputPath: diffPath }); + + assert.equal(result.match, false); + assert.equal(result.diffPath, diffPath); + assert.equal(hasPngSignature(fs.readFileSync(diffPath)), true); +}); + test('a JPEG input is measured at its decoded dimensions', async () => { const dir = tmpDir(); const baseline = path.join(dir, 'baseline.jpg'); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3f6c50844f..ff6c28e4b0 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1026,9 +1026,9 @@ agent-device record stop # Stop active recording - `screenshot --crop-on ` captures a fresh full snapshot of the same screen and crops the saved PNG to the frame the selector resolves to. The crop is re-encoded, so byte-comparing it against an older crop of the same frame is unreliable; a crop whose pixels are all opaque is written as truecolor RGB, while one containing transparency keeps RGBA. The selector must resolve to exactly one framed node; the result carries a `warnings` entry when the frame is clipped to the image. Currently accepted on iOS simulators and Android emulators — every other target is refused before any device work, and the flag cannot be combined with `--overlay-refs` or `--fullscreen` because both move the captured frame away from the snapshot viewport the crop is measured against. - `screenshot --normalize-status-bar` temporarily normalizes iOS simulator status-bar chrome for deterministic screenshot baselines; ordinary screenshots leave the simulator's current chrome visible. - `screenshot --scale --overlay-refs` writes a smaller image and draws refs for that final image size; avoid very small scales when text, icons, or labels need to remain readable. -- `diff screenshot` compares the current live screenshot to `--baseline`, or compares `--baseline` to an optional saved `current.png` path without requiring an active session. Each input is decoded from its own bytes, so `--baseline` and a saved current image may be PNG or JPEG whatever their extension says; `agent-device screenshot` itself always writes PNG. Its text output reports ranked changed regions with screen-space rectangles, changed-pixel counts, and each region's share of the diff; JSON also includes normalized rectangles. The earlier best-effort `ocr` and `nonTextDeltas` analyzers are retired; their optional result fields remain for source compatibility but are no longer emitted, so use the baseline/current images and diff artifact with vision for qualitative interpretation. It writes a diff PNG with a light grayscale current-screen context, red-tinted changed pixels, and outlined changed regions when `--out` is provided. Live iOS simulator diffs normalize status-bar chrome by default; use `screenshot --normalize-status-bar` when capturing reusable baselines. +- `diff screenshot` compares the current live screenshot to `--baseline`, or compares `--baseline` to an optional saved `current.png` path without requiring an active session. Each input is decoded from its own bytes, so `--baseline` and a saved current image may be PNG or JPEG whatever their extension says. Most `agent-device screenshot` artifacts are PNG; a HarmonyOS capture is the JPEG its device serves, stored under the requested name. Its text output reports ranked changed regions with screen-space rectangles, changed-pixel counts, and each region's share of the diff; JSON also includes normalized rectangles. The earlier best-effort `ocr` and `nonTextDeltas` analyzers are retired; their optional result fields remain for source compatibility but are no longer emitted, so use the baseline/current images and diff artifact with vision for qualitative interpretation. It writes a diff PNG with a light grayscale current-screen context, red-tinted changed pixels, and outlined changed regions when `--out` is provided. Live iOS simulator diffs normalize status-bar chrome by default; use `screenshot --normalize-status-bar` when capturing reusable baselines. - `diff screenshot --overlay-refs` additionally writes a separate current-screen overlay guide for live captures without using that annotated image for the pixel comparison. If current-screen refs intersect changed regions, the output lists the best ref matches under those regions. Saved-image comparisons do not have live accessibility refs, so `--overlay-refs` is unavailable when a `current.png` path is provided. -- `diff screenshot --threshold <0-1>` sets the per-pixel RGB tolerance (default `0.1`): `0` requires exact colors and `1` ignores all color differences. Image dimensions must still match at every threshold. JPEG is lossy and shifts pixels around hard edges, so keep the threshold above `0` whenever either input is JPEG; `0` is only meaningful between two PNG files. +- `diff screenshot --threshold <0-1>` sets the per-pixel RGB tolerance (default `0.1`): `0` requires exact colors and `1` ignores all color differences. Image dimensions must still match at every threshold. JPEG is lossy and shifts pixels around hard edges, so keep the threshold above `0` when comparing a JPEG against anything it was not encoded from. - In `--json` mode, each overlay ref also includes a screenshot-space `center` point for coordinate fallback like `press `. - Burned-in touch overlays are exported only on macOS hosts, because the overlay pipeline depends on Swift + AVFoundation helpers. - On Linux or other non-macOS hosts, `record stop` still succeeds and returns the raw video plus telemetry sidecar, and includes `overlayWarning` when burn-in overlays were skipped. From 8d3a8719a938d17ee22d80716cbd39dff0238b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 22:49:53 +0200 Subject: [PATCH 3/3] chore(gates): keep replay-compat corpus artifacts out of this branch The gate rewrites those timing and result artifacts on every run; they were swept in by a broad stage, not changed by this feature. --- .../01-flow.yaml/attempt-1/replay-timing.ndjson | 12 ++++++------ .../01-flow.yaml/attempt-1/result.txt | 2 +- .../02-flow.yaml/attempt-1/replay-timing.ndjson | 12 ++++++------ .../02-flow.yaml/attempt-1/result.txt | 2 +- .../01-flow.yaml/attempt-1/replay-timing.ndjson | 12 ++++++------ .../01-flow.yaml/attempt-1/result.txt | 2 +- .../02-flow.yaml/attempt-1/replay-timing.ndjson | 12 ++++++------ .../02-flow.yaml/attempt-1/result.txt | 2 +- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson index 44b19f42ca..c3de3c93c6 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.178Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} -{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.180Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.818Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":1} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt index 1c8af6a457..6cb55621a0 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/01-flow.yaml +file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml session: default:test:req-maestro-test-wire-true:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson index 358a21145c..5d0e648b70 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.181Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.181Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":1} +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.820Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt index 081503fdc4..e634dd45f4 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-zveL7p/02-flow.yaml +file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml session: default:test:req-maestro-test-wire-true:2-02-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson index 8e165964ab..4ae708cda6 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.184Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.184Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.823Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt index 51884ddd6a..69b4b78571 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/01-flow.yaml +file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml session: default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson index 77eb79cb2e..9107c923bb 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-20T20:30:32.185Z","replayPath":"/tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-20T20:30:32.185Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-20T20:30:32.186Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.824Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} +{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt index c7a299954b..a3bc90a611 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-31504-laLgZx/agent-device-maestro-remote-test-XSwPWo/02-flow.yaml +file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml session: default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1 attempt: 1/1 status: passed