Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ page coverage=100.0%

The shape of the diff is the diagnosis. A tight box is one component off, a box spanning the page points at a font that never loaded or a viewport mismatch. Read the shape, not just the number.

The same numbers are written to `metrics.json` in the run folder for scripting.
The same numbers, plus the paths of every artifact, are written to `metrics.json` in the run folder for scripting.

```json
{
Expand Down Expand Up @@ -149,7 +149,7 @@ For an agent the contract is simple. Read the JSON, act on `matchPercent` and `d
## Programmatic API

The CLI is a thin wrapper around `designDiff`, which returns the same data it
writes to `metrics.json` plus the paths of every artifact.
writes to `metrics.json`, including the paths of every artifact.

```ts
import { designDiff } from "design-diff";
Expand Down
51 changes: 32 additions & 19 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,24 @@ function parseArgs(argv: string[]): Args {
const positionals: string[] = [];
for (let i = 0; i < argv.length; i++) {
const t = argv[i]!;
switch (t) {
// Support --flag=value alongside space-separated --flag value.
let name = t;
let inline: string | undefined;
if (t.startsWith("--")) {
const eq = t.indexOf("=");
if (eq !== -1) {
name = t.slice(0, eq);
inline = t.slice(eq + 1);
}
}
// Consume this flag's value, rejecting a missing value or a following flag.
const value = (): string => {
if (inline !== undefined) return inline;
const next = argv[++i];
if (next === undefined || next.startsWith("--")) fail(`${name} expects a value`);
return next;
};
switch (name) {
case "-h":
case "--help": a.help = true; break;
case "-v":
Expand All @@ -84,32 +101,27 @@ function parseArgs(argv: string[]): Args {
case "--json": a.json = true; break;
case "--no-overlay": a.noOverlay = true; break;
case "--annotate": a.annotate = true; break;
case "--png": a.png = argv[++i]; break;
case "--actual": a.actual = argv[++i]; break;
case "--file": a.file = argv[++i]; break;
case "--frame": a.frame = argv[++i]; break;
case "--scale": a.scale = Number(argv[++i]); break;
case "--threshold": a.threshold = Number(argv[++i]); break;
case "--fail-under": a.failUnder = Number(argv[++i]); break;
case "--ignore": a.ignore.push(parseIgnore(argv[++i])); break;
case "--ignore-selector": a.ignoreSelectors.push(requireValue("--ignore-selector", argv[++i])); break;
case "--wait-for": a.waitFor.push(requireValue("--wait-for", argv[++i])); break;
case "--auth": a.auth = requireValue("--auth", argv[++i]); break;
case "--out": a.out = argv[++i] ?? a.out; break;
case "--png": a.png = value(); break;
case "--actual": a.actual = value(); break;
case "--file": a.file = value(); break;
case "--frame": a.frame = value(); break;
case "--scale": a.scale = Number(value()); break;
case "--threshold": a.threshold = Number(value()); break;
case "--fail-under": a.failUnder = Number(value()); break;
case "--ignore": a.ignore.push(parseIgnore(value())); break;
case "--ignore-selector": a.ignoreSelectors.push(value()); break;
case "--wait-for": a.waitFor.push(value()); break;
case "--auth": a.auth = value(); break;
case "--out": a.out = value(); break;
default:
if (t.startsWith("--")) fail(`unknown option ${t}`);
if (t.startsWith("--")) fail(`unknown option ${name}`);
positionals.push(t);
}
}
a.url = positionals[0];
return a;
}

function requireValue(flag: string, value: string | undefined): string {
if (!value) fail(`${flag} expects a value`);
return value;
}

function readVersion(): string {
try {
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
Expand Down Expand Up @@ -191,6 +203,7 @@ async function run(): Promise<void> {

if (args.json) {
console.log(JSON.stringify(metricsOf(result), null, 2));
if (args.open) console.warn("warning: --open is ignored with --json");
} else {
console.log(formatReport(result));
if (result.paths.overlay) console.log(`\noverlay: ${result.paths.overlay}`);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "design-diff",
"version": "0.0.4",
"description": "Give agents and humans a precise, machine-readable way to measure how closely a live web page matches a design.",
"description": "Pixel-accurate diffs between a live web page and its design, as JSON for agents and a visual report for humans.",
"keywords": [
"design-diff",
"visual-regression",
Expand Down
8 changes: 6 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface DesignDiffOptions {
waitFor?: string | string[];
/** Write overlay.html (default true). */
writeOverlay?: boolean;
/** Write heatmap.png (default true; forced on when the overlay is written). */
/** Write heatmap.png (defaults to writeOverlay; forced on when the overlay is written). */
writeHeatmap?: boolean;
/** Write annotated.png with the diff box drawn on the page (default false). */
writeAnnotated?: boolean;
Expand All @@ -49,6 +49,7 @@ export interface DesignDiffOptions {
}

export interface DesignDiffResult {
/** The page URL that was screenshotted, or the `actual` image path in image-vs-image mode. */
url: string;
matchPercent: number;
diffPercent: number;
Expand Down Expand Up @@ -90,7 +91,9 @@ export async function designDiff(opts: DesignDiffOptions): Promise<DesignDiffRes
if (opts.auth && !existsSync(opts.auth)) throw new Error(`auth file not found: ${resolve(opts.auth)}`);

const writeOverlay = opts.writeOverlay ?? true;
const writeHeatmap = (opts.writeHeatmap ?? true) || writeOverlay;
// The overlay embeds the heatmap, so writing the overlay forces it on;
// otherwise the heatmap follows the overlay unless explicitly requested.
const writeHeatmap = writeOverlay || (opts.writeHeatmap ?? false);

const ignoreRects: IgnoreRegion[] = [];
const ignoreSelectors: string[] = [];
Expand Down Expand Up @@ -242,6 +245,7 @@ export function metricsOf(result: DesignDiffResult) {
coveragePercent: result.coveragePercent,
diffBounds: result.diffBounds,
readiness: result.readiness,
paths: result.paths,
};
}

Expand Down
49 changes: 39 additions & 10 deletions src/fetch/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,31 @@ interface FigmaNode {
children?: FigmaNode[];
}

async function figmaGet(path: string, token: string): Promise<any> {
interface FigmaNodesResponse {
nodes?: Record<string, { document?: FigmaNode } | undefined>;
}

interface FigmaImagesResponse {
err?: string | null;
images?: Record<string, string | null | undefined>;
}

// Honour a Retry-After header (delta-seconds or HTTP date), else exponential backoff.
function backoffMs(res: Response, attempt: number): number {
const header = res.headers.get("retry-after");
if (header) {
const secs = Number(header);
if (Number.isFinite(secs)) return Math.min(Math.max(secs, 0) * 1000, 15000);
const at = Date.parse(header);
if (!Number.isNaN(at)) return Math.min(Math.max(at - Date.now(), 0), 15000);
}
return Math.min(1000 * 2 ** attempt, 8000);
}

async function figmaGet<T>(path: string, token: string): Promise<T> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${FIGMA_API}${path}`, { headers: { "X-Figma-Token": token } });
if (res.ok) return res.json();
if (res.ok) return (await res.json()) as T;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= MAX_RETRIES) {
if (res.status === 401 || res.status === 403) {
Expand All @@ -24,7 +45,7 @@ async function figmaGet(path: string, token: string): Promise<any> {
if (res.status === 404) throw new Error("Figma API 404: file not found — check the --file key");
throw new Error(`Figma API ${res.status} ${res.statusText}`);
}
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 8000)));
await new Promise((r) => setTimeout(r, backoffMs(res, attempt)));
}
}

