feat: add diff screenshot command for pixel-level image comparison (#214)

* fix: rebase PR 214 onto main

* fix: clean up screenshot diff output handling

* refactor: split android screenshot module

* chore: remove clack prompts dependency

* chore: format command schema

* refactor: unify user path resolution

* fix: let android screenshot ui settle

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
This commit is contained in:
Daniel Williams
2026-03-17 18:14:45 +00:00
committed by GitHub
parent 2be4de0a75
commit 3f9eee0704
24 changed files with 1619 additions and 109 deletions
+5 -1
View File
@@ -65,9 +65,13 @@
"trailingComma": "all",
"printWidth": 100
},
"dependencies": {
"pngjs": "^7.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@rslib/core": "0.19.4",
"@types/node": "^22.0.0",
"@types/pngjs": "^6.0.5",
"prettier": "^3.3.3",
"typescript": "^5.9.3"
}
+20
View File
@@ -7,6 +7,10 @@ settings:
importers:
.:
dependencies:
pngjs:
specifier: ^7.0.0
version: 7.0.0
devDependencies:
'@rslib/core':
specifier: 0.19.4
@@ -14,6 +18,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/pngjs':
specifier: ^6.0.5
version: 6.0.5
prettier:
specifier: ^3.3.3
version: 3.8.1
@@ -379,6 +386,9 @@ packages:
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@types/pngjs@6.0.5':
resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
@@ -909,6 +919,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
@@ -1595,6 +1609,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.7
'@types/react@19.2.13':
dependencies:
csstype: 3.2.3
@@ -2423,6 +2441,8 @@ snapshots:
picomatch@4.0.3: {}
pngjs@7.0.0: {}
prettier@3.8.1: {}
property-information@7.1.0: {}
+219 -24
View File
@@ -1,5 +1,9 @@
import test from 'node:test';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { runCli } from '../cli.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
@@ -19,7 +23,30 @@ type RunResult = {
calls: Omit<DaemonRequest, 'token'>[];
};
async function runCliCapture(argv: string[]): Promise<RunResult> {
type RunCliCaptureOptions = {
preserveHome?: boolean;
};
/** Create a solid-color PNG buffer. */
function solidPngBuffer(
width: number,
height: number,
color: { r: number; g: number; b: number },
): Buffer {
const png = new PNG({ width, height });
for (let i = 0; i < png.data.length; i += 4) {
png.data[i] = color.r;
png.data[i + 1] = color.g;
png.data[i + 2] = color.b;
png.data[i + 3] = 255;
}
return PNG.sync.write(png);
}
async function runCliCapture(
argv: string[],
options: RunCliCaptureOptions = {},
): Promise<RunResult> {
let stdout = '';
let stderr = '';
let code: number | null = null;
@@ -28,21 +55,45 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
const originalHome = process.env.HOME;
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-home-'));
// Disable ANSI colors so assertions can match plain text
process.env.FORCE_COLOR = '0';
delete process.env.NO_COLOR;
if (!options.preserveHome) {
process.env.HOME = tempHome;
}
(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
}) as typeof process.exit;
(process.stdout as any).write = ((chunk: unknown) => {
(process.stdout as any).write = ((chunk: unknown, ...args: unknown[]) => {
// Pass through the test runner's binary protocol messages (raw Buffers)
if (Buffer.isBuffer(chunk)) return originalStdoutWrite(chunk, ...(args as [any]));
stdout += String(chunk);
return true;
}) as typeof process.stdout.write;
(process.stderr as any).write = ((chunk: unknown) => {
(process.stderr as any).write = ((chunk: unknown, ...args: unknown[]) => {
if (Buffer.isBuffer(chunk)) return originalStderrWrite(chunk, ...(args as [any]));
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
calls.push(req);
if (req.command === 'screenshot') {
// The client-backed diff handler captures a screenshot via the client.
// Write a real PNG to the requested path so compareScreenshots can read it.
const outPath = req.positionals?.[0] ?? req.flags?.out;
if (typeof outPath === 'string') {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
}
return { ok: true, data: { path: outPath } };
}
return {
ok: true,
data: {
@@ -67,30 +118,174 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
if (typeof originalHome === 'string') process.env.HOME = originalHome;
else delete process.env.HOME;
fs.rmSync(tempHome, { recursive: true, force: true });
}
return { code, stdout, stderr, calls };
}
test('diff snapshot renders human-readable unified diff text', async () => {
const result = await runCliCapture(['diff', 'snapshot']);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.match(result.stdout, /^@e2 \[window\]/m);
assert.match(result.stdout, /^- @e3 \[text\] "67"$/m);
assert.match(result.stdout, /^\+ @e3 \[text\] "134"$/m);
assert.match(result.stdout, /1 additions, 1 removals, 1 unchanged/);
assert.equal(result.stderr, '');
});
// Tests must run serially because they monkey-patch process.exit and process.stdout.write.
describe('cli diff commands', { concurrency: false }, () => {
test('diff snapshot renders human-readable unified diff text', async () => {
const result = await runCliCapture(['diff', 'snapshot']);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.match(result.stdout, /^@e2 \[window\]/m);
assert.match(result.stdout, /^- @e3 \[text\] "67"$/m);
assert.match(result.stdout, /^\+ @e3 \[text\] "134"$/m);
assert.match(result.stdout, /1 additions, 1 removals, 1 unchanged/);
assert.equal(result.stderr, '');
});
test('diff snapshot --json passes daemon payload through unchanged', async () => {
const result = await runCliCapture(['diff', 'snapshot', '--json']);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
const payload = JSON.parse(result.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.mode, 'snapshot');
assert.equal(payload.data.baselineInitialized, false);
assert.equal(Array.isArray(payload.data.lines), true);
assert.equal(result.stderr, '');
test('diff snapshot --json passes daemon payload through unchanged', async () => {
const result = await runCliCapture(['diff', 'snapshot', '--json']);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
const payload = JSON.parse(result.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.mode, 'snapshot');
assert.equal(payload.data.baselineInitialized, false);
assert.equal(Array.isArray(payload.data.lines), true);
assert.equal(result.stderr, '');
});
test('diff screenshot renders human-readable mismatch output', async () => {
// Create a real baseline PNG (black) so compareScreenshots can run against it.
// The mock sendToDaemon writes a white PNG as the "current" screenshot.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
const baseline = path.join(dir, 'baseline.png');
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 0, g: 0, b: 0 }));
try {
const result = await runCliCapture([
'diff',
'screenshot',
'--baseline',
baseline,
'--threshold',
'0',
]);
assert.equal(result.code, null);
// Client-backed command sends a screenshot request to daemon
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]!.command, 'screenshot');
assert.match(result.stdout, /100% pixels differ/);
assert.match(result.stdout, /100 different \/ 100 total pixels/);
assert.equal(result.stdout.includes('Diff image:'), false);
assert.equal(result.stderr, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('diff screenshot --json outputs structured result', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
const baseline = path.join(dir, 'baseline.png');
// Same color as mock current screenshot → should match
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
try {
const result = await runCliCapture(['diff', 'screenshot', '--baseline', baseline, '--json']);
assert.equal(result.code, null);
const payload = JSON.parse(result.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.match, true);
assert.equal(payload.data.differentPixels, 0);
assert.equal(payload.data.totalPixels, 100);
assert.equal(payload.data.mismatchPercentage, 0);
assert.equal(result.stderr, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('diff screenshot sends screenshot capture request to daemon', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
const baseline = path.join(dir, 'baseline.png');
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
try {
const result = await runCliCapture([
'diff',
'screenshot',
'--baseline',
baseline,
'--threshold',
'0.2',
]);
assert.equal(result.code, null);
// The client-backed command captures a screenshot via the daemon client
assert.equal(result.calls.length, 1);
const call = result.calls[0]!;
assert.equal(call.command, 'screenshot');
assert.equal(result.stderr, '');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('diff screenshot uses os.tmpdir for temporary current capture', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
const baseline = path.join(dir, 'baseline.png');
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
try {
const result = await runCliCapture(['diff', 'screenshot', '--baseline', baseline]);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
const call = result.calls[0]!;
assert.equal(call.command, 'screenshot');
const capturePath = call.positionals?.[0];
assert.equal(typeof capturePath, 'string');
assert.equal(capturePath!.startsWith(os.tmpdir()), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('diff screenshot expands ~/ for baseline and out paths', async () => {
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-home-'));
const originalHome = process.env.HOME;
const baselineRelative = path.join('fixtures', 'baseline.png');
const diffRelative = path.join('fixtures', 'diff.png');
const baseline = path.join(fakeHome, baselineRelative);
const diffOut = path.join(fakeHome, diffRelative);
fs.mkdirSync(path.dirname(baseline), { recursive: true });
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
fs.writeFileSync(diffOut, 'stale diff');
process.env.HOME = fakeHome;
try {
const result = await runCliCapture(
[
'diff',
'screenshot',
'--baseline',
`~/${baselineRelative}`,
'--out',
`~/${diffRelative}`,
'--json',
],
{ preserveHome: true },
);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
const payload = JSON.parse(result.stdout);
assert.equal(payload.success, true);
assert.equal(payload.data.match, true);
assert.equal(fs.existsSync(diffOut), false);
} finally {
if (typeof originalHome === 'string') process.env.HOME = originalHome;
else delete process.env.HOME;
fs.rmSync(fakeHome, { recursive: true, force: true });
}
});
});
+52 -1
View File
@@ -1,5 +1,8 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { CliFlags } from './utils/command-schema.ts';
import { formatSnapshotText, printJson } from './utils/output.ts';
import { formatScreenshotDiffText, formatSnapshotText, printJson } from './utils/output.ts';
import { AppError } from './utils/errors.ts';
import {
serializeCloseResult,
@@ -11,6 +14,8 @@ import {
serializeSessionListEntry,
serializeSnapshotResult,
} from './client-shared.ts';
import { compareScreenshots, type ScreenshotDiffResult } from './utils/screenshot-diff.ts';
import { resolveUserPath } from './utils/path-resolution.ts';
import type {
AgentDeviceClient,
AgentDeviceDevice,
@@ -162,6 +167,52 @@ const clientCommandHandlers: Partial<Record<string, ClientCommandHandler>> = {
else process.stdout.write(`${result.path}\n`);
return true;
},
diff: async ({ positionals, flags, client }) => {
if (positionals[0] !== 'screenshot') return false;
const baselineRaw = flags.baseline;
if (!baselineRaw || typeof baselineRaw !== 'string') {
throw new AppError('INVALID_ARGS', 'diff screenshot requires --baseline <path>');
}
const baselinePath = resolveUserPath(baselineRaw);
const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined;
let thresholdNum = 0.1;
if (flags.threshold != null && flags.threshold !== '') {
thresholdNum = Number(flags.threshold);
if (Number.isNaN(thresholdNum) || thresholdNum < 0 || thresholdNum > 1) {
throw new AppError('INVALID_ARGS', '--threshold must be a number between 0 and 1');
}
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-diff-current-'));
const tmpScreenshotPath = path.join(tmpDir, `current-${Date.now()}.png`);
const screenshotResult = await client.capture.screenshot({ path: tmpScreenshotPath });
const currentPath = screenshotResult.path;
let result: ScreenshotDiffResult;
try {
result = await compareScreenshots(baselinePath, currentPath, {
threshold: thresholdNum,
outputPath,
});
} finally {
try {
fs.unlinkSync(currentPath);
} catch {}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {}
}
if (flags.json) {
printJson({ success: true, data: result });
} else {
process.stdout.write(formatScreenshotDiffText(result));
}
return true;
},
};
async function runDeployCommand(
-1
View File
@@ -10,7 +10,6 @@ import { readVersion } from './utils/version.ts';
import { pathToFileURL } from 'node:url';
import { sendToDaemon } from './daemon-client.ts';
import fs from 'node:fs';
import path from 'node:path';
import type { BatchStep } from './core/dispatch.ts';
import { parseBatchStepsJson } from './core/batch.ts';
import { createAgentDeviceClient, type AgentDeviceClientConfig } from './client.ts';
+3 -7
View File
@@ -1,5 +1,5 @@
import os from 'node:os';
import path from 'node:path';
import { expandUserHomePath, resolveUserPath } from '../utils/path-resolution.ts';
export type DaemonServerMode = 'socket' | 'http' | 'dual';
export type DaemonTransportPreference = 'auto' | 'socket' | 'http';
@@ -27,13 +27,9 @@ export function resolveDaemonPaths(stateDir: string | undefined): DaemonPaths {
export function resolveStateDir(raw: string | undefined): string {
const value = (raw ?? '').trim();
if (!value) {
return path.join(os.homedir(), '.agent-device');
return path.join(expandUserHomePath('~'), '.agent-device');
}
if (value === '~') return os.homedir();
if (value.startsWith('~/')) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
return resolveUserPath(value);
}
export function resolveDaemonServerMode(raw: string | undefined): DaemonServerMode {
@@ -125,7 +125,7 @@ test('diff rejects unsupported kind', async () => {
token: 't',
session: 'default',
command: 'diff',
positionals: ['screenshot'],
positionals: ['unknown'],
flags: {},
},
sessionName: 'default',
@@ -137,7 +137,32 @@ test('diff rejects unsupported kind', async () => {
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /diff snapshot/i);
assert.match(response.error.message, /diff.*supports.*snapshot/i);
}
});
test('diff screenshot is not handled daemon-side (client-backed command)', async () => {
const sessionStore = makeSessionStore();
const response = await handleSnapshotCommands({
req: {
token: 't',
session: 'default',
command: 'diff',
positionals: ['screenshot'],
flags: {},
},
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore,
});
// diff screenshot is a client-backed command, so the daemon rejects it
// as an unknown diff subcommand
assert.ok(response);
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /diff.*supports.*snapshot/i);
}
});
+2 -8
View File
@@ -1,9 +1,9 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { CommandFlags } from '../core/dispatch.ts';
import type { SessionAction, SessionRuntimeHints, SessionState } from './types.ts';
import { inferFillText } from './action-utils.ts';
import { resolveUserPath } from '../utils/path-resolution.ts';
import {
appendRuntimeHintFlags,
appendScriptSeriesFlags,
@@ -125,13 +125,7 @@ export class SessionStore {
}
static expandHome(filePath: string, cwd?: string): string {
if (filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
if (cwd && !path.isAbsolute(filePath)) {
return path.resolve(cwd, filePath);
}
return path.resolve(filePath);
return resolveUserPath(filePath, { cwd });
}
private resolveScriptPath(session: SessionState): string {
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { screenshotAndroid } from '../index.ts';
import { screenshotAndroid } from '../screenshot.ts';
import type { DeviceInfo } from '../../../utils/device.ts';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -47,6 +47,34 @@ async function withMockedAdb(
const catPayload = '#!/bin/bash\ncat "$(dirname "$0")/payload.bin"\n';
test('screenshotAndroid waits for transient UI to settle before capture', async () => {
const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};
const events: string[] = [];
await screenshotAndroid(device, '/tmp/out.png', {
enableDemoMode: async () => {
events.push('enable');
},
settle: async (ms) => {
events.push(`settle:${ms}`);
},
capture: async () => {
events.push('capture');
},
disableDemoMode: async () => {
events.push('disable');
},
});
assert.deepEqual(events, ['enable', 'settle:1000', 'capture', 'disable']);
});
test('screenshotAndroid writes a valid PNG when output is clean', async () => {
await withMockedAdb(
'screenshot-clean-',
+2 -1
View File
@@ -42,4 +42,5 @@ export { setAndroidSetting } from './settings.ts';
export { pushAndroidNotification } from './notifications.ts';
export { snapshotAndroid, screenshotAndroid } from './snapshot.ts';
export { snapshotAndroid } from './snapshot.ts';
export { screenshotAndroid } from './screenshot.ts';
+104
View File
@@ -0,0 +1,104 @@
import { promises as fs } from 'node:fs';
import { runCmd } from '../../utils/exec.ts';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { adbArgs, sleep } from './adb.ts';
// PNG file signature: 0x89 P N G \r \n 0x1A \n
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ANDROID_SCREENSHOT_SETTLE_DELAY_MS = 1_000;
type ScreenshotAndroidDeps = {
enableDemoMode: (device: DeviceInfo) => Promise<void>;
settle: (ms: number) => Promise<void>;
capture: (device: DeviceInfo, outPath: string) => Promise<void>;
disableDemoMode: (device: DeviceInfo) => Promise<void>;
};
const defaultScreenshotAndroidDeps: ScreenshotAndroidDeps = {
enableDemoMode: enableAndroidDemoMode,
settle: sleep,
capture: captureAndroidScreenshot,
disableDemoMode: disableAndroidDemoMode,
};
export async function screenshotAndroid(
device: DeviceInfo,
outPath: string,
deps: ScreenshotAndroidDeps = defaultScreenshotAndroidDeps,
): Promise<void> {
await deps.enableDemoMode(device);
try {
// Allow transient UI affordances like scrollbars to fade before capture.
await deps.settle(ANDROID_SCREENSHOT_SETTLE_DELAY_MS);
await deps.capture(device, outPath);
} finally {
await deps.disableDemoMode(device).catch(() => {});
}
}
/**
* Enable Android demo mode and set deterministic time in status bar
* for consistent screenshots.
*/
export async function enableAndroidDemoMode(device: DeviceInfo): Promise<void> {
const shell = (cmd: string) =>
runCmd('adb', adbArgs(device, ['shell', cmd]), { allowFailure: true });
await shell('settings put global sysui_demo_allowed 1');
const broadcast = (extra: string) =>
shell(`am broadcast -a com.android.systemui.demo -e command ${extra}`);
await broadcast('clock -e hhmm 0941');
await broadcast('notifications -e visible false');
}
/** Disable demo mode and restore the live status bar. */
export async function disableAndroidDemoMode(device: DeviceInfo): Promise<void> {
await runCmd(
'adb',
adbArgs(device, ['shell', 'am broadcast -a com.android.systemui.demo -e command exit']),
{
allowFailure: true,
},
);
}
async function captureAndroidScreenshot(device: DeviceInfo, outPath: string): Promise<void> {
const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), {
binaryStdout: true,
});
if (!result.stdoutBuffer) {
throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot');
}
// On multi-display devices (e.g. Galaxy Z Fold), adb screencap may write a
// warning to stdout before the PNG data. Strip any leading garbage by
// locating the PNG signature and discarding everything before it.
const pngOffset = result.stdoutBuffer.indexOf(PNG_SIGNATURE);
if (pngOffset < 0) {
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a valid PNG header');
}
const pngEndOffset = findPngEndOffset(result.stdoutBuffer, pngOffset);
if (!pngEndOffset) {
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a complete PNG payload');
}
await fs.writeFile(outPath, result.stdoutBuffer.subarray(pngOffset, pngEndOffset));
}
function findPngEndOffset(buffer: Buffer, pngStartOffset: number): number | null {
let offset = pngStartOffset + PNG_SIGNATURE.length;
while (offset + 8 <= buffer.length) {
const chunkLength = buffer.readUInt32BE(offset);
const chunkTypeOffset = offset + 4;
const chunkType = buffer.toString('ascii', chunkTypeOffset, chunkTypeOffset + 4);
const chunkEnd = offset + 12 + chunkLength; // len(4) + type(4) + data + crc(4)
if (chunkEnd > buffer.length) return null;
if (chunkType === 'IEND') return chunkEnd;
offset = chunkEnd;
}
return null;
}
-39
View File
@@ -1,4 +1,3 @@
import { promises as fs } from 'node:fs';
import { runCmd } from '../../utils/exec.ts';
import { withRetry } from '../../utils/retry.ts';
import { AppError } from '../../utils/errors.ts';
@@ -18,30 +17,6 @@ export async function snapshotAndroid(
return parseUiHierarchy(xml, 800, options);
}
// PNG file signature: 0x89 P N G \r \n 0x1A \n
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export async function screenshotAndroid(device: DeviceInfo, outPath: string): Promise<void> {
const result = await runCmd('adb', adbArgs(device, ['exec-out', 'screencap', '-p']), {
binaryStdout: true,
});
if (!result.stdoutBuffer) {
throw new AppError('COMMAND_FAILED', 'Failed to capture screenshot');
}
// On multi-display devices (e.g. Galaxy Z Fold), adb screencap may write a
// warning to stdout before the PNG data. Strip any leading garbage by
// locating the PNG signature and discarding everything before it.
const pngOffset = result.stdoutBuffer.indexOf(PNG_SIGNATURE);
if (pngOffset < 0) {
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a valid PNG header');
}
const pngEndOffset = findPngEndOffset(result.stdoutBuffer, pngOffset);
if (!pngEndOffset) {
throw new AppError('COMMAND_FAILED', 'Screenshot data does not contain a complete PNG payload');
}
await fs.writeFile(outPath, result.stdoutBuffer.subarray(pngOffset, pngEndOffset));
}
export async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
return withRetry(() => dumpUiHierarchyOnce(device), {
shouldRetry: isRetryableAdbError,
@@ -97,20 +72,6 @@ function extractUiDumpXml(stdout: string, stderr: string): string | null {
return xml.length > 0 ? xml : null;
}
function findPngEndOffset(buffer: Buffer, pngStartOffset: number): number | null {
let offset = pngStartOffset + PNG_SIGNATURE.length;
while (offset + 8 <= buffer.length) {
const chunkLength = buffer.readUInt32BE(offset);
const chunkTypeOffset = offset + 4;
const chunkType = buffer.toString('ascii', chunkTypeOffset, chunkTypeOffset + 4);
const chunkEnd = offset + 12 + chunkLength; // len(4) + type(4) + data + crc(4)
if (chunkEnd > buffer.length) return null;
if (chunkType === 'IEND') return chunkEnd;
offset = chunkEnd;
}
return null;
}
function isRetryableAdbError(err: unknown): boolean {
if (!(err instanceof AppError)) return false;
if (err.code !== 'COMMAND_FAILED') return false;
+2 -4
View File
@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { AppError } from '../utils/errors.ts';
import { runCmd } from '../utils/exec.ts';
import { expandUserHomePath } from '../utils/path-resolution.ts';
import { resolveTimeoutMs } from '../utils/timeouts.ts';
export type MaterializeInstallSource =
@@ -83,10 +84,7 @@ export async function materializeInstallablePath(
}
export function expandSourcePath(inputPath: string): string {
if (!inputPath.startsWith('~')) return inputPath;
if (inputPath === '~') return os.homedir();
if (inputPath.startsWith('~/')) return path.join(os.homedir(), inputPath.slice(2));
return inputPath;
return expandUserHomePath(inputPath);
}
async function materializeLocalSource(
+126
View File
@@ -22,6 +22,7 @@ import {
} from '../apps.ts';
import {
captureSimulatorScreenshotWithFallback,
prepareSimulatorStatusBarForScreenshot,
resolveSimulatorRunnerScreenshotCandidatePaths,
} from '../screenshot.ts';
import { focusIosSimulatorWindow } from '../simulator.ts';
@@ -176,6 +177,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex
ensureBooted: async () => {
ensureBootedCalls += 1;
},
prepareStatusBarForScreenshot: async () => async () => {},
captureWithRetry: async () => {
retryCalls += 1;
throw new AppError('COMMAND_FAILED', 'Detected file type from extension: PNG', {
@@ -202,6 +204,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s
'com.example.app',
{
ensureBooted: async () => {},
prepareStatusBarForScreenshot: async () => async () => {},
captureWithRetry: async () => {
throw new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', {
args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'],
@@ -217,6 +220,52 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s
assert.equal(runnerCalls, 1);
});
test('captureSimulatorScreenshotWithFallback continues when status bar preparation fails', async () => {
let retryCalls = 0;
await captureSimulatorScreenshotWithFallback(
IOS_TEST_SIMULATOR,
'/tmp/out.png',
'com.example.app',
{
ensureBooted: async () => {},
prepareStatusBarForScreenshot: async () => {
throw new AppError('COMMAND_FAILED', 'status_bar override failed');
},
captureWithRetry: async () => {
retryCalls += 1;
},
captureWithRunner: async () => {
throw new Error('runner should not be used when capture succeeds');
},
shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot,
},
);
assert.equal(retryCalls, 1);
});
test('captureSimulatorScreenshotWithFallback ignores status bar restore failures', async () => {
let retryCalls = 0;
await captureSimulatorScreenshotWithFallback(
IOS_TEST_SIMULATOR,
'/tmp/out.png',
'com.example.app',
{
ensureBooted: async () => {},
prepareStatusBarForScreenshot: async () => async () => {
throw new AppError('COMMAND_FAILED', 'status_bar clear failed');
},
captureWithRetry: async () => {
retryCalls += 1;
},
captureWithRunner: async () => {
throw new Error('runner should not be used when capture succeeds');
},
shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot,
},
);
assert.equal(retryCalls, 1);
});
test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before using runner', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-screenshot-diag-test-'));
const logPath = path.join(tmpDir, 'diag.ndjson');
@@ -236,6 +285,7 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us
'com.example.app',
{
ensureBooted: async () => {},
prepareStatusBarForScreenshot: async () => async () => {},
captureWithRetry: async () => {
throw new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', {
args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'],
@@ -286,6 +336,82 @@ test('focusIosSimulatorWindow times out instead of hanging indefinitely', async
}
});
test('prepareSimulatorStatusBarForScreenshot restores prior visible overrides', async () => {
await withMockedXcrun(
'agent-device-ios-status-bar-restore-test-',
`#!/bin/sh
echo "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "list" ]; then
cat <<'OUT'
Current Status Bar Overrides:
=============================
Time: 6:07
DataNetworkType: 0
WiFi Mode: 2, WiFi Bars: 0
Cell Mode: 2, Cell Bars: 0
Operator Name: No Service
Battery State: 1, Battery Level: 42, Not Charging: 0
OUT
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "clear" ]; then
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "override" ]; then
exit 0
fi
echo "unexpected xcrun args: $*" >&2
exit 1
`,
async ({ argsLogPath }) => {
const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR);
await restore();
const logLines = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
assert.deepEqual(logLines, [
'simctl status_bar sim-1 list',
'simctl status_bar sim-1 clear',
'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100',
'simctl status_bar sim-1 clear',
'simctl status_bar sim-1 override --dataNetwork hide --wifiMode failed --wifiBars 0 --cellularMode failed --cellularBars 0 --operatorName No Service',
]);
},
);
});
test('prepareSimulatorStatusBarForScreenshot still normalizes when snapshotting current overrides fails', async () => {
await withMockedXcrun(
'agent-device-ios-status-bar-snapshot-failure-test-',
`#!/bin/sh
echo "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "list" ]; then
echo "list failed" >&2
exit 1
fi
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "clear" ]; then
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "status_bar" ] && [ "$4" = "override" ]; then
exit 0
fi
echo "unexpected xcrun args: $*" >&2
exit 1
`,
async ({ argsLogPath }) => {
const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR);
await restore();
const logLines = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean);
assert.deepEqual(logLines, [
'simctl status_bar sim-1 list',
'simctl status_bar sim-1 clear',
'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100',
'simctl status_bar sim-1 clear',
]);
},
);
});
async function waitForFileText(filePath: string, attempts = 20): Promise<string> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
+262
View File
@@ -0,0 +1,262 @@
import type { DeviceInfo } from '../../utils/device.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import { AppError } from '../../utils/errors.ts';
import { runCmd } from '../../utils/exec.ts';
import { buildSimctlArgsForDevice } from './simctl.ts';
type RestorableStatusBarOverrides = Partial<
Record<
'dataNetwork' | 'wifiMode' | 'wifiBars' | 'cellularMode' | 'cellularBars' | 'operatorName',
string
>
>;
const DETERMINISTIC_SCREENSHOT_STATUS_BAR_ARGS = [
'--time',
'9:41',
'--dataNetwork',
'wifi',
'--wifiMode',
'active',
'--wifiBars',
'3',
'--batteryState',
'charged',
'--batteryLevel',
'100',
];
const DATA_NETWORK_TYPE_BY_CODE: Record<number, string> = {
0: 'hide',
1: 'wifi',
6: '3g',
7: '4g',
8: 'lte',
9: 'lte-a',
10: 'lte+',
11: '5g',
12: '5g+',
13: '5g-uwb',
14: '5g-uc',
};
const WIFI_MODE_BY_CODE: Partial<Record<number, string>> = {
1: 'searching',
2: 'failed',
3: 'active',
};
const CELLULAR_MODE_BY_CODE: Record<number, string> = {
0: 'notSupported',
1: 'searching',
2: 'failed',
3: 'active',
};
function simctlArgs(device: DeviceInfo, args: string[]): string[] {
return buildSimctlArgsForDevice(device, args);
}
function runSimctl(device: DeviceInfo, args: string[], options?: Parameters<typeof runCmd>[2]) {
return runCmd('xcrun', simctlArgs(device, args), options);
}
export async function prepareSimulatorStatusBarForScreenshot(
device: DeviceInfo,
): Promise<() => Promise<void>> {
let previousOverrides: RestorableStatusBarOverrides | null = null;
let canRestorePreviousOverrides = false;
try {
previousOverrides = await readSimulatorStatusBarOverrides(device);
canRestorePreviousOverrides = true;
} catch (error) {
emitStatusBarDiagnostic(device, 'snapshot_failed', error);
}
try {
await clearSimulatorStatusBarOverride(device);
await applySimulatorStatusBarOverrideArgs(device, DETERMINISTIC_SCREENSHOT_STATUS_BAR_ARGS);
} catch (error) {
emitStatusBarDiagnostic(device, 'prepare_failed', error);
}
return async () => {
await restoreSimulatorStatusBarOverrides(
device,
canRestorePreviousOverrides ? previousOverrides : null,
);
};
}
async function restoreSimulatorStatusBarOverrides(
device: DeviceInfo,
overrides: RestorableStatusBarOverrides | null,
): Promise<void> {
await clearSimulatorStatusBarOverride(device);
if (!overrides) return;
await applySimulatorStatusBarOverrideArgs(
device,
buildRestorableStatusBarOverrideArgs(overrides),
);
}
async function readSimulatorStatusBarOverrides(
device: DeviceInfo,
): Promise<RestorableStatusBarOverrides | null> {
const result = await runSimctl(device, ['status_bar', device.id, 'list'], {
allowFailure: true,
});
if (result.exitCode !== 0) {
throw new AppError('COMMAND_FAILED', 'Failed to read simulator status bar overrides', {
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
});
}
return parseSimulatorStatusBarOverrides(result.stdout);
}
async function clearSimulatorStatusBarOverride(device: DeviceInfo): Promise<void> {
await runSimctl(device, ['status_bar', device.id, 'clear']);
}
async function applySimulatorStatusBarOverrideArgs(
device: DeviceInfo,
args: string[],
): Promise<void> {
if (args.length === 0) return;
await runSimctl(device, ['status_bar', device.id, 'override', ...args]);
}
function parseSimulatorStatusBarOverrides(output: string): RestorableStatusBarOverrides | null {
const overrides: RestorableStatusBarOverrides = {};
const lines = output
.split('\n')
.map((line) => line.trim())
.filter(
(line) => line.length > 0 && line !== 'Current Status Bar Overrides:' && !/^=+$/.test(line),
);
for (const line of lines) {
const dataNetworkMatch = /^DataNetworkType:\s+(\d+)$/.exec(line);
if (dataNetworkMatch) {
const code = Number(dataNetworkMatch[1]);
const value = DATA_NETWORK_TYPE_BY_CODE[code];
if (!value) {
throw new AppError('COMMAND_FAILED', `Unsupported simulator data network type: ${code}`);
}
overrides.dataNetwork = value;
continue;
}
const wifiMatch = /^WiFi Mode:\s+(\d+),\s+WiFi Bars:\s+(\d+)$/.exec(line);
if (wifiMatch) {
const mode = WIFI_MODE_BY_CODE[Number(wifiMatch[1])];
if (mode) {
overrides.wifiMode = mode;
}
overrides.wifiBars = wifiMatch[2];
continue;
}
const cellMatch = /^Cell Mode:\s+(\d+),\s+Cell Bars:\s+(\d+)$/.exec(line);
if (cellMatch) {
const code = Number(cellMatch[1]);
const mode = CELLULAR_MODE_BY_CODE[code];
if (!mode) {
throw new AppError('COMMAND_FAILED', `Unsupported simulator cellular mode: ${code}`);
}
overrides.cellularMode = mode;
overrides.cellularBars = cellMatch[2];
continue;
}
const operatorNameMatch = /^Operator Name:\s*(.*)$/.exec(line);
if (operatorNameMatch) {
overrides.operatorName = operatorNameMatch[1] ?? '';
continue;
}
}
return Object.keys(overrides).length === 0 ? null : overrides;
}
function buildRestorableStatusBarOverrideArgs(overrides: RestorableStatusBarOverrides): string[] {
const args: string[] = [];
if (overrides.dataNetwork) {
args.push('--dataNetwork', overrides.dataNetwork);
}
if (overrides.wifiMode) {
args.push('--wifiMode', overrides.wifiMode);
}
if (
overrides.wifiBars !== undefined &&
(overrides.dataNetwork === 'wifi' || overrides.wifiMode)
) {
args.push('--wifiBars', overrides.wifiBars);
}
if (overrides.cellularMode) {
args.push('--cellularMode', overrides.cellularMode);
}
if (
overrides.cellularBars !== undefined &&
(overrides.cellularMode ||
isCellularDataNetworkType(overrides.dataNetwork) ||
overrides.operatorName !== undefined)
) {
args.push('--cellularBars', overrides.cellularBars);
}
if (overrides.operatorName !== undefined) {
args.push('--operatorName', overrides.operatorName);
}
return args;
}
function isCellularDataNetworkType(value: string | undefined): boolean {
return Boolean(value && value !== 'hide' && value !== 'wifi');
}
function emitStatusBarDiagnostic(
device: DeviceInfo,
phase: 'snapshot_failed' | 'prepare_failed' | 'restore_failed',
error: unknown,
): void {
emitDiagnostic({
level: 'warn',
phase: `ios_screenshot_status_bar_${phase}`,
data: {
platform: device.platform,
deviceKind: device.kind,
deviceId: device.id,
...extractStatusBarErrorMeta(error),
},
});
}
function extractStatusBarErrorMeta(error: unknown): Record<string, unknown> {
if (!(error instanceof AppError)) {
return { reason: error instanceof Error ? error.message : String(error) };
}
const details = (error.details ?? {}) as {
args?: unknown;
exitCode?: unknown;
stderr?: unknown;
stdout?: unknown;
timeoutMs?: unknown;
};
const args = Array.isArray(details.args)
? details.args.filter((value): value is string => typeof value === 'string').join(' ')
: undefined;
return {
errorCode: error.code,
reason: error.message,
timeoutMs: typeof details.timeoutMs === 'number' ? details.timeoutMs : undefined,
exitCode: typeof details.exitCode === 'number' ? details.exitCode : undefined,
stderr:
typeof details.stderr === 'string' && details.stderr.trim() ? details.stderr : undefined,
stdout:
typeof details.stdout === 'string' && details.stdout.trim() ? details.stdout : undefined,
commandArgs: args,
};
}
+41 -7
View File
@@ -15,6 +15,7 @@ import {
} from './config.ts';
import { runIosDevicectl } from './devicectl.ts';
import { runIosRunnerCommand, IOS_RUNNER_CONTAINER_BUNDLE_IDS } from './runner-client.ts';
import { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts';
import { ensureBootedSimulator, focusIosSimulatorWindow } from './simulator.ts';
import { buildSimctlArgsForDevice } from './simctl.ts';
@@ -28,6 +29,7 @@ function runSimctl(device: DeviceInfo, args: string[], options?: Parameters<type
type SimulatorScreenshotFlowDeps = {
ensureBooted: (device: DeviceInfo) => Promise<void>;
prepareStatusBarForScreenshot: (device: DeviceInfo) => Promise<() => Promise<void>>;
captureWithRetry: (device: DeviceInfo, outPath: string) => Promise<void>;
captureWithRunner: (device: DeviceInfo, outPath: string, appBundleId?: string) => Promise<void>;
shouldFallbackToRunner: (error: unknown) => boolean;
@@ -35,6 +37,7 @@ type SimulatorScreenshotFlowDeps = {
const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = {
ensureBooted: ensureBootedSimulator,
prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot,
captureWithRetry: captureSimulatorScreenshotWithRetry,
captureWithRunner: captureScreenshotViaRunner,
shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot,
@@ -80,16 +83,28 @@ export async function captureSimulatorScreenshotWithFallback(
}
await deps.ensureBooted(device);
let restoreStatusBar = async () => {};
try {
await deps.captureWithRetry(device, outPath);
return;
restoreStatusBar = await deps.prepareStatusBarForScreenshot(device);
} catch (error) {
if (!deps.shouldFallbackToRunner(error)) {
throw error;
}
emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', error);
emitStatusBarDiagnostic(device, 'prepare_failed', error);
}
try {
try {
await deps.captureWithRetry(device, outPath);
return;
} catch (error) {
if (!deps.shouldFallbackToRunner(error)) {
throw error;
}
emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', error);
}
await deps.captureWithRunner(device, outPath, appBundleId);
} finally {
await restoreStatusBar().catch((error) =>
emitStatusBarDiagnostic(device, 'restore_failed', error),
);
}
await deps.captureWithRunner(device, outPath, appBundleId);
}
async function captureSimulatorScreenshotWithRetry(
@@ -267,6 +282,23 @@ function emitScreenshotFallbackDiagnostic(
});
}
function emitStatusBarDiagnostic(
device: DeviceInfo,
phase: 'prepare_failed' | 'restore_failed',
error: unknown,
): void {
emitDiagnostic({
level: 'warn',
phase: `ios_screenshot_status_bar_${phase}`,
data: {
platform: device.platform,
deviceKind: device.kind,
deviceId: device.id,
...extractScreenshotFallbackErrorMeta(error),
},
});
}
function extractScreenshotFallbackErrorMeta(error: unknown): Record<string, unknown> {
if (!(error instanceof AppError)) {
return { reason: error instanceof Error ? error.message : String(error) };
@@ -372,3 +404,5 @@ export function shouldRetryIosSimulatorScreenshot(error: unknown): boolean {
(combined.includes('timed out') && combined.includes('screenshot'))
);
}
export { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts';
+142 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { stripVTControlCharacters } from 'node:util';
import { formatSnapshotDiffText } from '../output.ts';
import { formatScreenshotDiffText, formatSnapshotDiffText } from '../output.ts';
const DIFF_DATA = {
mode: 'snapshot',
@@ -54,3 +55,143 @@ test('formatSnapshotDiffText renders ANSI colors when forced', () => {
else delete process.env.NO_COLOR;
}
});
function withNoColor<T>(fn: () => T): T {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '0';
delete process.env.NO_COLOR;
try {
return fn();
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
}
function withColor<T>(fn: () => T): T {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '1';
delete process.env.NO_COLOR;
try {
return fn();
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
}
test('formatScreenshotDiffText renders match success without color', () => {
const text = withNoColor(() =>
formatScreenshotDiffText({
match: true,
differentPixels: 0,
totalPixels: 100,
mismatchPercentage: 0,
}),
);
assert.match(text, /✓ Screenshots match\./);
assert.equal(text.includes('\x1b['), false);
});
test('formatScreenshotDiffText renders mismatch with pixel counts without color', () => {
const text = withNoColor(() =>
formatScreenshotDiffText({
match: false,
differentPixels: 500,
totalPixels: 10000,
mismatchPercentage: 5,
diffPath: '/tmp/test/diff.png',
}),
);
assert.match(text, /✗ 5% pixels differ/);
assert.match(text, /Diff image:/);
assert.match(text, /500 different \/ 10000 total pixels/);
assert.equal(text.includes('\x1b['), false);
});
test('formatScreenshotDiffText renders dimension mismatch', () => {
const text = withNoColor(() =>
formatScreenshotDiffText({
match: false,
differentPixels: 100,
totalPixels: 100,
mismatchPercentage: 100,
dimensionMismatch: {
expected: { width: 1170, height: 2532 },
actual: { width: 1080, height: 1920 },
},
}),
);
assert.match(text, /✗ Screenshots have different dimensions/);
assert.match(text, /expected 1170x2532/);
assert.match(text, /got 1080x1920/);
assert.equal(text.includes('different /'), false);
});
test('formatScreenshotDiffText renders diff path relative to cwd', () => {
const cwd = process.cwd();
const text = withNoColor(() =>
formatScreenshotDiffText({
match: false,
differentPixels: 10,
totalPixels: 100,
mismatchPercentage: 10,
diffPath: `${cwd}/diff.png`,
}),
);
assert.match(text, /\.\/diff\.png/);
assert.equal(text.includes(cwd), false);
});
test('formatScreenshotDiffText keeps absolute diff path outside cwd', () => {
const cwd = process.cwd();
const parentDir = path.dirname(cwd);
const siblingDir = path.join(parentDir, `${path.basename(cwd)}-sibling`);
const diffPath = path.join(siblingDir, 'diff.png');
const text = withNoColor(() =>
formatScreenshotDiffText({
match: false,
differentPixels: 10,
totalPixels: 100,
mismatchPercentage: 10,
diffPath,
}),
);
assert.match(text, new RegExp(diffPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.equal(text.includes('./'), false);
});
test('formatScreenshotDiffText uses ANSI colors when enabled', () => {
const text = withColor(() =>
formatScreenshotDiffText({
match: false,
differentPixels: 10,
totalPixels: 100,
mismatchPercentage: 10,
diffPath: '/tmp/diff.png',
}),
);
assert.equal(text.includes('\x1b[31m'), true);
assert.equal(text.includes('\x1b[32m'), true);
assert.equal(text.includes('\x1b[2m'), true);
});
test('formatScreenshotDiffText does not show diff path when images match', () => {
const text = withNoColor(() =>
formatScreenshotDiffText({
match: true,
differentPixels: 0,
totalPixels: 100,
mismatchPercentage: 0,
diffPath: '/tmp/diff.png',
}),
);
assert.equal(text.includes('Diff image'), false);
assert.equal(text.includes('diff.png'), false);
});
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { expandUserHomePath, resolveUserPath } from '../path-resolution.ts';
test('expandUserHomePath expands the current user home prefix', () => {
const env = { HOME: '/tmp/agent-device-home' };
assert.equal(expandUserHomePath('~', { env }), '/tmp/agent-device-home');
assert.equal(
expandUserHomePath('~/flows/replay.ad', { env }),
path.join('/tmp/agent-device-home', 'flows', 'replay.ad'),
);
});
test('expandUserHomePath leaves non-home-prefixed paths unchanged', () => {
const env = { HOME: '/tmp/agent-device-home' };
assert.equal(expandUserHomePath('relative/path', { env }), 'relative/path');
assert.equal(expandUserHomePath('~other/path', { env }), '~other/path');
});
test('resolveUserPath resolves relative paths against cwd', () => {
assert.equal(
resolveUserPath('workflows/replay.ad', { cwd: '/tmp/agent-device-cwd' }),
path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad'),
);
});
test('resolveUserPath expands home-prefixed and absolute paths', () => {
const env = { HOME: '/tmp/agent-device-home' };
const absolutePath = '/tmp/agent-device-absolute.ad';
assert.equal(
resolveUserPath('~/flows/replay.ad', { cwd: '/tmp/ignored', env }),
path.join('/tmp/agent-device-home', 'flows', 'replay.ad'),
);
assert.equal(resolveUserPath(absolutePath, { cwd: '/tmp/ignored', env }), absolutePath);
});
+281
View File
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { compareScreenshots } from '../screenshot-diff.ts';
function tmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-screenshot-diff-'));
}
/** Create a solid-color PNG and write it to disk. */
function writeSolidPng(
filePath: string,
width: number,
height: number,
color: { r: number; g: number; b: number },
): void {
const png = new PNG({ width, height });
for (let i = 0; i < png.data.length; i += 4) {
png.data[i] = color.r;
png.data[i + 1] = color.g;
png.data[i + 2] = color.b;
png.data[i + 3] = 255;
}
fs.writeFileSync(filePath, PNG.sync.write(png));
}
test('identical images produce match: true with 0% mismatch', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
const diffOut = path.join(dir, 'diff.png');
writeSolidPng(baseline, 10, 10, { r: 100, g: 150, b: 200 });
writeSolidPng(current, 10, 10, { r: 100, g: 150, b: 200 });
const result = await compareScreenshots(baseline, current, { outputPath: diffOut });
assert.equal(result.match, true);
assert.equal(result.differentPixels, 0);
assert.equal(result.mismatchPercentage, 0);
assert.equal(result.totalPixels, 100);
assert.equal(result.dimensionMismatch, undefined);
assert.equal(result.diffPath, undefined, 'diffPath should not be set when images match');
// No diff image should be written when images match
assert.equal(fs.existsSync(diffOut), false);
});
test('matching images delete an existing diff artifact at outputPath', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
const diffOut = path.join(dir, 'diff.png');
writeSolidPng(baseline, 10, 10, { r: 100, g: 150, b: 200 });
writeSolidPng(current, 10, 10, { r: 100, g: 150, b: 200 });
fs.writeFileSync(diffOut, 'stale diff');
const result = await compareScreenshots(baseline, current, { outputPath: diffOut });
assert.equal(result.match, true);
assert.equal(fs.existsSync(diffOut), false);
});
test('completely different images produce match: false with 100% mismatch', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
const diffOut = path.join(dir, 'diff.png');
writeSolidPng(baseline, 10, 10, { r: 0, g: 0, b: 0 });
writeSolidPng(current, 10, 10, { r: 255, g: 255, b: 255 });
const result = await compareScreenshots(baseline, current, {
outputPath: diffOut,
threshold: 0,
});
assert.equal(result.match, false);
assert.equal(result.differentPixels, 100);
assert.equal(result.mismatchPercentage, 100);
assert.equal(result.totalPixels, 100);
assert.ok(fs.existsSync(diffOut), 'diff image should be written');
});
test('no diff path is persisted when outputPath is omitted', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
writeSolidPng(baseline, 10, 10, { r: 0, g: 0, b: 0 });
writeSolidPng(current, 10, 10, { r: 255, g: 255, b: 255 });
const result = await compareScreenshots(baseline, current, { threshold: 0 });
assert.equal(result.match, false);
assert.equal(result.diffPath, undefined);
});
test('diff image marks different pixels as red and unchanged as dimmed gray', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
const diffOut = path.join(dir, 'diff.png');
// 2x1 image: first pixel identical, second pixel different
const baselinePng = new PNG({ width: 2, height: 1 });
// pixel 0: white
baselinePng.data[0] = 255;
baselinePng.data[1] = 255;
baselinePng.data[2] = 255;
baselinePng.data[3] = 255;
// pixel 1: black
baselinePng.data[4] = 0;
baselinePng.data[5] = 0;
baselinePng.data[6] = 0;
baselinePng.data[7] = 255;
fs.writeFileSync(baseline, PNG.sync.write(baselinePng));
const currentPng = new PNG({ width: 2, height: 1 });
// pixel 0: white (same)
currentPng.data[0] = 255;
currentPng.data[1] = 255;
currentPng.data[2] = 255;
currentPng.data[3] = 255;
// pixel 1: white (different from black)
currentPng.data[4] = 255;
currentPng.data[5] = 255;
currentPng.data[6] = 255;
currentPng.data[7] = 255;
fs.writeFileSync(current, PNG.sync.write(currentPng));
const result = await compareScreenshots(baseline, current, {
outputPath: diffOut,
threshold: 0,
});
assert.equal(result.differentPixels, 1);
assert.equal(result.totalPixels, 2);
// Read the diff image and verify pixel colors
const diffPng = PNG.sync.read(fs.readFileSync(diffOut));
// Pixel 0 (unchanged white): should be dimmed gray
// gray = round((255+255+255)/3) = 255, dimmed = round(255*0.3) = 77
assert.equal(diffPng.data[0], 77); // R
assert.equal(diffPng.data[1], 77); // G
assert.equal(diffPng.data[2], 77); // B
// Pixel 1 (different): should be red
assert.equal(diffPng.data[4], 255); // R
assert.equal(diffPng.data[5], 0); // G
assert.equal(diffPng.data[6], 0); // B
});
test('dimension mismatch returns expected vs actual sizes', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
const diffOut = path.join(dir, 'diff.png');
writeSolidPng(baseline, 10, 20, { r: 0, g: 0, b: 0 });
writeSolidPng(current, 15, 25, { r: 0, g: 0, b: 0 });
const result = await compareScreenshots(baseline, current, { outputPath: diffOut });
assert.equal(result.match, false);
assert.equal(result.mismatchPercentage, 100);
assert.equal(result.diffPath, undefined, 'diffPath should not be set for dimension mismatch');
assert.deepEqual(result.dimensionMismatch, {
expected: { width: 10, height: 20 },
actual: { width: 15, height: 25 },
});
});
test('threshold controls sensitivity: small differences ignored at default threshold', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
// Colors differ by just a few units — within default threshold of 0.1
writeSolidPng(baseline, 5, 5, { r: 100, g: 100, b: 100 });
writeSolidPng(current, 5, 5, { r: 105, g: 105, b: 105 });
const loose = await compareScreenshots(baseline, current, {
outputPath: path.join(dir, 'diff-loose.png'),
threshold: 0.1,
});
assert.equal(loose.match, true, 'small color difference should be ignored at 0.1 threshold');
const strict = await compareScreenshots(baseline, current, {
outputPath: path.join(dir, 'diff-strict.png'),
threshold: 0,
});
assert.equal(strict.match, false, 'small color difference should be detected at 0 threshold');
assert.equal(strict.differentPixels, 25);
});
test('throws INVALID_ARGS when baseline file does not exist', async () => {
const dir = tmpDir();
const current = path.join(dir, 'current.png');
writeSolidPng(current, 5, 5, { r: 0, g: 0, b: 0 });
await assert.rejects(
() => compareScreenshots(path.join(dir, 'missing.png'), current),
(err: any) => {
assert.equal(err.code, 'INVALID_ARGS');
assert.match(err.message, /Baseline image not found/);
return true;
},
);
});
test('throws INVALID_ARGS when current file does not exist', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
writeSolidPng(baseline, 5, 5, { r: 0, g: 0, b: 0 });
await assert.rejects(
() => compareScreenshots(baseline, path.join(dir, 'missing.png')),
(err: any) => {
assert.equal(err.code, 'INVALID_ARGS');
assert.match(err.message, /Current screenshot not found/);
return true;
},
);
});
test('throws COMMAND_FAILED for invalid PNG data', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
fs.writeFileSync(baseline, 'not a png file');
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/);
return true;
},
);
});
test('mismatchPercentage is rounded to 2 decimal places', async () => {
const dir = tmpDir();
const baseline = path.join(dir, 'baseline.png');
const current = path.join(dir, 'current.png');
// 3x1 image: change 1 of 3 pixels → 33.333...%
const baselinePng = new PNG({ width: 3, height: 1 });
const currentPng = new PNG({ width: 3, height: 1 });
for (let i = 0; i < 12; i += 4) {
baselinePng.data[i] = 0;
baselinePng.data[i + 1] = 0;
baselinePng.data[i + 2] = 0;
baselinePng.data[i + 3] = 255;
currentPng.data[i] = 0;
currentPng.data[i + 1] = 0;
currentPng.data[i + 2] = 0;
currentPng.data[i + 3] = 255;
}
// Make the last pixel different
currentPng.data[8] = 255;
currentPng.data[9] = 255;
currentPng.data[10] = 255;
fs.writeFileSync(baseline, PNG.sync.write(baselinePng));
fs.writeFileSync(current, PNG.sync.write(currentPng));
const result = await compareScreenshots(baseline, current, {
outputPath: path.join(dir, 'diff.png'),
threshold: 0,
});
assert.equal(result.mismatchPercentage, 33.33);
});
+3 -8
View File
@@ -1,8 +1,8 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from './errors.ts';
import { type CliFlags, type FlagKey } from './command-schema.ts';
import { expandUserHomePath, resolveUserPath } from './path-resolution.ts';
import {
getConfigurableOptionSpecs,
getOptionSpec,
@@ -41,16 +41,11 @@ function resolveConfigPaths(
}
function resolveUserConfigPath(env: EnvMap): string {
const home = env.HOME?.trim() || os.homedir();
return path.join(home, '.agent-device', 'config.json');
return path.join(expandUserHomePath('~', { env }), '.agent-device', 'config.json');
}
function resolveInputPath(inputPath: string, cwd: string, env: EnvMap): string {
if (path.isAbsolute(inputPath)) return inputPath;
if (inputPath.startsWith('~')) {
return path.join(env.HOME?.trim() || os.homedir(), inputPath.slice(1));
}
return path.resolve(cwd, inputPath);
return resolveUserPath(inputPath, { cwd, env });
}
function loadConfigFileDefaults(
+20 -3
View File
@@ -37,6 +37,8 @@ export type CliFlags = {
snapshotDepth?: number;
snapshotScope?: string;
snapshotRaw?: boolean;
baseline?: string;
threshold?: string;
appsFilter?: 'user-installed' | 'all';
count?: number;
fps?: number;
@@ -559,6 +561,20 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--out <path>',
usageDescription: 'Output path',
},
{
key: 'baseline',
names: ['--baseline', '-b'],
type: 'string',
usageLabel: '--baseline, -b <path>',
usageDescription: 'Diff screenshot: path to baseline image file',
},
{
key: 'threshold',
names: ['--threshold'],
type: 'string',
usageLabel: '--threshold <0-1>',
usageDescription: 'Diff screenshot: color distance threshold (default 0.1)',
},
];
export const GLOBAL_FLAG_KEYS = new Set<FlagKey>([
@@ -627,10 +643,11 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
allowedFlags: [...SNAPSHOT_FLAGS],
},
diff: {
usageOverride: 'diff snapshot',
description: 'Diff current accessibility snapshot against previous baseline',
usageOverride:
'diff snapshot | diff screenshot --baseline <path> [--out <diff.png>] [--threshold <0-1>]',
description: 'Diff accessibility snapshot or compare screenshots pixel-by-pixel',
positionalArgs: ['kind'],
allowedFlags: [...SNAPSHOT_FLAGS],
allowedFlags: [...SNAPSHOT_FLAGS, 'baseline', 'threshold', 'out'],
},
'ensure-simulator': {
description: 'Ensure an iOS simulator exists in a device set (create if missing)',
+57 -1
View File
@@ -1,10 +1,12 @@
import path from 'node:path';
import { AppError, normalizeError, type NormalizedError } from './errors.ts';
import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts';
import type { SnapshotNode } from './snapshot.ts';
import type { ScreenshotDiffResult } from './screenshot-diff.ts';
import { styleText } from 'node:util';
type JsonResult =
| { success: true; data?: Record<string, unknown> }
| { success: true; data?: unknown }
| {
success: false;
error: {
@@ -111,6 +113,60 @@ export function formatSnapshotDiffText(data: Record<string, unknown>): string {
return `${body}${summary}\n`;
}
export function formatScreenshotDiffText(data: ScreenshotDiffResult): string {
const useColor = supportsColor();
const match = data.match === true;
const differentPixels = toNumber(data.differentPixels);
const totalPixels = toNumber(data.totalPixels);
const mismatchPercentage = toNumber(data.mismatchPercentage);
const diffPath = data.diffPath;
const dimensionMismatch = data.dimensionMismatch;
const lines: string[] = [];
if (match) {
const indicator = useColor ? colorize('✓', 'green') : '✓';
lines.push(`${indicator} Screenshots match.`);
} else if (dimensionMismatch) {
const indicator = useColor ? colorize('✗', 'red') : '✗';
const expected = dimensionMismatch.expected;
const actual = dimensionMismatch.actual;
lines.push(
`${indicator} Screenshots have different dimensions: ` +
`expected ${expected?.width}x${expected?.height}, ` +
`got ${actual?.width}x${actual?.height}`,
);
} else {
const indicator = useColor ? colorize('✗', 'red') : '✗';
const pctLabel =
mismatchPercentage === 0 && differentPixels > 0 ? '<0.01' : String(mismatchPercentage);
lines.push(`${indicator} ${pctLabel}% pixels differ`);
}
if (diffPath && !match) {
const relativePath = toRelativePath(diffPath);
const label = useColor ? colorize('Diff image:', 'dim') : 'Diff image:';
const displayPath = useColor ? colorize(relativePath, 'green') : relativePath;
lines.push(` ${label} ${displayPath}`);
}
if (!match && !dimensionMismatch) {
const diffCount = useColor ? colorize(String(differentPixels), 'red') : String(differentPixels);
lines.push(` ${diffCount} different / ${totalPixels} total pixels`);
}
return `${lines.join('\n')}\n`;
}
function toRelativePath(filePath: string): string {
const cwd = process.cwd();
const relativePath = path.relative(cwd, filePath);
if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) {
return relativePath === '' ? '.' : `.${path.sep}${relativePath}`;
}
return filePath;
}
function toNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
+27
View File
@@ -0,0 +1,27 @@
import os from 'node:os';
import path from 'node:path';
type EnvMap = Record<string, string | undefined>;
type PathResolutionOptions = {
cwd?: string;
env?: EnvMap;
};
function resolveHomeDirectory(env?: EnvMap): string {
return env?.HOME?.trim() || os.homedir();
}
export function expandUserHomePath(inputPath: string, options: PathResolutionOptions = {}): string {
if (inputPath === '~') return resolveHomeDirectory(options.env);
if (inputPath.startsWith('~/')) {
return path.join(resolveHomeDirectory(options.env), inputPath.slice(2));
}
return inputPath;
}
export function resolveUserPath(inputPath: string, options: PathResolutionOptions = {}): string {
const expandedPath = expandUserHomePath(inputPath, options);
if (path.isAbsolute(expandedPath)) return expandedPath;
return path.resolve(options.cwd ?? process.cwd(), expandedPath);
}
+156
View File
@@ -0,0 +1,156 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { PNG } from 'pngjs';
import { AppError } from '../utils/errors.ts';
export type ScreenshotDimensionMismatch = {
expected: { width: number; height: number };
actual: { width: number; height: number };
};
export type ScreenshotDiffResult = {
diffPath?: string;
totalPixels: number;
differentPixels: number;
mismatchPercentage: number;
match: boolean;
dimensionMismatch?: ScreenshotDimensionMismatch;
};
export type ScreenshotDiffOptions = {
threshold?: number;
outputPath?: string;
};
// Each pixel is a point in 3D RGB space (R, G, B each 0–255).
// The maximum possible distance between two colors is from black (0,0,0) to
// white (255,255,255): √(255² + 255² + 255²) = 255√3 ≈ 441.67.
// We use this as the denominator so threshold 0–1 maps linearly to the full
// color distance range: 0 = exact match only, 1 = everything matches.
const COLOR_DISTANCE_SCALE = 255 * Math.sqrt(3);
export async function compareScreenshots(
baselinePath: string,
currentPath: string,
options: ScreenshotDiffOptions = {},
): Promise<ScreenshotDiffResult> {
await validateFileExists(baselinePath, 'Baseline image not found');
await validateFileExists(currentPath, 'Current screenshot not found');
const diffOutputPath = options.outputPath;
const [baselineBuffer, currentBuffer] = await Promise.all([
fs.readFile(baselinePath),
fs.readFile(currentPath),
]);
const baseline = decodePng(baselineBuffer, 'baseline');
const current = decodePng(currentBuffer, 'current');
const threshold = options.threshold ?? 0.1;
// Handle dimension mismatch — no diff image can be generated for different-sized images
if (baseline.width !== current.width || baseline.height !== current.height) {
const totalPixels = baseline.width * baseline.height;
await removeStaleDiffOutput(options.outputPath);
return {
match: false,
mismatchPercentage: 100,
totalPixels,
differentPixels: totalPixels,
dimensionMismatch: {
expected: { width: baseline.width, height: baseline.height },
actual: { width: current.width, height: current.height },
},
};
}
const totalPixels = baseline.width * baseline.height;
const maxColorDistance = threshold * COLOR_DISTANCE_SCALE;
const diff = new PNG({ width: baseline.width, height: baseline.height });
let differentPixels = 0;
// PNG data is a flat RGBA buffer: [R, G, B, A, R, G, B, A, ...].
// We step by 4 to visit each pixel and compute its Euclidean distance
// in RGB space between the baseline and current image.
for (let index = 0; index < baseline.data.length; index += 4) {
const redDelta = baseline.data[index]! - current.data[index]!;
const greenDelta = baseline.data[index + 1]! - current.data[index + 1]!;
const blueDelta = baseline.data[index + 2]! - current.data[index + 2]!;
const colorDistance = Math.sqrt(redDelta ** 2 + greenDelta ** 2 + blueDelta ** 2);
if (colorDistance > maxColorDistance) {
differentPixels += 1;
// Red highlight for different pixels
diff.data[index] = 255;
diff.data[index + 1] = 0;
diff.data[index + 2] = 0;
diff.data[index + 3] = 255;
continue;
}
// Unchanged pixels are converted to a dimmed grayscale (30% brightness).
// This makes the diff image look like a faded version of the original with
// red pixels popping out where differences exist.
const gray = Math.round(
(baseline.data[index]! + baseline.data[index + 1]! + baseline.data[index + 2]!) / 3,
);
const dimmed = Math.round(gray * 0.3);
diff.data[index] = dimmed;
diff.data[index + 1] = dimmed;
diff.data[index + 2] = dimmed;
diff.data[index + 3] = 255;
}
if (differentPixels > 0 && diffOutputPath) {
await fs.mkdir(path.dirname(diffOutputPath), { recursive: true });
await fs.writeFile(diffOutputPath, PNG.sync.write(diff));
} else {
await removeStaleDiffOutput(options.outputPath);
}
// Round to 2 decimal places: multiply percentage by 100 before rounding,
// then divide back. e.g. 0.12345 → 12.345% → round(1234.5)/100 → 12.35%
const mismatchPercentage =
totalPixels > 0 ? Math.round((differentPixels / totalPixels) * 100 * 100) / 100 : 0;
return {
...(differentPixels > 0 && diffOutputPath ? { diffPath: diffOutputPath } : {}),
totalPixels,
differentPixels,
mismatchPercentage,
match: differentPixels === 0,
};
}
async function validateFileExists(filePath: string, errorMessage: string): Promise<void> {
try {
await fs.access(filePath);
} catch {
throw new AppError('INVALID_ARGS', `${errorMessage}: ${filePath}`);
}
}
function decodePng(buffer: Buffer, label: 'baseline' | 'current'): PNG {
try {
return PNG.sync.read(buffer);
} catch (error) {
throw new AppError('COMMAND_FAILED', `Failed to decode ${label} screenshot as PNG`, {
label,
reason: error instanceof Error ? error.message : String(error),
});
}
}
async function removeStaleDiffOutput(outputPath: string | undefined): Promise<void> {
if (!outputPath) return;
try {
await fs.unlink(outputPath);
} catch (error) {
if (!isFsError(error, 'ENOENT')) throw error;
}
}
function isFsError(error: unknown, code: string): error is NodeJS.ErrnoException {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}