mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
+5
-1
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+20
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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-',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user