pursr 0.7.1 → 0.7.2
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 +18 -0
- package/package.json +1 -1
- package/src/baseline.js +2 -1
- package/src/check.js +77 -0
- package/src/index.js +3 -0
- package/src/mcp.js +67 -7
package/bin/pursr.mjs
CHANGED
|
@@ -205,6 +205,12 @@ await loadPlugins(pluginPaths);
|
|
|
205
205
|
let meta = null;
|
|
206
206
|
if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
|
|
207
207
|
else if (flags.url) meta = { url: flags.url };
|
|
208
|
+
else {
|
|
209
|
+
const sidecar = png.replace(/\.png$/i, ".json");
|
|
210
|
+
if (existsSync(sidecar)) {
|
|
211
|
+
try { meta = JSON.parse(readFile(sidecar, "utf8")); } catch { meta = null; }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
208
214
|
const id = flags.id || diffKey({ url: meta?.url || "", viewport: meta?.viewport, flags: meta?.flags || {} });
|
|
209
215
|
const result = saveBaseline({ project, id, step, png, meta });
|
|
210
216
|
console.log(JSON.stringify({ saved: true, ...result }, null, 2));
|
|
@@ -318,6 +324,18 @@ await loadPlugins(pluginPaths);
|
|
|
318
324
|
}
|
|
319
325
|
break;
|
|
320
326
|
}
|
|
327
|
+
case "check": {
|
|
328
|
+
// pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
|
|
329
|
+
if (!url) die("check: missing <url>");
|
|
330
|
+
const flags = parseFlags(argv.slice(4));
|
|
331
|
+
const update = !!flags.update;
|
|
332
|
+
const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
|
|
333
|
+
const { runCheck } = await import("../src/check.js");
|
|
334
|
+
const r = await runCheck({ url, flags, threshold, update, out: flags.out || null });
|
|
335
|
+
if (flags.json) console.log(JSON.stringify(r, null, 2));
|
|
336
|
+
else console.log(JSON.stringify(r, null, 2));
|
|
337
|
+
process.exit(r.exitCode || 0);
|
|
338
|
+
}
|
|
321
339
|
default: { die(`unknown subcommand: ${cmd}`); }
|
|
322
340
|
}
|
|
323
341
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "pursr — 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
6
|
"homepage": "https://github.com/0xheycat/pursr",
|
package/src/baseline.js
CHANGED
|
@@ -28,7 +28,8 @@ import { __PURSR_GET } from "./util.js";
|
|
|
28
28
|
|
|
29
29
|
function baseDir(project) {
|
|
30
30
|
const root = __PURSR_GET("PURSR_BASELINES_DIR") || join(homedir(), ".pursr", "baselines");
|
|
31
|
-
|
|
31
|
+
// Normalize: strip trailing slashes so "http://x/" and "http://x" map to the same folder.
|
|
32
|
+
const proj = String(project || "default").replace(/\/+$/, "").replace(/[^a-zA-Z0-9._-]+/g, "_") || "default";
|
|
32
33
|
return join(root, proj);
|
|
33
34
|
}
|
|
34
35
|
|
package/src/check.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// pursr — CI-friendly visual regression check.
|
|
2
|
+
//
|
|
3
|
+
// Renders a URL at a given (url + viewport + flags) and diffs it against the
|
|
4
|
+
// stored baseline (identified by the same diffKey). Exits 0 if equal, 1 if
|
|
5
|
+
// different, 2 if no baseline.
|
|
6
|
+
|
|
7
|
+
import { resolveViewport } from "./viewport.js";
|
|
8
|
+
import { diffKey, loadBaseline, approveBaseline } from "./baseline.js";
|
|
9
|
+
import { runDiff } from "./diff.js";
|
|
10
|
+
import { makeOut, nowIso } from "./util.js";
|
|
11
|
+
|
|
12
|
+
export async function runCheck({ url, flags = {}, project = null, threshold = 0.1, update = false, out = null, baselineStep = "default" }) {
|
|
13
|
+
if (!url) throw new Error("runCheck: missing url");
|
|
14
|
+
// Strip action-only flags that don't affect rendering. If they were part of the
|
|
15
|
+
// diffKey, the same URL+preset combo would hash differently depending on which
|
|
16
|
+
// CLI flags the user passed, which is wrong.
|
|
17
|
+
const renderFlags = {};
|
|
18
|
+
for (const [k, v] of Object.entries(flags || {})) {
|
|
19
|
+
if (k === "update" || k === "threshold" || k === "out" || k === "json" || k === "project") continue;
|
|
20
|
+
renderFlags[k] = v;
|
|
21
|
+
}
|
|
22
|
+
const viewport = resolveViewport(renderFlags);
|
|
23
|
+
const id = diffKey({ url, viewport, flags: renderFlags });
|
|
24
|
+
let proj = project;
|
|
25
|
+
if (!proj) {
|
|
26
|
+
try { const u = new URL(url); proj = u.origin + (u.pathname === "/" ? "/" : u.pathname.replace(/\/$/, "")); }
|
|
27
|
+
catch { proj = url; }
|
|
28
|
+
}
|
|
29
|
+
const loaded = loadBaseline({ project: proj, id, step: baselineStep });
|
|
30
|
+
if (!loaded) {
|
|
31
|
+
return {
|
|
32
|
+
url, flags, viewport, baselineKey: { project: proj, id, step: baselineStep },
|
|
33
|
+
status: "no-baseline",
|
|
34
|
+
exitCode: 2,
|
|
35
|
+
ts: nowIso(),
|
|
36
|
+
hint: "No baseline found. Run `pursr shoot " + url + " --save-baseline` first, or `pursr check " + url + " --update` to capture the current render as the new baseline.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const diffOut = out || makeOut("check-diff.png");
|
|
40
|
+
const result = await runDiff(url, loaded.png, diffOut, threshold, flags);
|
|
41
|
+
if (result.error === "size mismatch") {
|
|
42
|
+
return {
|
|
43
|
+
url, flags, viewport, baselineKey: { project: proj, id, step: baselineStep },
|
|
44
|
+
refPng: loaded.png, currentPath: result.currentPath, out: diffOut,
|
|
45
|
+
status: "size-mismatch",
|
|
46
|
+
equal: false,
|
|
47
|
+
refSize: result.refSize, currentSize: result.currentSize,
|
|
48
|
+
exitCode: 1,
|
|
49
|
+
ts: nowIso(),
|
|
50
|
+
hint: "Reference baseline is " + result.refSize.w + "x" + result.refSize.h + " but current render is " + result.currentSize.w + "x" + result.currentSize.h + ". Re-baseline with the same viewport.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (update || result.numDiff === 0) {
|
|
54
|
+
const currentPng = result.currentPath;
|
|
55
|
+
const saved = (update || result.numDiff > 0) ? approveBaseline({ project: proj, id, step: baselineStep, fromPng: currentPng }) : null;
|
|
56
|
+
return {
|
|
57
|
+
url, flags, viewport, baselineKey: { project: proj, id, step: baselineStep },
|
|
58
|
+
refPng: loaded.png, currentPath: currentPng, out: diffOut,
|
|
59
|
+
refSize: result.refSize, totalPx: result.totalPx, numDiff: result.numDiff, diffPct: result.diffPct, threshold,
|
|
60
|
+
status: result.numDiff === 0 ? "equal" : "updated",
|
|
61
|
+
equal: result.numDiff === 0,
|
|
62
|
+
exitCode: 0,
|
|
63
|
+
saved: saved ? { ts: saved.ts, sha1: saved.sha1, approvedFrom: saved.approvedFrom } : null,
|
|
64
|
+
ts: nowIso(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
url, flags, viewport, baselineKey: { project: proj, id, step: baselineStep },
|
|
69
|
+
refPng: loaded.png, currentPath: result.currentPath, out: diffOut,
|
|
70
|
+
refSize: result.refSize, totalPx: result.totalPx, numDiff: result.numDiff, diffPct: result.diffPct, threshold,
|
|
71
|
+
status: "differ",
|
|
72
|
+
equal: false,
|
|
73
|
+
exitCode: 1,
|
|
74
|
+
ts: nowIso(),
|
|
75
|
+
hint: "Differences detected (" + result.numDiff + " px, " + result.diffPct + "%). Run `pursr check " + url + " --update` to approve the current render as the new baseline.",
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/index.js
CHANGED
|
@@ -41,6 +41,7 @@ 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 { runCheck } from "./check.js";
|
|
44
45
|
import { renderSweepPdf } from "./report.js";
|
|
45
46
|
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
46
47
|
|
|
@@ -71,6 +72,8 @@ export {
|
|
|
71
72
|
PursrMCPServer, loadMcpConfig, MCP_VERSION,
|
|
72
73
|
// v4: baselines, sweep validation, MCP resources
|
|
73
74
|
saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
|
|
75
|
+
// v0.7.2: CI visual regression check
|
|
76
|
+
runCheck,
|
|
74
77
|
validateSweepPlan, registerSweepOp,
|
|
75
78
|
listResources, readResource, recordResource,
|
|
76
79
|
// v5: HAR capture, auth state, parallel sweep
|
package/src/mcp.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
// Config via PURSR_MCP_CONFIG env or ~/./mcp-config.json:
|
|
8
8
|
// { "plugins": ["./my-plugin.js"], "defaultOutDir": "./mcp-output" }
|
|
9
9
|
|
|
10
|
-
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
11
11
|
import { __PURSR_GET } from "./util.js";
|
|
12
12
|
import { join, dirname } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { runProbe } from "./probe.js";
|
|
15
15
|
import { runShoot } from "./shoot.js";
|
|
16
16
|
import { runDiff } from "./diff.js";
|
|
17
|
+
import { runCheck } from "./check.js";
|
|
17
18
|
import { runSweep } from "./sweep.js";
|
|
18
19
|
import { runFrames } from "./frames.js";
|
|
19
20
|
import { runShootWithSidecar } from "./shoot.js";
|
|
@@ -236,14 +237,28 @@ class PursrMCPServer {
|
|
|
236
237
|
},
|
|
237
238
|
{
|
|
238
239
|
name: "pursr_diff",
|
|
239
|
-
description: "Pixel-diff a URL against a reference PNG. Returns diff stats and writes diff overlay image.",
|
|
240
|
+
description: "Pixel-diff a URL against a reference PNG. Honors the same viewport/camera/animation flags as pursr_shoot. Returns diff stats and writes diff overlay image.",
|
|
240
241
|
inputSchema: {
|
|
241
242
|
type: "object",
|
|
242
243
|
properties: {
|
|
243
|
-
url:
|
|
244
|
-
ref:
|
|
245
|
-
out:
|
|
246
|
-
threshold:
|
|
244
|
+
url: { type: "string", description: "URL to capture" },
|
|
245
|
+
ref: { type: "string", description: "Reference PNG path" },
|
|
246
|
+
out: { type: "string", description: "Diff output PNG (auto-gen if omitted)" },
|
|
247
|
+
threshold: { type: "number", description: "Pixelmatch threshold 0-1 (default 0.1)" },
|
|
248
|
+
preset: { type: "string", description: "Viewport preset" },
|
|
249
|
+
width: { type: "number", description: "Viewport width" },
|
|
250
|
+
height: { type: "number", description: "Viewport height" },
|
|
251
|
+
dpr: { type: "number", description: "Device pixel ratio" },
|
|
252
|
+
full: { type: "boolean", description: "Full-page screenshot" },
|
|
253
|
+
cursor: { type: "string", description: "Cursor: default|pointer|grab|grabbing|crosshair|none" },
|
|
254
|
+
grid: { type: "boolean", description: "Overlay grid" },
|
|
255
|
+
zoom: { type: "number", description: "Camera zoom" },
|
|
256
|
+
panX: { type: "number", description: "Camera pan X (px)" },
|
|
257
|
+
panY: { type: "number", description: "Camera pan Y (px)" },
|
|
258
|
+
"no-animation":{ type: "boolean", description: "Freeze CSS animations for a stable diff" },
|
|
259
|
+
"wait-frame": { type: "number", description: "Wait for stable canvas frame (ms)" },
|
|
260
|
+
"no-hud": { type: "boolean", description: "Hide header/footer/nav elements" },
|
|
261
|
+
settleMs: { type: "number", description: "Extra settle time before screenshot (ms, default 1200)" },
|
|
247
262
|
},
|
|
248
263
|
required: ["url", "ref"],
|
|
249
264
|
},
|
|
@@ -311,6 +326,29 @@ class PursrMCPServer {
|
|
|
311
326
|
required: ["url"],
|
|
312
327
|
},
|
|
313
328
|
},
|
|
329
|
+
{
|
|
330
|
+
name: "pursr_check",
|
|
331
|
+
description: "CI visual regression check. Renders a URL and diffs against the stored baseline. Exits 0 if equal, 1 if differs, 2 if no baseline. Use update:true to approve current as new baseline.",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: {
|
|
335
|
+
url: { type: "string", description: "Target URL" },
|
|
336
|
+
preset: { type: "string", description: "Viewport preset" },
|
|
337
|
+
width: { type: "number", description: "Viewport width" },
|
|
338
|
+
height: { type: "number", description: "Viewport height" },
|
|
339
|
+
dpr: { type: "number", description: "Device pixel ratio" },
|
|
340
|
+
full: { type: "boolean", description: "Full-page screenshot" },
|
|
341
|
+
zoom: { type: "number", description: "Camera zoom" },
|
|
342
|
+
panX: { type: "number", description: "Camera pan X" },
|
|
343
|
+
panY: { type: "number", description: "Camera pan Y" },
|
|
344
|
+
threshold: { type: "number", description: "Pixelmatch threshold 0-1 (default 0.1)" },
|
|
345
|
+
update: { type: "boolean", description: "Approve current render as the new baseline" },
|
|
346
|
+
out: { type: "string", description: "Diff output PNG path" },
|
|
347
|
+
project: { type: "string", description: "Project key (defaults to URL origin+path)" },
|
|
348
|
+
},
|
|
349
|
+
required: ["url"],
|
|
350
|
+
},
|
|
351
|
+
},
|
|
314
352
|
];
|
|
315
353
|
}
|
|
316
354
|
|
|
@@ -325,6 +363,7 @@ class PursrMCPServer {
|
|
|
325
363
|
case "pursr_probe": return await this._probe(args);
|
|
326
364
|
case "pursr_audit": return await this._audit(args);
|
|
327
365
|
case "pursr_dom_snapshot": return await this._domSnapshot(args);
|
|
366
|
+
case "pursr_check": return await this._check(args);
|
|
328
367
|
default: throw new McpError(-32602, `Unknown tool: ${name}`);
|
|
329
368
|
}
|
|
330
369
|
}
|
|
@@ -370,7 +409,11 @@ class PursrMCPServer {
|
|
|
370
409
|
const out = args.out || ref.replace(/\.png$/i, "-diff.png");
|
|
371
410
|
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
372
411
|
const threshold = args.threshold ?? 0.1;
|
|
373
|
-
const
|
|
412
|
+
const flags = {};
|
|
413
|
+
for (const [k, v] of Object.entries(args)) {
|
|
414
|
+
if (k !== "url" && k !== "ref" && k !== "out" && k !== "threshold") flags[k] = v;
|
|
415
|
+
}
|
|
416
|
+
const result = await runDiff(url, ref, out, threshold, flags);
|
|
374
417
|
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
375
418
|
}
|
|
376
419
|
|
|
@@ -432,6 +475,23 @@ class PursrMCPServer {
|
|
|
432
475
|
const result = await captureDomSnapshot({ url: args.url, out });
|
|
433
476
|
return [{ type: "text", text: JSON.stringify({ out, ...result }, null, 2) }];
|
|
434
477
|
}
|
|
478
|
+
|
|
479
|
+
async _check(args) {
|
|
480
|
+
if (!args.url) throw new McpError(-32602, "Missing required: url");
|
|
481
|
+
const flags = {};
|
|
482
|
+
for (const [k, v] of Object.entries(args)) {
|
|
483
|
+
if (k !== "url" && k !== "threshold" && k !== "update" && k !== "out" && k !== "project") flags[k] = v;
|
|
484
|
+
}
|
|
485
|
+
const result = await runCheck({
|
|
486
|
+
url: args.url,
|
|
487
|
+
flags,
|
|
488
|
+
threshold: args.threshold ?? 0.1,
|
|
489
|
+
update: !!args.update,
|
|
490
|
+
out: args.out,
|
|
491
|
+
project: args.project || null,
|
|
492
|
+
});
|
|
493
|
+
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
494
|
+
}
|
|
435
495
|
}
|
|
436
496
|
|
|
437
497
|
export { PursrMCPServer, McpError, loadConfig, MCP_VERSION };
|