pursr 0.5.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/README.md +549 -471
- package/bin/pursr.mjs +34 -2
- package/package.json +95 -92
- package/src/ai-diff.js +124 -0
- package/src/diff.js +76 -48
- package/src/index.js +9 -1
- package/src/report.js +176 -0
package/bin/pursr.mjs
CHANGED
|
@@ -9,7 +9,7 @@ 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";
|
|
@@ -32,6 +32,8 @@ const USAGE = `usage:
|
|
|
32
32
|
--grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
|
|
33
33
|
--no-animation --wait-frame 600 --full
|
|
34
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]
|
|
35
37
|
plugins: pursor automatically loads built-in plugins from plugins/.
|
|
36
38
|
You can also pass --plugin <path> to load custom plugins (repeatable).`;
|
|
37
39
|
|
|
@@ -74,7 +76,19 @@ await loadPlugins(pluginPaths);
|
|
|
74
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; }
|
|
75
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; }
|
|
76
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; }
|
|
77
|
-
case "diff": {
|
|
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
|
+
}
|
|
78
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; }
|
|
79
93
|
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
80
94
|
case "shoot": {
|
|
@@ -118,6 +132,24 @@ await loadPlugins(pluginPaths);
|
|
|
118
132
|
console.log(JSON.stringify(r, null, 2));
|
|
119
133
|
break;
|
|
120
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
|
+
}
|
|
121
153
|
case "every-viewport": {
|
|
122
154
|
if (!url) die("missing url");
|
|
123
155
|
const outDir = (b && !b.startsWith("--")) ? b : undefined;
|
package/package.json
CHANGED
|
@@ -1,92 +1,95 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pursr",
|
|
3
|
-
"version": "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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"
|
|
83
|
-
},
|
|
84
|
-
"
|
|
85
|
-
"playwright-core":
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
requireArg("
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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";
|
|
@@ -41,6 +41,8 @@ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
|
|
|
41
41
|
import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
|
|
42
42
|
import { startWatch, matchGlob, shouldFire } from "./watch.js";
|
|
43
43
|
import { runSnap, approveSnapsAsBaselines } from "./snap.js";
|
|
44
|
+
import { renderSweepPdf } from "./report.js";
|
|
45
|
+
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
// Derive VERSION from package.json to prevent drift
|
|
@@ -77,6 +79,10 @@ export {
|
|
|
77
79
|
// v6: watch mode, component snapshot
|
|
78
80
|
startWatch, matchGlob, shouldFire,
|
|
79
81
|
runSnap, approveSnapsAsBaselines,
|
|
82
|
+
// v6: PDF report, AI diff summary
|
|
83
|
+
runDiffWithAi,
|
|
84
|
+
renderSweepPdf,
|
|
85
|
+
aiDiffSummary, aiDiffSidecar,
|
|
80
86
|
VERSION,
|
|
81
87
|
};
|
|
82
88
|
|
|
@@ -96,5 +102,7 @@ export default {
|
|
|
96
102
|
saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
|
|
97
103
|
validateSweepPlan, registerSweepOp,
|
|
98
104
|
listResources, readResource, recordResource,
|
|
105
|
+
// v6: PDF report, AI diff summary
|
|
106
|
+
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
99
107
|
VERSION,
|
|
100
108
|
};
|