pursor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,595 +1,654 @@
1
- # pursor
2
-
3
- > **Visual QA & audit CLI + library + MCP server for the browser.**
4
- > Capture, diff, sweep, and audit any web target — with multi-viewport,
5
- > layered states, hover, grid overlays, animation freeze, camera control,
6
- > axe-core accessibility audit, CI output, and auto-healing selectors.
7
-
8
- ```bash
9
- npx pursor probe https://example.com
10
- npx pursor shoot https://example.com --preset mobile-375 --grid
11
- npx pursor sweep ./plan.json
12
- npx pursor audit https://example.com --tags wcag2a,wcag2aa
13
- npx pursor-mcp # MCP stdio server for Claude Code / Cursor
14
- ```
15
-
16
- ## Install
17
-
18
- ```bash
19
- npm install pursor
20
- npm install --save-dev playwright-core # peer dep — bring your own Chrome
21
- ```
22
-
23
- `pursor` does **not** bundle Chromium. It drives your system Chrome via
24
- Playwright. No extra browser downloads.
25
-
26
- ---
27
-
28
- ## Table of Contents
29
-
30
- - [CLI](#cli)
31
- - [MCP Server](#mcp-server)
32
- - [Accessibility Audit](#accessibility-audit)
33
- - [DOM Snapshot](#dom-snapshot)
34
- - [CI Output](#ci-output)
35
- - [Auto-heal Selectors](#auto-heal-selectors)
36
- - [Sweep Plans](#sweep-plans)
37
- - [Plugin API](#plugin-api)
38
- - [Library API](#library-api)
39
- - [Development](#development)
40
-
41
- ---
42
-
43
- ## CLI
44
-
45
- ```bash
46
- # Health check
47
- pursor probe https://example.com
48
-
49
- # Screenshot (simple)
50
- pursor shot https://example.com ./out/shot.png
51
-
52
- # Rich capture: viewport preset + cursor + grid
53
- pursor shoot https://example.com \
54
- --preset desktop-1280 \
55
- --cursor crosshair \
56
- --grid --grid-tile 64
57
-
58
- # Isolate a layer (entity / terrain / hud / ui)
59
- pursor layer https://example.com entity
60
-
61
- # Animation timeline: 8 frames at 200ms
62
- pursor frames https://example.com 8 200 ./frames/
63
-
64
- # Hover an element
65
- pursor hover https://example.com "text=Login"
66
-
67
- # Pixel diff vs a reference screenshot
68
- pursor diff https://example.com ./ref.png ./out/diff.png
69
-
70
- # Batched plan (see plans/ for examples)
71
- pursor sweep ./plan.json
72
-
73
- # Accessibility audit (requires: npm i axe-core)
74
- pursor audit https://example.com --tags wcag2a,wcag2aa
75
-
76
- # DOM + selector map snapshot
77
- pursor dom https://example.com
78
- ```
79
-
80
- ### Subcommands
81
-
82
- | Subcommand | Purpose |
83
- |---|---|
84
- | `probe` | Health check (HTTP status, page title) |
85
- | `shot` / `full` | Viewport / full-page screenshot |
86
- | `eval` | Execute JS in the page, return result |
87
- | `click` / `type` / `wait` / `seq` | Interaction primitives |
88
- | `diff` | Pixel-level diff vs a reference PNG |
89
- | `viewports` | List all registered viewport presets |
90
- | `shoot` | Rich capture (overlays, freeze, camera, plugins) |
91
- | `layer` | Capture one isolated layer (entity/hud/ui/terrain) |
92
- | `frames` | N-frame animation timeline at interval |
93
- | `hover` | Hover state capture |
94
- | `sweep` | Batched capture plan → HTML report + CI output |
95
- | `audit` | ⭐ axe-core WCAG accessibility audit + highlighted screenshot |
96
- | `dom` / `dom-snapshot` | ⭐ Serialized DOM + CSS selectors + XPath + bounding rects |
97
-
98
- ---
99
-
100
- ## MCP Server
101
-
102
- `pursor-mcp` exposes every capability as MCP tools over stdio —
103
- works with Claude Code, Cursor, Continue, and any MCP host.
104
-
105
- ```bash
106
- npx pursor-mcp
107
- # or with verbose logging:
108
- npx pursor-mcp --verbose
109
- ```
110
-
111
- ### Exposed Tools
112
-
113
- | Tool | Description |
114
- |---|---|
115
- | `pursor_shoot` | Full screenshot with viewport, grid, layer, cursor, camera, freeze |
116
- | `pursor_diff` | Pixel diff vs reference PNG + diff overlay |
117
- | `pursor_sweep` | Execute a batch plan JSON → summary |
118
- | `pursor_frames` | Animation frame timeline |
119
- | `pursor_probe` | Health-check a URL |
120
- | `pursor_audit` | axe-core accessibility audit |
121
- | `pursor_dom_snapshot` | DOM + CSSOM + selector map + bounding rects |
122
-
123
- ### Config
124
-
125
- Config via `PURSOR_MCP_CONFIG` env var (inline JSON or file path)
126
- or `~/.pursor/mcp-config.json`:
127
-
128
- ```json
129
- {
130
- "plugins": ["./my-plugin.js"],
131
- "defaultOutDir": "./mcp-output",
132
- "verbose": true
133
- }
134
- ```
135
-
136
- ### MCP Host Examples
137
-
138
- **Claude Code:**
139
- ```json
140
- {
141
- "mcpServers": {
142
- "pursor": {
143
- "command": "npx",
144
- "args": ["pursor-mcp"]
145
- }
146
- }
147
- }
148
- ```
149
-
150
- **Cursor:**
151
- ```json
152
- {
153
- "mcpServers": {
154
- "pursor": {
155
- "command": "npx",
156
- "args": ["pursor-mcp", "--verbose"]
157
- }
158
- }
159
- }
160
- ```
161
-
162
- ---
163
-
164
- ## Accessibility Audit
165
-
166
- Run axe-core WCAG audits on any URL. Optionally captures a highlighted
167
- screenshot with violated elements outlined in red.
168
-
169
- ```bash
170
- # Quick audit with default tags (wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice)
171
- pursor audit https://example.com
172
-
173
- # Specific WCAG tags
174
- pursor audit https://example.com --tags wcag2a,wcag2aa
175
-
176
- # Custom output directory
177
- pursor audit https://example.com ./audit-report/
178
- ```
179
-
180
- **Output:**
181
- - `audit.json` — full axe-core results with violation summary
182
- - `audit-summary.md` — readable Markdown report with severity breakdown
183
- - `audit-highlighted.png` — screenshot with violations visibly marked
184
-
185
- **Sweep plan usage:**
186
- ```json
187
- {
188
- "name": "accessibility-check",
189
- "base": "https://example.com",
190
- "steps": [
191
- {
192
- "name": "wcag-audit",
193
- "audit": {
194
- "tags": "wcag2a,wcag2aa,wcag21aa",
195
- "screenshot": true
196
- }
197
- }
198
- ]
199
- }
200
- ```
201
-
202
- ---
203
-
204
- ## DOM Snapshot
205
-
206
- Every capture can optionally produce a `.dom.json` sidecar with complete
207
- page structure — useful for debugging visual regression without opening
208
- a browser.
209
-
210
- ```bash
211
- pursor dom https://example.com
212
- ```
213
-
214
- **Captured data:**
215
- - `dom` — `document.documentElement.outerHTML`
216
- - `selectorMap[]` — every visible element with:
217
- - `tag`, `id`, `css` (CSS selector), `xpath`
218
- - `role`, `ariaLabel`, `ariaRole`, `text`, `placeholder`, `alt`, `href`, `src`
219
- - `rect` — viewport-relative bounding box `{x, y, w, h}`
220
- - `visible` — visibility flag
221
- - `styles` — computed stylesheet rules keyed by selector
222
- - `viewport` — current viewport dimensions + DPR
223
-
224
- **Programmatic:**
225
- ```js
226
- import { captureDomSnapshot } from "pursor";
227
-
228
- const snapshot = await captureDomSnapshot({
229
- url: "https://example.com",
230
- out: "./snapshot.dom.json",
231
- });
232
- console.log(snapshot.selectorMap.length, "elements found");
233
- ```
234
-
235
- ---
236
-
237
- ## CI Output
238
-
239
- Sweep plans automatically generate CI-compatible output files alongside
240
- the HTML report — no extra config needed.
241
-
242
- ```bash
243
- pursor sweep ./plan.json
244
- # Produces in the output directory:
245
- # sweep.json — raw summary
246
- # index.html — visual HTML dashboard
247
- # sweep.junit.xml — JUnit XML (GitLab CI, Jenkins, CircleCI)
248
- # sweep.github.json — GitHub Actions annotations format
249
- # sweep.md — Markdown summary
250
- ```
251
-
252
- ### GitHub Actions integration
253
-
254
- ```yaml
255
- - name: Visual QA
256
- run: npx pursor@latest sweep ./plan.json
257
- - name: Annotate
258
- uses: actions/github-script@v7
259
- with:
260
- script: |
261
- const fs = require('fs');
262
- const { annotations } = JSON.parse(fs.readFileSync('sweep-output/sweep.github.json'));
263
- annotations.forEach(a => core.error(a.message, {file: a.filename, title: a.title}));
264
- ```
265
-
266
- ### JUnit in GitLab CI
267
-
268
- ```yaml
269
- visual-qa:
270
- script: npx pursor@latest sweep ./plan.json
271
- artifacts:
272
- reports:
273
- junit: sweep-output/sweep.junit.xml
274
- ```
275
-
276
- ---
277
-
278
- ## Auto-heal Selectors
279
-
280
- In sweep plans, selectors can be an array of fallback strategies.
281
- pursor tries each one in order until a visible element is found:
282
-
283
- ```json
284
- {
285
- "name": "login-flow",
286
- "base": "https://example.com",
287
- "steps": [
288
- {
289
- "name": "click-login",
290
- "hover": {
291
- "selector": [
292
- "text=Login",
293
- "button[type=submit]",
294
- "#login-btn",
295
- "a[href*='login']"
296
- ]
297
- }
298
- }
299
- ]
300
- }
301
- ```
302
-
303
- **Supported selector types:**
304
- - `text=Login` — Playwright text locator (substring, or `text==Login` for exact)
305
- - `text~regex` — regex text match
306
- - `role=button|Submit` — ARIA role with accessible name
307
- - `aria=label` — accessibility label
308
- - `placeholder=Email` — placeholder text
309
- - CSS selectors — any valid CSS selector as fallback
310
-
311
- ---
312
-
313
- ## Sweep Plans
314
-
315
- Batch capture plans in JSON. Each step runs one operation.
316
-
317
- ```json
318
- {
319
- "name": "checkout-flow",
320
- "base": "https://example.com",
321
- "outDir": "./sweep-checkout",
322
- "steps": [
323
- { "name": "homepage", "shoot": { "preset": "desktop-1280", "cursor": "default" } },
324
- { "name": "mobile-view","shoot": { "preset": "mobile-375", "grid": true } },
325
- { "name": "nav-hover", "hover": { "selector": "text=Products", "settleMs": 400 } },
326
- { "name": "add-to-cart","frames": { "count": 6, "intervalMs": 200 } },
327
- { "name": "diff", "diff": { "ref": "baseline" } }
328
- ]
329
- }
330
- ```
331
-
332
- **Step operations:** `shoot`, `hover`, `frames`, `diff`, `audit`, or any
333
- registered plugin sweep-op.
334
-
335
- ---
336
-
337
- ## Plugin API
338
-
339
- Extend `pursor` with custom viewport presets, sweep operations, or
340
- capture hooks:
341
-
342
- ```js
343
- export default {
344
- name: "my-plugin",
345
- viewport: {
346
- "my-laptop": { width: 1440, height: 900, dpr: 2, label: "MBP 14" },
347
- },
348
- sweepOp: {
349
- lighthouse: async (ctx, opts) => {
350
- // run lighthouse audit, write result at ctx.out
351
- return { score: 95 };
352
- },
353
- },
354
- beforeShoot: async (ctx) => { /* mutate ctx.flags */ },
355
- afterShoot: async (ctx, meta) => { /* augment sidecar */ },
356
- };
357
- ```
358
-
359
- ```bash
360
- pursor shoot https://example.com --plugin ./my-plugin.js
361
- ```
362
-
363
- Publish as `pursor-plugin-*` for auto-discovery.
364
-
365
- ### Built-in plugins
366
-
367
- - **`plugin-audit`** — adds `audit` sweep-op (axe-core WCAG audit) and
368
- `every-viewport` sweep-op (capture at every preset). Adds `audit-canvas`
369
- viewport preset.
370
- - **`plugin-demo`** — Reference implementation showing every plugin API
371
- hook: viewport presets, `nav` sweep-op (navbar walker), `beforeShoot`/
372
- `afterShoot` sidecar augmentation, and flag help.
373
-
374
- ---
375
-
376
- ## All CLI flags
377
-
378
- | Flag | Type | Default | Description |
379
- |---|---|---|---|
380
- | `--preset` | string | `desktop-1280` | Named viewport preset |
381
- | `--width` / `--height` | number | — | Custom viewport size |
382
- | `--dpr` | number | `1` | Device pixel ratio |
383
- | `--cursor` | string | `default` | `pointer`, `grab`, `crosshair`, `none` |
384
- | `--grid` | bool | `false` | Overlay grid |
385
- | `--grid-tile` | number | `64` | Grid tile size (px) |
386
- | `--grid-color` | string | `rgba(255,0,255,0.35)` | Grid line color |
387
- | `--layer` | string | `all` | `entity`, `terrain`, `hud`, `ui` |
388
- | `--no-animation` | bool | `false` | Freeze CSS animations |
389
- | `--no-hud` | bool | `false` | Hide HUD elements |
390
- | `--wait-frame` | number | `600` | Wait ms for canvas stability |
391
- | `--zoom` | number | `1` | Zoom level |
392
- | `--panX` / `--panY` | number | `0` | Camera pan offset (px) |
393
- | `--full` | bool | `false` | Full-page (not just viewport) |
394
- | `--tags` | string | — | Comma-separated WCAG tags for audit |
395
- | `--plugin` | path | — | Load a plugin file (repeatable) |
396
- | `@file` | prefix | — | Read next arg from file |
397
-
398
- ---
399
-
400
- ## Library API
401
-
402
- All functions available as named or default import:
403
-
404
- ```js
405
- import { runShoot, runSweep, runAudit, captureDomSnapshot } from "pursor";
406
- // Or:
407
- import PurrVisual from "pursor";
408
- ```
409
-
410
- ### Capture functions
411
-
412
- | Function | Returns | Never throws |
413
- |---|---|---|
414
- | `runShoot({url, out, flags?, prepare?, browser?})` | `{ url, out, ts, status, title, viewport, flags, error? }` | ✅ |
415
- | `runShot(url, out, opts?)` | `{ url, out, status, title, fullPage }` | — |
416
- | `runProbe(url)` | `{ url, status, title, navError, viewport }` | — |
417
- | `runFrames({url, count?, intervalMs?, outDir?, flags?, browser?})` | `{ url, files[], viewport, ... }` | — |
418
- | `runHover({url, selector, out, flags?})` | `{ url, out, selector, viewport, ... }` | — |
419
- | `runDiff(url, refPath, out, threshold?, browser?)` | `{ url, refPath, numDiff, diffPct, equal, error? }` | — |
420
- | `runWait(url, selector, timeoutMs?)` | `{ url, selector, found, timeoutMs }` | — |
421
- | `runClick(url, selector, out?)` | `{ url, selector, clicked, out }` | — |
422
- | `runType(url, selector, text, out?)` | `{ url, selector, text, typed, out }` | — |
423
- | `runSeq(url, actionsJson, out?)` | `{ url, out, steps[], failed? }` | — |
424
- | `runEval(url, js, out?)` | `{ url, result, out, ... }` | — |
425
- | `runSweep(planPath, outDir?)` | `{ name, steps[], outDir, ... }` | ✅ (per-step) |
426
- | `runAudit({url, tags?, outDir?, screenshot?, flags?})` | `{ url, violations, violationSummary, highlightedScreenshot?, ... }` | — |
427
- | `captureDomSnapshot({url, out, flags?})` | `{ url, title, dom, selectorMap[], styles, viewport }` | — |
428
-
429
- ### Viewport helpers
430
-
431
- | Export | Description |
432
- |---|---|
433
- | `listViewports()` | All registered presets (built-in + plugin) |
434
- | `resolveViewport(flags)` | Resolve `--preset` / `--width` / `--height` to viewport object |
435
- | `VIEWPORTS` | Built-in preset map |
436
- | `applyCamera(page, opts)` | Zoom/pan via mouse wheel + drag on canvas |
437
- | `waitForStableFrame(page, ms)` | Poll canvas until stable for `ms` |
438
-
439
- ### Plugin system
440
-
441
- | Export | Description |
442
- |---|---|
443
- | `loadPlugins(paths?)` | Auto-load built-in plugins + user paths |
444
- | `registerPlugin(plugin)` | Register a plugin manually |
445
- | `listPlugins()` | Names of loaded plugins |
446
- | `getSweepOp(name)` | Get a registered sweep operation |
447
- | `getViewportPreset(name)` | Get a registered viewport preset |
448
- | `listViewportPresets()` | All plugin-registered presets |
449
- | `getFlagHelp()` | All plugin-registered flag descriptions |
450
-
451
- ### Selector healing
452
-
453
- | Export | Description |
454
- |---|---|
455
- | `resolveHealedSelector(page, selector, opts?)` | Try selector chain, return first visible match |
456
- | `healStepAction(page, action)` | Mutate action.selector → resolved selector |
457
-
458
- ### CI output
459
-
460
- | Export | Description |
461
- |---|---|
462
- | `writeCiOutput(summary, dir)` | Write JUnit XML + GitHub annotations + Markdown |
463
-
464
- ### MCP Server
465
-
466
- | Export | Description |
467
- |---|---|
468
- | `PurrVisualMCPServer` | MCP stdio server class |
469
- | `loadMcpConfig()` | Load config from env or `~/.pursor/mcp-config.json` |
470
- | `MCP_VERSION` | MCP protocol version string |
471
-
472
- ### Low-level (plugin authors)
473
-
474
- | Export | Source |
475
- |---|---|
476
- | `launch()` / `newPage(browser, viewport)` | `runway.js` |
477
- | `resolveLocator(page, selector)` / `parseTextSelector(s)` | `selector.js` |
478
- | `parseFlags(argv)` / `asNum(v, dflt)` / `asBool(v, dflt)` | `util.js` |
479
- | `nowIso()` / `shortHash(buf)` / `escapeHtml(s)` | `util.js` |
480
- | `readArg(arg)` / `makeOut(name)` / `findStepPng(dir, name)` | `util.js` |
481
- | `renderSweepHtml(summary)` | `util.js` |
482
-
483
- ### Subpath exports
484
-
485
- ```js
486
- import { resolveLocator } from "pursor/selector";
487
- import { launch } from "pursor/runway";
488
- import { parseFlags } from "pursor/util";
489
- import { overlayGrid } from "pursor/overlays";
490
- import { captureDomSnapshot } from "pursor/dom-snapshot";
491
- import { runAudit } from "pursor/plugin-audit";
492
- import { resolveHealedSelector } from "pursor/selector-heal";
493
- import { writeCiOutput } from "pursor/ci-output";
494
- import { PurrVisualMCPServer } from "pursor/mcp";
495
- ```
496
-
497
- ---
498
-
499
- ## Sidecar JSON
500
-
501
- Every capture writes a `.json` sidecar next to its PNG with metadata
502
- (url, viewport, flags, timestamp, file size, SHA1 hash). DOM snapshots
503
- write `.dom.json` with full element map. Audit reports write full
504
- axe-core results to `audit.json`.
505
-
506
- ---
507
-
508
- ## Development
509
-
510
- ```bash
511
- git clone <this repo>
512
- cd pursor
513
- npm install
514
- npm install --save-dev playwright-core
515
- npm test
516
- ```
517
-
518
- All 32 tests use Node's built-in test runner. Coverage: unit tests for
519
- viewport resolution, flag parsing, selector parsing, HTML escaping, hashing,
520
- and end-to-end smoke tests for the full CLI pipeline.
521
-
522
- ```
523
- src/ — 22 modules
524
- test/ — 32 tests, 0 failures
525
- plugins/ — 2 built-in plugins, auto-loaded
526
- ```
527
-
528
- ---
529
-
530
- ## Visual Regression Baselines
531
-
532
- ```bash
533
- # Save a baseline (computed id from url+viewport+flags)
534
- pursor baseline save myapp ./out/shoot.png home --url https://my.app
535
-
536
- # Approve a new baseline from a current capture
537
- pursor baseline approve myapp ./out/shoot.png home --url https://my.app
538
-
539
- # List all baselines
540
- pursor baseline list myapp
541
-
542
- # Show a specific baseline
543
- pursor baseline show myapp home --url https://my.app
544
- ```
545
-
546
- Baselines are stored under `~/.pursor/baselines/<project>/<id>/`. Override
547
- with `PURSOR_BASELINES_DIR`. The `id` is a 16-char SHA1 prefix of
548
- `url|viewport|flags` so re-running a sweep maps to the same slot
549
- deterministically. Use in code:
550
-
551
- ```js
552
- import { diffKey, saveBaseline, loadBaseline } from "pursor/baseline";
553
- const id = diffKey({ url: "https://my.app", viewport: { width: 1280, height: 800, dpr: 1 }, flags: { preset: "desktop-1280" } });
554
- saveBaseline({ project: "myapp", id, step: "home", png: "./shot.png", meta: { url: "https://my.app" } });
555
- ```
556
-
557
- ## Sweep Plan Validation
558
-
559
- ```bash
560
- pursor validate ./plan.json
561
- # { "valid": false, "errors": ["steps[2].frames.count: must be a number between 1 and 120"] }
562
- ```
563
-
564
- Catches: empty steps, unknown ops, out-of-range numbers, duplicate names,
565
- missing required fields. `pursor sweep` runs the same validator before
566
- executing — fail-fast.
567
-
568
- ## MCP Resources
569
-
570
- The MCP server now exposes `resources/list` and `resources/read` so
571
- Claude Code / Cursor can browse past captures:
572
-
573
- ```
574
- uri: pursor://shoot/<url|preset>
575
- uri: pursor://sweep/<plan-name>
576
- ```
577
-
578
- Resources are persisted to `~/.pursor/mcp/mcp-index.json` (override
579
- with `PURSOR_MCP_STATE`) and re-listed on every server start.
580
-
581
- ## Library additions (v0.2.0)
582
-
583
- | Export | Description |
584
- |---|---|
585
- | `diffKey`, `saveBaseline`, `loadBaseline`, `listBaselines`, `approveBaseline` | Visual regression baseline storage |
586
- | `validateSweepPlan`, `registerSweepOp` | Sweep plan schema validation |
587
- | `listResources`, `readResource`, `recordResource` | MCP resource adapter |
588
-
589
- Subpath exports: `pursor/baseline`, `pursor/sweep-schema`, `pursor/mcp-resources`.
590
-
591
- ---
592
-
593
- ## License
594
-
595
- MIT
1
+ # pursor
2
+
3
+ > **Visual QA & audit CLI + library + MCP server for the browser.**
4
+ > Capture, diff, sweep, and audit any web target — with multi-viewport,
5
+ > layered states, hover, grid overlays, animation freeze, camera control,
6
+ > axe-core accessibility audit, CI output, and auto-healing selectors.
7
+
8
+ ```bash
9
+ npx pursor probe https://example.com
10
+ npx pursor shoot https://example.com --preset mobile-375 --grid
11
+ npx pursor sweep ./plan.json
12
+ npx pursor audit https://example.com --tags wcag2a,wcag2aa
13
+ npx pursor-mcp # MCP stdio server for Claude Code / Cursor
14
+ ```
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install pursor
20
+ npm install --save-dev playwright-core # peer dep — bring your own Chrome
21
+ ```
22
+
23
+ `pursor` does **not** bundle Chromium. It drives your system Chrome via
24
+ Playwright. No extra browser downloads.
25
+
26
+ ---
27
+
28
+ ## Table of Contents
29
+
30
+ - [CLI](#cli)
31
+ - [MCP Server](#mcp-server)
32
+ - [Accessibility Audit](#accessibility-audit)
33
+ - [DOM Snapshot](#dom-snapshot)
34
+ - [CI Output](#ci-output)
35
+ - [Auto-heal Selectors](#auto-heal-selectors)
36
+ - [Sweep Plans](#sweep-plans)
37
+ - [Plugin API](#plugin-api)
38
+ - [Library API](#library-api)
39
+ - [Development](#development)
40
+
41
+ ---
42
+
43
+ ## CLI
44
+
45
+ ```bash
46
+ # Health check
47
+ pursor probe https://example.com
48
+
49
+ # Screenshot (simple)
50
+ pursor shot https://example.com ./out/shot.png
51
+
52
+ # Rich capture: viewport preset + cursor + grid
53
+ pursor shoot https://example.com \
54
+ --preset desktop-1280 \
55
+ --cursor crosshair \
56
+ --grid --grid-tile 64
57
+
58
+ # Isolate a layer (entity / terrain / hud / ui)
59
+ pursor layer https://example.com entity
60
+
61
+ # Animation timeline: 8 frames at 200ms
62
+ pursor frames https://example.com 8 200 ./frames/
63
+
64
+ # Hover an element
65
+ pursor hover https://example.com "text=Login"
66
+
67
+ # Pixel diff vs a reference screenshot
68
+ pursor diff https://example.com ./ref.png ./out/diff.png
69
+
70
+ # Batched plan (see plans/ for examples)
71
+ pursor sweep ./plan.json
72
+
73
+ # Accessibility audit (requires: npm i axe-core)
74
+ pursor audit https://example.com --tags wcag2a,wcag2aa
75
+
76
+ # DOM + selector map snapshot
77
+ pursor dom https://example.com
78
+ ```
79
+
80
+ ### Subcommands
81
+
82
+ | Subcommand | Purpose |
83
+ |---|---|
84
+ | `probe` | Health check (HTTP status, page title) |
85
+ | `shot` / `full` | Viewport / full-page screenshot |
86
+ | `eval` | Execute JS in the page, return result |
87
+ | `click` / `type` / `wait` / `seq` | Interaction primitives |
88
+ | `diff` | Pixel-level diff vs a reference PNG |
89
+ | `viewports` | List all registered viewport presets |
90
+ | `shoot` | Rich capture (overlays, freeze, camera, plugins) |
91
+ | `layer` | Capture one isolated layer (entity/hud/ui/terrain) |
92
+ | `frames` | N-frame animation timeline at interval |
93
+ | `hover` | Hover state capture |
94
+ | `sweep` | Batched capture plan → HTML report + CI output |
95
+ | `audit` | ⭐ axe-core WCAG accessibility audit + highlighted screenshot |
96
+ | `dom` / `dom-snapshot` | ⭐ Serialized DOM + CSS selectors + XPath + bounding rects |
97
+
98
+ ---
99
+
100
+ ## MCP Server
101
+
102
+ `pursor-mcp` exposes every capability as MCP tools over stdio —
103
+ works with Claude Code, Cursor, Continue, and any MCP host.
104
+
105
+ ```bash
106
+ npx pursor-mcp
107
+ # or with verbose logging:
108
+ npx pursor-mcp --verbose
109
+ ```
110
+
111
+ ### Exposed Tools
112
+
113
+ | Tool | Description |
114
+ |---|---|
115
+ | `pursor_shoot` | Full screenshot with viewport, grid, layer, cursor, camera, freeze |
116
+ | `pursor_diff` | Pixel diff vs reference PNG + diff overlay |
117
+ | `pursor_sweep` | Execute a batch plan JSON → summary |
118
+ | `pursor_frames` | Animation frame timeline |
119
+ | `pursor_probe` | Health-check a URL |
120
+ | `pursor_audit` | axe-core accessibility audit |
121
+ | `pursor_dom_snapshot` | DOM + CSSOM + selector map + bounding rects |
122
+
123
+ ### Config
124
+
125
+ Config via `PURSOR_MCP_CONFIG` env var (inline JSON or file path)
126
+ or `~/.pursor/mcp-config.json`:
127
+
128
+ ```json
129
+ {
130
+ "plugins": ["./my-plugin.js"],
131
+ "defaultOutDir": "./mcp-output",
132
+ "verbose": true
133
+ }
134
+ ```
135
+
136
+ ### MCP Host Examples
137
+
138
+ **Claude Code:**
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "pursor": {
143
+ "command": "npx",
144
+ "args": ["pursor-mcp"]
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ **Cursor:**
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "pursor": {
155
+ "command": "npx",
156
+ "args": ["pursor-mcp", "--verbose"]
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Accessibility Audit
165
+
166
+ Run axe-core WCAG audits on any URL. Optionally captures a highlighted
167
+ screenshot with violated elements outlined in red.
168
+
169
+ ```bash
170
+ # Quick audit with default tags (wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice)
171
+ pursor audit https://example.com
172
+
173
+ # Specific WCAG tags
174
+ pursor audit https://example.com --tags wcag2a,wcag2aa
175
+
176
+ # Custom output directory
177
+ pursor audit https://example.com ./audit-report/
178
+ ```
179
+
180
+ **Output:**
181
+ - `audit.json` — full axe-core results with violation summary
182
+ - `audit-summary.md` — readable Markdown report with severity breakdown
183
+ - `audit-highlighted.png` — screenshot with violations visibly marked
184
+
185
+ **Sweep plan usage:**
186
+ ```json
187
+ {
188
+ "name": "accessibility-check",
189
+ "base": "https://example.com",
190
+ "steps": [
191
+ {
192
+ "name": "wcag-audit",
193
+ "audit": {
194
+ "tags": "wcag2a,wcag2aa,wcag21aa",
195
+ "screenshot": true
196
+ }
197
+ }
198
+ ]
199
+ }
200
+ ```
201
+
202
+ ---
203
+
204
+ ## DOM Snapshot
205
+
206
+ Every capture can optionally produce a `.dom.json` sidecar with complete
207
+ page structure — useful for debugging visual regression without opening
208
+ a browser.
209
+
210
+ ```bash
211
+ pursor dom https://example.com
212
+ ```
213
+
214
+ **Captured data:**
215
+ - `dom` — `document.documentElement.outerHTML`
216
+ - `selectorMap[]` — every visible element with:
217
+ - `tag`, `id`, `css` (CSS selector), `xpath`
218
+ - `role`, `ariaLabel`, `ariaRole`, `text`, `placeholder`, `alt`, `href`, `src`
219
+ - `rect` — viewport-relative bounding box `{x, y, w, h}`
220
+ - `visible` — visibility flag
221
+ - `styles` — computed stylesheet rules keyed by selector
222
+ - `viewport` — current viewport dimensions + DPR
223
+
224
+ **Programmatic:**
225
+ ```js
226
+ import { captureDomSnapshot } from "pursor";
227
+
228
+ const snapshot = await captureDomSnapshot({
229
+ url: "https://example.com",
230
+ out: "./snapshot.dom.json",
231
+ });
232
+ console.log(snapshot.selectorMap.length, "elements found");
233
+ ```
234
+
235
+ ---
236
+
237
+ ## CI Output
238
+
239
+ Sweep plans automatically generate CI-compatible output files alongside
240
+ the HTML report — no extra config needed.
241
+
242
+ ```bash
243
+ pursor sweep ./plan.json
244
+ # Produces in the output directory:
245
+ # sweep.json — raw summary
246
+ # index.html — visual HTML dashboard
247
+ # sweep.junit.xml — JUnit XML (GitLab CI, Jenkins, CircleCI)
248
+ # sweep.github.json — GitHub Actions annotations format
249
+ # sweep.md — Markdown summary
250
+ ```
251
+
252
+ ### GitHub Actions integration
253
+
254
+ ```yaml
255
+ - name: Visual QA
256
+ run: npx pursor@latest sweep ./plan.json
257
+ - name: Annotate
258
+ uses: actions/github-script@v7
259
+ with:
260
+ script: |
261
+ const fs = require('fs');
262
+ const { annotations } = JSON.parse(fs.readFileSync('sweep-output/sweep.github.json'));
263
+ annotations.forEach(a => core.error(a.message, {file: a.filename, title: a.title}));
264
+ ```
265
+
266
+ ### JUnit in GitLab CI
267
+
268
+ ```yaml
269
+ visual-qa:
270
+ script: npx pursor@latest sweep ./plan.json
271
+ artifacts:
272
+ reports:
273
+ junit: sweep-output/sweep.junit.xml
274
+ ```
275
+
276
+ ---
277
+
278
+ ## Auto-heal Selectors
279
+
280
+ In sweep plans, selectors can be an array of fallback strategies.
281
+ pursor tries each one in order until a visible element is found:
282
+
283
+ ```json
284
+ {
285
+ "name": "login-flow",
286
+ "base": "https://example.com",
287
+ "steps": [
288
+ {
289
+ "name": "click-login",
290
+ "hover": {
291
+ "selector": [
292
+ "text=Login",
293
+ "button[type=submit]",
294
+ "#login-btn",
295
+ "a[href*='login']"
296
+ ]
297
+ }
298
+ }
299
+ ]
300
+ }
301
+ ```
302
+
303
+ **Supported selector types:**
304
+ - `text=Login` — Playwright text locator (substring, or `text==Login` for exact)
305
+ - `text~regex` — regex text match
306
+ - `role=button|Submit` — ARIA role with accessible name
307
+ - `aria=label` — accessibility label
308
+ - `placeholder=Email` — placeholder text
309
+ - CSS selectors — any valid CSS selector as fallback
310
+
311
+ ---
312
+
313
+ ## Sweep Plans
314
+
315
+ Batch capture plans in JSON. Each step runs one operation.
316
+
317
+ ```json
318
+ {
319
+ "name": "checkout-flow",
320
+ "base": "https://example.com",
321
+ "outDir": "./sweep-checkout",
322
+ "steps": [
323
+ { "name": "homepage", "shoot": { "preset": "desktop-1280", "cursor": "default" } },
324
+ { "name": "mobile-view","shoot": { "preset": "mobile-375", "grid": true } },
325
+ { "name": "nav-hover", "hover": { "selector": "text=Products", "settleMs": 400 } },
326
+ { "name": "add-to-cart","frames": { "count": 6, "intervalMs": 200 } },
327
+ { "name": "diff", "diff": { "ref": "baseline" } }
328
+ ]
329
+ }
330
+ ```
331
+
332
+ **Step operations:** `shoot`, `hover`, `frames`, `diff`, `audit`, or any
333
+ registered plugin sweep-op.
334
+
335
+ ---
336
+
337
+ ## Plugin API
338
+
339
+ Extend `pursor` with custom viewport presets, sweep operations, or
340
+ capture hooks:
341
+
342
+ ```js
343
+ export default {
344
+ name: "my-plugin",
345
+ viewport: {
346
+ "my-laptop": { width: 1440, height: 900, dpr: 2, label: "MBP 14" },
347
+ },
348
+ sweepOp: {
349
+ lighthouse: async (ctx, opts) => {
350
+ // run lighthouse audit, write result at ctx.out
351
+ return { score: 95 };
352
+ },
353
+ },
354
+ beforeShoot: async (ctx) => { /* mutate ctx.flags */ },
355
+ afterShoot: async (ctx, meta) => { /* augment sidecar */ },
356
+ };
357
+ ```
358
+
359
+ ```bash
360
+ pursor shoot https://example.com --plugin ./my-plugin.js
361
+ ```
362
+
363
+ Publish as `pursor-plugin-*` for auto-discovery.
364
+
365
+ ### Built-in plugins
366
+
367
+ - **`plugin-audit`** — adds `audit` sweep-op (axe-core WCAG audit) and
368
+ `every-viewport` sweep-op (capture at every preset). Adds `audit-canvas`
369
+ viewport preset.
370
+ - **`plugin-demo`** — Reference implementation showing every plugin API
371
+ hook: viewport presets, `nav` sweep-op (navbar walker), `beforeShoot`/
372
+ `afterShoot` sidecar augmentation, and flag help.
373
+
374
+ ---
375
+
376
+ ## All CLI flags
377
+
378
+ | Flag | Type | Default | Description |
379
+ |---|---|---|---|
380
+ | `--preset` | string | `desktop-1280` | Named viewport preset |
381
+ | `--width` / `--height` | number | — | Custom viewport size |
382
+ | `--dpr` | number | `1` | Device pixel ratio |
383
+ | `--cursor` | string | `default` | `pointer`, `grab`, `crosshair`, `none` |
384
+ | `--grid` | bool | `false` | Overlay grid |
385
+ | `--grid-tile` | number | `64` | Grid tile size (px) |
386
+ | `--grid-color` | string | `rgba(255,0,255,0.35)` | Grid line color |
387
+ | `--layer` | string | `all` | `entity`, `terrain`, `hud`, `ui` |
388
+ | `--no-animation` | bool | `false` | Freeze CSS animations |
389
+ | `--no-hud` | bool | `false` | Hide HUD elements |
390
+ | `--wait-frame` | number | `600` | Wait ms for canvas stability |
391
+ | `--zoom` | number | `1` | Zoom level |
392
+ | `--panX` / `--panY` | number | `0` | Camera pan offset (px) |
393
+ | `--full` | bool | `false` | Full-page (not just viewport) |
394
+ | `--tags` | string | — | Comma-separated WCAG tags for audit |
395
+ | `--plugin` | path | — | Load a plugin file (repeatable) |
396
+ | `@file` | prefix | — | Read next arg from file |
397
+
398
+ ---
399
+
400
+ ## Library API
401
+
402
+ All functions available as named or default import:
403
+
404
+ ```js
405
+ import { runShoot, runSweep, runAudit, captureDomSnapshot } from "pursor";
406
+ // Or:
407
+ import PurrVisual from "pursor";
408
+ ```
409
+
410
+ ### Capture functions
411
+
412
+ | Function | Returns | Never throws |
413
+ |---|---|---|
414
+ | `runShoot({url, out, flags?, prepare?, browser?})` | `{ url, out, ts, status, title, viewport, flags, error? }` | ✅ |
415
+ | `runShot(url, out, opts?)` | `{ url, out, status, title, fullPage }` | — |
416
+ | `runProbe(url)` | `{ url, status, title, navError, viewport }` | — |
417
+ | `runFrames({url, count?, intervalMs?, outDir?, flags?, browser?})` | `{ url, files[], viewport, ... }` | — |
418
+ | `runHover({url, selector, out, flags?})` | `{ url, out, selector, viewport, ... }` | — |
419
+ | `runDiff(url, refPath, out, threshold?, browser?)` | `{ url, refPath, numDiff, diffPct, equal, error? }` | — |
420
+ | `runWait(url, selector, timeoutMs?)` | `{ url, selector, found, timeoutMs }` | — |
421
+ | `runClick(url, selector, out?)` | `{ url, selector, clicked, out }` | — |
422
+ | `runType(url, selector, text, out?)` | `{ url, selector, text, typed, out }` | — |
423
+ | `runSeq(url, actionsJson, out?)` | `{ url, out, steps[], failed? }` | — |
424
+ | `runEval(url, js, out?)` | `{ url, result, out, ... }` | — |
425
+ | `runSweep(planPath, outDir?)` | `{ name, steps[], outDir, ... }` | ✅ (per-step) |
426
+ | `runAudit({url, tags?, outDir?, screenshot?, flags?})` | `{ url, violations, violationSummary, highlightedScreenshot?, ... }` | — |
427
+ | `captureDomSnapshot({url, out, flags?})` | `{ url, title, dom, selectorMap[], styles, viewport }` | — |
428
+
429
+ ### Viewport helpers
430
+
431
+ | Export | Description |
432
+ |---|---|
433
+ | `listViewports()` | All registered presets (built-in + plugin) |
434
+ | `resolveViewport(flags)` | Resolve `--preset` / `--width` / `--height` to viewport object |
435
+ | `VIEWPORTS` | Built-in preset map |
436
+ | `applyCamera(page, opts)` | Zoom/pan via mouse wheel + drag on canvas |
437
+ | `waitForStableFrame(page, ms)` | Poll canvas until stable for `ms` |
438
+
439
+ ### Plugin system
440
+
441
+ | Export | Description |
442
+ |---|---|
443
+ | `loadPlugins(paths?)` | Auto-load built-in plugins + user paths |
444
+ | `registerPlugin(plugin)` | Register a plugin manually |
445
+ | `listPlugins()` | Names of loaded plugins |
446
+ | `getSweepOp(name)` | Get a registered sweep operation |
447
+ | `getViewportPreset(name)` | Get a registered viewport preset |
448
+ | `listViewportPresets()` | All plugin-registered presets |
449
+ | `getFlagHelp()` | All plugin-registered flag descriptions |
450
+
451
+ ### Selector healing
452
+
453
+ | Export | Description |
454
+ |---|---|
455
+ | `resolveHealedSelector(page, selector, opts?)` | Try selector chain, return first visible match |
456
+ | `healStepAction(page, action)` | Mutate action.selector → resolved selector |
457
+
458
+ ### CI output
459
+
460
+ | Export | Description |
461
+ |---|---|
462
+ | `writeCiOutput(summary, dir)` | Write JUnit XML + GitHub annotations + Markdown |
463
+
464
+ ### MCP Server
465
+
466
+ | Export | Description |
467
+ |---|---|
468
+ | `PurrVisualMCPServer` | MCP stdio server class |
469
+ | `loadMcpConfig()` | Load config from env or `~/.pursor/mcp-config.json` |
470
+ | `MCP_VERSION` | MCP protocol version string |
471
+
472
+ ### Low-level (plugin authors)
473
+
474
+ | Export | Source |
475
+ |---|---|
476
+ | `launch()` / `newPage(browser, viewport)` | `runway.js` |
477
+ | `resolveLocator(page, selector)` / `parseTextSelector(s)` | `selector.js` |
478
+ | `parseFlags(argv)` / `asNum(v, dflt)` / `asBool(v, dflt)` | `util.js` |
479
+ | `nowIso()` / `shortHash(buf)` / `escapeHtml(s)` | `util.js` |
480
+ | `readArg(arg)` / `makeOut(name)` / `findStepPng(dir, name)` | `util.js` |
481
+ | `renderSweepHtml(summary)` | `util.js` |
482
+
483
+ ### Subpath exports
484
+
485
+ ```js
486
+ import { resolveLocator } from "pursor/selector";
487
+ import { launch } from "pursor/runway";
488
+ import { parseFlags } from "pursor/util";
489
+ import { overlayGrid } from "pursor/overlays";
490
+ import { captureDomSnapshot } from "pursor/dom-snapshot";
491
+ import { runAudit } from "pursor/plugin-audit";
492
+ import { resolveHealedSelector } from "pursor/selector-heal";
493
+ import { writeCiOutput } from "pursor/ci-output";
494
+ import { PurrVisualMCPServer } from "pursor/mcp";
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Sidecar JSON
500
+
501
+ Every capture writes a `.json` sidecar next to its PNG with metadata
502
+ (url, viewport, flags, timestamp, file size, SHA1 hash). DOM snapshots
503
+ write `.dom.json` with full element map. Audit reports write full
504
+ axe-core results to `audit.json`.
505
+
506
+ ---
507
+
508
+ ## Development
509
+
510
+ ```bash
511
+ git clone <this repo>
512
+ cd pursor
513
+ npm install
514
+ npm install --save-dev playwright-core
515
+ npm test
516
+ ```
517
+
518
+ All 32 tests use Node's built-in test runner. Coverage: unit tests for
519
+ viewport resolution, flag parsing, selector parsing, HTML escaping, hashing,
520
+ and end-to-end smoke tests for the full CLI pipeline.
521
+
522
+ ```
523
+ src/ — 22 modules
524
+ test/ — 32 tests, 0 failures
525
+ plugins/ — 2 built-in plugins, auto-loaded
526
+ ```
527
+
528
+ ---
529
+
530
+ ## Visual Regression Baselines
531
+
532
+ ```bash
533
+ # Save a baseline (computed id from url+viewport+flags)
534
+ pursor baseline save myapp ./out/shoot.png home --url https://my.app
535
+
536
+ # Approve a new baseline from a current capture
537
+ pursor baseline approve myapp ./out/shoot.png home --url https://my.app
538
+
539
+ # List all baselines
540
+ pursor baseline list myapp
541
+
542
+ # Show a specific baseline
543
+ pursor baseline show myapp home --url https://my.app
544
+ ```
545
+
546
+ Baselines are stored under `~/.pursor/baselines/<project>/<id>/`. Override
547
+ with `PURSOR_BASELINES_DIR`. The `id` is a 16-char SHA1 prefix of
548
+ `url|viewport|flags` so re-running a sweep maps to the same slot
549
+ deterministically. Use in code:
550
+
551
+ ```js
552
+ import { diffKey, saveBaseline, loadBaseline } from "pursor/baseline";
553
+ const id = diffKey({ url: "https://my.app", viewport: { width: 1280, height: 800, dpr: 1 }, flags: { preset: "desktop-1280" } });
554
+ saveBaseline({ project: "myapp", id, step: "home", png: "./shot.png", meta: { url: "https://my.app" } });
555
+ ```
556
+
557
+ ## Sweep Plan Validation
558
+
559
+ ```bash
560
+ pursor validate ./plan.json
561
+ # { "valid": false, "errors": ["steps[2].frames.count: must be a number between 1 and 120"] }
562
+ ```
563
+
564
+ Catches: empty steps, unknown ops, out-of-range numbers, duplicate names,
565
+ missing required fields. `pursor sweep` runs the same validator before
566
+ executing — fail-fast.
567
+
568
+ ## MCP Resources
569
+
570
+ The MCP server now exposes `resources/list` and `resources/read` so
571
+ Claude Code / Cursor can browse past captures:
572
+
573
+ ```
574
+ uri: pursor://shoot/<url|preset>
575
+ uri: pursor://sweep/<plan-name>
576
+ ```
577
+
578
+ Resources are persisted to `~/.pursor/mcp/mcp-index.json` (override
579
+ with `PURSOR_MCP_STATE`) and re-listed on every server start.
580
+
581
+ ## Library additions (v0.2.0)
582
+
583
+ | Export | Description |
584
+ |---|---|
585
+ | `diffKey`, `saveBaseline`, `loadBaseline`, `listBaselines`, `approveBaseline` | Visual regression baseline storage |
586
+ | `validateSweepPlan`, `registerSweepOp` | Sweep plan schema validation |
587
+ | `listResources`, `readResource`, `recordResource` | MCP resource adapter |
588
+
589
+ Subpath exports: `pursor/baseline`, `pursor/sweep-schema`, `pursor/mcp-resources`.
590
+
591
+ ---
592
+
593
+ ## HAR Capture (v0.3.0)
594
+
595
+ ```bash
596
+ # Capture network traffic during a shoot
597
+ pursor shoot https://example.com shot.png --har ./out/req.har.json
598
+ ```
599
+
600
+ In code:
601
+
602
+ ```js
603
+ import { startHarCapture, stopHarCapture, writeHar } from "pursor/har";
604
+ const state = await startHarCapture(page);
605
+ await page.goto(url);
606
+ // ... your capture logic ...
607
+ const har = stopHarCapture(page);
608
+ await writeHar(har, "./out/req.har.json");
609
+ ```
610
+
611
+ Output is HAR 1.2 spec — pipe to har-cli, perf-tools, or any visualizer.
612
+
613
+ ## Auth State (v0.3.0)
614
+
615
+ ```bash
616
+ # Save a state file you exported from Playwright/DevTools
617
+ pursor auth save myapp admin --from ./playwright-state.json
618
+
619
+ # Use it in a shoot
620
+ pursor shoot https://my.app/dashboard shot.png --auth-state admin --auth-project myapp
621
+
622
+ # Inspect / delete
623
+ pursor auth list myapp
624
+ pursor auth load myapp admin --out ./round-trip.json
625
+ pursor auth delete myapp admin
626
+ ```
627
+
628
+ States live in `~/.pursor/auth/<project>/<name>.json` (override with `PURSOR_AUTH_DIR`).
629
+ The on-disk format is the standard Playwright `storageState` shape: `{ cookies, origins }`.
630
+
631
+ ## Parallel Sweep (v0.3.0)
632
+
633
+ Add `parallel: N` to your plan to run steps concurrently in a worker pool:
634
+
635
+ ```json
636
+ {
637
+ "name": "matrix",
638
+ "base": "https://my.app",
639
+ "parallel": 4,
640
+ "steps": [
641
+ { "name": "home", "shoot": { "preset": "desktop-1280" } },
642
+ { "name": "pricing", "shoot": { "preset": "desktop-1280" } },
643
+ { "name": "docs", "shoot": { "preset": "desktop-1280" } }
644
+ ]
645
+ }
646
+ ```
647
+
648
+ Steps run in a shared browser context; results are still ordered by index in the summary.
649
+ Defaults to serial (`parallel: 1`) — opt in only when steps are independent.
650
+
651
+ ---
652
+ ## License
653
+
654
+ MIT