pursr 0.4.0 → 0.6.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 +549 -440
- package/bin/pursr.mjs +102 -3
- package/package.json +95 -90
- package/src/ai-diff.js +124 -0
- package/src/diff.js +76 -48
- package/src/index.js +14 -1
- package/src/report.js +176 -0
- package/src/snap.js +129 -0
- package/src/watch.js +134 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// pursor - PDF report generator.
|
|
2
|
+
//
|
|
3
|
+
// Renders a sweep summary into a styled, self-contained PDF report.
|
|
4
|
+
// Embeds each capture (PNG) inline so the report can be emailed/shared
|
|
5
|
+
// without any external assets.
|
|
6
|
+
//
|
|
7
|
+
// CLI:
|
|
8
|
+
// pursr report --sweep ./out/sweep-xxx/sweep.json --out ./out/report.pdf
|
|
9
|
+
// pursr sweep plan.json # writes sweep.json + .html; PDF generated separately
|
|
10
|
+
//
|
|
11
|
+
// Library:
|
|
12
|
+
// import { renderSweepPdf } from "pursr/report";
|
|
13
|
+
// const bytes = await renderSweepPdf(summary, { out: "report.pdf" });
|
|
14
|
+
|
|
15
|
+
import PDFDocument from "pdfkit";
|
|
16
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
17
|
+
import { join, dirname, basename } from "node:path";
|
|
18
|
+
import { writeFile } from "node:fs/promises";
|
|
19
|
+
import { escapeHtml } from "./util.js";
|
|
20
|
+
|
|
21
|
+
// A4 in points (1pt = 1/72 in)
|
|
22
|
+
const PAGE_W = 595.28;
|
|
23
|
+
const PAGE_H = 841.89;
|
|
24
|
+
const MARGIN = 40;
|
|
25
|
+
const CONTENT_W = PAGE_W - MARGIN * 2;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render a sweep summary as a PDF.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} summary - Sweep summary from runSweep()
|
|
31
|
+
* @param {object} [opts]
|
|
32
|
+
* @param {string} [opts.out] - Output PDF file path
|
|
33
|
+
* @param {string} [opts.title] - Report title (defaults to sweep.name)
|
|
34
|
+
* @param {string} [opts.subtitle] - Subtitle
|
|
35
|
+
* @param {boolean} [opts.embedImages=true] - Embed each capture PNG inline
|
|
36
|
+
* @returns {Promise<Buffer>} - PDF bytes (also written to opts.out if set)
|
|
37
|
+
*/
|
|
38
|
+
export async function renderSweepPdf(summary, opts = {}) {
|
|
39
|
+
if (!summary || !Array.isArray(summary.steps)) {
|
|
40
|
+
throw new Error("renderSweepPdf: summary.steps must be an array");
|
|
41
|
+
}
|
|
42
|
+
const title = opts.title || `pursr sweep: ${summary.name || "(unnamed)"}`;
|
|
43
|
+
const subtitle = opts.subtitle || `${summary.steps.length} steps · ${summary.ts || ""} · ${summary.outDir || ""}`;
|
|
44
|
+
const embedImages = opts.embedImages !== false;
|
|
45
|
+
|
|
46
|
+
const doc = new PDFDocument({ size: "A4", margin: MARGIN, info: {
|
|
47
|
+
Title: title,
|
|
48
|
+
Author: "pursr",
|
|
49
|
+
Subject: "Visual sweep report",
|
|
50
|
+
CreationDate: new Date(),
|
|
51
|
+
}});
|
|
52
|
+
|
|
53
|
+
// Collect bytes
|
|
54
|
+
const chunks = [];
|
|
55
|
+
doc.on("data", (c) => chunks.push(c));
|
|
56
|
+
const done = new Promise((resolve, reject) => {
|
|
57
|
+
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
58
|
+
doc.on("error", reject);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ---- Header ----
|
|
62
|
+
doc.fillColor("#0B0B0F").rect(0, 0, PAGE_W, 80).fill();
|
|
63
|
+
doc.fillColor("#FF2EA6").fontSize(22).font("Helvetica-Bold").text("pursr", MARGIN, 30);
|
|
64
|
+
doc.fillColor("#FFFFFF").fontSize(14).font("Helvetica").text(title, MARGIN + 70, 36);
|
|
65
|
+
doc.fillColor("#A0A0AA").fontSize(9).text(subtitle, MARGIN + 70, 56);
|
|
66
|
+
doc.moveDown(3);
|
|
67
|
+
|
|
68
|
+
// ---- Summary stats ----
|
|
69
|
+
const total = summary.steps.length;
|
|
70
|
+
const passed = summary.steps.filter((s) => s.ok).length;
|
|
71
|
+
const failed = total - passed;
|
|
72
|
+
const totalMs = summary.steps.reduce((acc, s) => acc + (s.ms || 0), 0);
|
|
73
|
+
|
|
74
|
+
doc.fillColor("#0B0B0F").font("Helvetica-Bold").fontSize(14).text("Summary", MARGIN, doc.y);
|
|
75
|
+
doc.moveDown(0.5);
|
|
76
|
+
const stats = [
|
|
77
|
+
["Steps", `${total}`],
|
|
78
|
+
["Passed", `${passed}`],
|
|
79
|
+
["Failed", `${failed}`],
|
|
80
|
+
["Total time", `${(totalMs / 1000).toFixed(1)}s`],
|
|
81
|
+
];
|
|
82
|
+
drawStatGrid(doc, stats, MARGIN, doc.y, CONTENT_W);
|
|
83
|
+
doc.moveDown(1.5);
|
|
84
|
+
|
|
85
|
+
// ---- Per-step results ----
|
|
86
|
+
for (let i = 0; i < summary.steps.length; i++) {
|
|
87
|
+
const step = summary.steps[i];
|
|
88
|
+
// Page break if less than 200pt left
|
|
89
|
+
if (doc.y > PAGE_H - 200) doc.addPage();
|
|
90
|
+
|
|
91
|
+
// Step header
|
|
92
|
+
const status = step.ok ? "OK" : "FAIL";
|
|
93
|
+
const statusColor = step.ok ? "#0a8a4a" : "#d03030";
|
|
94
|
+
doc.fillColor("#0B0B0F").font("Helvetica-Bold").fontSize(12).text(`#${step.i} ${step.name || "step-" + step.i}`, MARGIN, doc.y);
|
|
95
|
+
doc.fillColor(statusColor).font("Helvetica-Bold").fontSize(10).text(status, MARGIN + CONTENT_W - 40, doc.y - 12, { width: 40, align: "right" });
|
|
96
|
+
doc.moveDown(0.3);
|
|
97
|
+
doc.fillColor("#666").font("Helvetica").fontSize(9);
|
|
98
|
+
const opLine = `${step.op || "?"} · ${step.ms || 0}ms${step.meta?.url ? " · " + step.meta.url : ""}`;
|
|
99
|
+
doc.text(opLine, MARGIN, doc.y);
|
|
100
|
+
doc.moveDown(0.3);
|
|
101
|
+
|
|
102
|
+
// Embed image
|
|
103
|
+
if (embedImages) {
|
|
104
|
+
const img = step.meta?.out || (step.meta?.currentPath);
|
|
105
|
+
if (img && existsSync(img)) {
|
|
106
|
+
try {
|
|
107
|
+
const maxW = CONTENT_W;
|
|
108
|
+
const maxH = 280;
|
|
109
|
+
doc.image(img, MARGIN, doc.y, { fit: [maxW, maxH], align: "center" });
|
|
110
|
+
doc.moveDown(0.5);
|
|
111
|
+
doc.y += 5;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
doc.fillColor("#999").font("Helvetica-Oblique").fontSize(8).text(`[image error: ${e.message}]`, MARGIN, doc.y);
|
|
114
|
+
doc.moveDown(0.5);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Diffs / errors
|
|
120
|
+
if (step.meta?.numDiff !== undefined) {
|
|
121
|
+
doc.fillColor("#444").font("Helvetica").fontSize(9);
|
|
122
|
+
const pct = step.meta.diffPct?.toFixed?.(3) ?? "?";
|
|
123
|
+
doc.text(`Diff: ${step.meta.numDiff} pixels (${pct}%) differ from reference`, MARGIN, doc.y);
|
|
124
|
+
doc.moveDown(0.3);
|
|
125
|
+
}
|
|
126
|
+
if (!step.ok && step.error) {
|
|
127
|
+
doc.fillColor("#a01010").font("Helvetica").fontSize(9).text(`Error: ${step.error}`, MARGIN, doc.y);
|
|
128
|
+
doc.moveDown(0.3);
|
|
129
|
+
}
|
|
130
|
+
if (step.meta?.har) {
|
|
131
|
+
doc.fillColor("#666").font("Helvetica").fontSize(8).text(`HAR: ${step.meta.har}`, MARGIN, doc.y);
|
|
132
|
+
doc.moveDown(0.3);
|
|
133
|
+
}
|
|
134
|
+
if (step.meta?.violations !== undefined) {
|
|
135
|
+
const v = step.meta.violations || step.meta.violationSummary;
|
|
136
|
+
const total = typeof v === "object" ? v.total : v;
|
|
137
|
+
doc.fillColor("#444").font("Helvetica").fontSize(9).text(`Audit: ${total} violations`, MARGIN, doc.y);
|
|
138
|
+
doc.moveDown(0.3);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
doc.moveDown(0.8);
|
|
142
|
+
// Separator
|
|
143
|
+
doc.strokeColor("#e0e0e8").lineWidth(0.5).moveTo(MARGIN, doc.y).lineTo(MARGIN + CONTENT_W, doc.y).stroke();
|
|
144
|
+
doc.moveDown(0.5);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- Footer ----
|
|
148
|
+
const range = doc.bufferedPageRange();
|
|
149
|
+
for (let i = 0; i < range.count; i++) {
|
|
150
|
+
doc.switchToPage(range.start + i);
|
|
151
|
+
doc.fillColor("#999").fontSize(8).font("Helvetica");
|
|
152
|
+
doc.text(`${i + 1} / ${range.count}`, MARGIN, PAGE_H - 30, { width: CONTENT_W, align: "center" });
|
|
153
|
+
doc.text("Generated by pursr", MARGIN, PAGE_H - 30, { width: CONTENT_W, align: "right" });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
doc.end();
|
|
157
|
+
const buf = await done;
|
|
158
|
+
if (opts.out) {
|
|
159
|
+
await writeFile(opts.out, buf);
|
|
160
|
+
}
|
|
161
|
+
return buf;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function drawStatGrid(doc, items, x, y, w) {
|
|
165
|
+
const cols = items.length;
|
|
166
|
+
const cellW = w / cols;
|
|
167
|
+
for (let i = 0; i < cols; i++) {
|
|
168
|
+
const [label, value] = items[i];
|
|
169
|
+
const cx = x + cellW * i;
|
|
170
|
+
doc.fillColor("#FF2EA6").font("Helvetica-Bold").fontSize(20).text(value, cx, y, { width: cellW - 8, align: "left" });
|
|
171
|
+
doc.fillColor("#666").font("Helvetica").fontSize(8).text(label, cx, y + 24, { width: cellW - 8, align: "left" });
|
|
172
|
+
}
|
|
173
|
+
return y + 50;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { renderSweepPdf as default };
|
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 };
|