pursr 0.7.0 → 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 CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  // pursr CLI. Thin wrapper around src/* that mirrors the npm bin.
3
3
 
4
4
  import { VERSION } from "../src/index.js";
@@ -79,14 +79,15 @@ await loadPlugins(pluginPaths);
79
79
  case "diff": {
80
80
  if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
81
81
  const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1;
82
+ const flags = parseFlags(argv.slice(5));
82
83
  // --ai / --ai-model / --ai-base-url / --ai-api-key
83
84
  const useAi = argv.includes("--ai");
84
85
  const aiModel = (() => { const i = argv.indexOf("--ai-model"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
85
86
  const aiBaseUrl = (() => { const i = argv.indexOf("--ai-base-url"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
86
87
  const aiApiKey = (() => { const i = argv.indexOf("--ai-api-key"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
87
88
  const r = useAi
88
- ? await runDiffWithAi(url, ref, out, threshold, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
89
- : await runDiff(url, ref, out, threshold);
89
+ ? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
90
+ : await runDiff(url, ref, out, threshold, flags);
90
91
  console.log(JSON.stringify(r, null, 2)); break;
91
92
  }
92
93
  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; }
@@ -204,6 +205,12 @@ await loadPlugins(pluginPaths);
204
205
  let meta = null;
205
206
  if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
206
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
+ }
207
214
  const id = flags.id || diffKey({ url: meta?.url || "", viewport: meta?.viewport, flags: meta?.flags || {} });
208
215
  const result = saveBaseline({ project, id, step, png, meta });
209
216
  console.log(JSON.stringify({ saved: true, ...result }, null, 2));
@@ -317,6 +324,18 @@ await loadPlugins(pluginPaths);
317
324
  }
318
325
  break;
319
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
+ }
320
339
  default: { die(`unknown subcommand: ${cmd}`); }
321
340
  }
322
341
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.7.0",
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
- const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
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/diff.js CHANGED
@@ -3,9 +3,9 @@
3
3
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
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";
6
+ import { resolveViewport } from "./viewport.js";
7
+ import { gotoOrThrow, settle, applyCamera, waitForStableFrame } from "./overlays.js";
8
+ import { asNum, requireArg } from "./util.js";
9
9
  import { aiDiffSidecar } from "./ai-diff.js";
10
10
 
11
11
  const DIFF_DEFAULT_THRESHOLD = 0.1;
@@ -20,7 +20,7 @@ async function loadPixelmatch() {
20
20
  catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
21
21
  }
22
22
 
23
- export async function runDiff(url, refPath, out, threshold, browser) {
23
+ export async function runDiff(url, refPath, out, threshold, flags = {}, browser) {
24
24
  requireArg("url", url, "string");
25
25
  requireArg("refPath", refPath, "string");
26
26
  const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
@@ -28,10 +28,21 @@ export async function runDiff(url, refPath, out, threshold, browser) {
28
28
  const PNG = await loadPngjs();
29
29
  const pixelmatch = await loadPixelmatch();
30
30
  const ownBrowser = !browser;
31
+ const viewport = resolveViewport(flags || {});
31
32
  browser = browser || await launch();
32
33
  try {
33
- const page = await newPage(browser, DEFAULT_VIEWPORT);
34
+ const page = await newPage(browser, viewport);
34
35
  const r = await gotoOrThrow(page, url); await settle(page);
36
+ if (flags && (flags["wait-frame"] || flags["no-animation"])) {
37
+ await waitForStableFrame(page, asNum(flags["wait-frame"], 600));
38
+ }
39
+ if (flags && (flags.zoom || flags.panX || flags.panY)) {
40
+ await applyCamera(page, {
41
+ zoom: asNum(flags.zoom, 1),
42
+ panX: asNum(flags.panX, 0),
43
+ panY: asNum(flags.panY, 0),
44
+ });
45
+ }
35
46
  const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
36
47
  await page.screenshot({ path: currentPath, fullPage: false });
37
48
  const refPng = PNG.sync.read(readFileSync(refPath));
@@ -53,8 +64,8 @@ export async function runDiff(url, refPath, out, threshold, browser) {
53
64
  * summary of the visual differences. The AI summary is written to <out>.ai.json
54
65
  * and also returned on the result object.
55
66
  */
56
- export async function runDiffWithAi(url, refPath, out, threshold, aiOpts, browser) {
57
- const r = await runDiff(url, refPath, out, threshold, browser);
67
+ export async function runDiffWithAi(url, refPath, out, threshold, flags, aiOpts, browser) {
68
+ const r = await runDiff(url, refPath, out, threshold, flags, browser);
58
69
  if (r.error) return r;
59
70
  try {
60
71
  const curPath = r.currentPath;
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: { type: "string", description: "URL to capture" },
244
- ref: { type: "string", description: "Reference PNG path" },
245
- out: { type: "string", description: "Diff output PNG (auto-gen if omitted)" },
246
- threshold: { type: "number", description: "Pixelmatch threshold 0-1 (default 0.1)" },
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 result = await runDiff(url, ref, out, threshold);
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 };
@@ -10,6 +10,7 @@
10
10
  // Dependencies: axe-core (npm i axe-core)
11
11
 
12
12
  import { launch, newPage } from "./runway.js";
13
+ import { createRequire } from "node:module";
13
14
  import { resolveViewport } from "./viewport.js";
14
15
  import { gotoOrThrow, settle } from "./overlays.js";
15
16
  import { writeFileSync, mkdirSync, readFileSync, existsSync } from "node:fs";
@@ -87,11 +88,29 @@ let _axeSource = null;
87
88
  async function getAxeSource() {
88
89
  if (_axeSource) return _axeSource;
89
90
  // Try node_modules/axe-core
91
+ // Primary: use createRequire to resolve axe-core from this module (works for the
92
+ // pursr package itself, the linked repo, or any project that has axe-core installed).
93
+ try {
94
+ const req = createRequire(import.meta.url);
95
+ const resolved = req.resolve("axe-core");
96
+ const dir = resolved.replace(/[\\\/][^\\\/]+$/, "");
97
+ for (const fname of ["axe.min.js", "axe.js"]) {
98
+ const p = dir + "/" + fname;
99
+ if (existsSync(p)) { _axeSource = readFileSync(p, "utf8"); return _axeSource; }
100
+ }
101
+ } catch { /* fall through to path-based lookup */ }
90
102
  const paths = [
91
103
  join(process.cwd(), "node_modules", "axe-core", "axe.min.js"),
92
104
  join(process.cwd(), "node_modules", "axe-core", "axe.js"),
93
105
  new URL("..", import.meta.url).pathname && join(dirname(new URL(import.meta.url).pathname), "node_modules", "axe-core", "axe.min.js"),
94
106
  join(dirname(process.execPath), "node_modules", "axe-core", "axe.min.js"),
107
+ // Global npm install (Windows: %APPDATA%\npm\node_modules, Unix: /usr/lib/node_modules)
108
+ join(process.env.APPDATA || "", "npm", "node_modules", "axe-core", "axe.min.js"),
109
+ join(process.env.HOME || "", ".npm-global", "lib", "node_modules", "axe-core", "axe.min.js"),
110
+ join("/usr", "lib", "node_modules", "axe-core", "axe.min.js"),
111
+ join("/usr", "local", "lib", "node_modules", "axe-core", "axe.min.js"),
112
+ // The pursr package's own node_modules (when running from local repo or via node bin)
113
+ join(dirname(new URL(import.meta.url).pathname), "..", "node_modules", "axe-core", "axe.min.js"),
95
114
  ];
96
115
  for (const p of paths) {
97
116
  if (p && existsSync(p)) {
@@ -99,8 +118,7 @@ async function getAxeSource() {
99
118
  return _axeSource;
100
119
  }
101
120
  }
102
- throw new Error("axe-core not found. Install: npm i axe-core");
103
- }
121
+ throw new Error("axe-core not found. Install: npm i axe-core");}
104
122
 
105
123
  // ─── Group helper ───────────────────────────────────────────────────────
106
124