pursr 0.10.0 → 0.10.1
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 +15 -2
- package/SKILL.md +82 -0
- package/bin/pursr.mjs +80 -74
- package/package.json +2 -1
- package/src/cli-args.js +33 -0
- package/src/diff.js +3 -2
- package/src/eval.js +16 -10
- package/src/hover.js +8 -3
- package/src/interact.js +20 -14
- package/src/shoot.js +7 -4
- package/src/shot.js +14 -10
package/README.md
CHANGED
|
@@ -154,7 +154,16 @@ pursr baseline approve myapp ./new.png home --url https://example.com
|
|
|
154
154
|
pursr validate ./plan.json
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
### Subcommands
|
|
157
|
+
### Subcommands
|
|
158
|
+
|
|
159
|
+
Flags are order-independent. Both commands below are valid, and explicit output paths override Pursr's default output directory:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
pursr shot --preset desktop-1280 https://example.com --out ./captures/home.png
|
|
163
|
+
pursr full https://example.com --preset desktop-1280 --out-dir ./captures
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`--out` is a complete file path. `--out-dir` is a directory where Pursr writes the command's standard filename.
|
|
158
167
|
|
|
159
168
|
| Subcommand | Purpose |
|
|
160
169
|
| --- | --- |
|
|
@@ -175,7 +184,11 @@ pursr validate ./plan.json
|
|
|
175
184
|
| `every-viewport` | Capture once per preset in parallel (3-wide pool) |
|
|
176
185
|
| `baseline` | save / list / approve / show visual baselines |
|
|
177
186
|
| `auth` | save / load / list / delete Playwright storageState |
|
|
178
|
-
| `validate` | Validate a sweep plan JSON without running it |
|
|
187
|
+
| `validate` | Validate a sweep plan JSON without running it |
|
|
188
|
+
|
|
189
|
+
### Agent Skill
|
|
190
|
+
|
|
191
|
+
The npm package includes [`SKILL.md`](./SKILL.md), a compact operating guide for coding agents. Point an agent at `node_modules/pursr/SKILL.md`, or copy it into the skill directory used by your agent host. It explains when to use CLI versus MCP, correct argument order, action plans, visual verification, and safety boundaries.
|
|
179
192
|
|
|
180
193
|
## MCP Server
|
|
181
194
|
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pursr
|
|
3
|
+
description: Use Pursr for browser screenshots, scripted visual operation, visual regression, accessibility audits, DOM inspection, and MCP-driven browser sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pursr
|
|
7
|
+
|
|
8
|
+
Use this skill when a user asks an agent to inspect, operate, record, test, or compare a browser interface.
|
|
9
|
+
|
|
10
|
+
## Choose The Right Surface
|
|
11
|
+
|
|
12
|
+
- Use the CLI for repeatable commands and prewritten action plans.
|
|
13
|
+
- Use MCP when the agent must inspect the current page, choose the next action, verify it, or pause for human approval.
|
|
14
|
+
- Use `pursr operator` for a visible scripted walkthrough or silent WebM recording.
|
|
15
|
+
- Use `pursr shoot` for a rich screenshot with viewport, layer, camera, grid, or animation controls.
|
|
16
|
+
- Use `pursr check` for CI regression against an approved baseline.
|
|
17
|
+
- Use `pursr sweep` only with a local JSON plan path. A URL is not a sweep plan.
|
|
18
|
+
|
|
19
|
+
## CLI Argument Contract
|
|
20
|
+
|
|
21
|
+
Flags may appear before or after positional arguments.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pursr shot --preset desktop-1280 https://example.com --out ./out/page.png
|
|
25
|
+
pursr full https://example.com --out-dir ./out
|
|
26
|
+
pursr eval --preset desktop-1280 https://example.com "document.title" --out ./out/eval.png
|
|
27
|
+
pursr click https://example.com "role=button|Continue" --out ./out/click.png
|
|
28
|
+
pursr type https://example.com "#email" "hello@example.com" --out ./out/type.png
|
|
29
|
+
pursr hover https://example.com "text=Pricing" --out ./out/hover.png
|
|
30
|
+
pursr seq https://example.com ./actions.json --out ./out/final.png
|
|
31
|
+
pursr sweep ./sweep-plan.json --out-dir ./out/sweep
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`--out` is a complete file path. `--out-dir` is a directory; Pursr chooses the command's standard filename inside it.
|
|
35
|
+
|
|
36
|
+
For `seq` and `operator`, actions may be inline JSON, a plain `.json` path, or an `@file.json` reference.
|
|
37
|
+
|
|
38
|
+
## Visual Operator
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pursr operator https://example.com ./actions.json \
|
|
42
|
+
--visible --start-delay 1500 --slow-mo 80 \
|
|
43
|
+
--video ./recordings --out ./recordings/final.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The result includes the action trace, final screenshot, diagnostics, and WebM path. Browser video is silent.
|
|
47
|
+
|
|
48
|
+
Common actions:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
[
|
|
52
|
+
{ "type": "annotate", "selector": "role=button|Continue", "label": "Continue" },
|
|
53
|
+
{ "type": "click", "selector": "role=button|Continue" },
|
|
54
|
+
{ "type": "fill", "selector": "#email", "text": "hello@example.com" },
|
|
55
|
+
{ "type": "drag", "fromX": 200, "fromY": 300, "toX": 600, "toY": 300 },
|
|
56
|
+
{ "type": "press", "key": "Escape" }
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## MCP Agent Loop
|
|
61
|
+
|
|
62
|
+
1. Open one stable session with `pursr_session_open`.
|
|
63
|
+
2. Read rendered state with `pursr_snapshot`.
|
|
64
|
+
3. Perform a small action sequence with `pursr_act`.
|
|
65
|
+
4. Use `pursr_screenshot` when visual judgment is needed.
|
|
66
|
+
5. Use `pursr_inspect` for geometry, clipping, style, or stacking issues.
|
|
67
|
+
6. Read `pursr_diagnostics` after failures.
|
|
68
|
+
7. Close with `pursr_session_close`; this finalizes any video recording.
|
|
69
|
+
|
|
70
|
+
## Safety
|
|
71
|
+
|
|
72
|
+
- Inspect before acting on unfamiliar pages.
|
|
73
|
+
- Ask for human confirmation immediately before publishing, sending, purchasing, deleting, or changing permissions.
|
|
74
|
+
- Keep CDP endpoints on localhost. CDP preserves the browser profile but cannot record video.
|
|
75
|
+
- Do not claim a visual result passed until the produced screenshot or video has been checked.
|
|
76
|
+
|
|
77
|
+
## Avoid
|
|
78
|
+
|
|
79
|
+
- Do not pass a URL to `sweep`; pass a JSON plan file.
|
|
80
|
+
- Do not treat `viewports` as a capture command; it only lists presets.
|
|
81
|
+
- Do not use `probe` as visual evidence; it only returns HTTP and page metadata.
|
|
82
|
+
- Do not invent selectors. Snapshot or inspect the page first when using MCP.
|
package/bin/pursr.mjs
CHANGED
|
@@ -16,9 +16,10 @@ import { runEveryViewport } from "../src/every-viewport.js";
|
|
|
16
16
|
import { runAudit } from "../src/plugin-audit.js";
|
|
17
17
|
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
18
18
|
import { listViewports } from "../src/viewport.js";
|
|
19
|
-
import {
|
|
19
|
+
import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
|
|
20
|
+
import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
|
|
20
21
|
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
-
import { dirname } from "node:path";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
22
23
|
import { readFileSync as _readFileSync } from "node:fs";
|
|
23
24
|
const readFile = _readFileSync;
|
|
24
25
|
import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
@@ -44,19 +45,24 @@ function die(msg, code = 2) {
|
|
|
44
45
|
process.exit(code);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const argv = process.argv;
|
|
48
|
-
const [, , cmd
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
const argv = process.argv;
|
|
49
|
+
const [, , cmd] = argv;
|
|
50
|
+
const { flags: cliFlags, positionals } = parseCommandArgs(argv.slice(3));
|
|
51
|
+
const [a, b, c, d] = positionals;
|
|
52
|
+
const url = __PURSR_GET("PURSR_URL") || a;
|
|
53
|
+
const opts = { plan: cliFlags.plan, out: cliFlags.out };
|
|
54
|
+
|
|
55
|
+
function outputPath(positional, filename) {
|
|
56
|
+
if (cliFlags.out) return String(cliFlags.out);
|
|
57
|
+
if (positional) return positional;
|
|
58
|
+
if (cliFlags["out-dir"]) return join(String(cliFlags["out-dir"]), filename);
|
|
59
|
+
return makeOut(filename);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dataArg(value) {
|
|
63
|
+
if (value && !value.startsWith("@") && existsSync(value)) return readFile(value, "utf8").replace(/\r?\n$/, "");
|
|
64
|
+
return readArg(value);
|
|
65
|
+
}
|
|
60
66
|
|
|
61
67
|
// Plugin loading: scan for --plugin <path> and built-in plugins/
|
|
62
68
|
const pluginPaths = [];
|
|
@@ -64,40 +70,41 @@ for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv
|
|
|
64
70
|
await loadPlugins(pluginPaths);
|
|
65
71
|
|
|
66
72
|
(async () => {
|
|
67
|
-
try {
|
|
68
|
-
|
|
73
|
+
try {
|
|
74
|
+
if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
|
|
75
|
+
switch (cmd) {
|
|
69
76
|
case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
|
|
70
77
|
case "version": case "--version": case "-v": {
|
|
71
78
|
console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
|
|
72
79
|
break;
|
|
73
80
|
}
|
|
74
81
|
case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
|
|
75
|
-
case "shot": { if (!url) die("missing url"); const out = b
|
|
76
|
-
case "full": { if (!url) die("missing url"); const out = b
|
|
77
|
-
case "eval": { if (!url) die("missing url"); const js =
|
|
78
|
-
case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = c
|
|
79
|
-
case "type": { if (!url) die("missing url"); const sel = b; const text =
|
|
80
|
-
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; }
|
|
82
|
+
case "shot": { if (!url) die("missing url"); const out = outputPath(b, "shot.png"); const r = await runShot(url, out, { ...cliFlags, fullPage: false }); console.log(JSON.stringify(r, null, 2)); break; }
|
|
83
|
+
case "full": { if (!url) die("missing url"); const out = outputPath(b, "full.png"); const r = await runShot(url, out, { ...cliFlags, fullPage: true }); console.log(JSON.stringify(r, null, 2)); break; }
|
|
84
|
+
case "eval": { if (!url) die("missing url"); const js = dataArg(b); if (!js) die("eval: missing <js> (or @file)"); const out = outputPath(c, "eval.png"); const r = await runEval(url, js, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
|
|
85
|
+
case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = outputPath(c, `click-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runClick(url, sel, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
|
|
86
|
+
case "type": { if (!url) die("missing url"); const sel = b; const text = dataArg(c); if (!sel || text === undefined) die("type: missing <selector> or <text> (text can be @file)"); const out = outputPath(d, `type-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runType(url, sel, text, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
|
|
87
|
+
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, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
|
|
81
88
|
case "diff": {
|
|
82
89
|
if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
|
|
83
|
-
const out = c
|
|
84
|
-
const flags =
|
|
90
|
+
const out = outputPath(c, "diff.png"); const threshold = cliFlags.threshold !== undefined ? Number(cliFlags.threshold) : d !== undefined ? Number(d) : 0.1;
|
|
91
|
+
const flags = { ...cliFlags };
|
|
85
92
|
// --ai / --ai-model / --ai-base-url / --ai-api-key
|
|
86
93
|
const useAi = argv.includes("--ai");
|
|
87
|
-
const aiModel =
|
|
88
|
-
const aiBaseUrl =
|
|
89
|
-
const aiApiKey =
|
|
94
|
+
const aiModel = cliFlags["ai-model"];
|
|
95
|
+
const aiBaseUrl = cliFlags["ai-base-url"];
|
|
96
|
+
const aiApiKey = cliFlags["ai-api-key"];
|
|
90
97
|
const r = useAi
|
|
91
98
|
? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
|
|
92
99
|
: await runDiff(url, ref, out, threshold, flags);
|
|
93
100
|
console.log(JSON.stringify(r, null, 2)); break;
|
|
94
101
|
}
|
|
95
|
-
case "seq": { if (!url) die("missing url"); const actions =
|
|
102
|
+
case "seq": { if (!url) die("missing url"); const actions = dataArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = outputPath(c, "seq.png"); const r = await runSeq(url, actions, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
|
|
96
103
|
case "operator": {
|
|
97
104
|
if (!url) die("operator: missing <url>");
|
|
98
|
-
const actions =
|
|
99
|
-
const flags =
|
|
100
|
-
const out =
|
|
105
|
+
const actions = dataArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
|
|
106
|
+
const flags = { ...cliFlags };
|
|
107
|
+
const out = outputPath(null, "operator.png");
|
|
101
108
|
const videoValue = flags.video ?? flags["record-video"];
|
|
102
109
|
const recordVideoDir = videoValue
|
|
103
110
|
? (videoValue === true ? dirname(out) : String(videoValue))
|
|
@@ -130,16 +137,16 @@ await loadPlugins(pluginPaths);
|
|
|
130
137
|
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
131
138
|
case "shoot": {
|
|
132
139
|
if (!url) die("missing url");
|
|
133
|
-
const out = (b
|
|
134
|
-
const r = await runShootWithSidecar({ url, out, flags:
|
|
140
|
+
const out = outputPath(b, "shoot.png");
|
|
141
|
+
const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
|
|
135
142
|
console.log(JSON.stringify(r, null, 2));
|
|
136
143
|
break;
|
|
137
144
|
}
|
|
138
145
|
case "layer": {
|
|
139
146
|
if (!url) die("missing url");
|
|
140
147
|
const layerName = b; if (!layerName) die("layer: missing <name>");
|
|
141
|
-
const out = (c
|
|
142
|
-
const flags =
|
|
148
|
+
const out = outputPath(c, `layer-${layerName}.png`);
|
|
149
|
+
const flags = { ...cliFlags, layer: layerName };
|
|
143
150
|
const r = await runShootWithSidecar({ url, out, flags });
|
|
144
151
|
console.log(JSON.stringify(r, null, 2));
|
|
145
152
|
break;
|
|
@@ -148,71 +155,70 @@ await loadPlugins(pluginPaths);
|
|
|
148
155
|
if (!url) die("missing url");
|
|
149
156
|
const count = asNum(b, 8);
|
|
150
157
|
const stepMs = asNum(c, 250);
|
|
151
|
-
const outDir =
|
|
152
|
-
const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags:
|
|
158
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || d || makeOut(`frames-${count}x${stepMs}ms`);
|
|
159
|
+
const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: { ...cliFlags } });
|
|
153
160
|
console.log(JSON.stringify(r, null, 2));
|
|
154
161
|
break;
|
|
155
162
|
}
|
|
156
163
|
case "hover": {
|
|
157
164
|
if (!url) die("missing url");
|
|
158
165
|
const sel = b; if (!sel) die("hover: missing <selector>");
|
|
159
|
-
const out = (c
|
|
160
|
-
const r = await runHover({ url, selector: sel, out, flags:
|
|
166
|
+
const out = outputPath(c, `hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
|
|
167
|
+
const r = await runHover({ url, selector: sel, out, flags: { ...cliFlags } });
|
|
161
168
|
console.log(JSON.stringify(r, null, 2));
|
|
162
169
|
break;
|
|
163
170
|
}
|
|
164
171
|
case "sweep": {
|
|
165
|
-
const planPath =
|
|
166
|
-
if (!planPath) die("sweep: missing <plan.json>
|
|
167
|
-
|
|
172
|
+
const planPath = filePathArg(a);
|
|
173
|
+
if (!planPath) die("sweep: missing <plan.json>");
|
|
174
|
+
if (/^https?:\/\//i.test(planPath)) die("sweep: expected a local JSON plan path, not a URL");
|
|
175
|
+
const outDirArg = cliFlags["out-dir"] || cliFlags.out || b;
|
|
168
176
|
const r = await runSweep(planPath, outDirArg);
|
|
169
177
|
console.log(JSON.stringify(r, null, 2));
|
|
170
178
|
break;
|
|
171
179
|
}
|
|
172
180
|
case "report": {
|
|
173
181
|
// pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
|
|
174
|
-
const
|
|
175
|
-
const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
|
|
182
|
+
const sweepPath = cliFlags.sweep || a;
|
|
176
183
|
if (!sweepPath) die("report: missing --sweep <sweep.json>");
|
|
177
184
|
if (!existsSync(sweepPath)) die("report: sweep not found: " + sweepPath);
|
|
178
|
-
const
|
|
179
|
-
const outPath = outIdx >= 0 && outIdx + 1 < argv.length ? argv[outIdx + 1] : (opts.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf"));
|
|
185
|
+
const outPath = cliFlags.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf");
|
|
180
186
|
if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const noEmbed = argv.includes("--no-embed");
|
|
187
|
+
const title = cliFlags.title;
|
|
188
|
+
const noEmbed = !!cliFlags["no-embed"];
|
|
184
189
|
const summary = JSON.parse(readFile(sweepPath, "utf8"));
|
|
185
190
|
const { renderSweepPdf } = await import("../src/report.js");
|
|
186
191
|
const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
|
|
187
192
|
console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
|
|
188
193
|
break;
|
|
189
194
|
}
|
|
190
|
-
case "every-viewport": {
|
|
191
|
-
if (!url) die("missing url");
|
|
192
|
-
const outDir =
|
|
193
|
-
const viewports = c?.
|
|
195
|
+
case "every-viewport": {
|
|
196
|
+
if (!url) die("missing url");
|
|
197
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || b;
|
|
198
|
+
const viewports = c?.split(",");
|
|
194
199
|
const r = await runEveryViewport({ url, outDir, viewports });
|
|
195
200
|
console.log(JSON.stringify(r, null, 2));
|
|
196
201
|
break;
|
|
197
202
|
}
|
|
198
|
-
case "audit": {
|
|
199
|
-
if (!url) die("missing url");
|
|
200
|
-
const tags =
|
|
201
|
-
const outDir =
|
|
203
|
+
case "audit": {
|
|
204
|
+
if (!url) die("missing url");
|
|
205
|
+
const tags = cliFlags.tags || b;
|
|
206
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || c;
|
|
202
207
|
const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
|
|
203
208
|
console.log(JSON.stringify(r, null, 2));
|
|
204
209
|
break;
|
|
205
210
|
}
|
|
206
|
-
case "dom-snapshot": case "dom": {
|
|
207
|
-
if (!url) die("missing url");
|
|
208
|
-
const out =
|
|
211
|
+
case "dom-snapshot": case "dom": {
|
|
212
|
+
if (!url) die("missing url");
|
|
213
|
+
const out = cliFlags.out || b;
|
|
209
214
|
const r = await captureDomSnapshot({ url, out });
|
|
210
215
|
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));
|
|
211
216
|
break;
|
|
212
217
|
}
|
|
213
|
-
case "validate": {
|
|
214
|
-
const planPath =
|
|
215
|
-
if (!planPath) die("validate: missing <plan.json>
|
|
218
|
+
case "validate": {
|
|
219
|
+
const planPath = filePathArg(a);
|
|
220
|
+
if (!planPath) die("validate: missing <plan.json>");
|
|
221
|
+
if (/^https?:\/\//i.test(planPath)) die("validate: expected a local JSON plan path, not a URL");
|
|
216
222
|
let plan;
|
|
217
223
|
try { plan = JSON.parse(readFile(planPath, "utf8")); }
|
|
218
224
|
catch (e) { die("validate: " + e.message); }
|
|
@@ -237,7 +243,7 @@ await loadPlugins(pluginPaths);
|
|
|
237
243
|
} else if (sub === "save") {
|
|
238
244
|
if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
|
|
239
245
|
const project = b, png = c, step = d;
|
|
240
|
-
const flags =
|
|
246
|
+
const flags = { ...cliFlags };
|
|
241
247
|
let meta = null;
|
|
242
248
|
if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
|
|
243
249
|
else if (flags.url) meta = { url: flags.url };
|
|
@@ -253,14 +259,14 @@ await loadPlugins(pluginPaths);
|
|
|
253
259
|
} else if (sub === "approve") {
|
|
254
260
|
if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
|
|
255
261
|
const project = b, png = c, step = d;
|
|
256
|
-
const flags =
|
|
262
|
+
const flags = { ...cliFlags };
|
|
257
263
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
258
264
|
const result = approveBaseline({ project, id, step, fromPng: png });
|
|
259
265
|
console.log(JSON.stringify({ approved: true, ...result }, null, 2));
|
|
260
266
|
} else if (sub === "show") {
|
|
261
267
|
if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
|
|
262
268
|
const project = b, step = c;
|
|
263
|
-
const flags =
|
|
269
|
+
const flags = { ...cliFlags };
|
|
264
270
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
265
271
|
const r = loadBaseline({ project, id, step });
|
|
266
272
|
console.log(JSON.stringify(r, null, 2));
|
|
@@ -282,14 +288,14 @@ await loadPlugins(pluginPaths);
|
|
|
282
288
|
console.log(JSON.stringify(listAuthStates(project), null, 2));
|
|
283
289
|
} else if (sub === "save") {
|
|
284
290
|
if (!b || !c) die("auth save: <project> <name> --from <state.json>");
|
|
285
|
-
const fromFile =
|
|
291
|
+
const fromFile = cliFlags.from;
|
|
286
292
|
if (!fromFile) die("auth save: missing --from <state.json>");
|
|
287
293
|
const state = JSON.parse(readFile(fromFile, "utf8"));
|
|
288
294
|
const r = saveAuthState({ project: b, name: c, state });
|
|
289
295
|
console.log(JSON.stringify({ saved: true, ...r }, null, 2));
|
|
290
296
|
} else if (sub === "load") {
|
|
291
297
|
if (!b || !c) die("auth load: <project> <name> --out <state.json>");
|
|
292
|
-
const outFile =
|
|
298
|
+
const outFile = cliFlags.out;
|
|
293
299
|
if (!outFile) die("auth load: missing --out <state.json>");
|
|
294
300
|
const state = loadAuthState({ project: b, name: c });
|
|
295
301
|
if (!state) { console.error("not found"); process.exit(2); }
|
|
@@ -312,9 +318,9 @@ await loadPlugins(pluginPaths);
|
|
|
312
318
|
die("watch: missing <url> (or use --plan <plan.json>)");
|
|
313
319
|
}
|
|
314
320
|
const { startWatch } = await import("../src/watch.js");
|
|
315
|
-
const out =
|
|
321
|
+
const out = opts.out || b || makeOut("watch.png");
|
|
316
322
|
if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
|
|
317
|
-
const flags =
|
|
323
|
+
const flags = { ...cliFlags };
|
|
318
324
|
const onGlobs = [];
|
|
319
325
|
for (let i = 0; i < argv.length; i++) {
|
|
320
326
|
if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
|
|
@@ -341,9 +347,9 @@ await loadPlugins(pluginPaths);
|
|
|
341
347
|
// pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
|
|
342
348
|
if (!url) die("snap: missing <url>");
|
|
343
349
|
const sel = b; if (!sel) die("snap: missing <selector>");
|
|
344
|
-
const flags =
|
|
350
|
+
const flags = { ...cliFlags };
|
|
345
351
|
const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
|
|
346
|
-
const outDir = flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
|
|
352
|
+
const outDir = flags["out-dir"] || flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
|
|
347
353
|
const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
|
|
348
354
|
console.log(JSON.stringify({
|
|
349
355
|
url: snap.url,
|
|
@@ -363,7 +369,7 @@ await loadPlugins(pluginPaths);
|
|
|
363
369
|
case "check": {
|
|
364
370
|
// pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
|
|
365
371
|
if (!url) die("check: missing <url>");
|
|
366
|
-
const flags =
|
|
372
|
+
const flags = { ...cliFlags };
|
|
367
373
|
const update = !!flags.update;
|
|
368
374
|
const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
|
|
369
375
|
const { runCheck } = await import("../src/check.js");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"plugins",
|
|
50
50
|
"plans",
|
|
51
51
|
"assets",
|
|
52
|
+
"SKILL.md",
|
|
52
53
|
"README.md",
|
|
53
54
|
"LICENSE"
|
|
54
55
|
],
|
package/src/cli-args.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Order-independent CLI argument parsing for pursr subcommands.
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BOOLEAN_FLAGS = new Set([
|
|
4
|
+
"ai", "full", "grid", "help", "json", "no-animation", "no-embed",
|
|
5
|
+
"no-hud", "no-visual", "update", "visible",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function parseCommandArgs(args = [], { booleanFlags = DEFAULT_BOOLEAN_FLAGS } = {}) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positionals = [];
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const token = args[i];
|
|
13
|
+
if (!token?.startsWith("--")) {
|
|
14
|
+
positionals.push(token);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const eq = token.indexOf("=");
|
|
19
|
+
const key = token.slice(2, eq >= 0 ? eq : undefined);
|
|
20
|
+
let value;
|
|
21
|
+
if (eq >= 0) value = token.slice(eq + 1);
|
|
22
|
+
else if (booleanFlags.has(key)) value = true;
|
|
23
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--")) value = args[++i];
|
|
24
|
+
else value = true;
|
|
25
|
+
flags[key] = value;
|
|
26
|
+
}
|
|
27
|
+
return { flags, positionals };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function filePathArg(value) {
|
|
31
|
+
if (typeof value !== "string") return value;
|
|
32
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
33
|
+
}
|
package/src/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pixelmatch diff against a reference PNG.
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { launch, newPage } from "./runway.js";
|
|
6
6
|
import { resolveViewport } from "./viewport.js";
|
|
@@ -24,7 +24,8 @@ 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;
|
|
27
|
-
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
27
|
+
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
28
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
28
29
|
const PNG = await loadPngjs();
|
|
29
30
|
const pixelmatch = await loadPixelmatch();
|
|
30
31
|
const ownBrowser = !browser;
|
package/src/eval.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// Evaluate a JS string in the page, optionally screenshot after.
|
|
2
2
|
|
|
3
3
|
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow } from "./overlays.js";
|
|
6
|
-
import { requireArg } from "./util.js";
|
|
6
|
+
import { requireArg } from "./util.js";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
7
9
|
|
|
8
|
-
export async function runEval(url, js, out) {
|
|
9
|
-
requireArg("url", url, "string");
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export async function runEval(url, js, out, flags = {}) {
|
|
11
|
+
requireArg("url", url, "string");
|
|
12
|
+
const viewport = resolveViewport(flags);
|
|
13
|
+
const browser = await launch();
|
|
14
|
+
try {
|
|
15
|
+
const page = await newPage(browser, viewport);
|
|
13
16
|
const r = await gotoOrThrow(page, url);
|
|
14
17
|
const result = await page.evaluate(js);
|
|
15
|
-
if (out)
|
|
16
|
-
|
|
18
|
+
if (out) {
|
|
19
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
20
|
+
await page.screenshot({ path: out, fullPage: false });
|
|
21
|
+
}
|
|
22
|
+
return { ...r, url, out, viewport, result };
|
|
17
23
|
} finally { try { await browser.close(); } catch {} }
|
|
18
|
-
}
|
|
24
|
+
}
|
package/src/hover.js
CHANGED
|
@@ -4,7 +4,9 @@ import { launch, newPage } from "./runway.js";
|
|
|
4
4
|
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
6
6
|
import { resolveLocator } from "./selector.js";
|
|
7
|
-
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
7
|
+
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
8
10
|
|
|
9
11
|
export async function runHover({ url, selector, out, flags = {} }) {
|
|
10
12
|
requireArg("url", url, "string");
|
|
@@ -18,9 +20,12 @@ export async function runHover({ url, selector, out, flags = {} }) {
|
|
|
18
20
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
19
21
|
await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
20
22
|
await page.waitForTimeout(asNum(flags["hover-ms"], 250));
|
|
21
|
-
if (out)
|
|
23
|
+
if (out) {
|
|
24
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
25
|
+
await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
|
|
26
|
+
}
|
|
22
27
|
const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
|
|
23
28
|
if (out) await writeSidecar(meta);
|
|
24
29
|
return meta;
|
|
25
30
|
} finally { try { await browser.close(); } catch {} }
|
|
26
|
-
}
|
|
31
|
+
}
|
package/src/interact.js
CHANGED
|
@@ -1,50 +1,56 @@
|
|
|
1
1
|
// click, type, wait, seq — interaction primitives.
|
|
2
2
|
|
|
3
3
|
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
6
6
|
import { resolveLocator } from "./selector.js";
|
|
7
|
-
import { requireArg } from "./util.js";
|
|
7
|
+
import { requireArg } from "./util.js";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
function ensureScreenshotDir(out) {
|
|
12
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
13
|
+
}
|
|
8
14
|
|
|
9
|
-
export async function runClick(url, selector, out) {
|
|
15
|
+
export async function runClick(url, selector, out, flags = {}) {
|
|
10
16
|
requireArg("url", url, "string");
|
|
11
17
|
requireArg("selector", selector, "string");
|
|
12
18
|
const browser = await launch();
|
|
13
19
|
try {
|
|
14
|
-
const page = await newPage(browser,
|
|
20
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
15
21
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
16
22
|
const loc = await resolveLocator(page, selector);
|
|
17
23
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
18
24
|
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
19
25
|
await settle(page);
|
|
20
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
26
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
21
27
|
return { ...r, url, out, selector, clicked: true };
|
|
22
28
|
} finally { try { await browser.close(); } catch {} }
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
export async function runType(url, selector, text, out) {
|
|
31
|
+
export async function runType(url, selector, text, out, flags = {}) {
|
|
26
32
|
requireArg("url", url, "string");
|
|
27
33
|
requireArg("selector", selector, "string");
|
|
28
34
|
const browser = await launch();
|
|
29
35
|
try {
|
|
30
|
-
const page = await newPage(browser,
|
|
36
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
31
37
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
32
38
|
const loc = await resolveLocator(page, selector);
|
|
33
39
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
34
40
|
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
35
41
|
await page.keyboard.type(String(text ?? ""), { delay: 10 });
|
|
36
42
|
await settle(page);
|
|
37
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
43
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
38
44
|
return { ...r, url, out, selector, text, typed: true };
|
|
39
45
|
} finally { try { await browser.close(); } catch {} }
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export async function runWait(url, selector, timeoutMs) {
|
|
48
|
+
export async function runWait(url, selector, timeoutMs, flags = {}) {
|
|
43
49
|
requireArg("url", url, "string");
|
|
44
50
|
requireArg("selector", selector, "string");
|
|
45
51
|
const browser = await launch();
|
|
46
52
|
try {
|
|
47
|
-
const page = await newPage(browser,
|
|
53
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
48
54
|
const r = await gotoOrThrow(page, url);
|
|
49
55
|
const loc = await resolveLocator(page, selector);
|
|
50
56
|
const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
|
|
@@ -57,7 +63,7 @@ export async function runWait(url, selector, timeoutMs) {
|
|
|
57
63
|
} finally { try { await browser.close(); } catch {} }
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
export async function runSeq(url, actionsJson, out) {
|
|
66
|
+
export async function runSeq(url, actionsJson, out, flags = {}) {
|
|
61
67
|
requireArg("url", url, "string");
|
|
62
68
|
let actions;
|
|
63
69
|
try { actions = JSON.parse(actionsJson); }
|
|
@@ -66,7 +72,7 @@ export async function runSeq(url, actionsJson, out) {
|
|
|
66
72
|
if (!actions.length) throw new Error("actions array is empty");
|
|
67
73
|
const browser = await launch();
|
|
68
74
|
try {
|
|
69
|
-
const page = await newPage(browser,
|
|
75
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
70
76
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
71
77
|
const trace = [];
|
|
72
78
|
let failed = false;
|
|
@@ -132,7 +138,7 @@ export async function runSeq(url, actionsJson, out) {
|
|
|
132
138
|
trace.push(step);
|
|
133
139
|
if (failed) break;
|
|
134
140
|
}
|
|
135
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
141
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
136
142
|
return { ...r, url, out, steps: trace, failed };
|
|
137
143
|
} finally { try { await browser.close(); } catch {} }
|
|
138
|
-
}
|
|
144
|
+
}
|
package/src/shoot.js
CHANGED
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
11
11
|
import { runBeforeShoot, runAfterShoot } from "./plugin.js";
|
|
12
12
|
import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
|
|
13
|
-
import { loadAuthState } from "./auth.js";
|
|
13
|
+
import { loadAuthState } from "./auth.js";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
14
16
|
|
|
15
|
-
export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
|
|
16
|
-
requireArg("url", url, "string");
|
|
17
|
+
export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
|
|
18
|
+
requireArg("url", url, "string");
|
|
19
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
17
20
|
const viewport = resolveViewport(flags);
|
|
18
21
|
const ownBrowser = !extBrowser;
|
|
19
22
|
const browser = extBrowser || await launch();
|
|
@@ -71,4 +74,4 @@ export async function runShootWithSidecar(args) {
|
|
|
71
74
|
const meta = await runShoot(args);
|
|
72
75
|
await writeSidecar(meta);
|
|
73
76
|
return meta;
|
|
74
|
-
}
|
|
77
|
+
}
|
package/src/shot.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// Simple screenshot (no flags / overlays).
|
|
2
2
|
|
|
3
3
|
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow, settle } from "./overlays.js";
|
|
6
|
-
import { requireArg } from "./util.js";
|
|
6
|
+
import { requireArg } from "./util.js";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
7
9
|
|
|
8
|
-
export async function runShot(url, out, opts = {}) {
|
|
9
|
-
requireArg("url", url, "string");
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export async function runShot(url, out, opts = {}) {
|
|
11
|
+
requireArg("url", url, "string");
|
|
12
|
+
const viewport = resolveViewport(opts);
|
|
13
|
+
const browser = await launch();
|
|
14
|
+
try {
|
|
15
|
+
const page = await newPage(browser, viewport);
|
|
13
16
|
const r = await gotoOrThrow(page, url);
|
|
14
17
|
await settle(page);
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
19
|
+
await page.screenshot({ path: out, fullPage: !!opts.fullPage });
|
|
20
|
+
return { ...r, url, out, viewport, fullPage: !!opts.fullPage };
|
|
17
21
|
} finally { try { await browser.close(); } catch {} }
|
|
18
|
-
}
|
|
22
|
+
}
|