Expand All @@ -45,7 +66,7 @@ export async function exportDesignFrame(
const token = getFigmaToken();
const id = normalizeId(frameNodeId);

const data = await figmaGet(
const data = await figmaGet<FigmaNodesResponse>(
`/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(id)}`,
token
);
Expand All @@ -68,18 +89,26 @@ async function exportNodePng(
token: string
): Promise<Buffer> {
for (let attempt = 0; ; attempt++) {
const data = await figmaGet(
const data = await figmaGet<FigmaImagesResponse>(
`/images/${encodeURIComponent(fileKey)}?ids=${encodeURIComponent(nodeId)}&scale=${scale}&format=png`,
token
);
if (data.err) throw new Error(`Figma image export error: ${data.err}`);
const url: string | null | undefined = data.images?.[nodeId];
if (url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`image download failed: ${res.status} ${res.statusText}`);
return Buffer.from(await res.arrayBuffer());
}
if (url) return downloadImage(url);
if (attempt >= MAX_RETRIES) throw new Error(`image export not ready for node ${nodeId}`);
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 8000)));
}
}

async function downloadImage(url: string): Promise<Buffer> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url);
if (res.ok) return Buffer.from(await res.arrayBuffer());
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= MAX_RETRIES) {
throw new Error(`image download failed: ${res.status} ${res.statusText}`);
}
await new Promise((r) => setTimeout(r, backoffMs(res, attempt)));
}
}
100 changes: 100 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { PNG } from "pngjs";

const dir = join(".design-diff", "test-cli");
const designPath = join(dir, "design.png");
const samePath = join(dir, "same.png");
const changedPath = join(dir, "changed.png");

