diff (#510)
* diff * fixes * fixes * fixes * fixes * fixes * better docs
This commit is contained in:
+117
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user