pursor 0.2.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/LICENSE +21 -0
- package/README.md +595 -0
- package/bin/pursor-mcp.mjs +21 -0
- package/bin/pursor.mjs +191 -0
- package/package.json +73 -0
- package/plans/m5.4-polish.json +22 -0
- package/plugins/plugin-audit.js +57 -0
- package/plugins/plugin-demo.js +63 -0
- package/src/baseline.js +126 -0
- package/src/ci-output.js +156 -0
- package/src/diff.js +48 -0
- package/src/dom-snapshot.js +192 -0
- package/src/eval.js +18 -0
- package/src/every-viewport.js +51 -0
- package/src/frames.js +34 -0
- package/src/hover.js +26 -0
- package/src/index.js +90 -0
- package/src/interact.js +138 -0
- package/src/mcp-resources.js +111 -0
- package/src/mcp.js +436 -0
- package/src/overlays.js +170 -0
- package/src/plugin-audit.js +260 -0
- package/src/plugin.js +121 -0
- package/src/probe.js +20 -0
- package/src/runway.js +63 -0
- package/src/selector-heal.js +85 -0
- package/src/selector.js +39 -0
- package/src/shoot.js +62 -0
- package/src/shot.js +18 -0
- package/src/sweep-schema.js +70 -0
- package/src/sweep.js +105 -0
- package/src/util.js +188 -0
- package/src/viewport.js +39 -0
package/bin/pursor.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pursor CLI. Thin wrapper around src/* that mirrors the npm bin.
|
|
3
|
+
|
|
4
|
+
import { VERSION } from "../src/index.js";
|
|
5
|
+
import { runClick, runType, runWait, runSeq } from "../src/interact.js";
|
|
6
|
+
import { runEval } from "../src/eval.js";
|
|
7
|
+
import { runProbe } from "../src/probe.js";
|
|
8
|
+
import { runShot } from "../src/shot.js";
|
|
9
|
+
import { runShootWithSidecar } from "../src/shoot.js";
|
|
10
|
+
import { runHover } from "../src/hover.js";
|
|
11
|
+
import { runFrames } from "../src/frames.js";
|
|
12
|
+
import { runDiff } from "../src/diff.js";
|
|
13
|
+
import { runSweep } from "../src/sweep.js";
|
|
14
|
+
import { runEveryViewport } from "../src/every-viewport.js";
|
|
15
|
+
import { runAudit } from "../src/plugin-audit.js";
|
|
16
|
+
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
17
|
+
import { listViewports } from "../src/viewport.js";
|
|
18
|
+
import { parseFlags, asNum, readArg, makeOut, pickOutPath } from "../src/util.js";
|
|
19
|
+
import { readFileSync as _readFileSync } from "node:fs";
|
|
20
|
+
const readFile = _readFileSync;
|
|
21
|
+
import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
22
|
+
|
|
23
|
+
const USAGE = `usage:
|
|
24
|
+
v1: pursor {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
|
|
25
|
+
v2: pursor {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
26
|
+
flags: --preset <name> --width N --height N --dpr N
|
|
27
|
+
--zoom 1.5 --panX 200 --panY -100
|
|
28
|
+
--cursor pointer|grab|grabbing|crosshair|none
|
|
29
|
+
--layer entity|terrain|hud|ui
|
|
30
|
+
--grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
|
|
31
|
+
--no-animation --wait-frame 600 --full
|
|
32
|
+
@file prefix reads argv contents from file (UTF-8, newline trimmed).
|
|
33
|
+
plugins: pursor automatically loads built-in plugins from plugins/.
|
|
34
|
+
You can also pass --plugin <path> to load custom plugins (repeatable).`;
|
|
35
|
+
|
|
36
|
+
function die(msg, code = 2) {
|
|
37
|
+
console.error(JSON.stringify({ error: msg, usage: USAGE }, null, 2));
|
|
38
|
+
process.exit(code);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const argv = process.argv;
|
|
42
|
+
const [, , cmd, a, b, c, d] = argv;
|
|
43
|
+
const url = process.env.PURSOR_URL || a;
|
|
44
|
+
|
|
45
|
+
// Plugin loading: scan for --plugin <path> and built-in plugins/
|
|
46
|
+
const pluginPaths = [];
|
|
47
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv.length) pluginPaths.push(argv[++i]);
|
|
48
|
+
await loadPlugins(pluginPaths);
|
|
49
|
+
|
|
50
|
+
(async () => {
|
|
51
|
+
try {
|
|
52
|
+
switch (cmd) {
|
|
53
|
+
case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
|
|
54
|
+
case "version": case "--version": case "-v": {
|
|
55
|
+
console.log(JSON.stringify({ name: "pursor", version: VERSION, plugins: listPlugins() }, null, 2));
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
|
|
59
|
+
case "shot": { if (!url) die("missing url"); const out = b || makeOut("shot.png"); const r = await runShot(url, out, { fullPage: false }); console.log(JSON.stringify(r, null, 2)); break; }
|
|
60
|
+
case "full": { if (!url) die("missing url"); const out = b || makeOut("full.png"); const r = await runShot(url, out, { fullPage: true }); console.log(JSON.stringify(r, null, 2)); break; }
|
|
61
|
+
case "eval": { if (!url) die("missing url"); const js = readArg(b); if (!js) die("eval: missing <js> (or @file)"); const out = c || makeOut("eval.png"); const r = await runEval(url, js, out); console.log(JSON.stringify(r, null, 2)); break; }
|
|
62
|
+
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; }
|
|
63
|
+
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; }
|
|
64
|
+
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; }
|
|
65
|
+
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; }
|
|
66
|
+
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; }
|
|
67
|
+
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
68
|
+
case "shoot": {
|
|
69
|
+
if (!url) die("missing url");
|
|
70
|
+
const out = (b && !b.startsWith("--")) ? b : pickOutPath(argv.slice(5)) || makeOut("shoot.png");
|
|
71
|
+
const r = await runShootWithSidecar({ url, out, flags: parseFlags(argv.slice(5)) });
|
|
72
|
+
console.log(JSON.stringify(r, null, 2));
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "layer": {
|
|
76
|
+
if (!url) die("missing url");
|
|
77
|
+
const layerName = b; if (!layerName) die("layer: missing <name>");
|
|
78
|
+
const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`layer-${layerName}.png`);
|
|
79
|
+
const flags = parseFlags(argv.slice(7)); flags.layer = layerName;
|
|
80
|
+
const r = await runShootWithSidecar({ url, out, flags });
|
|
81
|
+
console.log(JSON.stringify(r, null, 2));
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "frames": {
|
|
85
|
+
if (!url) die("missing url");
|
|
86
|
+
const count = asNum(b, 8);
|
|
87
|
+
const stepMs = asNum(c, 250);
|
|
88
|
+
const outDir = (d && !d.startsWith("--")) ? d : makeOut(`frames-${count}x${stepMs}ms`);
|
|
89
|
+
const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: parseFlags(argv.slice(7)) });
|
|
90
|
+
console.log(JSON.stringify(r, null, 2));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "hover": {
|
|
94
|
+
if (!url) die("missing url");
|
|
95
|
+
const sel = b; if (!sel) die("hover: missing <selector>");
|
|
96
|
+
const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
|
|
97
|
+
const r = await runHover({ url, selector: sel, out, flags: parseFlags(argv.slice(6)) });
|
|
98
|
+
console.log(JSON.stringify(r, null, 2));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "sweep": {
|
|
102
|
+
const planPath = readArg(a);
|
|
103
|
+
if (!planPath) die("sweep: missing <plan.json> (or @file)");
|
|
104
|
+
const outDirArg = (b && !b.startsWith("--")) ? b : undefined;
|
|
105
|
+
const r = await runSweep(planPath, outDirArg);
|
|
106
|
+
console.log(JSON.stringify(r, null, 2));
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "every-viewport": {
|
|
110
|
+
if (!url) die("missing url");
|
|
111
|
+
const outDir = (b && !b.startsWith("--")) ? b : undefined;
|
|
112
|
+
const viewports = c?.startsWith("--") ? undefined : c?.split(",");
|
|
113
|
+
const r = await runEveryViewport({ url, outDir, viewports });
|
|
114
|
+
console.log(JSON.stringify(r, null, 2));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case "audit": {
|
|
118
|
+
if (!url) die("missing url");
|
|
119
|
+
const tags = (b && !b.startsWith("--")) ? b : undefined;
|
|
120
|
+
const outDir = (c && !c.startsWith("--")) ? c : undefined;
|
|
121
|
+
const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
|
|
122
|
+
console.log(JSON.stringify(r, null, 2));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "dom-snapshot": case "dom": {
|
|
126
|
+
if (!url) die("missing url");
|
|
127
|
+
const out = (b && !b.startsWith("--")) ? b : undefined;
|
|
128
|
+
const r = await captureDomSnapshot({ url, out });
|
|
129
|
+
console.log(JSON.stringify({ url: r.url, title: r.title, elements: r.selectorMap?.length, domSize: r.dom?.length, out: r.url?.replace(/[^/]+$/, "") + "dom.json" }, null, 2));
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "validate": {
|
|
133
|
+
const planPath = readArg(a);
|
|
134
|
+
if (!planPath) die("validate: missing <plan.json> (or @file)");
|
|
135
|
+
let plan;
|
|
136
|
+
try { plan = JSON.parse(readFile(planPath, "utf8")); }
|
|
137
|
+
catch (e) { die("validate: " + e.message); }
|
|
138
|
+
const { validateSweepPlan } = await import("../src/sweep-schema.js");
|
|
139
|
+
const v = validateSweepPlan(plan);
|
|
140
|
+
console.log(JSON.stringify({ valid: v.valid, errors: v.errors, plan: planPath }, null, 2));
|
|
141
|
+
if (!v.valid) process.exit(1);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "baseline": {
|
|
145
|
+
// pursor baseline <sub> [...args]
|
|
146
|
+
// sub=list -> list baselines
|
|
147
|
+
// sub=save <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]
|
|
148
|
+
// sub=approve <project> <png> <step> [--id <id>] [--url <u>]
|
|
149
|
+
// sub=show <project> <step> [--id <id>] [--url <u>]
|
|
150
|
+
const sub = a;
|
|
151
|
+
const { saveBaseline, listBaselines, loadBaseline, approveBaseline, diffKey } = await import("../src/baseline.js");
|
|
152
|
+
if (sub === "list") {
|
|
153
|
+
// baseline list [project]
|
|
154
|
+
const project = b;
|
|
155
|
+
console.log(JSON.stringify(listBaselines(project), null, 2));
|
|
156
|
+
} else if (sub === "save") {
|
|
157
|
+
if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
|
|
158
|
+
const project = b, png = c, step = d;
|
|
159
|
+
const flags = parseFlags(argv.slice(7));
|
|
160
|
+
let meta = null;
|
|
161
|
+
if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
|
|
162
|
+
else if (flags.url) meta = { url: flags.url };
|
|
163
|
+
const id = flags.id || diffKey({ url: meta?.url || "", viewport: meta?.viewport, flags: meta?.flags || {} });
|
|
164
|
+
const result = saveBaseline({ project, id, step, png, meta });
|
|
165
|
+
console.log(JSON.stringify({ saved: true, ...result }, null, 2));
|
|
166
|
+
} else if (sub === "approve") {
|
|
167
|
+
if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
|
|
168
|
+
const project = b, png = c, step = d;
|
|
169
|
+
const flags = parseFlags(argv.slice(7));
|
|
170
|
+
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
171
|
+
const result = approveBaseline({ project, id, step, fromPng: png });
|
|
172
|
+
console.log(JSON.stringify({ approved: true, ...result }, null, 2));
|
|
173
|
+
} else if (sub === "show") {
|
|
174
|
+
if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
|
|
175
|
+
const project = b, step = c;
|
|
176
|
+
const flags = parseFlags(argv.slice(5));
|
|
177
|
+
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
178
|
+
const r = loadBaseline({ project, id, step });
|
|
179
|
+
console.log(JSON.stringify(r, null, 2));
|
|
180
|
+
} else {
|
|
181
|
+
die("baseline subcommand: list | save | approve | show");
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
default: { die(`unknown subcommand: ${cmd}`); }
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(JSON.stringify({ error: e.message, stack: e.stack?.split("\n").slice(0, 3).join("\n") }, null, 2));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pursor",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Visual QA & audit & MCP for the browser. Capture, sweep, and review any web target with multi-viewport, layered, animated, hover, grid, and cursor states.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pursor": "./bin/pursor.mjs",
|
|
9
|
+
"pursor-mcp": "./bin/pursor-mcp.mjs"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/index.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.js",
|
|
14
|
+
"./plugin": "./src/plugin.js",
|
|
15
|
+
"./plugins/*": "./plugins/*.js",
|
|
16
|
+
"./util": "./src/util.js",
|
|
17
|
+
"./runway": "./src/runway.js",
|
|
18
|
+
"./selector": "./src/selector.js",
|
|
19
|
+
"./overlays": "./src/overlays.js",
|
|
20
|
+
"./viewport": "./src/viewport.js",
|
|
21
|
+
"./dom-snapshot": "./src/dom-snapshot.js",
|
|
22
|
+
"./plugin-audit": "./src/plugin-audit.js",
|
|
23
|
+
"./selector-heal": "./src/selector-heal.js",
|
|
24
|
+
"./ci-output": "./src/ci-output.js",
|
|
25
|
+
"./mcp": "./src/mcp.js",
|
|
26
|
+
"./baseline": "./src/baseline.js",
|
|
27
|
+
"./sweep-schema": "./src/sweep-schema.js",
|
|
28
|
+
"./mcp-resources": "./src/mcp-resources.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"plugins",
|
|
34
|
+
"plans",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"start": "node bin/pursor.mjs",
|
|
40
|
+
"test": "node --test \"test/*.test.js\"",
|
|
41
|
+
"smoke": "node bin/pursor.mjs viewports"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"visual-qa",
|
|
48
|
+
"visual-regression",
|
|
49
|
+
"screenshot",
|
|
50
|
+
"audit",
|
|
51
|
+
"isometric",
|
|
52
|
+
"playwright",
|
|
53
|
+
"testing",
|
|
54
|
+
"devtools"
|
|
55
|
+
],
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"axe-core": "^4.12.1",
|
|
59
|
+
"pixelmatch": "^5.3.0",
|
|
60
|
+
"pngjs": "^7.0.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"playwright-core": "*"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"playwright-core": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"playwright-core": "^1.61.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "m5.4-polish",
|
|
3
|
+
"base": "http://localhost:3010",
|
|
4
|
+
"outDir": "./out/m54-sweep",
|
|
5
|
+
"steps": [
|
|
6
|
+
{ "name": "baseline", "shoot": { "preset": "desktop-1280" } },
|
|
7
|
+
{ "name": "cursor-pointer", "shoot": { "preset": "desktop-1280", "cursor": "pointer" } },
|
|
8
|
+
{ "name": "grid-64", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 64 } },
|
|
9
|
+
{ "name": "grid-128", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 128 } },
|
|
10
|
+
{ "name": "layer-entity", "shoot": { "preset": "desktop-1280", "layer": "entity" } },
|
|
11
|
+
{ "name": "layer-terrain", "shoot": { "preset": "desktop-1280", "layer": "terrain" } },
|
|
12
|
+
{ "name": "no-hud", "shoot": { "preset": "desktop-1280", "no-hud": true } },
|
|
13
|
+
{ "name": "frozen", "shoot": { "preset": "desktop-1280", "no-animation": true } },
|
|
14
|
+
{ "name": "tablet-768", "shoot": { "preset": "tablet-768" } },
|
|
15
|
+
{ "name": "mobile-375", "shoot": { "preset": "mobile-375" } },
|
|
16
|
+
{ "name": "ultrawide-3440", "shoot": { "preset": "ultrawide-3440" } },
|
|
17
|
+
{ "name": "hover-build", "hover": { "selector": "text=Build" } },
|
|
18
|
+
{ "name": "hover-decor", "hover": { "selector": "text=Decor" } },
|
|
19
|
+
{ "name": "frames-8", "frames": { "count": 8, "intervalMs": 200 } },
|
|
20
|
+
{ "name": "diff-vs-baseline", "diff": { "ref": "baseline.png" } }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Built-in plugin: axe-core accessibility audit.
|
|
2
|
+
//
|
|
3
|
+
// Adds:
|
|
4
|
+
// viewport "audit-canvas" — 1280x800 @1x ideal for audit screenshots
|
|
5
|
+
// sweepOp "audit" — run axe-core WCAG audit in a sweep plan
|
|
6
|
+
// sweepOp "every-viewport" — capture every viewport preset
|
|
7
|
+
//
|
|
8
|
+
// The audit sweep-op stores results to ctx.out/audit.json plus a
|
|
9
|
+
// highlighted screenshot. This is a thin wrapper around src/plugin-audit.js
|
|
10
|
+
// that exposes the same functionality through the plugin system.
|
|
11
|
+
|
|
12
|
+
import { runAudit } from "../src/plugin-audit.js";
|
|
13
|
+
import { launch, newPage } from "../src/runway.js";
|
|
14
|
+
import { listViewports, resolveViewport } from "../src/viewport.js";
|
|
15
|
+
import { runShootWithSidecar } from "../src/shoot.js";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
name: "audit",
|
|
21
|
+
|
|
22
|
+
viewport: {
|
|
23
|
+
"audit-canvas": { width: 1280, height: 800, dpr: 1, label: "Audit canvas 1280x800" },
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
sweepOp: {
|
|
27
|
+
// Run axe-core accessibility audit
|
|
28
|
+
"audit": async (ctx, opts) => {
|
|
29
|
+
const url = opts.url || ctx.url;
|
|
30
|
+
if (!url) throw new Error("audit: missing url");
|
|
31
|
+
const tags = opts.tags ? opts.tags.split(",").map(t => t.trim()) : undefined;
|
|
32
|
+
const outDir = opts.outDir || ctx.out?.replace(/\.png$/i, "-audit") || join(process.cwd(), `audit-${Date.now()}`);
|
|
33
|
+
const result = await runAudit({ url, tags, outDir, screenshot: opts.screenshot !== false });
|
|
34
|
+
return { url, mode: "audit", outDir, violations: result.violationSummary?.total || 0, summary: result.violationSummary };
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Capture one shot per viewport preset
|
|
38
|
+
"every-viewport": async (ctx, opts) => {
|
|
39
|
+
const url = opts.base || ctx.url;
|
|
40
|
+
if (!url) throw new Error("every-viewport: missing base url");
|
|
41
|
+
const wanted = opts.viewports?.length ? opts.viewports : listViewports().map(v => v.name);
|
|
42
|
+
const dir = ctx.out.replace(/\.png$/i, "-every-viewport");
|
|
43
|
+
const captures = [];
|
|
44
|
+
for (const name of wanted) {
|
|
45
|
+
const out = join(dir, `${name}.png`);
|
|
46
|
+
try {
|
|
47
|
+
const meta = await runShootWithSidecar({ url, out, flags: { preset: name } });
|
|
48
|
+
captures.push({ name, out, ok: true, meta });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
captures.push({ name, out, ok: false, error: e.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(join(dir, "every-viewport.json"), JSON.stringify({ url, captures, ts: new Date().toISOString() }, null, 2));
|
|
54
|
+
return { url, mode: "every-viewport", outDir: dir, captures };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Built-in demo plugin — shows every plugin API surface.
|
|
2
|
+
//
|
|
3
|
+
// Serves as both a reference implementation AND a useful tool:
|
|
4
|
+
// - adds a `demo-canvas` viewport alias
|
|
5
|
+
// - adds a `nav` sweep-op that clicks each navbar link in turn
|
|
6
|
+
// and captures a screenshot per page
|
|
7
|
+
// - augments sidecar with `demo: { mode }` when `--demo-mode` flag is set
|
|
8
|
+
//
|
|
9
|
+
// Copy this file as a starting point for your own plugin.
|
|
10
|
+
|
|
11
|
+
import { newPage } from "../src/runway.js";
|
|
12
|
+
import { resolveViewport } from "../src/viewport.js";
|
|
13
|
+
import { gotoOrThrow, settle } from "../src/overlays.js";
|
|
14
|
+
import { resolveLocator } from "../src/selector.js";
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
name: "demo",
|
|
18
|
+
|
|
19
|
+
viewport: {
|
|
20
|
+
"demo-canvas": { width: 1280, height: 800, dpr: 1, label: "Demo canvas 1280x800" },
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
flagHelp: {
|
|
24
|
+
"demo-mode": "logical mode label recorded in sidecar (e.g. dark / light / settings).",
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
sweepOp: {
|
|
28
|
+
"nav": async (ctx, opts) => {
|
|
29
|
+
// opts: { buttons: string[], settleMs?: number }
|
|
30
|
+
const browser = ctx.browser;
|
|
31
|
+
const page = ctx.page || await newPage(browser, resolveViewport({}));
|
|
32
|
+
const url = ctx.url;
|
|
33
|
+
if (!url) throw new Error("nav: missing url (provide plan.base)");
|
|
34
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
35
|
+
const buttons = opts.buttons || ["Home", "About", "Services", "Portfolio", "Contact"];
|
|
36
|
+
const frames = [];
|
|
37
|
+
for (const label of buttons) {
|
|
38
|
+
try {
|
|
39
|
+
const loc = await resolveLocator(page, `text=${label}`);
|
|
40
|
+
await loc.first().waitFor({ state: "visible", timeout: 5000 });
|
|
41
|
+
await loc.first().click({ timeout: 5000 });
|
|
42
|
+
await page.waitForTimeout(opts.settleMs || 600);
|
|
43
|
+
const f = ctx.out.replace(/\.png$/i, `-${label.toLowerCase()}.png`);
|
|
44
|
+
await page.screenshot({ path: f, fullPage: false });
|
|
45
|
+
frames.push({ button: label, out: f });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
frames.push({ button: label, error: e.message });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { ...r, url, mode: "nav-sweep", frames };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
beforeShoot: async (ctx) => {
|
|
55
|
+
if (ctx.flags["demo-mode"]) {
|
|
56
|
+
ctx._demoMode = ctx.flags["demo-mode"];
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
afterShoot: async (ctx, meta) => {
|
|
61
|
+
if (ctx._demoMode) meta.demo = { mode: ctx._demoMode };
|
|
62
|
+
},
|
|
63
|
+
};
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// pursor — baseline storage for visual regression.
|
|
2
|
+
//
|
|
3
|
+
// Baselines live under $PURSOR_BASELINES_DIR || ~/.pursor/baselines/<project>/
|
|
4
|
+
// Each baseline is keyed by a stable id derived from the URL + viewport +
|
|
5
|
+
// flag set (a short hash), so re-running a sweep deterministically points
|
|
6
|
+
// to the same baseline slot.
|
|
7
|
+
//
|
|
8
|
+
// Layout:
|
|
9
|
+
// ~/.pursor/baselines/<project>/<id>/<step>.png
|
|
10
|
+
// ~/.pursor/baselines/<project>/<id>/manifest.json (url, viewport, flags, ts)
|
|
11
|
+
//
|
|
12
|
+
// Public API:
|
|
13
|
+
// resolveBaselinePath({ project, id, step }) -> { dir, file, manifest? }
|
|
14
|
+
// saveBaseline({ project, id, step, png, meta }) -> manifest path
|
|
15
|
+
// loadBaseline({ project, id, step }) -> { png, manifest } | null
|
|
16
|
+
// listBaselines(project) -> [{ id, step, ts, url }]
|
|
17
|
+
// approveBaseline({ project, id, step, fromPng }) -> manifest path
|
|
18
|
+
// diffKey({ url, viewport, flags }) -> string (stable id)
|
|
19
|
+
//
|
|
20
|
+
// CLI subcommands added: pursor baseline save/approve/list/diff-key
|
|
21
|
+
|
|
22
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
23
|
+
import { join, dirname, basename } from "node:path";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { shortHash, nowIso } from "./util.js";
|
|
27
|
+
|
|
28
|
+
function baseDir(project) {
|
|
29
|
+
const root = process.env.PURSOR_BASELINES_DIR || join(homedir(), ".pursor", "baselines");
|
|
30
|
+
const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
31
|
+
return join(root, proj);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function diffKey({ url = "", viewport = {}, flags = {} } = {}) {
|
|
35
|
+
// Stable hash: project-agnostic, viewport+flag+url sensitive.
|
|
36
|
+
const v = `${viewport.width || 0}x${viewport.height || 0}@${viewport.dpr || 1}${viewport.name ? ":" + viewport.name : ""}`;
|
|
37
|
+
const fl = Object.keys(flags).sort().filter(k => k !== "out").map(k => `${k}=${flags[k]}`).join("&");
|
|
38
|
+
const src = `${url}|${v}|${fl}`;
|
|
39
|
+
return createHash("sha1").update(src).digest("hex").slice(0, 16);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveBaselinePath({ project, id, step }) {
|
|
43
|
+
const dir = join(baseDir(project), id);
|
|
44
|
+
const file = join(dir, `${String(step).replace(/[^a-z0-9._-]+/gi, "_")}.png`);
|
|
45
|
+
const manifestPath = join(dir, "manifest.json");
|
|
46
|
+
return { dir, file, manifestPath, manifest: existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveBaseline({ project, id, step, png, meta }) {
|
|
50
|
+
if (!png) throw new Error("saveBaseline: missing png path");
|
|
51
|
+
if (!existsSync(png)) throw new Error(`saveBaseline: png not found: ${png}`);
|
|
52
|
+
const { dir, file, manifestPath } = resolveBaselinePath({ project, id, step });
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
// Copy file (renameSync would also work but keep semantics explicit)
|
|
55
|
+
const buf = readFileSync(png);
|
|
56
|
+
writeFileSync(file, buf);
|
|
57
|
+
const manifest = {
|
|
58
|
+
project: project || "default",
|
|
59
|
+
id,
|
|
60
|
+
step: String(step),
|
|
61
|
+
file,
|
|
62
|
+
size: buf.length,
|
|
63
|
+
sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
|
|
64
|
+
url: meta?.url || null,
|
|
65
|
+
viewport: meta?.viewport || null,
|
|
66
|
+
flags: meta?.flags || null,
|
|
67
|
+
ts: nowIso(),
|
|
68
|
+
};
|
|
69
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
70
|
+
return { ...manifest, manifestPath };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function loadBaseline({ project, id, step }) {
|
|
74
|
+
const { file, manifest } = resolveBaselinePath({ project, id, step });
|
|
75
|
+
if (!existsSync(file)) return null;
|
|
76
|
+
return {
|
|
77
|
+
png: file,
|
|
78
|
+
size: statSync(file).size,
|
|
79
|
+
hash: shortHash(readFileSync(file)),
|
|
80
|
+
manifest: manifest || null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function listBaselines(project) {
|
|
85
|
+
const root = baseDir(project);
|
|
86
|
+
if (!existsSync(root)) return [];
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const id of readdirSync(root)) {
|
|
89
|
+
const dir = join(root, id);
|
|
90
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
91
|
+
const manifestPath = join(dir, "manifest.json");
|
|
92
|
+
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
|
|
93
|
+
if (!manifest) continue;
|
|
94
|
+
out.push({
|
|
95
|
+
id,
|
|
96
|
+
step: manifest.step,
|
|
97
|
+
url: manifest.url,
|
|
98
|
+
ts: manifest.ts,
|
|
99
|
+
sha1: manifest.sha1,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function approveBaseline({ project, id, step, fromPng }) {
|
|
106
|
+
if (!fromPng) throw new Error("approveBaseline: missing fromPng");
|
|
107
|
+
if (!existsSync(fromPng)) throw new Error(`approveBaseline: fromPng not found: ${fromPng}`);
|
|
108
|
+
const buf = readFileSync(fromPng);
|
|
109
|
+
const { file, dir, manifestPath } = resolveBaselinePath({ project, id, step });
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
writeFileSync(file, buf);
|
|
112
|
+
const prev = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {};
|
|
113
|
+
const manifest = {
|
|
114
|
+
...prev,
|
|
115
|
+
project: project || prev.project || "default",
|
|
116
|
+
id,
|
|
117
|
+
step: String(step),
|
|
118
|
+
file,
|
|
119
|
+
size: buf.length,
|
|
120
|
+
sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
|
|
121
|
+
ts: nowIso(),
|
|
122
|
+
approvedFrom: fromPng,
|
|
123
|
+
};
|
|
124
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
125
|
+
return manifest;
|
|
126
|
+
}
|