pursr 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pursr.mjs CHANGED
@@ -9,14 +9,15 @@ import { runShot } from "../src/shot.js";
9
9
  import { runShootWithSidecar } from "../src/shoot.js";
10
10
  import { runHover } from "../src/hover.js";
11
11
  import { runFrames } from "../src/frames.js";
12
- import { runDiff } from "../src/diff.js";
12
+ import { runDiff, runDiffWithAi } from "../src/diff.js";
13
13
  import { runSweep } from "../src/sweep.js";
14
14
  import { runEveryViewport } from "../src/every-viewport.js";
15
15
  import { runAudit } from "../src/plugin-audit.js";
16
16
  import { captureDomSnapshot } from "../src/dom-snapshot.js";
17
17
  import { listViewports } from "../src/viewport.js";
18
18
  import { parseFlags, asNum, readArg, makeOut, pickOutPath } from "../src/util.js";
19
- import { writeFileSync } from "node:fs";
19
+ import { writeFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { dirname } from "node:path";
20
21
  import { readFileSync as _readFileSync } from "node:fs";
21
22
  const readFile = _readFileSync;
22
23
  import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
@@ -31,6 +32,8 @@ const USAGE = `usage:
31
32
  --grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
32
33
  --no-animation --wait-frame 600 --full
33
34
  @file prefix reads argv contents from file (UTF-8, newline trimmed).
35
+ report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
36
+ diff extras: --ai [--ai-model M] [--ai-base-url U] [--ai-api-key K]
34
37
  plugins: pursor automatically loads built-in plugins from plugins/.
35
38
  You can also pass --plugin <path> to load custom plugins (repeatable).`;
36
39
 
@@ -42,6 +45,16 @@ function die(msg, code = 2) {
42
45
  const argv = process.argv;
43
46
  const [, , cmd, a, b, c, d] = argv;
44
47
  const url = process.env.PURSOR_URL || a;
48
+ // Top-level --plan / --out parsing for subcommands that need it before dispatch
49
+ function _topOpts() {
50
+ const o = {};
51
+ for (let i = 2; i < argv.length; i++) {
52
+ if (argv[i] === "--plan" && i + 1 < argv.length) o.plan = argv[++i];
53
+ if (argv[i] === "--out" && i + 1 < argv.length) o.out = argv[++i];
54
+ }
55
+ return o;
56
+ }
57
+ const opts = _topOpts();
45
58
 
46
59
  // Plugin loading: scan for --plugin <path> and built-in plugins/
47
60
  const pluginPaths = [];
@@ -63,7 +76,19 @@ await loadPlugins(pluginPaths);
63
76
  case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = c || makeOut(`click-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runClick(url, sel, out); console.log(JSON.stringify(r, null, 2)); break; }
64
77
  case "type": { if (!url) die("missing url"); const sel = b; const text = readArg(c); if (!sel || text === undefined) die("type: missing <selector> or <text> (text can be @file)"); const out = d || makeOut(`type-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runType(url, sel, text, out); console.log(JSON.stringify(r, null, 2)); break; }
65
78
  case "wait": { if (!url) die("missing url"); const sel = b; if (!sel) die("wait: missing <selector>"); const t = c !== undefined ? asNum(c, 30000) : 30000; const r = await runWait(url, sel, t); console.log(JSON.stringify(r, null, 2)); break; }
66
- case "diff": { if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>"); const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1; const r = await runDiff(url, ref, out, threshold); console.log(JSON.stringify(r, null, 2)); break; }
79
+ case "diff": {
80
+ if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
81
+ const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1;
82
+ // --ai / --ai-model / --ai-base-url / --ai-api-key
83
+ const useAi = argv.includes("--ai");
84
+ const aiModel = (() => { const i = argv.indexOf("--ai-model"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
85
+ const aiBaseUrl = (() => { const i = argv.indexOf("--ai-base-url"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
86
+ const aiApiKey = (() => { const i = argv.indexOf("--ai-api-key"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
87
+ const r = useAi
88
+ ? await runDiffWithAi(url, ref, out, threshold, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
89
+ : await runDiff(url, ref, out, threshold);
90
+ console.log(JSON.stringify(r, null, 2)); break;
91
+ }
67
92
  case "seq": { if (!url) die("missing url"); const actions = readArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = c || makeOut("seq.png"); const r = await runSeq(url, actions, out); console.log(JSON.stringify(r, null, 2)); break; }
68
93
  case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
69
94
  case "shoot": {
@@ -107,6 +132,24 @@ await loadPlugins(pluginPaths);
107
132
  console.log(JSON.stringify(r, null, 2));
108
133
  break;
109
134
  }
135
+ case "report": {
136
+ // pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
137
+ const sweepIdx = argv.indexOf("--sweep");
138
+ const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
139
+ if (!sweepPath) die("report: missing --sweep <sweep.json>");
140
+ if (!existsSync(sweepPath)) die("report: sweep not found: " + sweepPath);
141
+ const outIdx = argv.indexOf("--out");
142
+ const outPath = outIdx >= 0 && outIdx + 1 < argv.length ? argv[outIdx + 1] : (opts.out || makeOut("report.pdf").replace(/pursor-[^-]+-shot.png$/, "report.pdf"));
143
+ if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
144
+ const titleIdx = argv.indexOf("--title");
145
+ const title = titleIdx >= 0 && titleIdx + 1 < argv.length ? argv[titleIdx + 1] : undefined;
146
+ const noEmbed = argv.includes("--no-embed");
147
+ const summary = JSON.parse(readFile(sweepPath, "utf8"));
148
+ const { renderSweepPdf } = await import("../src/report.js");
149
+ const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
150
+ console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
151
+ break;
152
+ }
110
153
  case "every-viewport": {
111
154
  if (!url) die("missing url");
112
155
  const outDir = (b && !b.startsWith("--")) ? b : undefined;
@@ -218,6 +261,62 @@ await loadPlugins(pluginPaths);
218
261
  }
219
262
  break;
220
263
  }
264
+ case "watch": {
265
+ // pursr watch <url> [--out ./shot.png] [--on <glob>...] [--plan <plan.json>]
266
+ if (opts.plan) {
267
+ if (!existsSync(opts.plan)) die("watch: plan not found: " + opts.plan);
268
+ } else if (!url) {
269
+ die("watch: missing <url> (or use --plan <plan.json>)");
270
+ }
271
+ const { startWatch } = await import("../src/watch.js");
272
+ const out = (b && !b.startsWith("--")) ? b : (opts.out || makeOut("watch.png"));
273
+ if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
274
+ const flags = parseFlags(argv.slice(3));
275
+ const onGlobs = [];
276
+ for (let i = 0; i < argv.length; i++) {
277
+ if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
278
+ }
279
+ console.error(JSON.stringify({ watching: true, url: opts.plan ? null : url, plan: opts.plan || null, out, on: onGlobs }));
280
+ const w = await startWatch({
281
+ url: opts.plan ? undefined : url,
282
+ out,
283
+ plan: opts.plan,
284
+ on: onGlobs,
285
+ flags,
286
+ verbose: true,
287
+ onChange: (e) => console.error(JSON.stringify({ event: e.type, path: e.path, captureOk: !e.capture?.error, captureOut: e.capture?.out, ts: e.ts })),
288
+ });
289
+ // Keep alive until SIGINT
290
+ await new Promise((resolve) => {
291
+ process.on("SIGINT", () => { console.error("[pursr watch] stopping..."); w.close().then(resolve); });
292
+ process.on("SIGTERM", () => { w.close().then(resolve); });
293
+ });
294
+ console.log(JSON.stringify({ fires: w.fires() }, null, 2));
295
+ break;
296
+ }
297
+ case "snap": {
298
+ // pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
299
+ if (!url) die("snap: missing <url>");
300
+ const sel = b; if (!sel) die("snap: missing <selector>");
301
+ const flags = parseFlags(argv.slice(4));
302
+ const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
303
+ const outDir = flags.out || makeOut("snaps").replace(/pursor-[^-]+-snap\.png$/, "snaps");
304
+ const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
305
+ console.log(JSON.stringify({
306
+ url: snap.url,
307
+ selector: snap.selector,
308
+ count: snap.count,
309
+ captured: snap.captured,
310
+ outDir: snap.outDir,
311
+ captures: snap.captures,
312
+ nav: snap.nav,
313
+ }, null, 2));
314
+ if (flags.baseline) {
315
+ const r = approveSnapsAsBaselines({ project: flags.baseline, snapResult: snap });
316
+ console.error(JSON.stringify({ approved: r.length, project: flags.baseline }));
317
+ }
318
+ break;
319
+ }
221
320
  default: { die(`unknown subcommand: ${cmd}`); }
222
321
  }
223
322
  } catch (e) {
package/package.json CHANGED
@@ -1,90 +1,95 @@
1
- {
2
- "name": "pursr",
3
- "version": "0.4.0",
4
- "private": false,
5
- "description": "Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
6
- "homepage": "https://github.com/0xheycat/pursr",
7
- "bugs": "https://github.com/0xheycat/pursr/issues",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/0xheycat/pursr.git"
11
- },
12
- "funding": "https://github.com/sponsors/0xheycat",
13
- "type": "module",
14
- "bin": {
15
- "pursr": "./bin/pursr.mjs",
16
- "pursr-mcp": "./bin/pursr-mcp.mjs"
17
- },
18
- "main": "./src/index.js",
19
- "exports": {
20
- ".": "./src/index.js",
21
- "./plugin": "./src/plugin.js",
22
- "./plugins/*": "./plugins/*.js",
23
- "./util": "./src/util.js",
24
- "./runway": "./src/runway.js",
25
- "./selector": "./src/selector.js",
26
- "./overlays": "./src/overlays.js",
27
- "./viewport": "./src/viewport.js",
28
- "./dom-snapshot": "./src/dom-snapshot.js",
29
- "./plugin-audit": "./src/plugin-audit.js",
30
- "./selector-heal": "./src/selector-heal.js",
31
- "./ci-output": "./src/ci-output.js",
32
- "./mcp": "./src/mcp.js",
33
- "./baseline": "./src/baseline.js",
34
- "./sweep-schema": "./src/sweep-schema.js",
35
- "./mcp-resources": "./src/mcp-resources.js",
36
- "./har": "./src/har.js",
37
- "./auth": "./src/auth.js"
38
- },
39
- "files": [
40
- "bin",
41
- "src",
42
- "plugins",
43
- "plans",
44
- "assets",
45
- "README.md",
46
- "LICENSE"
47
- ],
48
- "scripts": {
49
- "start": "node bin/pursor.mjs",
50
- "test": "node --test \"test/*.test.js\"",
51
- "smoke": "node bin/pursor.mjs viewports"
52
- },
53
- "engines": {
54
- "node": ">=18"
55
- },
56
- "keywords": [
57
- "visual-qa",
58
- "visual-regression",
59
- "screenshot",
60
- "audit",
61
- "accessibility",
62
- "axe-core",
63
- "playwright",
64
- "mcp",
65
- "model-context-protocol",
66
- "baseline",
67
- "diff",
68
- "har",
69
- "testing",
70
- "devtools",
71
- "pursr"
72
- ],
73
- "license": "MIT",
74
- "dependencies": {
75
- "axe-core": "^4.12.1",
76
- "pixelmatch": "^5.3.0",
77
- "pngjs": "^7.0.0"
78
- },
79
- "peerDependencies": {
80
- "playwright-core": "*"
81
- },
82
- "peerDependenciesMeta": {
83
- "playwright-core": {
84
- "optional": true
85
- }
86
- },
87
- "devDependencies": {
88
- "playwright-core": "^1.61.0"
89
- }
90
- }
1
+ {
2
+ "name": "pursr",
3
+ "version": "0.6.0",
4
+ "private": false,
5
+ "description": "Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
6
+ "homepage": "https://github.com/0xheycat/pursr",
7
+ "bugs": "https://github.com/0xheycat/pursr/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/0xheycat/pursr.git"
11
+ },
12
+ "funding": "https://github.com/sponsors/0xheycat",
13
+ "type": "module",
14
+ "bin": {
15
+ "pursr": "./bin/pursr.mjs",
16
+ "pursr-mcp": "./bin/pursr-mcp.mjs"
17
+ },
18
+ "main": "./src/index.js",
19
+ "exports": {
20
+ ".": "./src/index.js",
21
+ "./plugin": "./src/plugin.js",
22
+ "./plugins/*": "./plugins/*.js",
23
+ "./util": "./src/util.js",
24
+ "./runway": "./src/runway.js",
25
+ "./selector": "./src/selector.js",
26
+ "./overlays": "./src/overlays.js",
27
+ "./viewport": "./src/viewport.js",
28
+ "./dom-snapshot": "./src/dom-snapshot.js",
29
+ "./plugin-audit": "./src/plugin-audit.js",
30
+ "./selector-heal": "./src/selector-heal.js",
31
+ "./ci-output": "./src/ci-output.js",
32
+ "./mcp": "./src/mcp.js",
33
+ "./baseline": "./src/baseline.js",
34
+ "./sweep-schema": "./src/sweep-schema.js",
35
+ "./mcp-resources": "./src/mcp-resources.js",
36
+ "./har": "./src/har.js",
37
+ "./auth": "./src/auth.js",
38
+ "./watch": "./src/watch.js",
39
+ "./snap": "./src/snap.js",
40
+ "./report": "./src/report.js",
41
+ "./ai-diff": "./src/ai-diff.js"
42
+ },
43
+ "files": [
44
+ "bin",
45
+ "src",
46
+ "plugins",
47
+ "plans",
48
+ "assets",
49
+ "README.md",
50
+ "LICENSE"
51
+ ],
52
+ "scripts": {
53
+ "start": "node bin/pursor.mjs",
54
+ "test": "node --test \"test/*.test.js\"",
55
+ "smoke": "node bin/pursor.mjs viewports"
56
+ },
57
+ "engines": {
58
+ "node": ">=18"
59
+ },
60
+ "keywords": [
61
+ "visual-qa",
62
+ "visual-regression",
63
+ "screenshot",
64
+ "audit",
65
+ "accessibility",
66
+ "axe-core",
67
+ "playwright",
68
+ "mcp",
69
+ "model-context-protocol",
70
+ "baseline",
71
+ "diff",
72
+ "har",
73
+ "testing",
74
+ "devtools",
75
+ "pursr"
76
+ ],
77
+ "license": "MIT",
78
+ "dependencies": {
79
+ "axe-core": "^4.12.1",
80
+ "pdfkit": "^0.19.1",
81
+ "pixelmatch": "^5.3.0",
82
+ "pngjs": "^7.0.0"
83
+ },
84
+ "peerDependencies": {
85
+ "playwright-core": "*"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "playwright-core": {
89
+ "optional": true
90
+ }
91
+ },
92
+ "devDependencies": {
93
+ "playwright-core": "^1.61.0"
94
+ }
95
+ }
package/src/ai-diff.js ADDED
@@ -0,0 +1,124 @@
1
+ // pursor - AI diff summary.
2
+ //
3
+ // Sends two images (reference + current) to a vision-capable LLM and asks
4
+ // it to describe the visual differences in plain language. This gives you
5
+ // a human-readable summary alongside the pixel-diff percentage.
6
+ //
7
+ // Supports any OpenAI-compatible chat completions endpoint that accepts
8
+ // image_url content parts (OpenAI, Anthropic via proxy, local llama.cpp,
9
+ // Codex tokenrouter, etc).
10
+ //
11
+ // CLI:
12
+ // pursr diff <url> <ref.png> <out.png> --ai
13
+ // pursr diff <url> <ref.png> <out.png> --ai-model gh/gpt-5.4
14
+ //
15
+ // Library:
16
+ // import { aiDiffSummary } from "pursr/ai-diff";
17
+ // const summary = await aiDiffSummary({ refPath, curPath, url, model });
18
+
19
+ import { readFileSync, existsSync } from "node:fs";
20
+
21
+ // Read env at call time so tests can mutate process.env between calls.
22
+ function _defaultBase() { return process.env.PURSOR_AI_BASE_URL || process.env.ANTHROPIC_BASE_URL || "https://api.openai.com/v1"; }
23
+ function _defaultKey() { return process.env.PURSOR_AI_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.OPENAI_API_KEY; }
24
+ function _defaultModel(){ return process.env.PURSOR_AI_MODEL || process.env.ANTHROPIC_DEFAULT_SONNET_MODEL || "gpt-4o"; }
25
+
26
+ const SYSTEM_PROMPT = `You are a visual regression analyst. Given two screenshots of the same web page (reference vs current), produce a concise, structured report of the visual differences.
27
+
28
+ Output format (markdown, keep under 250 words):
29
+
30
+ **Overall:** one sentence verdict (looks identical / minor changes / major regression).
31
+ **Layout shifts:** list any element that moved, resized, or appeared/disappeared.
32
+ **Color / style:** any color, font, or spacing changes.
33
+ **Content:** new, removed, or changed text/imagery.
34
+ **Likely cause:** best guess at what code or content change caused this.
35
+
36
+ Be specific (mention element labels, regions). Be honest about uncertainty.`;
37
+
38
+ /**
39
+ * Send reference + current PNGs to a vision model and return a textual diff summary.
40
+ *
41
+ * @param {object} opts
42
+ * @param {string} opts.refPath - Path to reference PNG
43
+ * @param {string} opts.curPath - Path to current PNG
44
+ * @param {string} [opts.url] - URL that was captured (for context)
45
+ * @param {string} [opts.model] - Model id (default: gpt-4o)
46
+ * @param {string} [opts.baseUrl] - OpenAI-compatible base URL
47
+ * @param {string} [opts.apiKey] - API key
48
+ * @param {number} [opts.maxTokens=600]
49
+ * @returns {Promise<{ summary: string, model: string, elapsedMs: number, usage?: object }>}
50
+ */
51
+ export async function aiDiffSummary(opts) {
52
+ if (!opts.refPath || !opts.curPath) throw new Error("aiDiffSummary: refPath and curPath required");
53
+ if (!existsSync(opts.refPath)) throw new Error(`aiDiffSummary: ref not found: ${opts.refPath}`);
54
+ if (!existsSync(opts.curPath)) throw new Error(`aiDiffSummary: cur not found: ${opts.curPath}`);
55
+
56
+ const baseUrl = (opts.baseUrl || _defaultBase()).replace(/\/+$/, "");
57
+ const apiKey = opts.apiKey || _defaultKey();
58
+ const model = opts.model || _defaultModel();
59
+ if (!apiKey) {
60
+ throw new Error("aiDiffSummary: no API key. Set PURSOR_AI_API_KEY, ANTHROPIC_AUTH_TOKEN, or OPENAI_API_KEY.");
61
+ }
62
+
63
+ const refB64 = readFileSync(opts.refPath).toString("base64");
64
+ const curB64 = readFileSync(opts.curPath).toString("base64");
65
+ const userText = opts.url
66
+ ? `URL: ${opts.url}\n\nCompare these two screenshots of the same page (reference first, current second). Describe the visual differences.`
67
+ : `Compare these two screenshots (reference first, current second). Describe the visual differences.`;
68
+
69
+ const body = {
70
+ model,
71
+ max_tokens: opts.maxTokens || 600,
72
+ temperature: 0.2,
73
+ messages: [
74
+ { role: "system", content: SYSTEM_PROMPT },
75
+ {
76
+ role: "user",
77
+ content: [
78
+ { type: "text", text: "REFERENCE:" },
79
+ { type: "image_url", image_url: { url: `data:image/png;base64,${refB64}` } },
80
+ { type: "text", text: "CURRENT:" },
81
+ { type: "image_url", image_url: { url: `data:image/png;base64,${curB64}` } },
82
+ { type: "text", text: userText },
83
+ ],
84
+ },
85
+ ],
86
+ };
87
+
88
+ const t0 = Date.now();
89
+ const res = await fetch(`${baseUrl}/chat/completions`, {
90
+ method: "POST",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Authorization: `Bearer ${apiKey}`,
94
+ },
95
+ body: JSON.stringify(body),
96
+ });
97
+
98
+ if (!res.ok) {
99
+ const errText = await res.text().catch(() => "");
100
+ throw new Error(`aiDiffSummary: ${res.status} ${res.statusText} - ${errText.slice(0, 300)}`);
101
+ }
102
+ const data = await res.json();
103
+ const summary = data.choices?.[0]?.message?.content?.trim() || "(empty response)";
104
+ return {
105
+ summary,
106
+ model,
107
+ elapsedMs: Date.now() - t0,
108
+ usage: data.usage,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Compare two PNGs and return a JSON-friendly object suitable for embedding
114
+ * in a sweep step's meta sidecar.
115
+ */
116
+ export async function aiDiffSidecar(opts) {
117
+ const r = await aiDiffSummary(opts);
118
+ return {
119
+ aiSummary: r.summary,
120
+ aiModel: r.model,
121
+ aiElapsedMs: r.elapsedMs,
122
+ aiAt: new Date().toISOString(),
123
+ };
124
+ }
package/src/diff.js CHANGED
@@ -1,48 +1,76 @@
1
- // Pixelmatch diff against a reference PNG.
2
-
3
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import { launch, newPage } from "./runway.js";
6
- import { DEFAULT_VIEWPORT } from "./viewport.js";
7
- import { gotoOrThrow, settle } from "./overlays.js";
8
- import { requireArg } from "./util.js";
9
-
10
- const DIFF_DEFAULT_THRESHOLD = 0.1;
11
-
12
- async function loadPngjs() {
13
- try { return (await import("pngjs")).PNG; }
14
- catch { throw new Error("pngjs not found. Install: npm i pngjs"); }
15
- }
16
-
17
- async function loadPixelmatch() {
18
- try { return (await import("pixelmatch")).default; }
19
- catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
20
- }
21
-
22
- export async function runDiff(url, refPath, out, threshold, browser) {
23
- requireArg("url", url, "string");
24
- requireArg("refPath", refPath, "string");
25
- const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
26
- if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
27
- const PNG = await loadPngjs();
28
- const pixelmatch = await loadPixelmatch();
29
- const ownBrowser = !browser;
30
- browser = browser || await launch();
31
- try {
32
- const page = await newPage(browser, DEFAULT_VIEWPORT);
33
- const r = await gotoOrThrow(page, url); await settle(page);
34
- const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
35
- await page.screenshot({ path: currentPath, fullPage: false });
36
- const refPng = PNG.sync.read(readFileSync(refPath));
37
- const curPng = PNG.sync.read(readFileSync(currentPath));
38
- if (refPng.width !== curPng.width || refPng.height !== curPng.height) {
39
- return { ...r, url, refPath, currentPath, error: "size mismatch", refSize: { w: refPng.width, h: refPng.height }, currentSize: { w: curPng.width, h: curPng.height } };
40
- }
41
- const diffPng = new PNG({ width: refPng.width, height: refPng.height });
42
- const numDiff = pixelmatch(refPng.data, curPng.data, diffPng.data, refPng.width, refPng.height, { threshold: t });
43
- const totalPx = refPng.width * refPng.height;
44
- const diffPct = (numDiff / totalPx) * 100;
45
- if (out) writeFileSync(out, PNG.sync.write(diffPng));
46
- return { ...r, url, refPath, currentPath, out: out || null, threshold: t, refSize: { w: refPng.width, h: refPng.height }, totalPx, numDiff, diffPct: Number(diffPct.toFixed(3)), equal: numDiff === 0 };
47
- } finally { if (ownBrowser) try { await browser.close(); } catch {} }
48
- }
1
+ // Pixelmatch diff against a reference PNG.
2
+
3
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { launch, newPage } from "./runway.js";
6
+ import { DEFAULT_VIEWPORT } from "./viewport.js";
7
+ import { gotoOrThrow, settle } from "./overlays.js";
8
+ import { requireArg } from "./util.js";
9
+ import { aiDiffSidecar } from "./ai-diff.js";
10
+
11
+ const DIFF_DEFAULT_THRESHOLD = 0.1;
12
+
13
+ async function loadPngjs() {
14
+ try { return (await import("pngjs")).PNG; }
15
+ catch { throw new Error("pngjs not found. Install: npm i pngjs"); }
16
+ }
17
+
18
+ async function loadPixelmatch() {
19
+ try { return (await import("pixelmatch")).default; }
20
+ catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
21
+ }
22
+
23
+ export async function runDiff(url, refPath, out, threshold, browser) {
24
+ requireArg("url", url, "string");
25
+ requireArg("refPath", refPath, "string");
26
+ const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
27
+ if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
28
+ const PNG = await loadPngjs();
29
+ const pixelmatch = await loadPixelmatch();
30
+ const ownBrowser = !browser;
31
+ browser = browser || await launch();
32
+ try {
33
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
34
+ const r = await gotoOrThrow(page, url); await settle(page);
35
+ const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
36
+ await page.screenshot({ path: currentPath, fullPage: false });
37
+ const refPng = PNG.sync.read(readFileSync(refPath));
38
+ const curPng = PNG.sync.read(readFileSync(currentPath));
39
+ if (refPng.width !== curPng.width || refPng.height !== curPng.height) {
40
+ return { ...r, url, refPath, currentPath, error: "size mismatch", refSize: { w: refPng.width, h: refPng.height }, currentSize: { w: curPng.width, h: curPng.height } };
41
+ }
42
+ const diffPng = new PNG({ width: refPng.width, height: refPng.height });
43
+ const numDiff = pixelmatch(refPng.data, curPng.data, diffPng.data, refPng.width, refPng.height, { threshold: t });
44
+ const totalPx = refPng.width * refPng.height;
45
+ const diffPct = (numDiff / totalPx) * 100;
46
+ if (out) writeFileSync(out, PNG.sync.write(diffPng));
47
+ return { ...r, url, refPath, currentPath, out: out || null, threshold: t, refSize: { w: refPng.width, h: refPng.height }, totalPx, numDiff, diffPct: Number(diffPct.toFixed(3)), equal: numDiff === 0 };
48
+ } finally { if (ownBrowser) try { await browser.close(); } catch {} }
49
+ }
50
+
51
+ /**
52
+ * Like runDiff, but additionally calls a vision LLM to produce a human-readable
53
+ * summary of the visual differences. The AI summary is written to <out>.ai.json
54
+ * and also returned on the result object.
55
+ */
56
+ export async function runDiffWithAi(url, refPath, out, threshold, aiOpts, browser) {
57
+ const r = await runDiff(url, refPath, out, threshold, browser);
58
+ if (r.error) return r;
59
+ try {
60
+ const curPath = r.currentPath;
61
+ const sidecar = await aiDiffSidecar({
62
+ refPath, curPath, url,
63
+ model: aiOpts && aiOpts.model,
64
+ baseUrl: aiOpts && aiOpts.baseUrl,
65
+ apiKey: aiOpts && aiOpts.apiKey,
66
+ maxTokens: aiOpts && aiOpts.maxTokens,
67
+ });
68
+ r.ai = sidecar;
69
+ const sidecarPath = (out || curPath).replace(/.png$/i, "") + ".ai.json";
70
+ fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), "utf8");
71
+ r.aiFile = sidecarPath;
72
+ } catch (e) {
73
+ r.ai = { error: e.message };
74
+ }
75
+ return r;
76
+ }
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import { runSweep } from "./sweep.js";
17
17
  import { runEveryViewport } from "./every-viewport.js";
18
18
  import { runFrames } from "./frames.js";
19
19
  import { runHover } from "./hover.js";
20
- import { runDiff } from "./diff.js";
20
+ import { runDiff, runDiffWithAi } from "./diff.js";
21
21
  import { runProbe } from "./probe.js";
22
22
  import { runShot } from "./shot.js";
23
23
  import { runEval } from "./eval.js";
@@ -39,6 +39,10 @@ import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
39
39
  import { listResources, readResource, recordResource } from "./mcp-resources.js";
40
40
  import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
41
41
  import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
42
+ import { startWatch, matchGlob, shouldFire } from "./watch.js";
43
+ import { runSnap, approveSnapsAsBaselines } from "./snap.js";
44
+ import { renderSweepPdf } from "./report.js";
45
+ import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
42
46
 
43
47
 
44
48
  // Derive VERSION from package.json to prevent drift
@@ -72,6 +76,13 @@ export {
72
76
  // v5: HAR capture, auth state, parallel sweep
73
77
  startHarCapture, stopHarCapture, writeHar,
74
78
  saveAuthState, loadAuthState, listAuthStates, deleteAuthState,
79
+ // v6: watch mode, component snapshot
80
+ startWatch, matchGlob, shouldFire,
81
+ runSnap, approveSnapsAsBaselines,
82
+ // v6: PDF report, AI diff summary
83
+ runDiffWithAi,
84
+ renderSweepPdf,
85
+ aiDiffSummary, aiDiffSidecar,
75
86
  VERSION,
76
87
  };
77
88
 
@@ -91,5 +102,7 @@ export default {
91
102
  saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
92
103
  validateSweepPlan, registerSweepOp,
93
104
  listResources, readResource, recordResource,
105
+ // v6: PDF report, AI diff summary
106
+ runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
94
107
  VERSION,
95
108
  };