pursr 0.4.0 → 0.5.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 +35 -4
- package/bin/pursr.mjs +68 -1
- package/package.json +4 -2
- package/src/index.js +5 -0
- package/src/snap.js +129 -0
- package/src/watch.js +134 -0
package/README.md
CHANGED
|
@@ -413,11 +413,11 @@ npm install --save-dev playwright-core
|
|
|
413
413
|
npm test
|
|
414
414
|
```
|
|
415
415
|
|
|
416
|
-
`npm test` runs
|
|
416
|
+
`npm test` runs 53 unit + integration tests (Node's built-in test runner, zero test deps). Coverage includes: viewport resolution, flag parsing, selector parsing, HTML escaping, hashing, baseline storage, sweep-plan validation, MCP resources, HAR 1.2 shape, auth state, and end-to-end CLI smoke tests.
|
|
417
417
|
|
|
418
418
|
```
|
|
419
419
|
src/ - 25 modules
|
|
420
|
-
test/ -
|
|
420
|
+
test/ - 53 tests, 0 failures
|
|
421
421
|
plugins/ - 2 built-in plugins, auto-loaded
|
|
422
422
|
```
|
|
423
423
|
|
|
@@ -429,12 +429,43 @@ plugins/ - 2 built-in plugins, auto-loaded
|
|
|
429
429
|
- [x] HAR 1.2 capture
|
|
430
430
|
- [x] Auth state (Playwright storageState)
|
|
431
431
|
- [x] Parallel sweep workers
|
|
432
|
-
- [
|
|
433
|
-
- [
|
|
432
|
+
- [x] Watch mode (`pursr watch <url>`)
|
|
433
|
+
- [x] Component-level snapshot (`pursr snap <selector>`)
|
|
434
434
|
- [ ] PDF report export
|
|
435
435
|
- [ ] Cloud output adapters (S3 / GCS)
|
|
436
436
|
- [ ] AI diff summary (vision model)
|
|
437
437
|
|
|
438
|
+
## Watch Mode (v0.5.0)
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
# Re-shoot every time a CSS or HTML file changes
|
|
442
|
+
pursr watch https://my.app --on src/**/*.css --on src/**/*.html
|
|
443
|
+
|
|
444
|
+
# Re-run a sweep plan on file change
|
|
445
|
+
pursr watch --plan ./plan.json --on src/**/*.{css,html}
|
|
446
|
+
|
|
447
|
+
# Default (no --on) = watch everything in cwd
|
|
448
|
+
pursr watch https://my.app
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Glob patterns: * (one path segment), ** (any depth), ? (one char), backslash-X (literal X). Debounce is 300ms by default.
|
|
452
|
+
|
|
453
|
+
## Component Snapshots (v0.5.0)
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
# Capture one screenshot per matched element
|
|
457
|
+
pursr snap https://my.app a.btn --out ./snaps --max 20
|
|
458
|
+
|
|
459
|
+
# Use auto-heal selector chain
|
|
460
|
+
pursr snap https://my.app "text=Sign up" --out ./snaps
|
|
461
|
+
|
|
462
|
+
# Promote to baselines in one command
|
|
463
|
+
pursr snap https://my.app article.product --baseline myapp
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Each capture is clipped precisely to the elements bounding box (even when scrolled offscreen), labelled with aria-label / text / tag, and written to ./snaps/<index>-<label>.png + snap.json summary.
|
|
467
|
+
|
|
468
|
+
---
|
|
438
469
|
## License
|
|
439
470
|
|
|
440
471
|
MIT (c) 2026 - [0xheycat](https://github.com/0xheycat)
|
package/bin/pursr.mjs
CHANGED
|
@@ -16,7 +16,8 @@ import { runAudit } from "../src/plugin-audit.js";
|
|
|
16
16
|
import { captureDomSnapshot } from "../src/dom-snapshot.js";
|
|
17
17
|
import { listViewports } from "../src/viewport.js";
|
|
18
18
|
import { parseFlags, asNum, readArg, makeOut, pickOutPath } from "../src/util.js";
|
|
19
|
-
import { writeFileSync } from "node:fs";
|
|
19
|
+
import { writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { dirname } from "node:path";
|
|
20
21
|
import { readFileSync as _readFileSync } from "node:fs";
|
|
21
22
|
const readFile = _readFileSync;
|
|
22
23
|
import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
@@ -42,6 +43,16 @@ function die(msg, code = 2) {
|
|
|
42
43
|
const argv = process.argv;
|
|
43
44
|
const [, , cmd, a, b, c, d] = argv;
|
|
44
45
|
const url = process.env.PURSOR_URL || a;
|
|
46
|
+
// Top-level --plan / --out parsing for subcommands that need it before dispatch
|
|
47
|
+
function _topOpts() {
|
|
48
|
+
const o = {};
|
|
49
|
+
for (let i = 2; i < argv.length; i++) {
|
|
50
|
+
if (argv[i] === "--plan" && i + 1 < argv.length) o.plan = argv[++i];
|
|
51
|
+
if (argv[i] === "--out" && i + 1 < argv.length) o.out = argv[++i];
|
|
52
|
+
}
|
|
53
|
+
return o;
|
|
54
|
+
}
|
|
55
|
+
const opts = _topOpts();
|
|
45
56
|
|
|
46
57
|
// Plugin loading: scan for --plugin <path> and built-in plugins/
|
|
47
58
|
const pluginPaths = [];
|
|
@@ -218,6 +229,62 @@ await loadPlugins(pluginPaths);
|
|
|
218
229
|
}
|
|
219
230
|
break;
|
|
220
231
|
}
|
|
232
|
+
case "watch": {
|
|
233
|
+
// pursr watch <url> [--out ./shot.png] [--on <glob>...] [--plan <plan.json>]
|
|
234
|
+
if (opts.plan) {
|
|
235
|
+
if (!existsSync(opts.plan)) die("watch: plan not found: " + opts.plan);
|
|
236
|
+
} else if (!url) {
|
|
237
|
+
die("watch: missing <url> (or use --plan <plan.json>)");
|
|
238
|
+
}
|
|
239
|
+
const { startWatch } = await import("../src/watch.js");
|
|
240
|
+
const out = (b && !b.startsWith("--")) ? b : (opts.out || makeOut("watch.png"));
|
|
241
|
+
if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
|
|
242
|
+
const flags = parseFlags(argv.slice(3));
|
|
243
|
+
const onGlobs = [];
|
|
244
|
+
for (let i = 0; i < argv.length; i++) {
|
|
245
|
+
if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
|
|
246
|
+
}
|
|
247
|
+
console.error(JSON.stringify({ watching: true, url: opts.plan ? null : url, plan: opts.plan || null, out, on: onGlobs }));
|
|
248
|
+
const w = await startWatch({
|
|
249
|
+
url: opts.plan ? undefined : url,
|
|
250
|
+
out,
|
|
251
|
+
plan: opts.plan,
|
|
252
|
+
on: onGlobs,
|
|
253
|
+
flags,
|
|
254
|
+
verbose: true,
|
|
255
|
+
onChange: (e) => console.error(JSON.stringify({ event: e.type, path: e.path, captureOk: !e.capture?.error, captureOut: e.capture?.out, ts: e.ts })),
|
|
256
|
+
});
|
|
257
|
+
// Keep alive until SIGINT
|
|
258
|
+
await new Promise((resolve) => {
|
|
259
|
+
process.on("SIGINT", () => { console.error("[pursr watch] stopping..."); w.close().then(resolve); });
|
|
260
|
+
process.on("SIGTERM", () => { w.close().then(resolve); });
|
|
261
|
+
});
|
|
262
|
+
console.log(JSON.stringify({ fires: w.fires() }, null, 2));
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case "snap": {
|
|
266
|
+
// pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
|
|
267
|
+
if (!url) die("snap: missing <url>");
|
|
268
|
+
const sel = b; if (!sel) die("snap: missing <selector>");
|
|
269
|
+
const flags = parseFlags(argv.slice(4));
|
|
270
|
+
const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
|
|
271
|
+
const outDir = flags.out || makeOut("snaps").replace(/pursor-[^-]+-snap\.png$/, "snaps");
|
|
272
|
+
const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
|
|
273
|
+
console.log(JSON.stringify({
|
|
274
|
+
url: snap.url,
|
|
275
|
+
selector: snap.selector,
|
|
276
|
+
count: snap.count,
|
|
277
|
+
captured: snap.captured,
|
|
278
|
+
outDir: snap.outDir,
|
|
279
|
+
captures: snap.captures,
|
|
280
|
+
nav: snap.nav,
|
|
281
|
+
}, null, 2));
|
|
282
|
+
if (flags.baseline) {
|
|
283
|
+
const r = approveSnapsAsBaselines({ project: flags.baseline, snapResult: snap });
|
|
284
|
+
console.error(JSON.stringify({ approved: r.length, project: flags.baseline }));
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
221
288
|
default: { die(`unknown subcommand: ${cmd}`); }
|
|
222
289
|
}
|
|
223
290
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "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",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"./sweep-schema": "./src/sweep-schema.js",
|
|
35
35
|
"./mcp-resources": "./src/mcp-resources.js",
|
|
36
36
|
"./har": "./src/har.js",
|
|
37
|
-
"./auth": "./src/auth.js"
|
|
37
|
+
"./auth": "./src/auth.js",
|
|
38
|
+
"./watch": "./src/watch.js",
|
|
39
|
+
"./snap": "./src/snap.js"
|
|
38
40
|
},
|
|
39
41
|
"files": [
|
|
40
42
|
"bin",
|
package/src/index.js
CHANGED
|
@@ -39,6 +39,8 @@ import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
|
|
|
39
39
|
import { listResources, readResource, recordResource } from "./mcp-resources.js";
|
|
40
40
|
import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
|
|
41
41
|
import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
|
|
42
|
+
import { startWatch, matchGlob, shouldFire } from "./watch.js";
|
|
43
|
+
import { runSnap, approveSnapsAsBaselines } from "./snap.js";
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
// Derive VERSION from package.json to prevent drift
|
|
@@ -72,6 +74,9 @@ export {
|
|
|
72
74
|
// v5: HAR capture, auth state, parallel sweep
|
|
73
75
|
startHarCapture, stopHarCapture, writeHar,
|
|
74
76
|
saveAuthState, loadAuthState, listAuthStates, deleteAuthState,
|
|
77
|
+
// v6: watch mode, component snapshot
|
|
78
|
+
startWatch, matchGlob, shouldFire,
|
|
79
|
+
runSnap, approveSnapsAsBaselines,
|
|
75
80
|
VERSION,
|
|
76
81
|
};
|
|
77
82
|
|
package/src/snap.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// pursor — component-level snapshot.
|
|
2
|
+
//
|
|
3
|
+
// Capture one screenshot per matched element (Percy / Chromatic style).
|
|
4
|
+
// Uses Playwright's elementHandle.screenshot() to clip precisely to the
|
|
5
|
+
// element's bounding box, even if it scrolls offscreen.
|
|
6
|
+
//
|
|
7
|
+
// CLI:
|
|
8
|
+
// pursr snap <url> "<selector>" [--out ./snaps/] [--selector "a.btn"]
|
|
9
|
+
// pursr snap <url> "<selector>" --baseline myapp
|
|
10
|
+
//
|
|
11
|
+
// Library:
|
|
12
|
+
// import { runSnap } from "pursr/snap";
|
|
13
|
+
// const result = await runSnap({ url, selector, outDir: "./snaps" });
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { launch, newPage } from "./runway.js";
|
|
18
|
+
import { resolveViewport } from "./viewport.js";
|
|
19
|
+
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
20
|
+
import { resolveLocator, parseTextSelector } from "./selector.js";
|
|
21
|
+
import { resolveHealedSelector } from "./selector-heal.js";
|
|
22
|
+
import { asNum, nowIso, requireArg } from "./util.js";
|
|
23
|
+
import { saveBaseline, diffKey } from "./baseline.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Capture one screenshot per matched element on a page.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {string} opts.url Target URL
|
|
30
|
+
* @param {string|string[]} opts.selector CSS selector, or chain (heal-fallback)
|
|
31
|
+
* @param {string} [opts.outDir=./snaps] Output directory
|
|
32
|
+
* @param {string} [opts.name] Optional name prefix (defaults to selector slug)
|
|
33
|
+
* @param {object} [opts.flags] Viewport/flags (resolved via resolveViewport)
|
|
34
|
+
* @param {number} [opts.settleMs=400] Wait after locator resolves
|
|
35
|
+
* @param {number} [opts.max=20] Max elements to capture (safety)
|
|
36
|
+
* @param {boolean} [opts.scrollIntoView=true] Scroll each into view before capture
|
|
37
|
+
* @param {boolean} [opts.omitBackground=false] Transparent background
|
|
38
|
+
* @returns {Promise<{ url, selector, count, captures: [...], outDir, ts }>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runSnap(opts) {
|
|
41
|
+
requireArg("url", opts?.url, "string");
|
|
42
|
+
requireArg("selector", opts?.selector, "string");
|
|
43
|
+
const url = opts.url;
|
|
44
|
+
const selector = opts.selector;
|
|
45
|
+
const outDir = opts.outDir || "./snaps";
|
|
46
|
+
const flags = opts.flags || {};
|
|
47
|
+
const viewport = resolveViewport(flags);
|
|
48
|
+
const settleMs = asNum(opts.settleMs, 400);
|
|
49
|
+
const max = Math.max(1, asNum(opts.max, 20));
|
|
50
|
+
const scrollIntoView = opts.scrollIntoView !== false;
|
|
51
|
+
const omitBackground = !!opts.omitBackground;
|
|
52
|
+
const name = opts.name || (Array.isArray(selector) ? selector[0] : selector).replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "snap";
|
|
53
|
+
|
|
54
|
+
mkdirSync(outDir, { recursive: true });
|
|
55
|
+
const browser = await launch();
|
|
56
|
+
const captures = [];
|
|
57
|
+
try {
|
|
58
|
+
const page = await newPage(browser, viewport);
|
|
59
|
+
const r = await gotoOrThrow(page, url);
|
|
60
|
+
await settle(page);
|
|
61
|
+
|
|
62
|
+
// Resolve to a locator (with auto-heal chain support)
|
|
63
|
+
const locator = Array.isArray(selector)
|
|
64
|
+
? (await resolveHealedSelector(page, selector)).locator
|
|
65
|
+
: await resolveLocator(page, selector);
|
|
66
|
+
|
|
67
|
+
const count = await locator.count();
|
|
68
|
+
if (!count) throw new Error(`snap: selector matched 0 elements`);
|
|
69
|
+
|
|
70
|
+
const limit = Math.min(count, max);
|
|
71
|
+
for (let i = 0; i < limit; i++) {
|
|
72
|
+
const handle = locator.nth(i);
|
|
73
|
+
try {
|
|
74
|
+
if (scrollIntoView) await handle.scrollIntoViewIfNeeded({ timeout: CLICK_TIMEOUT_MS }).catch(() => {});
|
|
75
|
+
await page.waitForTimeout(settleMs);
|
|
76
|
+
const file = join(outDir, `${String(i).padStart(2, "0")}-${name}.png`);
|
|
77
|
+
await handle.screenshot({ path: file, omitBackground });
|
|
78
|
+
// Try to get a human label
|
|
79
|
+
let label = null;
|
|
80
|
+
try {
|
|
81
|
+
label = (await handle.evaluate((el) => {
|
|
82
|
+
return el.getAttribute("aria-label")
|
|
83
|
+
|| el.getAttribute("title")
|
|
84
|
+
|| el.getAttribute("alt")
|
|
85
|
+
|| (el.textContent || "").trim().slice(0, 80)
|
|
86
|
+
|| el.tagName.toLowerCase();
|
|
87
|
+
}));
|
|
88
|
+
} catch {}
|
|
89
|
+
captures.push({ i, file, label });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
captures.push({ i, error: e.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Write summary
|
|
96
|
+
const summary = {
|
|
97
|
+
url,
|
|
98
|
+
selector,
|
|
99
|
+
viewport: { width: viewport.width, height: viewport.height, dpr: viewport.dpr },
|
|
100
|
+
count,
|
|
101
|
+
captured: captures.length,
|
|
102
|
+
outDir,
|
|
103
|
+
captures,
|
|
104
|
+
ts: nowIso(),
|
|
105
|
+
nav: { status: r.status, title: r.title },
|
|
106
|
+
};
|
|
107
|
+
writeFileSync(join(outDir, "snap.json"), JSON.stringify(summary, null, 2));
|
|
108
|
+
return summary;
|
|
109
|
+
} finally {
|
|
110
|
+
try { await browser.close(); } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Save a snap result as baselines (one per captured element).
|
|
116
|
+
* Useful for "approve all current component screenshots as the new baseline".
|
|
117
|
+
*/
|
|
118
|
+
export async function approveSnapsAsBaselines({ project, snapResult, id }) {
|
|
119
|
+
if (!snapResult?.captures) throw new Error("approveSnapsAsBaselines: missing snapResult");
|
|
120
|
+
const _id = id || diffKey({ url: snapResult.url, viewport: snapResult.viewport, flags: {} });
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const c of snapResult.captures) {
|
|
123
|
+
if (c.error || !c.file) continue;
|
|
124
|
+
const step = `snap-${String(c.i).padStart(2, "0")}-${(c.label || "elem").replace(/[^a-z0-9._-]+/gi, "_").slice(0, 32)}`;
|
|
125
|
+
const saved = saveBaseline({ project, id: _id, step, png: c.file, meta: { url: snapResult.url, viewport: snapResult.viewport, flags: {} } });
|
|
126
|
+
out.push(saved);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
package/src/watch.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// pursor - watch mode.
|
|
2
|
+
//
|
|
3
|
+
// Re-runs a shoot/sweep whenever watched files change.
|
|
4
|
+
|
|
5
|
+
import { watch as fsWatch, existsSync } from "node:fs";
|
|
6
|
+
import { resolve, join, dirname, relative } from "node:path";
|
|
7
|
+
import { runShootWithSidecar } from "./shoot.js";
|
|
8
|
+
import { runSweep } from "./sweep.js";
|
|
9
|
+
import { nowIso } from "./util.js";
|
|
10
|
+
|
|
11
|
+
function normalizeGlobs(globs) {
|
|
12
|
+
if (!globs) return null;
|
|
13
|
+
const arr = Array.isArray(globs) ? globs : [globs];
|
|
14
|
+
return arr.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BC = String.fromCharCode(92);
|
|
18
|
+
const ESC_RX = /[.+^$X()|YZ\\]/g;
|
|
19
|
+
function escapeForRegex(s) {
|
|
20
|
+
return s.replace(ESC_RX, function (m) {
|
|
21
|
+
if (m === "X") return BC + "$";
|
|
22
|
+
if (m === "Y") return BC + "{";
|
|
23
|
+
if (m === "Z") return BC + "}";
|
|
24
|
+
return BC + m;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchGlob(path, pattern) {
|
|
29
|
+
const p = String(path).split(BC).join("/");
|
|
30
|
+
const pat = String(pattern);
|
|
31
|
+
let re = "^";
|
|
32
|
+
for (let i = 0; i < pat.length; i++) {
|
|
33
|
+
const c = pat[i];
|
|
34
|
+
if (c === BC) {
|
|
35
|
+
const next = pat[i + 1];
|
|
36
|
+
if (next === undefined) { re += BC + BC; continue; }
|
|
37
|
+
re += escapeForRegex(next);
|
|
38
|
+
i++;
|
|
39
|
+
} else if (c === "*") {
|
|
40
|
+
if (pat[i + 1] === "*") { re += ".*"; i++; }
|
|
41
|
+
else { re += "[^/]*"; }
|
|
42
|
+
} else if (c === "?") {
|
|
43
|
+
re += "[^/]";
|
|
44
|
+
} else {
|
|
45
|
+
re += escapeForRegex(c);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
re += "$";
|
|
49
|
+
return new RegExp(re).test(p);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shouldFire(path, globs) {
|
|
53
|
+
if (!globs || globs.length === 0) return true;
|
|
54
|
+
return globs.some((g) => matchGlob(path, g));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function debounce(fn, ms) {
|
|
58
|
+
let t = null;
|
|
59
|
+
return (...args) => {
|
|
60
|
+
if (t) clearTimeout(t);
|
|
61
|
+
t = setTimeout(() => { t = null; fn(...args); }, ms);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function startWatch(opts) {
|
|
66
|
+
if (!opts.url && !opts.plan) throw new Error("startWatch: requires url or plan");
|
|
67
|
+
const globs = normalizeGlobs(opts.on);
|
|
68
|
+
const debounceMs = opts.debounceMs ?? 300;
|
|
69
|
+
const verbose = !!opts.verbose;
|
|
70
|
+
|
|
71
|
+
let fireCount = 0;
|
|
72
|
+
let runningPromise = Promise.resolve();
|
|
73
|
+
let closed = false;
|
|
74
|
+
|
|
75
|
+
const runOne = async (event) => {
|
|
76
|
+
if (closed) return;
|
|
77
|
+
fireCount++;
|
|
78
|
+
try {
|
|
79
|
+
let capture = null;
|
|
80
|
+
if (opts.plan) {
|
|
81
|
+
capture = await runSweep(opts.plan, opts.outDir);
|
|
82
|
+
} else {
|
|
83
|
+
capture = await runShootWithSidecar({ url: opts.url, out: opts.out, flags: opts.flags || {} });
|
|
84
|
+
}
|
|
85
|
+
if (typeof opts.onChange === "function") {
|
|
86
|
+
try { await opts.onChange({ ...event, capture }); } catch (e) {
|
|
87
|
+
if (verbose) console.error("[pursr watch] onChange error:", e.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (verbose) console.error("[pursr watch] capture error:", e.message);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const debouncedRun = debounce((event) => { runningPromise = runningPromise.then(() => runOne(event)); }, debounceMs);
|
|
96
|
+
|
|
97
|
+
const targets = globs && globs.length > 0
|
|
98
|
+
? globs.map((g) => {
|
|
99
|
+
const lit = g.split(/[*?]/)[0];
|
|
100
|
+
return resolve(lit || ".");
|
|
101
|
+
})
|
|
102
|
+
: [resolve(process.cwd())];
|
|
103
|
+
|
|
104
|
+
const watchers = [];
|
|
105
|
+
for (const target of targets) {
|
|
106
|
+
if (!existsSync(target)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const w = fsWatch(target, { recursive: true }, (eventType, filename) => {
|
|
109
|
+
if (!filename) return;
|
|
110
|
+
const full = join(target, filename);
|
|
111
|
+
const rel = relative(process.cwd(), full).split(BC).join("/");
|
|
112
|
+
if (!shouldFire(rel, globs)) return;
|
|
113
|
+
debouncedRun({ type: eventType, path: full, ts: nowIso() });
|
|
114
|
+
});
|
|
115
|
+
w.on("error", (e) => { if (verbose) console.error("[pursr watch] watcher error:", e.message); });
|
|
116
|
+
watchers.push(w);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (verbose) console.error("[pursr watch] cannot watch", target, e.message);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
runningPromise = runningPromise.then(() => runOne({ type: "init", path: null, ts: nowIso() }));
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
close: async () => {
|
|
126
|
+
closed = true;
|
|
127
|
+
for (const w of watchers) { try { w.close(); } catch {} }
|
|
128
|
+
await runningPromise.catch(() => {});
|
|
129
|
+
},
|
|
130
|
+
fires: () => fireCount,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { matchGlob, shouldFire };
|