diff --git a/README.md b/README.md index dcd7d64..62a14ec 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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"; diff --git a/index.ts b/index.ts index 2876758..0df556d 100644 --- a/index.ts +++ b/index.ts @@ -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": @@ -84,20 +101,20 @@ 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); } } @@ -105,11 +122,6 @@ function parseArgs(argv: string[]): Args { 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")); @@ -191,6 +203,7 @@ async function run(): Promise { 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}`); diff --git a/package.json b/package.json index 32bacb9..c8133bc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core.ts b/src/core.ts index ce14948..e3fe915 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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; @@ -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; @@ -90,7 +91,9 @@ export async function designDiff(opts: DesignDiffOptions): Promise { +interface FigmaNodesResponse { + nodes?: Record; +} + +interface FigmaImagesResponse { + err?: string | null; + images?: Record; +} + +// 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(path: string, token: string): Promise { 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) { @@ -24,7 +45,7 @@ async function figmaGet(path: string, token: string): Promise { 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))); } } @@ -45,7 +66,7 @@ export async function exportDesignFrame( const token = getFigmaToken(); const id = normalizeId(frameNodeId); - const data = await figmaGet( + const data = await figmaGet( `/files/${encodeURIComponent(fileKey)}/nodes?ids=${encodeURIComponent(id)}`, token ); @@ -68,18 +89,26 @@ async function exportNodePng( token: string ): Promise { for (let attempt = 0; ; attempt++) { - const data = await figmaGet( + const data = await figmaGet( `/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 { + 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))); + } +} diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..0698388 --- /dev/null +++ b/test/cli.test.ts @@ -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); + }); +}); diff --git a/test/core.test.ts b/test/core.test.ts index 600a900..3102286 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -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 }); @@ -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"); + }); }); diff --git a/test/figma.test.ts b/test/figma.test.ts new file mode 100644 index 0000000..0ed7e6d --- /dev/null +++ b/test/figma.test.ts @@ -0,0 +1,91 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { PNG } from "pngjs"; +import { exportDesignFrame } from "../src/fetch/figma.ts"; + +const NODE_ID = "10:2"; +const FRAME_ID = "10-2"; +const IMG_URL = "https://figma-exports.example/img.png"; + +const dir = join(".design-diff", "test-figma"); +mkdirSync(dir, { recursive: true }); +const pngBytes = PNG.sync.write(new PNG({ width: 4, height: 4 })); +const originalFetch = globalThis.fetch; +const originalToken = process.env.DESIGN_DIFF_FIGMA_TOKEN; +process.env.DESIGN_DIFF_FIGMA_TOKEN = "test-token"; + +function json(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function happyDispatcher(url: string): Response { + if (url.includes("/files/")) { + return json({ nodes: { [NODE_ID]: { document: { id: NODE_ID, absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 200 } } } } }); + } + if (url.includes("/images/")) { + return json({ images: { [NODE_ID]: IMG_URL } }); + } + return new Response(new Uint8Array(pngBytes), { status: 200 }); +} + +describe("exportDesignFrame", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + if (originalToken === undefined) delete process.env.DESIGN_DIFF_FIGMA_TOKEN; + else process.env.DESIGN_DIFF_FIGMA_TOKEN = originalToken; + }); + + test("returns the frame box and writes the exported png", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => + happyDispatcher(String(input))) as unknown as typeof fetch; + + const out = join(dir, "design.png"); + const res = await exportDesignFrame("KEY", FRAME_ID, 1, out); + expect(res.box).toEqual({ x: 0, y: 0, w: 400, h: 200 }); + const png = PNG.sync.read(readFileSync(out)); + expect(png.width).toBe(4); + }); + + test("maps 404 to a clear file-not-found error", async () => { + globalThis.fetch = (async () => + new Response("not found", { status: 404, statusText: "Not Found" })) as unknown as typeof fetch; + await expect(exportDesignFrame("KEY", FRAME_ID, 1, join(dir, "x.png"))).rejects.toThrow( + /file not found/ + ); + }); + + test("maps 401/403 to an auth error", async () => { + globalThis.fetch = (async () => + new Response("nope", { status: 403, statusText: "Forbidden" })) as unknown as typeof fetch; + await expect(exportDesignFrame("KEY", FRAME_ID, 1, join(dir, "x.png"))).rejects.toThrow( + /invalid or lacks access/ + ); + }); + + test("retries on 429 honouring Retry-After, then succeeds", async () => { + let filesCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/files/")) { + filesCalls++; + if (filesCalls === 1) { + return new Response("slow down", { status: 429, headers: { "retry-after": "0" } }); + } + } + return happyDispatcher(url); + }) as unknown as typeof fetch; + + const res = await exportDesignFrame("KEY", FRAME_ID, 1, join(dir, "retry.png")); + expect(filesCalls).toBe(2); + expect(res.box.w).toBe(400); + }); +});