pursr 0.9.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 +60 -5
- package/SKILL.md +82 -0
- package/bin/pursr.mjs +116 -74
- package/package.json +3 -1
- package/plans/operator-tutorial.json +16 -0
- 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/index.js +3 -0
- package/src/interact.js +20 -14
- package/src/mcp.js +3 -1
- package/src/operator.js +61 -0
- package/src/runway.js +4 -0
- package/src/session.js +48 -5
- package/src/shoot.js +7 -4
- package/src/shot.js +14 -10
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
|
|
|
35
35
|
- **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
|
|
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
|
-
- **A library API** with
|
|
38
|
+
- **A library API** with 25 subpath modules, so you can embed the browser and QA primitives in your own tooling.
|
|
39
39
|
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
40
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
41
|
- **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
|
|
@@ -81,7 +81,7 @@ pursr sweep ./plan.json # see plans/ for an example
|
|
|
81
81
|
| Layered states | entity / terrain / hud / ui isolation | `--layer entity` |
|
|
82
82
|
| Animation freeze | pause CSS/JS animations for stable frames | `--no-animation` |
|
|
83
83
|
| Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
|
|
84
|
-
| Visual Operator | rendered cursor, target labels, click markers, headed and CDP sessions | MCP session tools |
|
|
84
|
+
| Visual Operator | rendered cursor, target labels, click markers, WebM recording, headed and CDP sessions | `operator` CLI + MCP session tools |
|
|
85
85
|
| Grid overlay | spacing guides, custom color + tile size | `--grid --grid-tile 64` |
|
|
86
86
|
| Camera control | zoom + pan via mouse wheel/drag | `--zoom 1.5 --panX 200` |
|
|
87
87
|
| Frame timeline | N captures at intervalMs for animations | `pursr frames <url> 8 200` |
|
|
@@ -154,14 +154,24 @@ 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
|
| --- | --- |
|
|
161
170
|
| `probe` | Health check (HTTP status, page title) |
|
|
162
171
|
| `shot` / `full` | Viewport / full-page screenshot |
|
|
163
172
|
| `eval` | Execute JS in the page, return result |
|
|
164
|
-
| `click` / `type` / `wait` / `seq` | Interaction primitives |
|
|
173
|
+
| `click` / `type` / `wait` / `seq` | Interaction primitives |
|
|
174
|
+
| `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
|
|
165
175
|
| `diff` | Pixel-level diff vs a reference PNG |
|
|
166
176
|
| `viewports` | List all registered viewport presets |
|
|
167
177
|
| `shoot` | Rich capture (overlays, freeze, camera, plugins) |
|
|
@@ -174,7 +184,11 @@ pursr validate ./plan.json
|
|
|
174
184
|
| `every-viewport` | Capture once per preset in parallel (3-wide pool) |
|
|
175
185
|
| `baseline` | save / list / approve / show visual baselines |
|
|
176
186
|
| `auth` | save / load / list / delete Playwright storageState |
|
|
177
|
-
| `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.
|
|
178
192
|
|
|
179
193
|
## MCP Server
|
|
180
194
|
|
|
@@ -236,6 +250,45 @@ Example action arguments:
|
|
|
236
250
|
|
|
237
251
|
Set `visual: true` to render the agent cursor and interaction feedback into screenshots. `mode: "visible"` enables it automatically and opens a Chrome window that a developer can watch.
|
|
238
252
|
|
|
253
|
+
#### CLI: scripted tutorials and repeatable recordings
|
|
254
|
+
|
|
255
|
+
Use the CLI when the steps are already known. It needs no MCP host and produces a final screenshot, JSON trace, diagnostics, and an optional WebM recording.
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
pursr operator http://localhost:3000 @plans/operator-tutorial.json \
|
|
259
|
+
--visible \
|
|
260
|
+
--start-delay 3000 \
|
|
261
|
+
--slow-mo 100 \
|
|
262
|
+
--video ./recordings \
|
|
263
|
+
--out ./recordings/final.png
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The action plan is a JSON array. The same action objects work through `pursr_act` in MCP:
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
[
|
|
270
|
+
{ "type": "annotate", "selector": "role=button|Build", "label": "Open build menu" },
|
|
271
|
+
{ "type": "click", "selector": "role=button|Build", "durationMs": 350, "settleMs": 500 },
|
|
272
|
+
{ "type": "click", "x": 640, "y": 420, "durationMs": 250 },
|
|
273
|
+
{ "type": "drag", "fromX": 520, "fromY": 400, "toX": 760, "toY": 520, "steps": 30 },
|
|
274
|
+
{ "type": "keyDown", "key": "Shift" },
|
|
275
|
+
{ "type": "keyUp", "key": "Shift" },
|
|
276
|
+
{ "type": "press", "key": "Escape" },
|
|
277
|
+
{ "type": "sleep", "ms": 800 },
|
|
278
|
+
{ "type": "clearAnnotations", "keepCursor": true }
|
|
279
|
+
]
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Chrome records the browser viewport as silent WebM video. Add narration or system audio in your editor, and convert to MP4 when needed:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
ffmpeg -i recording.webm -c:v libx264 -pix_fmt yuv420p tutorial.mp4
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### MCP: adaptive agent operation
|
|
289
|
+
|
|
290
|
+
Use MCP when the agent must inspect the current page, decide the next action, verify visual results, or pause for human approval. MCP is not required for CLI recording. Both interfaces use the same session and Visual Operator engine.
|
|
291
|
+
|
|
239
292
|
```json
|
|
240
293
|
{
|
|
241
294
|
"url": "http://localhost:3000",
|
|
@@ -246,6 +299,8 @@ Set `visual: true` to render the agent cursor and interaction feedback into scre
|
|
|
246
299
|
}
|
|
247
300
|
```
|
|
248
301
|
|
|
302
|
+
Add `recordVideoDir` to record an MCP session in headless or visible mode. The final video path is returned by `pursr_session_close`. CDP sessions preserve an existing browser profile but cannot record video because Chrome owns that context.
|
|
303
|
+
|
|
249
304
|
Visual actions use the regular `pursr_act` tool:
|
|
250
305
|
|
|
251
306
|
```json
|
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
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// pursr CLI. Thin wrapper around src/* that mirrors the npm bin.
|
|
3
3
|
|
|
4
4
|
import { VERSION } from "../src/index.js";
|
|
5
|
-
import { runClick, runType, runWait, runSeq } from "../src/interact.js";
|
|
5
|
+
import { runClick, runType, runWait, runSeq } from "../src/interact.js";
|
|
6
|
+
import { runOperator } from "../src/operator.js";
|
|
6
7
|
import { runEval } from "../src/eval.js";
|
|
7
8
|
import { runProbe } from "../src/probe.js";
|
|
8
9
|
import { runShot } from "../src/shot.js";
|
|
@@ -15,16 +16,18 @@ import { runEveryViewport } from "../src/every-viewport.js";
|
|
|
15
16
|
import { runAudit } from "../src/plugin-audit.js";
|
|
16
17
|
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
17
18
|
import { listViewports } from "../src/viewport.js";
|
|
18
|
-
import {
|
|
19
|
+
import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
|
|
20
|
+
import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
|
|
19
21
|
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
20
|
-
import { dirname } from "node:path";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
21
23
|
import { readFileSync as _readFileSync } from "node:fs";
|
|
22
24
|
const readFile = _readFileSync;
|
|
23
25
|
import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
24
26
|
|
|
25
27
|
const USAGE = `usage:
|
|
26
28
|
v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
|
|
27
|
-
v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
29
|
+
v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
30
|
+
operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
|
|
28
31
|
flags: --preset <name> --width N --height N --dpr N
|
|
29
32
|
--zoom 1.5 --panX 200 --panY -100
|
|
30
33
|
--cursor pointer|grab|grabbing|crosshair|none
|
|
@@ -42,19 +45,24 @@ function die(msg, code = 2) {
|
|
|
42
45
|
process.exit(code);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
const argv = process.argv;
|
|
46
|
-
const [, , cmd
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
66
|
|
|
59
67
|
// Plugin loading: scan for --plugin <path> and built-in plugins/
|
|
60
68
|
const pluginPaths = [];
|
|
@@ -62,48 +70,83 @@ for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv
|
|
|
62
70
|
await loadPlugins(pluginPaths);
|
|
63
71
|
|
|
64
72
|
(async () => {
|
|
65
|
-
try {
|
|
66
|
-
|
|
73
|
+
try {
|
|
74
|
+
if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
|
|
75
|
+
switch (cmd) {
|
|
67
76
|
case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
|
|
68
77
|
case "version": case "--version": case "-v": {
|
|
69
78
|
console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
|
|
70
79
|
break;
|
|
71
80
|
}
|
|
72
81
|
case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
|
|
73
|
-
case "shot": { if (!url) die("missing url"); const out = b
|
|
74
|
-
case "full": { if (!url) die("missing url"); const out = b
|
|
75
|
-
case "eval": { if (!url) die("missing url"); const js =
|
|
76
|
-
case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = c
|
|
77
|
-
case "type": { if (!url) die("missing url"); const sel = b; const text =
|
|
78
|
-
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; }
|
|
79
88
|
case "diff": {
|
|
80
89
|
if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
|
|
81
|
-
const out = c
|
|
82
|
-
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 };
|
|
83
92
|
// --ai / --ai-model / --ai-base-url / --ai-api-key
|
|
84
93
|
const useAi = argv.includes("--ai");
|
|
85
|
-
const aiModel =
|
|
86
|
-
const aiBaseUrl =
|
|
87
|
-
const aiApiKey =
|
|
94
|
+
const aiModel = cliFlags["ai-model"];
|
|
95
|
+
const aiBaseUrl = cliFlags["ai-base-url"];
|
|
96
|
+
const aiApiKey = cliFlags["ai-api-key"];
|
|
88
97
|
const r = useAi
|
|
89
98
|
? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
|
|
90
99
|
: await runDiff(url, ref, out, threshold, flags);
|
|
91
100
|
console.log(JSON.stringify(r, null, 2)); break;
|
|
92
101
|
}
|
|
93
|
-
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; }
|
|
103
|
+
case "operator": {
|
|
104
|
+
if (!url) die("operator: missing <url>");
|
|
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");
|
|
108
|
+
const videoValue = flags.video ?? flags["record-video"];
|
|
109
|
+
const recordVideoDir = videoValue
|
|
110
|
+
? (videoValue === true ? dirname(out) : String(videoValue))
|
|
111
|
+
: null;
|
|
112
|
+
const r = await runOperator({
|
|
113
|
+
url,
|
|
114
|
+
actions,
|
|
115
|
+
out,
|
|
116
|
+
outputDir: dirname(out),
|
|
117
|
+
sessionId: flags.session || undefined,
|
|
118
|
+
flags: {
|
|
119
|
+
mode: flags.mode || (flags.cdp ? "cdp" : flags.visible ? "visible" : "headless"),
|
|
120
|
+
visual: !flags["no-visual"],
|
|
121
|
+
cdpUrl: flags.cdp || flags["cdp-url"],
|
|
122
|
+
slowMo: asNum(flags["slow-mo"] ?? flags.slowMo, 0),
|
|
123
|
+
startDelayMs: asNum(flags["start-delay"] ?? flags.startDelayMs, 0),
|
|
124
|
+
operatorColor: flags["operator-color"] || flags.operatorColor,
|
|
125
|
+
recordVideoDir,
|
|
126
|
+
width: flags.width,
|
|
127
|
+
height: flags.height,
|
|
128
|
+
dpr: flags.dpr,
|
|
129
|
+
preset: flags.preset,
|
|
130
|
+
full: !!flags.full,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
console.log(JSON.stringify(r, null, 2));
|
|
134
|
+
if (!r.ok) process.exitCode = 1;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
94
137
|
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
95
138
|
case "shoot": {
|
|
96
139
|
if (!url) die("missing url");
|
|
97
|
-
const out = (b
|
|
98
|
-
const r = await runShootWithSidecar({ url, out, flags:
|
|
140
|
+
const out = outputPath(b, "shoot.png");
|
|
141
|
+
const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
|
|
99
142
|
console.log(JSON.stringify(r, null, 2));
|
|
100
143
|
break;
|
|
101
144
|
}
|
|
102
145
|
case "layer": {
|
|
103
146
|
if (!url) die("missing url");
|
|
104
147
|
const layerName = b; if (!layerName) die("layer: missing <name>");
|
|
105
|
-
const out = (c
|
|
106
|
-
const flags =
|
|
148
|
+
const out = outputPath(c, `layer-${layerName}.png`);
|
|
149
|
+
const flags = { ...cliFlags, layer: layerName };
|
|
107
150
|
const r = await runShootWithSidecar({ url, out, flags });
|
|
108
151
|
console.log(JSON.stringify(r, null, 2));
|
|
109
152
|
break;
|
|
@@ -112,71 +155,70 @@ await loadPlugins(pluginPaths);
|
|
|
112
155
|
if (!url) die("missing url");
|
|
113
156
|
const count = asNum(b, 8);
|
|
114
157
|
const stepMs = asNum(c, 250);
|
|
115
|
-
const outDir =
|
|
116
|
-
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 } });
|
|
117
160
|
console.log(JSON.stringify(r, null, 2));
|
|
118
161
|
break;
|
|
119
162
|
}
|
|
120
163
|
case "hover": {
|
|
121
164
|
if (!url) die("missing url");
|
|
122
165
|
const sel = b; if (!sel) die("hover: missing <selector>");
|
|
123
|
-
const out = (c
|
|
124
|
-
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 } });
|
|
125
168
|
console.log(JSON.stringify(r, null, 2));
|
|
126
169
|
break;
|
|
127
170
|
}
|
|
128
171
|
case "sweep": {
|
|
129
|
-
const planPath =
|
|
130
|
-
if (!planPath) die("sweep: missing <plan.json>
|
|
131
|
-
|
|
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;
|
|
132
176
|
const r = await runSweep(planPath, outDirArg);
|
|
133
177
|
console.log(JSON.stringify(r, null, 2));
|
|
134
178
|
break;
|
|
135
179
|
}
|
|
136
180
|
case "report": {
|
|
137
181
|
// pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
|
|
138
|
-
const
|
|
139
|
-
const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
|
|
182
|
+
const sweepPath = cliFlags.sweep || a;
|
|
140
183
|
if (!sweepPath) die("report: missing --sweep <sweep.json>");
|
|
141
184
|
if (!existsSync(sweepPath)) die("report: sweep not found: " + sweepPath);
|
|
142
|
-
const
|
|
143
|
-
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");
|
|
144
186
|
if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const noEmbed = argv.includes("--no-embed");
|
|
187
|
+
const title = cliFlags.title;
|
|
188
|
+
const noEmbed = !!cliFlags["no-embed"];
|
|
148
189
|
const summary = JSON.parse(readFile(sweepPath, "utf8"));
|
|
149
190
|
const { renderSweepPdf } = await import("../src/report.js");
|
|
150
191
|
const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
|
|
151
192
|
console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
|
|
152
193
|
break;
|
|
153
194
|
}
|
|
154
|
-
case "every-viewport": {
|
|
155
|
-
if (!url) die("missing url");
|
|
156
|
-
const outDir =
|
|
157
|
-
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(",");
|
|
158
199
|
const r = await runEveryViewport({ url, outDir, viewports });
|
|
159
200
|
console.log(JSON.stringify(r, null, 2));
|
|
160
201
|
break;
|
|
161
202
|
}
|
|
162
|
-
case "audit": {
|
|
163
|
-
if (!url) die("missing url");
|
|
164
|
-
const tags =
|
|
165
|
-
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;
|
|
166
207
|
const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
|
|
167
208
|
console.log(JSON.stringify(r, null, 2));
|
|
168
209
|
break;
|
|
169
210
|
}
|
|
170
|
-
case "dom-snapshot": case "dom": {
|
|
171
|
-
if (!url) die("missing url");
|
|
172
|
-
const out =
|
|
211
|
+
case "dom-snapshot": case "dom": {
|
|
212
|
+
if (!url) die("missing url");
|
|
213
|
+
const out = cliFlags.out || b;
|
|
173
214
|
const r = await captureDomSnapshot({ url, out });
|
|
174
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));
|
|
175
216
|
break;
|
|
176
217
|
}
|
|
177
|
-
case "validate": {
|
|
178
|
-
const planPath =
|
|
179
|
-
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");
|
|
180
222
|
let plan;
|
|
181
223
|
try { plan = JSON.parse(readFile(planPath, "utf8")); }
|
|
182
224
|
catch (e) { die("validate: " + e.message); }
|
|
@@ -201,7 +243,7 @@ await loadPlugins(pluginPaths);
|
|
|
201
243
|
} else if (sub === "save") {
|
|
202
244
|
if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
|
|
203
245
|
const project = b, png = c, step = d;
|
|
204
|
-
const flags =
|
|
246
|
+
const flags = { ...cliFlags };
|
|
205
247
|
let meta = null;
|
|
206
248
|
if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
|
|
207
249
|
else if (flags.url) meta = { url: flags.url };
|
|
@@ -217,14 +259,14 @@ await loadPlugins(pluginPaths);
|
|
|
217
259
|
} else if (sub === "approve") {
|
|
218
260
|
if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
|
|
219
261
|
const project = b, png = c, step = d;
|
|
220
|
-
const flags =
|
|
262
|
+
const flags = { ...cliFlags };
|
|
221
263
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
222
264
|
const result = approveBaseline({ project, id, step, fromPng: png });
|
|
223
265
|
console.log(JSON.stringify({ approved: true, ...result }, null, 2));
|
|
224
266
|
} else if (sub === "show") {
|
|
225
267
|
if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
|
|
226
268
|
const project = b, step = c;
|
|
227
|
-
const flags =
|
|
269
|
+
const flags = { ...cliFlags };
|
|
228
270
|
const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
|
|
229
271
|
const r = loadBaseline({ project, id, step });
|
|
230
272
|
console.log(JSON.stringify(r, null, 2));
|
|
@@ -246,14 +288,14 @@ await loadPlugins(pluginPaths);
|
|
|
246
288
|
console.log(JSON.stringify(listAuthStates(project), null, 2));
|
|
247
289
|
} else if (sub === "save") {
|
|
248
290
|
if (!b || !c) die("auth save: <project> <name> --from <state.json>");
|
|
249
|
-
const fromFile =
|
|
291
|
+
const fromFile = cliFlags.from;
|
|
250
292
|
if (!fromFile) die("auth save: missing --from <state.json>");
|
|
251
293
|
const state = JSON.parse(readFile(fromFile, "utf8"));
|
|
252
294
|
const r = saveAuthState({ project: b, name: c, state });
|
|
253
295
|
console.log(JSON.stringify({ saved: true, ...r }, null, 2));
|
|
254
296
|
} else if (sub === "load") {
|
|
255
297
|
if (!b || !c) die("auth load: <project> <name> --out <state.json>");
|
|
256
|
-
const outFile =
|
|
298
|
+
const outFile = cliFlags.out;
|
|
257
299
|
if (!outFile) die("auth load: missing --out <state.json>");
|
|
258
300
|
const state = loadAuthState({ project: b, name: c });
|
|
259
301
|
if (!state) { console.error("not found"); process.exit(2); }
|
|
@@ -276,9 +318,9 @@ await loadPlugins(pluginPaths);
|
|
|
276
318
|
die("watch: missing <url> (or use --plan <plan.json>)");
|
|
277
319
|
}
|
|
278
320
|
const { startWatch } = await import("../src/watch.js");
|
|
279
|
-
const out =
|
|
321
|
+
const out = opts.out || b || makeOut("watch.png");
|
|
280
322
|
if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
|
|
281
|
-
const flags =
|
|
323
|
+
const flags = { ...cliFlags };
|
|
282
324
|
const onGlobs = [];
|
|
283
325
|
for (let i = 0; i < argv.length; i++) {
|
|
284
326
|
if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
|
|
@@ -305,9 +347,9 @@ await loadPlugins(pluginPaths);
|
|
|
305
347
|
// pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
|
|
306
348
|
if (!url) die("snap: missing <url>");
|
|
307
349
|
const sel = b; if (!sel) die("snap: missing <selector>");
|
|
308
|
-
const flags =
|
|
350
|
+
const flags = { ...cliFlags };
|
|
309
351
|
const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
|
|
310
|
-
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");
|
|
311
353
|
const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
|
|
312
354
|
console.log(JSON.stringify({
|
|
313
355
|
url: snap.url,
|
|
@@ -327,7 +369,7 @@ await loadPlugins(pluginPaths);
|
|
|
327
369
|
case "check": {
|
|
328
370
|
// pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
|
|
329
371
|
if (!url) die("check: missing <url>");
|
|
330
|
-
const flags =
|
|
372
|
+
const flags = { ...cliFlags };
|
|
331
373
|
const update = !!flags.update;
|
|
332
374
|
const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
|
|
333
375
|
const { runCheck } = await import("../src/check.js");
|
|
@@ -342,4 +384,4 @@ await loadPlugins(pluginPaths);
|
|
|
342
384
|
console.error(JSON.stringify({ error: e.message, stack: e.stack?.split("\n").slice(0, 3).join("\n") }, null, 2));
|
|
343
385
|
process.exit(1);
|
|
344
386
|
}
|
|
345
|
-
})();
|
|
387
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"./report": "./src/report.js",
|
|
41
41
|
"./ai-diff": "./src/ai-diff.js",
|
|
42
42
|
"./session": "./src/session.js",
|
|
43
|
+
"./operator": "./src/operator.js",
|
|
43
44
|
"./visual-operator": "./src/visual-operator.js"
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"plugins",
|
|
49
50
|
"plans",
|
|
50
51
|
"assets",
|
|
52
|
+
"SKILL.md",
|
|
51
53
|
"README.md",
|
|
52
54
|
"LICENSE"
|
|
53
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/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { runCheck } from "./check.js";
|
|
|
45
45
|
import { renderSweepPdf } from "./report.js";
|
|
46
46
|
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
47
47
|
import { BrowserSessionManager } from "./session.js";
|
|
48
|
+
import { runOperator } from "./operator.js";
|
|
48
49
|
import {
|
|
49
50
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
50
51
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
@@ -92,6 +93,7 @@ export {
|
|
|
92
93
|
renderSweepPdf,
|
|
93
94
|
aiDiffSummary, aiDiffSidecar,
|
|
94
95
|
BrowserSessionManager,
|
|
96
|
+
runOperator,
|
|
95
97
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
96
98
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
97
99
|
VERSION,
|
|
@@ -116,6 +118,7 @@ export default {
|
|
|
116
118
|
// v6: PDF report, AI diff summary
|
|
117
119
|
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
118
120
|
BrowserSessionManager,
|
|
121
|
+
runOperator,
|
|
119
122
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
120
123
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
121
124
|
VERSION,
|
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/mcp.js
CHANGED
|
@@ -161,6 +161,7 @@ class PursrMCPServer {
|
|
|
161
161
|
cdpUrl: { type: "string", description: "Chrome DevTools endpoint for mode=cdp, e.g. http://127.0.0.1:9222" },
|
|
162
162
|
slowMo: { type: "number", description: "Delay Playwright operations in milliseconds" },
|
|
163
163
|
operatorColor: { type: "string", description: "Visual Operator accent color" },
|
|
164
|
+
recordVideoDir: { type: "string", description: "Directory for a WebM recording (headless or visible mode only)" },
|
|
164
165
|
timeoutMs: { type: "number", description: "Navigation/CDP connection timeout" },
|
|
165
166
|
storageState: { description: "Playwright storageState object or file path" },
|
|
166
167
|
},
|
|
@@ -187,7 +188,7 @@ class PursrMCPServer {
|
|
|
187
188
|
},
|
|
188
189
|
{
|
|
189
190
|
name: "pursr_act",
|
|
190
|
-
description: "Perform ordered actions in a persistent session. Supports click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
|
|
191
|
+
description: "Perform ordered actions in a persistent session. Supports selector or coordinate click/doubleClick, drag, hover, fill, type, check, select, press, keyDown, keyUp, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
|
|
191
192
|
inputSchema: {
|
|
192
193
|
type: "object",
|
|
193
194
|
properties: {
|
|
@@ -416,6 +417,7 @@ class PursrMCPServer {
|
|
|
416
417
|
preset: args.preset, width: args.width, height: args.height, dpr: args.dpr,
|
|
417
418
|
mode: args.mode, visible: args.visible, visual: args.visual, cdpUrl: args.cdpUrl,
|
|
418
419
|
slowMo: args.slowMo, operatorColor: args.operatorColor, timeoutMs: args.timeoutMs,
|
|
420
|
+
recordVideoDir: args.recordVideoDir,
|
|
419
421
|
};
|
|
420
422
|
const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
|
|
421
423
|
return this._text(result);
|
package/src/operator.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// One-shot Visual Operator workflow for CLI and library consumers.
|
|
2
|
+
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { BrowserSessionManager } from "./session.js";
|
|
6
|
+
|
|
7
|
+
function normalizeActions(actions) {
|
|
8
|
+
if (typeof actions === "string") actions = JSON.parse(actions);
|
|
9
|
+
if (!Array.isArray(actions) || !actions.length) throw new Error("operator actions must be a non-empty JSON array");
|
|
10
|
+
return actions;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runOperator({
|
|
14
|
+
url,
|
|
15
|
+
actions,
|
|
16
|
+
out,
|
|
17
|
+
outputDir = process.cwd(),
|
|
18
|
+
sessionId = `operator-${Date.now().toString(36)}`,
|
|
19
|
+
flags = {},
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!url) throw new Error("operator url is required");
|
|
22
|
+
const steps = normalizeActions(actions);
|
|
23
|
+
const screenshotOut = resolve(out || join(outputDir, `${sessionId}.png`));
|
|
24
|
+
mkdirSync(dirname(screenshotOut), { recursive: true });
|
|
25
|
+
|
|
26
|
+
const manager = new BrowserSessionManager({ outputDir });
|
|
27
|
+
let opened = null;
|
|
28
|
+
let acted = null;
|
|
29
|
+
let shot = null;
|
|
30
|
+
let diagnostics = null;
|
|
31
|
+
let closed = null;
|
|
32
|
+
try {
|
|
33
|
+
opened = await manager.open({
|
|
34
|
+
sessionId,
|
|
35
|
+
url,
|
|
36
|
+
storageState: flags.storageState,
|
|
37
|
+
flags: { ...flags, visual: flags.visual !== false },
|
|
38
|
+
});
|
|
39
|
+
if (Number(flags.startDelayMs) > 0) {
|
|
40
|
+
await manager.get(sessionId).page.waitForTimeout(Number(flags.startDelayMs));
|
|
41
|
+
}
|
|
42
|
+
acted = await manager.act(sessionId, steps);
|
|
43
|
+
shot = await manager.screenshot(sessionId, { out: screenshotOut, full: !!flags.full });
|
|
44
|
+
diagnostics = manager.diagnostics(sessionId);
|
|
45
|
+
} finally {
|
|
46
|
+
closed = await manager.close(sessionId).catch(() => ({ sessionId, closed: false, video: null }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: !acted?.failed,
|
|
51
|
+
sessionId,
|
|
52
|
+
mode: opened?.mode,
|
|
53
|
+
visual: opened?.visual,
|
|
54
|
+
url: acted?.url || opened?.url || url,
|
|
55
|
+
title: acted?.title || opened?.title || null,
|
|
56
|
+
trace: acted?.trace || [],
|
|
57
|
+
screenshot: shot?.out || null,
|
|
58
|
+
video: closed?.video || null,
|
|
59
|
+
diagnostics,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/runway.js
CHANGED
|
@@ -70,6 +70,10 @@ export async function newPage(browser, viewport, opts = {}) {
|
|
|
70
70
|
hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
71
71
|
isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
72
72
|
storageState: opts.storageState || undefined,
|
|
73
|
+
recordVideo: opts.recordVideoDir ? {
|
|
74
|
+
dir: opts.recordVideoDir,
|
|
75
|
+
size: { width: viewport.width, height: viewport.height },
|
|
76
|
+
} : undefined,
|
|
73
77
|
});
|
|
74
78
|
const page = await ctx.newPage();
|
|
75
79
|
if (opts.context) await page.setViewportSize({ width: viewport.width, height: viewport.height }).catch(() => {});
|
package/src/session.js
CHANGED
|
@@ -71,6 +71,9 @@ export class BrowserSessionManager {
|
|
|
71
71
|
const mode = flags.mode || (flags.cdpUrl ? "cdp" : flags.visible ? "visible" : "headless");
|
|
72
72
|
if (!new Set(["headless", "visible", "cdp"]).has(mode)) throw new Error("mode must be headless, visible, or cdp");
|
|
73
73
|
const visual = flags.visual === true || mode === "visible";
|
|
74
|
+
const recordVideoDir = flags.recordVideoDir || null;
|
|
75
|
+
if (recordVideoDir && mode === "cdp") throw new Error("video recording is not available in CDP mode; use visible or headless mode");
|
|
76
|
+
if (recordVideoDir) mkdirSync(recordVideoDir, { recursive: true });
|
|
74
77
|
const operatorOptions = { color: flags.operatorColor || "#ff2ea6" };
|
|
75
78
|
const browser = mode === "cdp"
|
|
76
79
|
? await this.connectBrowser(flags.cdpUrl, { timeoutMs: flags.timeoutMs })
|
|
@@ -79,14 +82,18 @@ export class BrowserSessionManager {
|
|
|
79
82
|
const viewport = resolveViewport(flags);
|
|
80
83
|
const context = mode === "cdp" ? browser.contexts()[0] : null;
|
|
81
84
|
if (mode === "cdp" && !context) throw new Error("CDP browser has no default context");
|
|
82
|
-
const page = await newPage(browser, viewport, { storageState, context });
|
|
85
|
+
const page = await newPage(browser, viewport, { storageState, context, recordVideoDir });
|
|
83
86
|
const diagnostics = { console: [], errors: [], requests: [], responses: [] };
|
|
84
87
|
attachDiagnostics(page, diagnostics);
|
|
85
88
|
if (visual) page.on("domcontentloaded", () => installVisualOperator(page, operatorOptions).catch(() => {}));
|
|
86
89
|
const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
|
|
87
90
|
await settle(page);
|
|
88
91
|
if (visual) await installVisualOperator(page, operatorOptions);
|
|
89
|
-
const session = {
|
|
92
|
+
const session = {
|
|
93
|
+
id, browser, page, context: page._pursrContext, viewport, mode, visual,
|
|
94
|
+
operatorOptions, diagnostics, video: page.video?.() || null,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
90
97
|
this.sessions.set(id, session);
|
|
91
98
|
return { sessionId: id, url: page.url(), title: await page.title(), viewport, mode, visual, status: nav.status, createdAt: session.createdAt };
|
|
92
99
|
} catch (error) {
|
|
@@ -158,7 +165,7 @@ export class BrowserSessionManager {
|
|
|
158
165
|
const op = action.type || action.op;
|
|
159
166
|
const step = { index: i, type: op };
|
|
160
167
|
try {
|
|
161
|
-
if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
|
|
168
|
+
if (["click", "doubleClick", "hover", "fill", "type", "check", "select"].includes(op) && action.selector) {
|
|
162
169
|
const locator = await resolveLocator(page, action.selector);
|
|
163
170
|
await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
|
|
164
171
|
let point = null;
|
|
@@ -169,14 +176,42 @@ export class BrowserSessionManager {
|
|
|
169
176
|
step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
|
|
170
177
|
}
|
|
171
178
|
if (op === "click") await locator.first().click();
|
|
179
|
+
else if (op === "doubleClick") await locator.first().dblclick();
|
|
172
180
|
else if (op === "hover") await locator.first().hover();
|
|
173
181
|
else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
|
|
174
182
|
else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
|
|
175
183
|
else if (op === "check") await locator.first().setChecked(action.checked !== false);
|
|
176
184
|
else await locator.first().selectOption(action.value);
|
|
177
|
-
if (visual &&
|
|
185
|
+
if (visual && ["click", "doubleClick"].includes(op) && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
|
|
178
186
|
step.selector = action.selector;
|
|
187
|
+
} else if (["click", "doubleClick"].includes(op) && Number.isFinite(Number(action.x)) && Number.isFinite(Number(action.y))) {
|
|
188
|
+
const x = Number(action.x), y = Number(action.y);
|
|
189
|
+
if (visual) await moveVisualCursor(page, x, y, { ...operatorOptions, durationMs: action.durationMs });
|
|
190
|
+
await page.mouse[op === "doubleClick" ? "dblclick" : "click"](x, y, { button: action.button || "left" });
|
|
191
|
+
if (visual) await markVisualClick(page, x, y, { ...operatorOptions, color: action.color });
|
|
192
|
+
step.cursor = { x: Math.round(x), y: Math.round(y) };
|
|
193
|
+
} else if (op === "drag") {
|
|
194
|
+
const start = action.fromSelector
|
|
195
|
+
? await visualPointForLocator((await resolveLocator(page, action.fromSelector)).first())
|
|
196
|
+
: { x: Number(action.fromX), y: Number(action.fromY) };
|
|
197
|
+
const end = action.toSelector
|
|
198
|
+
? await visualPointForLocator((await resolveLocator(page, action.toSelector)).first())
|
|
199
|
+
: { x: Number(action.toX), y: Number(action.toY) };
|
|
200
|
+
if (![start.x, start.y, end.x, end.y].every(Number.isFinite)) throw new Error("drag requires from/to coordinates or selectors");
|
|
201
|
+
if (visual) await moveVisualCursor(page, start.x, start.y, { ...operatorOptions, durationMs: action.durationMs });
|
|
202
|
+
await page.mouse.move(start.x, start.y);
|
|
203
|
+
await page.mouse.down({ button: action.button || "left" });
|
|
204
|
+
const steps = Math.max(1, Math.min(100, Number(action.steps) || 20));
|
|
205
|
+
await page.mouse.move(end.x, end.y, { steps });
|
|
206
|
+
await page.mouse.up({ button: action.button || "left" });
|
|
207
|
+
if (visual) {
|
|
208
|
+
await moveVisualCursor(page, end.x, end.y, { ...operatorOptions, durationMs: 0 });
|
|
209
|
+
await markVisualClick(page, end.x, end.y, { ...operatorOptions, color: action.color });
|
|
210
|
+
}
|
|
211
|
+
step.cursor = { x: Math.round(end.x), y: Math.round(end.y) };
|
|
179
212
|
} else if (op === "press") await page.keyboard.press(String(action.key));
|
|
213
|
+
else if (op === "keyDown") await page.keyboard.down(String(action.key));
|
|
214
|
+
else if (op === "keyUp") await page.keyboard.up(String(action.key));
|
|
180
215
|
else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
|
|
181
216
|
else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
|
|
182
217
|
else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
|
|
@@ -242,8 +277,16 @@ export class BrowserSessionManager {
|
|
|
242
277
|
const session = this.sessions.get(id);
|
|
243
278
|
if (!session) return { sessionId: id, closed: false };
|
|
244
279
|
this.sessions.delete(id);
|
|
280
|
+
let video = null;
|
|
281
|
+
try {
|
|
282
|
+
if (session.mode === "cdp") await session.page.close();
|
|
283
|
+
else await session.context.close();
|
|
284
|
+
} catch {}
|
|
245
285
|
try { await session.browser.close(); } catch {}
|
|
246
|
-
|
|
286
|
+
if (session.video) {
|
|
287
|
+
try { video = await session.video.path(); } catch {}
|
|
288
|
+
}
|
|
289
|
+
return { sessionId: id, closed: true, video };
|
|
247
290
|
}
|
|
248
291
|
|
|
249
292
|
async closeAll() {
|
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
|
+
}
|