pursr 0.10.0 → 0.10.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/README.md +51 -19
- package/SKILL.md +169 -0
- package/bin/pursr.mjs +111 -86
- package/package.json +5 -1
- package/src/browser-discovery.js +196 -0
- package/src/cli-args.js +33 -0
- package/src/diff.js +3 -2
- package/src/doctor.js +88 -0
- package/src/eval.js +16 -10
- package/src/hover.js +8 -3
- package/src/interact.js +20 -14
- package/src/runway.js +40 -42
- package/src/shoot.js +7 -4
- package/src/shot.js +14 -10
- package/src/update-notifier.js +74 -0
package/README.md
CHANGED
|
@@ -36,23 +36,40 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
|
|
|
36
36
|
- **An agent-grade MCP stdio server** (`pursr-mcp`) built on the official Model Context Protocol SDK, with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
|
|
37
37
|
- **Visual Operator** sessions with a rendered cursor, target labels, click markers, visible Chrome windows, and authenticated Chrome attachment over CDP.
|
|
38
38
|
- **A library API** with 25 subpath modules, so you can embed the browser and QA primitives in your own tooling.
|
|
39
|
-
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
40
|
-
- **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
|
|
41
|
-
- **Zero browser bundled** - drives your system Chrome via Playwright. No
|
|
39
|
+
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
40
|
+
- **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
|
|
41
|
+
- **Zero browser bundled** - drives your system Chrome, Edge, Brave, or Chromium via Playwright. No surprise browser download during install.
|
|
42
|
+
- **First-run doctor + update notices** - diagnose missing browser/runtime pieces and get lightweight release notifications without breaking CI or JSON output.
|
|
42
43
|
|
|
43
44
|
## Install
|
|
44
45
|
|
|
45
46
|
```bash
|
|
46
|
-
npm install pursr
|
|
47
|
-
npm install --save-dev playwright-core # peer dep - bring your own Chrome
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Then verify:
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
pursr
|
|
54
|
-
pursr
|
|
55
|
-
|
|
47
|
+
npm install pursr
|
|
48
|
+
npm install --save-dev playwright-core # peer dep - bring your own Chrome
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then verify:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pursr doctor # check Node, playwright-core, browser, and SKILL.md
|
|
55
|
+
pursr setup # print next steps if anything is missing
|
|
56
|
+
pursr viewports # list 10+ registered viewport presets
|
|
57
|
+
pursr probe https://example.com # health check
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Pursr intentionally does **not** download browsers during `npm install`. Install Chrome, Edge, Brave, or Chromium yourself, or point Pursr at a Chrome-compatible executable:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Windows
|
|
64
|
+
setx PURSR_BROWSER_PATH "C:\Program Files\Google\Chrome\Application\chrome.exe"
|
|
65
|
+
|
|
66
|
+
# macOS/Linux
|
|
67
|
+
export PURSR_BROWSER_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Browser discovery checks standard global installs, user installs, Dev/Beta/Canary/Nightly channels, and executable names found in `PATH` across Windows, macOS, and Linux. Current target is Chromium-family browsers; Firefox/WebKit are planned, but not claimed as stable targets yet.
|
|
71
|
+
|
|
72
|
+
CLI update notifications are cached for 24 hours, written to stderr, and disabled automatically in CI/non-interactive runs. Disable them manually with `PURSR_NO_UPDATE_NOTIFIER=1`.
|
|
56
73
|
|
|
57
74
|
## 30 seconds
|
|
58
75
|
|
|
@@ -154,12 +171,23 @@ pursr baseline approve myapp ./new.png home --url https://example.com
|
|
|
154
171
|
pursr validate ./plan.json
|
|
155
172
|
```
|
|
156
173
|
|
|
157
|
-
### Subcommands
|
|
174
|
+
### Subcommands
|
|
175
|
+
|
|
176
|
+
Flags are order-independent. Both commands below are valid, and explicit output paths override Pursr's default output directory:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pursr shot --preset desktop-1280 https://example.com --out ./captures/home.png
|
|
180
|
+
pursr full https://example.com --preset desktop-1280 --out-dir ./captures
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`--out` is a complete file path. `--out-dir` is a directory where Pursr writes the command's standard filename.
|
|
158
184
|
|
|
159
|
-
| Subcommand | Purpose |
|
|
160
|
-
| --- | --- |
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
185
|
+
| Subcommand | Purpose |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `doctor` | Diagnose Node, `playwright-core`, browser discovery, and packaged skill availability |
|
|
188
|
+
| `setup` | Print first-run setup guidance without auto-installing browsers |
|
|
189
|
+
| `probe` | Health check (HTTP status, page title) |
|
|
190
|
+
| `shot` / `full` | Viewport / full-page screenshot |
|
|
163
191
|
| `eval` | Execute JS in the page, return result |
|
|
164
192
|
| `click` / `type` / `wait` / `seq` | Interaction primitives |
|
|
165
193
|
| `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
|
|
@@ -175,7 +203,11 @@ pursr validate ./plan.json
|
|
|
175
203
|
| `every-viewport` | Capture once per preset in parallel (3-wide pool) |
|
|
176
204
|
| `baseline` | save / list / approve / show visual baselines |
|
|
177
205
|
| `auth` | save / load / list / delete Playwright storageState |
|
|
178
|
-
| `validate` | Validate a sweep plan JSON without running it |
|
|
206
|
+
| `validate` | Validate a sweep plan JSON without running it |
|
|
207
|
+
|
|
208
|
+
### Agent Skill
|
|
209
|
+
|
|
210
|
+
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
211
|
|
|
180
212
|
## MCP Server
|
|
181
213
|
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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. Use when a user asks an agent to inspect a site, operate an existing browser session, fill or draft UI content, record a tutorial, compare visuals, debug layout, or behave like a careful browser operator that observes, acts, verifies, and pauses before external side effects.
|
|
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
|
+
## Operator Mindset
|
|
11
|
+
|
|
12
|
+
Act like a careful visual browser operator, not a command runner.
|
|
13
|
+
|
|
14
|
+
- Infer the user's practical goal from the page and request, then state the first step briefly.
|
|
15
|
+
- Prefer the smallest useful action: observe, act once, verify, then continue.
|
|
16
|
+
- Use the current authenticated browser session when the task depends on login state, open tabs, cookies, or a dashboard the user already opened.
|
|
17
|
+
- Use a fresh session for repeatable tests, visual regression, tutorial recordings, or unauthenticated pages.
|
|
18
|
+
- Draft or prepare external communication, but pause before publishing, sending, buying, deleting, or changing permissions.
|
|
19
|
+
- Report what changed, what was verified, and what still needs user approval.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
|
|
23
|
+
- If the user says "help me make a first post on the Twitter tab I opened", find the open tab, inspect the composer, write a polished draft, then ask before clicking `Post`.
|
|
24
|
+
- If the user says "check why this dashboard looks wrong", open a session, capture a screenshot, inspect geometry/styles for the suspect element, make a small diagnosis, and propose or apply the fix if the repo is available.
|
|
25
|
+
- If the user says "make a tutorial video", use `pursr operator` or MCP actions with visible cursor, labels, slow motion, screenshots, and WebM recording.
|
|
26
|
+
|
|
27
|
+
## Choose The Right Surface
|
|
28
|
+
|
|
29
|
+
- Use the CLI for repeatable commands and prewritten action plans.
|
|
30
|
+
- Use MCP when the agent must inspect the current page, choose the next action, verify it, or pause for human approval.
|
|
31
|
+
- Use `pursr doctor` before first-run debugging or when browser/runtime setup is uncertain.
|
|
32
|
+
- Use `pursr setup` to give the user safe install guidance without downloading browsers automatically.
|
|
33
|
+
- Use `pursr operator` for a visible scripted walkthrough or silent WebM recording.
|
|
34
|
+
- Use `pursr shoot` for a rich screenshot with viewport, layer, camera, grid, or animation controls.
|
|
35
|
+
- Use `pursr check` for CI regression against an approved baseline.
|
|
36
|
+
- Use `pursr sweep` only with a local JSON plan path. A URL is not a sweep plan.
|
|
37
|
+
|
|
38
|
+
## Capability Check
|
|
39
|
+
|
|
40
|
+
Before operating a browser, pick the strongest available path:
|
|
41
|
+
|
|
42
|
+
1. Existing browser-control plugin or app: use it for logged-in tabs and user-opened pages.
|
|
43
|
+
2. Pursr MCP session: use it for inspect-act-verify loops, visual debugging, and agent-driven browser work.
|
|
44
|
+
3. Pursr CLI: use it for screenshots, scripted operator plans, regression checks, and recordings.
|
|
45
|
+
4. No browser tools: provide the draft, action plan JSON, or exact command for the user to run.
|
|
46
|
+
|
|
47
|
+
Do not pretend to control the user's live browser if only the CLI is available. Use CDP attach only when the user intentionally starts Chrome with a local debugging port.
|
|
48
|
+
|
|
49
|
+
## First Run And Setup
|
|
50
|
+
|
|
51
|
+
Do not auto-install browsers during package installation. Pursr is intentionally lightweight at install time.
|
|
52
|
+
|
|
53
|
+
Run:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pursr doctor
|
|
57
|
+
pursr setup
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Expected support target:
|
|
61
|
+
|
|
62
|
+
- Chrome-compatible browsers first: Google Chrome, Microsoft Edge, Brave, and Chromium.
|
|
63
|
+
- Discovery should cover global installs, user installs, Dev/Beta/Canary/Nightly channels, and executables found in `PATH` across Windows, macOS, and Linux.
|
|
64
|
+
- Use `PURSR_BROWSER_PATH` when discovery misses the executable.
|
|
65
|
+
- Firefox and WebKit are not the primary supported target yet; do not claim full all-browser support unless the implementation has been added and verified.
|
|
66
|
+
- Update notifications are informational only, cached, written to stderr, and disabled by `PURSR_NO_UPDATE_NOTIFIER=1`.
|
|
67
|
+
|
|
68
|
+
## Autonomous Browser Workflow
|
|
69
|
+
|
|
70
|
+
For open-ended browser tasks:
|
|
71
|
+
|
|
72
|
+
1. Discover context: list or open the relevant tab, read URL/title, and snapshot the visible page.
|
|
73
|
+
2. Build intent: identify the user's real goal, the current page state, and the next safe action.
|
|
74
|
+
3. Act narrowly: click, type, scroll, or run an action plan only after the target is visible or identified from a snapshot.
|
|
75
|
+
4. Verify immediately: take a new snapshot, screenshot, URL check, or diagnostics read after meaningful actions.
|
|
76
|
+
5. Stop at side effects: ask for confirmation immediately before final publish/send/delete/purchase/permission actions.
|
|
77
|
+
6. Leave a handoff: keep important tabs or artifacts available and summarize the exact state.
|
|
78
|
+
|
|
79
|
+
Use this loop for social posts, dashboard setup, design QA, local app testing, checkout-like flows, account settings, and tutorial recording.
|
|
80
|
+
|
|
81
|
+
Default short progress pattern:
|
|
82
|
+
|
|
83
|
+
- "I will inspect the current page first."
|
|
84
|
+
- "I found the relevant tab/page and I am preparing the smallest safe action."
|
|
85
|
+
- "Draft/action is ready; I am stopping before the final external side effect."
|
|
86
|
+
- "Verified: [state]. Remaining approval/action: [item]."
|
|
87
|
+
|
|
88
|
+
## CLI Argument Contract
|
|
89
|
+
|
|
90
|
+
Flags may appear before or after positional arguments.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pursr shot --preset desktop-1280 https://example.com --out ./out/page.png
|
|
94
|
+
pursr full https://example.com --out-dir ./out
|
|
95
|
+
pursr eval --preset desktop-1280 https://example.com "document.title" --out ./out/eval.png
|
|
96
|
+
pursr click https://example.com "role=button|Continue" --out ./out/click.png
|
|
97
|
+
pursr type https://example.com "#email" "hello@example.com" --out ./out/type.png
|
|
98
|
+
pursr hover https://example.com "text=Pricing" --out ./out/hover.png
|
|
99
|
+
pursr seq https://example.com ./actions.json --out ./out/final.png
|
|
100
|
+
pursr sweep ./sweep-plan.json --out-dir ./out/sweep
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`--out` is a complete file path. `--out-dir` is a directory; Pursr chooses the command's standard filename inside it.
|
|
104
|
+
|
|
105
|
+
For `seq` and `operator`, actions may be inline JSON, a plain `.json` path, or an `@file.json` reference.
|
|
106
|
+
|
|
107
|
+
## Visual Operator
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
pursr operator https://example.com ./actions.json \
|
|
111
|
+
--visible --start-delay 1500 --slow-mo 80 \
|
|
112
|
+
--video ./recordings --out ./recordings/final.png
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The result includes the action trace, final screenshot, diagnostics, and WebM path. Browser video is silent.
|
|
116
|
+
|
|
117
|
+
Common actions:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
[
|
|
121
|
+
{ "type": "annotate", "selector": "role=button|Continue", "label": "Continue" },
|
|
122
|
+
{ "type": "click", "selector": "role=button|Continue" },
|
|
123
|
+
{ "type": "fill", "selector": "#email", "text": "hello@example.com" },
|
|
124
|
+
{ "type": "drag", "fromX": 200, "fromY": 300, "toX": 600, "toY": 300 },
|
|
125
|
+
{ "type": "press", "key": "Escape" }
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## MCP Agent Loop
|
|
130
|
+
|
|
131
|
+
1. Open one stable session with `pursr_session_open`.
|
|
132
|
+
2. Read rendered state with `pursr_snapshot`.
|
|
133
|
+
3. Perform a small action sequence with `pursr_act`.
|
|
134
|
+
4. Use `pursr_screenshot` when visual judgment is needed.
|
|
135
|
+
5. Use `pursr_inspect` for geometry, clipping, style, or stacking issues.
|
|
136
|
+
6. Read `pursr_diagnostics` after failures.
|
|
137
|
+
7. Close with `pursr_session_close`; this finalizes any video recording.
|
|
138
|
+
|
|
139
|
+
Recommended MCP behavior:
|
|
140
|
+
|
|
141
|
+
- Keep one persistent session per task.
|
|
142
|
+
- Prefer snapshots for target discovery and screenshots for visual judgment.
|
|
143
|
+
- Use `pursr_inspect` when the issue is position, z-index, clipping, opacity, transform, or layout.
|
|
144
|
+
- Use `pursr_act` for small verified action batches, not long blind scripts.
|
|
145
|
+
- When recording, add visible cursor movement, target labels, and short pauses so the output is understandable.
|
|
146
|
+
|
|
147
|
+
## Communication Drafting
|
|
148
|
+
|
|
149
|
+
When operating social media, email, forms, comments, or public dashboards:
|
|
150
|
+
|
|
151
|
+
1. Draft text in the UI or provide it to the user.
|
|
152
|
+
2. Verify the account/page context before filling anything.
|
|
153
|
+
3. Keep tone natural and specific to the product; avoid generic AI-marketing language.
|
|
154
|
+
4. Ask for final approval before clicking the button that transmits the content.
|
|
155
|
+
5. After approval and posting, verify the success state or resulting URL.
|
|
156
|
+
|
|
157
|
+
## Safety
|
|
158
|
+
|
|
159
|
+
- Inspect before acting on unfamiliar pages.
|
|
160
|
+
- Ask for human confirmation immediately before publishing, sending, purchasing, deleting, or changing permissions.
|
|
161
|
+
- Keep CDP endpoints on localhost. CDP preserves the browser profile but cannot record video.
|
|
162
|
+
- Do not claim a visual result passed until the produced screenshot or video has been checked.
|
|
163
|
+
|
|
164
|
+
## Avoid
|
|
165
|
+
|
|
166
|
+
- Do not pass a URL to `sweep`; pass a JSON plan file.
|
|
167
|
+
- Do not treat `viewports` as a capture command; it only lists presets.
|
|
168
|
+
- Do not use `probe` as visual evidence; it only returns HTTP and page metadata.
|
|
169
|
+
- Do not invent selectors. Snapshot or inspect the page first when using MCP.
|
package/bin/pursr.mjs
CHANGED
|
@@ -13,12 +13,14 @@ import { runFrames } from "../src/frames.js";
|
|
|
13
13
|
import { runDiff, runDiffWithAi } from "../src/diff.js";
|
|
14
14
|
import { runSweep } from "../src/sweep.js";
|
|
15
15
|
import { runEveryViewport } from "../src/every-viewport.js";
|
|
16
|
-
import { runAudit } from "../src/plugin-audit.js";
|
|
17
|
-
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
18
|
-
import { listViewports } from "../src/viewport.js";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
16
|
+
import { runAudit } from "../src/plugin-audit.js";
|
|
17
|
+
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
18
|
+
import { listViewports } from "../src/viewport.js";
|
|
19
|
+
import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
|
|
20
|
+
import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
|
|
21
|
+
import { notifyUpdate } from "../src/update-notifier.js";
|
|
22
|
+
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
22
24
|
import { readFileSync as _readFileSync } from "node:fs";
|
|
23
25
|
const readFile = _readFileSync;
|
|
24
26
|
import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
@@ -27,15 +29,16 @@ const USAGE = `usage:
|
|
|
27
29
|
v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
|
|
28
30
|
v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
29
31
|
operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
|
|
30
|
-
flags: --preset <name> --width N --height N --dpr N
|
|
32
|
+
flags: --preset <name> --width N --height N --dpr N
|
|
31
33
|
--zoom 1.5 --panX 200 --panY -100
|
|
32
34
|
--cursor pointer|grab|grabbing|crosshair|none
|
|
33
35
|
--layer entity|terrain|hud|ui
|
|
34
36
|
--grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
|
|
35
37
|
--no-animation --wait-frame 600 --full
|
|
36
38
|
@file prefix reads argv contents from file (UTF-8, newline trimmed).
|
|
37
|
-
report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
|
|
38
|
-
|
|
39
|
+
report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
|
|
40
|
+
setup: pursr doctor [--json] | pursr setup [--json]
|
|
41
|
+
diff extras: --ai [--ai-model M] [--ai-base-url U] [--ai-api-key K]
|
|
39
42
|
plugins: pursr automatically loads built-in plugins from plugins/.
|
|
40
43
|
You can also pass --plugin <path> to load custom plugins (repeatable).`;
|
|
41
44
|
|
|
@@ -44,60 +47,67 @@ function die(msg, code = 2) {
|
|
|
44
47
|
process.exit(code);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
const argv = process.argv;
|
|
48
|
-
const [, , cmd
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
const argv = process.argv;
|
|
51
|
+
const [, , cmd] = argv;
|
|
52
|
+
const { flags: cliFlags, positionals } = parseCommandArgs(argv.slice(3));
|
|
53
|
+
const [a, b, c, d] = positionals;
|
|
54
|
+
const url = __PURSR_GET("PURSR_URL") || a;
|
|
55
|
+
const opts = { plan: cliFlags.plan, out: cliFlags.out };
|
|
56
|
+
|
|
57
|
+
function outputPath(positional, filename) {
|
|
58
|
+
if (cliFlags.out) return String(cliFlags.out);
|
|
59
|
+
if (positional) return positional;
|
|
60
|
+
if (cliFlags["out-dir"]) return join(String(cliFlags["out-dir"]), filename);
|
|
61
|
+
return makeOut(filename);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function dataArg(value) {
|
|
65
|
+
if (value && !value.startsWith("@") && existsSync(value)) return readFile(value, "utf8").replace(/\r?\n$/, "");
|
|
66
|
+
return readArg(value);
|
|
67
|
+
}
|
|
60
68
|
|
|
61
69
|
// Plugin loading: scan for --plugin <path> and built-in plugins/
|
|
62
70
|
const pluginPaths = [];
|
|
63
71
|
for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv.length) pluginPaths.push(argv[++i]);
|
|
64
|
-
await loadPlugins(pluginPaths);
|
|
65
|
-
|
|
66
|
-
(async () => {
|
|
67
|
-
try {
|
|
68
|
-
|
|
72
|
+
await loadPlugins(pluginPaths);
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
await notifyUpdate({ currentVersion: VERSION, stdout: process.stdout, stderr: process.stderr });
|
|
77
|
+
if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
|
|
78
|
+
switch (cmd) {
|
|
69
79
|
case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
|
|
70
80
|
case "version": case "--version": case "-v": {
|
|
71
81
|
console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
|
|
72
82
|
break;
|
|
73
83
|
}
|
|
74
84
|
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; }
|
|
85
|
+
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; }
|
|
86
|
+
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; }
|
|
87
|
+
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; }
|
|
88
|
+
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; }
|
|
89
|
+
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; }
|
|
90
|
+
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
91
|
case "diff": {
|
|
82
92
|
if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
|
|
83
|
-
const out = c
|
|
84
|
-
const flags =
|
|
93
|
+
const out = outputPath(c, "diff.png"); const threshold = cliFlags.threshold !== undefined ? Number(cliFlags.threshold) : d !== undefined ? Number(d) : 0.1;
|
|
94
|
+
const flags = { ...cliFlags };
|
|
85
95
|
// --ai / --ai-model / --ai-base-url / --ai-api-key
|
|
86
96
|
const useAi = argv.includes("--ai");
|
|
87
|
-
const aiModel =
|
|
88
|
-
const aiBaseUrl =
|
|
89
|
-
const aiApiKey =
|
|
97
|
+
const aiModel = cliFlags["ai-model"];
|
|
98
|
+
const aiBaseUrl = cliFlags["ai-base-url"];
|
|
99
|
+
const aiApiKey = cliFlags["ai-api-key"];
|
|
90
100
|
const r = useAi
|
|
91
101
|
? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
|
|
92
102
|
: await runDiff(url, ref, out, threshold, flags);
|
|
93
103
|
console.log(JSON.stringify(r, null, 2)); break;
|
|
94
104
|
}
|
|
95
|
-
case "seq": { if (!url) die("missing url"); const actions =
|
|
105
|
+
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
106
|
case "operator": {
|
|
97
107
|
if (!url) die("operator: missing <url>");
|
|
98
|
-
const actions =
|
|
99
|
-
const flags =
|
|
100
|
-
const out =
|
|
108
|
+
const actions = dataArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
|
|
109
|
+
const flags = { ...cliFlags };
|
|
110
|
+
const out = outputPath(null, "operator.png");
|
|
101
111
|
const videoValue = flags.video ?? flags["record-video"];
|
|
102
112
|
const recordVideoDir = videoValue
|
|
103
113
|
? (videoValue === true ? dirname(out) : String(videoValue))
|
|
@@ -127,19 +137,35 @@ await loadPlugins(pluginPaths);
|
|
|
127
137
|
if (!r.ok) process.exitCode = 1;
|
|
128
138
|
break;
|
|
129
139
|
}
|
|
130
|
-
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
131
|
-
case "
|
|
140
|
+
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
141
|
+
case "doctor": {
|
|
142
|
+
const { runDoctor, renderDoctorText } = await import("../src/doctor.js");
|
|
143
|
+
const r = await runDoctor();
|
|
144
|
+
if (cliFlags.json) console.log(JSON.stringify(r, null, 2));
|
|
145
|
+
else console.log(renderDoctorText(r));
|
|
146
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "setup": {
|
|
150
|
+
const { runSetup, renderSetupText } = await import("../src/doctor.js");
|
|
151
|
+
const r = await runSetup();
|
|
152
|
+
if (cliFlags.json) console.log(JSON.stringify(r, null, 2));
|
|
153
|
+
else console.log(renderSetupText(r));
|
|
154
|
+
process.exitCode = r.ok ? 0 : 1;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "shoot": {
|
|
132
158
|
if (!url) die("missing url");
|
|
133
|
-
const out = (b
|
|
134
|
-
const r = await runShootWithSidecar({ url, out, flags:
|
|
159
|
+
const out = outputPath(b, "shoot.png");
|
|
160
|
+
const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
|
|
135
161
|
console.log(JSON.stringify(r, null, 2));
|
|
136
162
|
break;
|
|
137
163
|
}
|
|
138
164
|
case "layer": {
|
|
139
165
|
if (!url) die("missing url");
|
|
140
166
|
const layerName = b; if (!layerName) die("layer: missing <name>");
|
|
141
|
-
const out = (c
|
|
142
|
-
const flags =
|
|
167
|
+
const out = outputPath(c, `layer-${layerName}.png`);
|
|
168
|
+
const flags = { ...cliFlags, layer: layerName };
|
|
143
169
|
const r = await runShootWithSidecar({ url, out, flags });
|
|
144
170
|
console.log(JSON.stringify(r, null, 2));
|
|
145
171
|
break;
|
|
@@ -148,71 +174,70 @@ await loadPlugins(pluginPaths);
|
|
|
148
174
|
if (!url) die("missing url");
|
|
149
175
|
const count = asNum(b, 8);
|
|
150
176
|
const stepMs = asNum(c, 250);
|
|
151
|
-
const outDir =
|
|
152
|
-
const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags:
|
|
177
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || d || makeOut(`frames-${count}x${stepMs}ms`);
|
|
178
|
+
const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: { ...cliFlags } });
|
|
153
179
|
console.log(JSON.stringify(r, null, 2));
|
|
154
180
|
break;
|
|
155
181
|
}
|
|
156
182
|
case "hover": {
|
|
157
183
|
if (!url) die("missing url");
|
|
158
184
|
const sel = b; if (!sel) die("hover: missing <selector>");
|
|
159
|
-
const out = (c
|
|
160
|
-
const r = await runHover({ url, selector: sel, out, flags:
|
|
185
|
+
const out = outputPath(c, `hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
|
|
186
|
+
const r = await runHover({ url, selector: sel, out, flags: { ...cliFlags } });
|
|
161
187
|
console.log(JSON.stringify(r, null, 2));
|
|
162
188
|
break;
|
|
163
189
|
}
|
|
164
190
|
case "sweep": {
|
|
165
|
-
const planPath =
|
|
166
|
-
if (!planPath) die("sweep: missing <plan.json>
|
|
167
|
-
|
|
191
|
+
const planPath = filePathArg(a);
|
|
192
|
+
if (!planPath) die("sweep: missing <plan.json>");
|
|
193
|
+
if (/^https?:\/\//i.test(planPath)) die("sweep: expected a local JSON plan path, not a URL");
|
|
194
|
+
const outDirArg = cliFlags["out-dir"] || cliFlags.out || b;
|
|
168
195
|
const r = await runSweep(planPath, outDirArg);
|
|
169
196
|
console.log(JSON.stringify(r, null, 2));
|
|
170
197
|
break;
|
|
171
198
|
}
|
|
172
199
|
case "report": {
|
|
173
200
|
// pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
|
|
174
|
-
const
|
|
175
|
-
const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
|
|
201
|
+
const sweepPath = cliFlags.sweep || a;
|
|
176
202
|
if (!sweepPath) die("report: missing --sweep <sweep.json>");
|
|
177
203
|
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"));
|
|
204
|
+
const outPath = cliFlags.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf");
|
|
180
205
|
if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
const noEmbed = argv.includes("--no-embed");
|
|
206
|
+
const title = cliFlags.title;
|
|
207
|
+
const noEmbed = !!cliFlags["no-embed"];
|
|
184
208
|
const summary = JSON.parse(readFile(sweepPath, "utf8"));
|
|
185
209
|
const { renderSweepPdf } = await import("../src/report.js");
|
|
186
210
|
const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
|
|
187
211
|
console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
|
|
188
212
|
break;
|
|
189
213
|
}
|
|
190
|
-
case "every-viewport": {
|
|
191
|
-
if (!url) die("missing url");
|
|
192
|
-
const outDir =
|
|
193
|
-
const viewports = c?.
|
|
214
|
+
case "every-viewport": {
|
|
215
|
+
if (!url) die("missing url");
|
|
216
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || b;
|
|
217
|
+
const viewports = c?.split(",");
|
|
194
218
|
const r = await runEveryViewport({ url, outDir, viewports });
|
|
195
219
|
console.log(JSON.stringify(r, null, 2));
|
|
196
220
|
break;
|
|
197
221
|
}
|
|
198
|
-
case "audit": {
|
|
199
|
-
if (!url) die("missing url");
|
|
200
|
-
const tags =
|
|
201
|
-
const outDir =
|
|
222
|
+
case "audit": {
|
|
223
|
+
if (!url) die("missing url");
|
|
224
|
+
const tags = cliFlags.tags || b;
|
|
225
|
+
const outDir = cliFlags["out-dir"] || cliFlags.out || c;
|
|
202
226
|
const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
|
|
203
227
|
console.log(JSON.stringify(r, null, 2));
|
|
204
228
|
break;
|
|
205
229
|
}
|
|
206
|
-
case "dom-snapshot": case "dom": {
|
|
207
|
-
if (!url) die("missing url");
|
|
208
|
-
const out =
|
|
230
|
+
case "dom-snapshot": case "dom": {
|
|
231
|
+
if (!url) die("missing url");
|
|
232
|
+
const out = cliFlags.out || b;
|
|
209
233
|
const r = await captureDomSnapshot({ url, out });
|
|
210
234
|
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
235
|
break;
|
|
212
236
|
}
|
|
213
|
-
case "validate": {
|
|
214
|
-
const planPath =
|
|
215
|
-
if (!planPath) die("validate: missing <plan.json>
|
|
237
|
+
case "validate": {
|
|
238
|
+
const planPath = filePathArg(a);
|
|
239
|
+
if (!planPath) die("validate: missing <plan.json>");
|
|
240
|
+
if (/^https?:\/\//i.test(planPath)) die("validate: expected a local JSON plan path, not a URL");
|
|
216
241
|
let plan;
|
|
217
242
|
try { plan = JSON.parse(readFile(planPath, "utf8")); }
|
|
218
243
|
catch (e) { die("validate: " + e.message); }
|
|
@@ -237,7 +262,7 @@ await loadPlugins(pluginPaths);
|
|
|
237
262
|
} else if (sub === "save") {
|
|
238
263
|
if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
|
|
239
264
|
const project = b, png = c, step = d;
|
|
240
|
-
const flags =
|
|
265
|
+
const flags = { ...cliFlags };
|
|
241
266
|
let meta = null;
|
|
242
267
|
if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
|
|
243
268
|
else if (flags.url) meta = { url: flags.url };
|
|
@@ -253,14 +278,14 @@ await loadPlugins(pluginPaths);
|
|
|
253
278
|
} else if (sub === "approve") {
|
|
254
279
|
if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
|
|
255
280
|
const project = b, png = c, step = d;
|
|
256
|
-
const flags =
|
|
281
|
+
const flags = { ...cliFlags };
|
|
257
282
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
258
283
|
const result = approveBaseline({ project, id, step, fromPng: png });
|
|
259
284
|
console.log(JSON.stringify({ approved: true, ...result }, null, 2));
|
|
260
285
|
} else if (sub === "show") {
|
|
261
286
|
if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
|
|
262
287
|
const project = b, step = c;
|
|
263
|
-
const flags =
|
|
288
|
+
const flags = { ...cliFlags };
|
|
264
289
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
265
290
|
const r = loadBaseline({ project, id, step });
|
|
266
291
|
console.log(JSON.stringify(r, null, 2));
|
|
@@ -282,14 +307,14 @@ await loadPlugins(pluginPaths);
|
|
|
282
307
|
console.log(JSON.stringify(listAuthStates(project), null, 2));
|
|
283
308
|
} else if (sub === "save") {
|
|
284
309
|
if (!b || !c) die("auth save: <project> <name> --from <state.json>");
|
|
285
|
-
const fromFile =
|
|
310
|
+
const fromFile = cliFlags.from;
|
|
286
311
|
if (!fromFile) die("auth save: missing --from <state.json>");
|
|
287
312
|
const state = JSON.parse(readFile(fromFile, "utf8"));
|
|
288
313
|
const r = saveAuthState({ project: b, name: c, state });
|
|
289
314
|
console.log(JSON.stringify({ saved: true, ...r }, null, 2));
|
|
290
315
|
} else if (sub === "load") {
|
|
291
316
|
if (!b || !c) die("auth load: <project> <name> --out <state.json>");
|
|
292
|
-
const outFile =
|
|
317
|
+
const outFile = cliFlags.out;
|
|
293
318
|
if (!outFile) die("auth load: missing --out <state.json>");
|
|
294
319
|
const state = loadAuthState({ project: b, name: c });
|
|
295
320
|
if (!state) { console.error("not found"); process.exit(2); }
|
|
@@ -312,9 +337,9 @@ await loadPlugins(pluginPaths);
|
|
|
312
337
|
die("watch: missing <url> (or use --plan <plan.json>)");
|
|
313
338
|
}
|
|
314
339
|
const { startWatch } = await import("../src/watch.js");
|
|
315
|
-
const out =
|
|
340
|
+
const out = opts.out || b || makeOut("watch.png");
|
|
316
341
|
if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
|
|
317
|
-
const flags =
|
|
342
|
+
const flags = { ...cliFlags };
|
|
318
343
|
const onGlobs = [];
|
|
319
344
|
for (let i = 0; i < argv.length; i++) {
|
|
320
345
|
if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
|
|
@@ -341,9 +366,9 @@ await loadPlugins(pluginPaths);
|
|
|
341
366
|
// pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
|
|
342
367
|
if (!url) die("snap: missing <url>");
|
|
343
368
|
const sel = b; if (!sel) die("snap: missing <selector>");
|
|
344
|
-
const flags =
|
|
369
|
+
const flags = { ...cliFlags };
|
|
345
370
|
const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
|
|
346
|
-
const outDir = flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
|
|
371
|
+
const outDir = flags["out-dir"] || flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
|
|
347
372
|
const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
|
|
348
373
|
console.log(JSON.stringify({
|
|
349
374
|
url: snap.url,
|
|
@@ -363,7 +388,7 @@ await loadPlugins(pluginPaths);
|
|
|
363
388
|
case "check": {
|
|
364
389
|
// pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
|
|
365
390
|
if (!url) die("check: missing <url>");
|
|
366
|
-
const flags =
|
|
391
|
+
const flags = { ...cliFlags };
|
|
367
392
|
const update = !!flags.update;
|
|
368
393
|
const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
|
|
369
394
|
const { runCheck } = await import("../src/check.js");
|