* diff

* fixes

* fixes

* fixes

* fixes

* fixes

* better docs
This commit is contained in:
Chris Tate
2026-02-19 23:51:09 -06:00
committed by GitHub
parent 9732031087
commit d5a667ea2d
21 changed files with 2446 additions and 88 deletions
+117
View File
@@ -123,10 +123,16 @@ import type {
RecordingStartCommand,
RecordingStopCommand,
RecordingRestartCommand,
DiffSnapshotCommand,
DiffScreenshotCommand,
DiffUrlCommand,
Annotation,
NavigateData,
ScreenshotData,
EvaluateData,
DiffSnapshotData,
DiffScreenshotData,
DiffUrlData,
ContentData,
TabListData,
TabNewData,
@@ -141,6 +147,8 @@ import type {
StylesData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
import { diffSnapshots, diffScreenshots } from './diff.js';
import { getEnhancedSnapshot } from './snapshot.js';
// Callback for screencast frames - will be set by the daemon when streaming is active
let screencastFrameCallback: ((frame: ScreencastFrame) => void) | null = null;
@@ -486,6 +494,12 @@ export async function executeCommand(command: Command, browser: BrowserManager):
return await handleRecordingStop(command, browser);
case 'recording_restart':
return await handleRecordingRestart(command, browser);
case 'diff_snapshot':
return await handleDiffSnapshot(command, browser);
case 'diff_screenshot':
return await handleDiffScreenshot(command, browser);
case 'diff_url':
return await handleDiffUrl(command, browser);
default: {
// TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string };
@@ -2464,3 +2478,106 @@ async function handleRecordingRestart(
stopped: result.stopped,
});
}
// Diff handlers
async function handleDiffSnapshot(
command: DiffSnapshotCommand,
browser: BrowserManager
): Promise<Response> {
let before: string;
if (command.baseline) {
try {
before = fs.readFileSync(command.baseline, 'utf-8');
} catch {
return errorResponse(command.id, `Cannot read baseline file: ${command.baseline}`);
}
} else {
before = browser.getLastSnapshot();
if (!before) {
return errorResponse(
command.id,
'No previous snapshot in this session. Take a snapshot first, or use --baseline <file>.'
);
}
}
const page = browser.getPage();
const { tree } = await getEnhancedSnapshot(page, {
selector: command.selector,
compact: command.compact,
maxDepth: command.maxDepth,
});
const after = tree || 'Empty page';
const result = diffSnapshots(before, after);
browser.setLastSnapshot(after);
return successResponse(command.id, result);
}
async function handleDiffScreenshot(
command: DiffScreenshotCommand,
browser: BrowserManager
): Promise<Response> {
if (!fs.existsSync(command.baseline)) {
return errorResponse(command.id, `Baseline file not found: ${command.baseline}`);
}
const page = browser.getPage();
let screenshotBuffer: Buffer;
if (command.selector) {
const locator = browser.getLocatorFromRef(command.selector) || page.locator(command.selector);
screenshotBuffer = await locator.screenshot({ type: 'png' });
} else {
screenshotBuffer = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
}
const baselineBuffer = fs.readFileSync(command.baseline);
const ext = path.extname(command.baseline).toLowerCase();
const baselineMime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
const result = await diffScreenshots(page.context(), baselineBuffer, screenshotBuffer, {
threshold: command.threshold,
outputPath: command.output,
baselineMime,
});
return successResponse(command.id, result);
}
async function handleDiffUrl(command: DiffUrlCommand, browser: BrowserManager): Promise<Response> {
const page = browser.getPage();
const waitUntil = command.waitUntil ?? 'load';
const snapshotOpts = {
selector: command.selector,
compact: command.compact,
maxDepth: command.maxDepth,
};
// Capture state of url1
await page.goto(command.url1, { waitUntil });
const { tree: tree1 } = await getEnhancedSnapshot(page, snapshotOpts);
const snapshot1 = tree1 || 'Empty page';
let screenshot1: Buffer | undefined;
if (command.screenshot) {
screenshot1 = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
}
// Capture state of url2
await page.goto(command.url2, { waitUntil });
const { tree: tree2 } = await getEnhancedSnapshot(page, snapshotOpts);
const snapshot2 = tree2 || 'Empty page';
const snapshotDiff = diffSnapshots(snapshot1, snapshot2);
const result: DiffUrlData = { snapshot: snapshotDiff };
if (command.screenshot && screenshot1) {
const screenshot2 = await page.screenshot({ fullPage: command.fullPage, type: 'png' });
result.screenshot = await diffScreenshots(page.context(), screenshot1, screenshot2, {});
}
return successResponse(command.id, result);
}
+14
View File
@@ -154,6 +154,20 @@ export class BrowserManager {
return snapshot;
}
/**
* Get the last snapshot tree text (empty string if no snapshot has been taken)
*/
getLastSnapshot(): string {
return this.lastSnapshot;
}
/**
* Update the stored snapshot (used by diff to keep the baseline current)
*/
setLastSnapshot(snapshot: string): void {
this.lastSnapshot = snapshot;
}
/**
* Get the cached ref map from last snapshot
*/
+189
View File
@@ -0,0 +1,189 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { diffSnapshots, diffScreenshots } from './diff.js';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
describe('diffSnapshots', () => {
it('should report no changes for identical inputs', () => {
const text = 'heading "Hello"\nbutton "Submit" [ref=e1]';
const result = diffSnapshots(text, text);
expect(result.changed).toBe(false);
expect(result.additions).toBe(0);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(2);
});
it('should report no changes for empty inputs', () => {
const result = diffSnapshots('', '');
expect(result.changed).toBe(false);
expect(result.additions).toBe(0);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(1);
});
it('should detect a single-line addition', () => {
const before = 'heading "Hello"';
const after = 'heading "Hello"\nbutton "New"';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(1);
expect(result.removals).toBe(0);
expect(result.unchanged).toBe(1);
expect(result.diff).toContain('+ button "New"');
});
it('should detect a single-line removal', () => {
const before = 'heading "Hello"\nbutton "Gone"';
const after = 'heading "Hello"';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(0);
expect(result.removals).toBe(1);
expect(result.unchanged).toBe(1);
expect(result.diff).toContain('- button "Gone"');
});
it('should detect completely different inputs', () => {
const before = 'line A\nline B';
const after = 'line C\nline D';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBe(2);
expect(result.removals).toBe(2);
expect(result.unchanged).toBe(0);
});
it('should handle mixed additions, removals, and unchanged lines', () => {
const before = [
'heading "Title"',
'button "Submit" [ref=e2]',
'text "old value"',
'footer "Copyright"',
].join('\n');
const after = [
'heading "Title"',
'button "Submit" [ref=e2] [disabled]',
'text "new value"',
'link "Help" [ref=e5]',
'footer "Copyright"',
].join('\n');
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBeGreaterThan(0);
expect(result.removals).toBeGreaterThan(0);
expect(result.unchanged).toBeGreaterThan(0);
expect(result.diff).toContain('+ ');
expect(result.diff).toContain('- ');
});
it('should use + prefix for insertions and - prefix for deletions', () => {
const before = 'alpha';
const after = 'beta';
const result = diffSnapshots(before, after);
const lines = result.diff.split('\n');
const deletions = lines.filter((l) => l.startsWith('- '));
const insertions = lines.filter((l) => l.startsWith('+ '));
expect(deletions.length).toBe(1);
expect(insertions.length).toBe(1);
expect(deletions[0]).toBe('- alpha');
expect(insertions[0]).toBe('+ beta');
});
it('should use two-space prefix for unchanged lines', () => {
const text = 'unchanged line';
const result = diffSnapshots(text, text);
expect(result.diff).toBe(' unchanged line');
});
it('should handle multiline to empty', () => {
const before = 'line 1\nline 2\nline 3';
const after = '';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.removals).toBeGreaterThanOrEqual(3);
});
it('should handle empty to multiline', () => {
const before = '';
const after = 'line 1\nline 2\nline 3';
const result = diffSnapshots(before, after);
expect(result.changed).toBe(true);
expect(result.additions).toBeGreaterThanOrEqual(3);
});
});
const canLaunchBrowser = await (async () => {
try {
const b = await chromium.launch({ headless: true });
await b.close();
return true;
} catch {
return false;
}
})();
describe.skipIf(!canLaunchBrowser)('diffScreenshots', () => {
let browser: Browser;
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
context = await browser.newContext({ viewport: { width: 200, height: 200 } });
page = await context.newPage();
});
afterAll(async () => {
await browser.close();
});
async function screenshotOfColor(color: string): Promise<Buffer> {
await page.setContent(`<div style="width:200px;height:200px;background:${color}"></div>`);
return await page.screenshot({ type: 'png' });
}
it('should report match for identical images', async () => {
const img = await screenshotOfColor('red');
const result = await diffScreenshots(context, img, img, {});
expect(result.match).toBe(true);
expect(result.differentPixels).toBe(0);
expect(result.mismatchPercentage).toBe(0);
expect(result.dimensionMismatch).toBeUndefined();
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should detect differences between distinct images', async () => {
const imgA = await screenshotOfColor('red');
const imgB = await screenshotOfColor('blue');
const result = await diffScreenshots(context, imgA, imgB, {});
expect(result.match).toBe(false);
expect(result.differentPixels).toBeGreaterThan(0);
expect(result.mismatchPercentage).toBeGreaterThan(0);
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should detect dimension mismatch', async () => {
const imgA = await screenshotOfColor('white');
await page.setViewportSize({ width: 100, height: 100 });
const imgB = await screenshotOfColor('white');
await page.setViewportSize({ width: 200, height: 200 });
const result = await diffScreenshots(context, imgA, imgB, {});
expect(result.dimensionMismatch).toBe(true);
expect(result.mismatchPercentage).toBe(100);
if (result.diffPath) fs.unlinkSync(result.diffPath);
});
it('should write diff image to custom outputPath', async () => {
const imgA = await screenshotOfColor('green');
const imgB = await screenshotOfColor('yellow');
const outputPath = path.join(os.tmpdir(), `diff-test-${Date.now()}.png`);
const result = await diffScreenshots(context, imgA, imgB, { outputPath });
expect(result.diffPath).toBe(outputPath);
expect(fs.existsSync(outputPath)).toBe(true);
const stat = fs.statSync(outputPath);
expect(stat.size).toBeGreaterThan(0);
fs.unlinkSync(outputPath);
});
});
+339
View File
@@ -0,0 +1,339 @@
import type { BrowserContext } from 'playwright-core';
import type { DiffSnapshotData, DiffScreenshotData } from './types.js';
import { writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
// --- Text diffing (Myers algorithm, line-level) ---
interface DiffEdit {
type: 'equal' | 'insert' | 'delete';
line: string;
}
/**
* Myers diff algorithm operating on arrays of lines.
* Returns a minimal edit script.
*/
function myersDiff(a: string[], b: string[]): DiffEdit[] {
const n = a.length;
const m = b.length;
const max = n + m;
if (max === 0) return [];
// Optimize: if both are identical, skip diff
if (n === m) {
let identical = true;
for (let i = 0; i < n; i++) {
if (a[i] !== b[i]) {
identical = false;
break;
}
}
if (identical) return a.map((line) => ({ type: 'equal' as const, line }));
}
const vSize = 2 * max + 1;
const v = new Int32Array(vSize);
v.fill(-1);
const trace: Int32Array[] = [];
v[max + 1] = 0;
for (let d = 0; d <= max; d++) {
const snapshot = new Int32Array(v);
trace.push(snapshot);
for (let k = -d; k <= d; k += 2) {
const idx = k + max;
let x: number;
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
x = v[idx + 1];
} else {
x = v[idx - 1] + 1;
}
let y = x - k;
while (x < n && y < m && a[x] === b[y]) {
x++;
y++;
}
v[idx] = x;
if (x >= n && y >= m) {
return buildEditScript(trace, a, b, max);
}
}
}
return buildEditScript(trace, a, b, max);
}
function buildEditScript(trace: Int32Array[], a: string[], b: string[], max: number): DiffEdit[] {
const edits: DiffEdit[] = [];
let x = a.length;
let y = b.length;
for (let d = trace.length - 1; d > 0; d--) {
const v = trace[d];
const k = x - y;
const idx = k + max;
let prevK: number;
if (k === -d || (k !== d && v[idx - 1] < v[idx + 1])) {
prevK = k + 1;
} else {
prevK = k - 1;
}
const prevIdx = prevK + max;
let prevX = v[prevIdx];
let prevY = prevX - prevK;
// Diagonal (equal lines)
while (x > prevX && y > prevY) {
x--;
y--;
edits.push({ type: 'equal', line: a[x] });
}
if (x === prevX) {
y--;
edits.push({ type: 'insert', line: b[y] });
} else {
x--;
edits.push({ type: 'delete', line: a[x] });
}
}
// Remaining diagonal at d=0
while (x > 0 && y > 0) {
x--;
y--;
edits.push({ type: 'equal', line: a[x] });
}
edits.reverse();
return edits;
}
/**
* Produce a unified diff string and stats from two snapshot texts.
*/
export function diffSnapshots(before: string, after: string): DiffSnapshotData {
const linesA = before.split('\n');
const linesB = after.split('\n');
const edits = myersDiff(linesA, linesB);
let additions = 0;
let removals = 0;
let unchanged = 0;
const diffLines: string[] = [];
for (const edit of edits) {
switch (edit.type) {
case 'equal':
unchanged++;
diffLines.push(` ${edit.line}`);
break;
case 'insert':
additions++;
diffLines.push(`+ ${edit.line}`);
break;
case 'delete':
removals++;
diffLines.push(`- ${edit.line}`);
break;
}
}
return {
diff: diffLines.join('\n'),
additions,
removals,
unchanged,
changed: additions > 0 || removals > 0,
};
}
// --- Image diffing (via browser Canvas API) ---
interface PixelDiffResult {
totalPixels: number;
differentPixels: number;
mismatchPercentage: number;
diffBase64: string;
dimensionMismatch: boolean;
}
const DIFF_ROUTE_PREFIX = 'https://agent-browser-diff.localhost';
/**
* Compare two image buffers using the browser's Canvas API for pixel comparison.
* Uses an isolated blank page to avoid CSP interference or DOM side effects on the
* user's page. Images are served via intercepted routes to avoid large base64 payloads
* through page.evaluate (which can be slow or hit CDP message size limits).
*/
export async function diffScreenshots(
context: BrowserContext,
baselineBuffer: Buffer,
currentBuffer: Buffer,
opts: { threshold?: number; outputPath?: string; baselineMime?: string }
): Promise<DiffScreenshotData> {
const baselineMime = opts.baselineMime ?? 'image/png';
const threshold = opts.threshold ?? 0.1;
const nonce = Math.random().toString(36).slice(2, 10);
const blankUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/index.html`;
const baselineUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/baseline.png`;
const currentUrl = `${DIFF_ROUTE_PREFIX}/${nonce}/current.png`;
const diffPage = await context.newPage();
let blankRouted = false;
let baselineRouted = false;
let currentRouted = false;
try {
await diffPage.route(blankUrl, (route) =>
route.fulfill({ body: '<html><body></body></html>', contentType: 'text/html' })
);
blankRouted = true;
await diffPage.route(baselineUrl, (route) =>
route.fulfill({ body: baselineBuffer, contentType: baselineMime })
);
baselineRouted = true;
await diffPage.route(currentUrl, (route) =>
route.fulfill({ body: currentBuffer, contentType: 'image/png' })
);
currentRouted = true;
await diffPage.goto(blankUrl);
const pixelDiffFn = async (args: {
baselineUrl: string;
currentUrl: string;
threshold: number;
}) => {
const g = globalThis as any;
const doc = g.document;
const Img = g.Image as new () => any;
function loadImage(url: string) {
return new Promise((resolve, reject) => {
const img = new Img();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Failed to load image'));
img.src = url;
});
}
const [imgA, imgB] = (await Promise.all([
loadImage(args.baselineUrl),
loadImage(args.currentUrl),
])) as any[];
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
const c = doc.createElement('canvas');
c.width = 1;
c.height = 1;
return {
totalPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
differentPixels: Math.max(imgA.width * imgA.height, imgB.width * imgB.height),
mismatchPercentage: 100,
diffBase64: c.toDataURL('image/png').split(',')[1],
dimensionMismatch: true,
};
}
const w = imgA.width;
const h = imgA.height;
const canvasA = doc.createElement('canvas');
canvasA.width = w;
canvasA.height = h;
const ctxA = canvasA.getContext('2d')!;
ctxA.drawImage(imgA, 0, 0);
const dataA = ctxA.getImageData(0, 0, w, h).data;
const canvasB = doc.createElement('canvas');
canvasB.width = w;
canvasB.height = h;
const ctxB = canvasB.getContext('2d')!;
ctxB.drawImage(imgB, 0, 0);
const dataB = ctxB.getImageData(0, 0, w, h).data;
const diffCanvas = doc.createElement('canvas');
diffCanvas.width = w;
diffCanvas.height = h;
const ctxDiff = diffCanvas.getContext('2d')!;
const diffImageData = ctxDiff.createImageData(w, h);
const diffData = diffImageData.data;
const maxColorDistance = args.threshold * 255 * Math.sqrt(3);
let differentPixels = 0;
const totalPixels = w * h;
for (let i = 0; i < totalPixels; i++) {
const offset = i * 4;
const rA = dataA[offset],
gA = dataA[offset + 1],
bA = dataA[offset + 2];
const rB = dataB[offset],
gB = dataB[offset + 1],
bB = dataB[offset + 2];
const dr = rA - rB,
dg = gA - gB,
db = bA - bB;
const dist = Math.sqrt(dr * dr + dg * dg + db * db);
if (dist > maxColorDistance) {
differentPixels++;
diffData[offset] = 255;
diffData[offset + 1] = 0;
diffData[offset + 2] = 0;
diffData[offset + 3] = 255;
} else {
diffData[offset] = Math.round(rA * 0.3);
diffData[offset + 1] = Math.round(gA * 0.3);
diffData[offset + 2] = Math.round(bA * 0.3);
diffData[offset + 3] = 255;
}
}
ctxDiff.putImageData(diffImageData, 0, 0);
const diffBase64 = diffCanvas.toDataURL('image/png').split(',')[1];
return {
totalPixels,
differentPixels,
mismatchPercentage: Math.round((differentPixels / totalPixels) * 10000) / 100,
diffBase64,
dimensionMismatch: false,
};
};
const result = (await diffPage.evaluate(pixelDiffFn, {
baselineUrl,
currentUrl,
threshold,
})) as PixelDiffResult;
let outputPath = opts.outputPath;
if (!outputPath) {
const tmpDir = path.join(
process.env.HOME || process.env.USERPROFILE || '/tmp',
'.agent-browser',
'tmp',
'diffs'
);
await mkdir(tmpDir, { recursive: true });
outputPath = path.join(tmpDir, `diff-${Date.now()}.png`);
}
const diffBuffer = Buffer.from(result.diffBase64, 'base64');
await writeFile(outputPath, diffBuffer);
return {
diffPath: outputPath,
totalPixels: result.totalPixels,
differentPixels: result.differentPixels,
mismatchPercentage: result.mismatchPercentage,
match: result.differentPixels === 0,
...(result.dimensionMismatch ? { dimensionMismatch: true } : {}),
};
} finally {
if (blankRouted) await diffPage.unroute(blankUrl).catch(() => {});
if (baselineRouted) await diffPage.unroute(baselineUrl).catch(() => {});
if (currentRouted) await diffPage.unroute(currentUrl).catch(() => {});
await diffPage.close().catch(() => {});
}
}
+203
View File
@@ -1209,6 +1209,209 @@ describe('parseCommand', () => {
});
});
describe('diff', () => {
it('should parse diff_snapshot with no options', () => {
const result = parseCommand(cmd({ id: '1', action: 'diff_snapshot' }));
expect(result.success).toBe(true);
});
it('should parse diff_snapshot with baseline', () => {
const result = parseCommand(
cmd({ id: '1', action: 'diff_snapshot', baseline: 'before.txt' })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.baseline).toBe('before.txt');
}
});
it('should parse diff_snapshot with all options', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_snapshot',
baseline: 'snap.txt',
selector: '#main',
compact: true,
maxDepth: 3,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.baseline).toBe('snap.txt');
expect(result.command.selector).toBe('#main');
expect(result.command.compact).toBe(true);
expect(result.command.maxDepth).toBe(3);
}
});
it('should reject diff_snapshot with negative maxDepth', () => {
const result = parseCommand(cmd({ id: '1', action: 'diff_snapshot', maxDepth: -1 }));
expect(result.success).toBe(false);
});
it('should parse diff_screenshot with baseline', () => {
const result = parseCommand(
cmd({ id: '1', action: 'diff_screenshot', baseline: 'before.png' })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.baseline).toBe('before.png');
}
});
it('should parse diff_screenshot with all options', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_screenshot',
baseline: 'before.png',
output: 'diff.png',
threshold: 0.2,
selector: '#hero',
fullPage: true,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.baseline).toBe('before.png');
expect(result.command.output).toBe('diff.png');
expect(result.command.threshold).toBe(0.2);
expect(result.command.selector).toBe('#hero');
expect(result.command.fullPage).toBe(true);
}
});
it('should reject diff_screenshot without baseline', () => {
const result = parseCommand(cmd({ id: '1', action: 'diff_screenshot' }));
expect(result.success).toBe(false);
});
it('should reject diff_screenshot with threshold out of range', () => {
const result = parseCommand(
cmd({ id: '1', action: 'diff_screenshot', baseline: 'b.png', threshold: 1.5 })
);
expect(result.success).toBe(false);
});
it('should parse diff_url with two URLs', () => {
const result = parseCommand(
cmd({ id: '1', action: 'diff_url', url1: 'https://a.com', url2: 'https://b.com' })
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.url1).toBe('https://a.com');
expect(result.command.url2).toBe('https://b.com');
}
});
it('should parse diff_url with screenshot and fullPage', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
screenshot: true,
fullPage: true,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.screenshot).toBe(true);
expect(result.command.fullPage).toBe(true);
}
});
it('should parse diff_url with waitUntil', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
waitUntil: 'networkidle',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.waitUntil).toBe('networkidle');
}
});
it('should reject diff_url without url1', () => {
const result = parseCommand(cmd({ id: '1', action: 'diff_url', url2: 'https://b.com' }));
expect(result.success).toBe(false);
});
it('should reject diff_url without url2', () => {
const result = parseCommand(cmd({ id: '1', action: 'diff_url', url1: 'https://a.com' }));
expect(result.success).toBe(false);
});
it('should reject diff_url with invalid waitUntil', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
waitUntil: 'invalid',
})
);
expect(result.success).toBe(false);
});
it('should parse diff_url with selector', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
selector: '#main',
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.selector).toBe('#main');
}
});
it('should parse diff_url with all snapshot options', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
selector: '#content',
compact: true,
maxDepth: 5,
})
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.command.selector).toBe('#content');
expect(result.command.compact).toBe(true);
expect(result.command.maxDepth).toBe(5);
}
});
it('should reject diff_url with negative maxDepth', () => {
const result = parseCommand(
cmd({
id: '1',
action: 'diff_url',
url1: 'https://a.com',
url2: 'https://b.com',
maxDepth: -1,
})
);
expect(result.success).toBe(false);
});
});
describe('invalid commands', () => {
it('should reject unknown action', () => {
const result = parseCommand(cmd({ id: '1', action: 'unknown' }));
+33
View File
@@ -740,6 +740,36 @@ const deviceListSchema = baseCommandSchema.extend({
action: z.literal('device_list'),
});
// Diff schemas
const diffSnapshotSchema = baseCommandSchema.extend({
action: z.literal('diff_snapshot'),
baseline: z.string().optional(),
selector: z.string().optional(),
compact: z.boolean().optional(),
maxDepth: z.number().nonnegative().optional(),
});
const diffScreenshotSchema = baseCommandSchema.extend({
action: z.literal('diff_screenshot'),
baseline: z.string().min(1),
output: z.string().optional(),
threshold: z.number().min(0).max(1).optional(),
selector: z.string().min(1).optional(),
fullPage: z.boolean().optional(),
});
const diffUrlSchema = baseCommandSchema.extend({
action: z.literal('diff_url'),
url1: z.string().min(1),
url2: z.string().min(1),
screenshot: z.boolean().optional(),
fullPage: z.boolean().optional(),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
selector: z.string().optional(),
compact: z.boolean().optional(),
maxDepth: z.number().nonnegative().optional(),
});
const pressSchema = baseCommandSchema.extend({
action: z.literal('press'),
key: z.string().min(1),
@@ -972,6 +1002,9 @@ const commandSchema = z.discriminatedUnion('action', [
inputTouchSchema,
swipeSchema,
deviceListSchema,
diffSnapshotSchema,
diffScreenshotSchema,
diffUrlSchema,
]);
// Parse result type
+57 -1
View File
@@ -1014,7 +1014,40 @@ export type Command =
| InputKeyboardCommand
| InputTouchCommand
| SwipeCommand
| DeviceListCommand;
| DeviceListCommand
| DiffSnapshotCommand
| DiffScreenshotCommand
| DiffUrlCommand;
// Diff commands
export interface DiffSnapshotCommand extends BaseCommand {
action: 'diff_snapshot';
baseline?: string;
selector?: string;
compact?: boolean;
maxDepth?: number;
}
export interface DiffScreenshotCommand extends BaseCommand {
action: 'diff_screenshot';
baseline: string;
output?: string;
threshold?: number;
selector?: string;
fullPage?: boolean;
}
export interface DiffUrlCommand extends BaseCommand {
action: 'diff_url';
url1: string;
url2: string;
screenshot?: boolean;
fullPage?: boolean;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
selector?: string;
compact?: boolean;
maxDepth?: number;
}
// Response types
export interface SuccessResponse<T = unknown> {
@@ -1145,6 +1178,29 @@ export interface StylesData {
elements: ElementStyleInfo[];
}
// Diff response data
export interface DiffSnapshotData {
diff: string;
additions: number;
removals: number;
unchanged: number;
changed: boolean;
}
export interface DiffScreenshotData {
diffPath: string;
totalPixels: number;
differentPixels: number;
mismatchPercentage: number;
match: boolean;
dimensionMismatch?: boolean;
}
export interface DiffUrlData {
snapshot: DiffSnapshotData;
screenshot?: DiffScreenshotData;
}
// Browser state
export interface BrowserState {
browser: Browser | null;