function solid(w: number, h: number, rgba: [number, number, number, number]): PNG {
const png = new PNG({ width: w, height: h });
for (let i = 0; i < w * h; i++) {
png.data[i * 4] = rgba[0];
png.data[i * 4 + 1] = rgba[1];
png.data[i * 4 + 2] = rgba[2];
png.data[i * 4 + 3] = rgba[3];
}
return png;
}

function run(args: string[]): { code: number; stdout: string; stderr: string } {
const res = Bun.spawnSync([process.execPath, "index.ts", ...args]);
return {
code: res.exitCode,
stdout: res.stdout.toString(),
stderr: res.stderr.toString(),
};
}

beforeAll(() => {
mkdirSync(dir, { recursive: true });
const design = solid(20, 10, [255, 255, 255, 255]);
const changed = solid(20, 10, [255, 255, 255, 255]);
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 4; x++) {
const i = (y * 20 + x) * 4;
changed.data[i] = 255;
changed.data[i + 1] = 0;
changed.data[i + 2] = 0;
changed.data[i + 3] = 255;
}
}
writeFileSync(designPath, PNG.sync.write(design));
writeFileSync(samePath, PNG.sync.write(solid(20, 10, [255, 255, 255, 255])));
writeFileSync(changedPath, PNG.sync.write(changed));
});

afterAll(() => rmSync(dir, { recursive: true, force: true }));

describe("cli", () => {
test("--json emits metrics including artifact paths", () => {
const { code, stdout } = run(["--actual", samePath, "--png", designPath, "--out", dir, "--json"]);
expect(code).toBe(0);
const metrics = JSON.parse(stdout);
expect(metrics.matchPercent).toBe(100);
expect(metrics.paths.metrics).toContain("metrics.json");
});

test("--no-overlay omits overlay and heatmap paths", () => {
const { code, stdout } = run([
"--actual", changedPath, "--png", designPath, "--out", dir, "--no-overlay", "--json",
]);
expect(code).toBe(0);
const metrics = JSON.parse(stdout);
expect(metrics.paths.overlay).toBeUndefined();
expect(metrics.paths.heatmap).toBeUndefined();
});

test("--fail-under exits 1 when the match is below the bar", () => {
const { code } = run([
"--actual", changedPath, "--png", designPath, "--out", dir, "--json", "--fail-under", "100",
]);
expect(code).toBe(1);
});

test("--ignore masks a region so the diff clears", () => {
const { code, stdout } = run([
"--actual", changedPath, "--png", designPath, "--out", dir, "--json",
"--ignore", "0,0,4,4",
]);
expect(code).toBe(0);
const metrics = JSON.parse(stdout);
expect(metrics.matchPercent).toBe(100);
});

test("a flag with a missing value fails clearly instead of swallowing the next flag", () => {
const { code, stderr } = run(["--actual", samePath, "--png", "--json", "--out", dir]);
expect(code).toBe(1);
expect(stderr).toContain("--png expects a value");
});

test("supports --flag=value syntax", () => {
const { code, stdout } = run([
"--actual=" + samePath, "--png=" + designPath, "--out=" + dir, "--json",
]);
expect(code).toBe(0);
expect(JSON.parse(stdout).matchPercent).toBe(100);
});
});
38 changes: 36 additions & 2 deletions test/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterAll, describe, expect, test } from "bun:test";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { PNG } from "pngjs";
import { designDiff } from "../src/core.ts";
import { designDiff, metricsOf } from "../src/core.ts";

const dir = join(".design-diff", "test-core");
mkdirSync(dir, { recursive: true });
Expand Down Expand Up @@ -114,4 +114,38 @@ describe("designDiff image-vs-image mode", () => {
});
expect(noDiff.paths.annotated).toBeUndefined();
});

test("writeOverlay:false suppresses both overlay.html and heatmap.png", async () => {
const res = await designDiff({
actual: changedPath,
design: designPath,
outDir: dir,
writeOverlay: false,
});
expect(res.paths.overlay).toBeUndefined();
expect(res.paths.heatmap).toBeUndefined();
});

test("heatmap is written by default alongside the overlay", async () => {
const res = await designDiff({
actual: changedPath,
design: designPath,
outDir: dir,
});
expect(res.paths.overlay).toBeDefined();
expect(res.paths.heatmap).toBeDefined();
expect(existsSync(res.paths.heatmap!)).toBe(true);
});

test("metrics include the artifact paths for scripting", async () => {
const res = await designDiff({
actual: changedPath,
design: designPath,
outDir: dir,
writeOverlay: false,
});
const metrics = metricsOf(res);
expect(metrics.paths).toEqual(res.paths);
expect(metrics.paths.metrics).toContain("metrics.json");
});
});
Loading
Loading