pursr 0.6.0 → 0.7.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/LICENSE +20 -20
- package/README.md +9 -9
- package/assets/icon.svg +20 -20
- package/assets/logo.svg +28 -28
- package/assets/social-preview.svg +76 -76
- package/bin/pursr-mcp.mjs +10 -9
- package/bin/pursr.mjs +11 -11
- package/package.json +4 -4
- package/plans/m5.4-polish.json +21 -21
- package/plugins/plugin-audit.js +57 -57
- package/plugins/plugin-demo.js +63 -63
- package/src/ai-diff.js +7 -6
- package/src/auth.js +92 -91
- package/src/baseline.js +126 -125
- package/src/ci-output.js +156 -156
- package/src/dom-snapshot.js +192 -192
- package/src/eval.js +17 -17
- package/src/every-viewport.js +51 -51
- package/src/frames.js +33 -33
- package/src/har.js +158 -158
- package/src/hover.js +25 -25
- package/src/index.js +6 -6
- package/src/interact.js +137 -137
- package/src/mcp-resources.js +111 -110
- package/src/mcp.js +436 -435
- package/src/overlays.js +169 -169
- package/src/plugin-audit.js +260 -260
- package/src/plugin.js +120 -120
- package/src/probe.js +19 -19
- package/src/report.js +175 -175
- package/src/runway.js +65 -65
- package/src/selector-heal.js +85 -85
- package/src/selector.js +38 -38
- package/src/shoot.js +73 -73
- package/src/shot.js +17 -17
- package/src/snap.js +128 -128
- package/src/sweep-schema.js +69 -69
- package/src/sweep.js +1 -1
- package/src/util.js +204 -188
- package/src/viewport.js +38 -38
- package/src/watch.js +134 -134
package/src/plugin.js
CHANGED
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// A plugin is a plain ES module that exports a default object with one
|
|
4
|
-
// or more hook handlers. The host loads plugins from:
|
|
5
|
-
// 1. The built-in `plugins/` directory (shipped with the package).
|
|
6
|
-
// 2. A path passed to `loadPlugins([...paths])`.
|
|
7
|
-
// 3. Any package named `
|
|
8
|
-
//
|
|
9
|
-
// Hook reference:
|
|
10
|
-
//
|
|
11
|
-
// name: "my-plugin" // optional, for logs
|
|
12
|
-
// viewport: { <presetName>: { width, height, dpr, label } }
|
|
13
|
-
// sweepOp: { <opName>: async (ctx, opts) => Result }
|
|
14
|
-
// beforeShoot: async (ctx) => void // mutate ctx.flags / ctx.viewport
|
|
15
|
-
// afterShoot: async (ctx, meta) => void // augment sidecar
|
|
16
|
-
// flagHelp: { "my-flag": "what it does" }
|
|
17
|
-
//
|
|
18
|
-
// ctx fields available to hooks:
|
|
19
|
-
// url, out, viewport, flags, browser, page
|
|
20
|
-
//
|
|
21
|
-
// Example plugin (plugins/my-plugin.js):
|
|
22
|
-
//
|
|
23
|
-
// export default {
|
|
24
|
-
// name: "my-plugin",
|
|
25
|
-
// viewport: {
|
|
26
|
-
// "my-laptop": { width: 1440, height: 900, dpr: 2, label: "MBP 14" },
|
|
27
|
-
// },
|
|
28
|
-
// sweepOp: {
|
|
29
|
-
// "lighthouse": async (ctx, opts) => {
|
|
30
|
-
// // ... run lighthouse, return { out, meta }
|
|
31
|
-
// },
|
|
32
|
-
// },
|
|
33
|
-
// beforeShoot: async (ctx) => {
|
|
34
|
-
// // e.g. set extra cookies, click a button, etc.
|
|
35
|
-
// },
|
|
36
|
-
// };
|
|
37
|
-
|
|
38
|
-
const plugins = [];
|
|
39
|
-
const registeredRefs = new Set();
|
|
40
|
-
const sweepOps = new Map();
|
|
41
|
-
const viewportPresets = new Map();
|
|
42
|
-
const flagHelp = new Map();
|
|
43
|
-
|
|
44
|
-
export function registerPlugin(p) {
|
|
45
|
-
if (!p || typeof p !== "object") return;
|
|
46
|
-
if (registeredRefs.has(p)) return;
|
|
47
|
-
registeredRefs.add(p);
|
|
48
|
-
plugins.push(p);
|
|
49
|
-
if (p.name) console.error(`[
|
|
50
|
-
if (p.viewport) {
|
|
51
|
-
for (const [k, v] of Object.entries(p.viewport)) viewportPresets.set(k, v);
|
|
52
|
-
}
|
|
53
|
-
if (p.sweepOp) {
|
|
54
|
-
for (const [k, v] of Object.entries(p.sweepOp)) sweepOps.set(k, v);
|
|
55
|
-
}
|
|
56
|
-
if (p.flagHelp) {
|
|
57
|
-
for (const [k, v] of Object.entries(p.flagHelp)) flagHelp.set(k, v);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function loadPlugins(paths = []) {
|
|
62
|
-
if (typeof paths === "string") paths = [paths];
|
|
63
|
-
const loadedPaths = new Set();
|
|
64
|
-
// auto-load built-in plugins from plugins/ directory
|
|
65
|
-
const { readdirSync, existsSync } = await import("node:fs");
|
|
66
|
-
const { join, dirname } = await import("node:path");
|
|
67
|
-
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
|
68
|
-
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
69
|
-
const pluginDir = join(selfDir, "..", "plugins");
|
|
70
|
-
if (existsSync(pluginDir)) {
|
|
71
|
-
for (const f of readdirSync(pluginDir)) {
|
|
72
|
-
if (f.endsWith(".js")) {
|
|
73
|
-
const fullPath = pathToFileURL(join(pluginDir, f)).href;
|
|
74
|
-
if (loadedPaths.has(fullPath)) continue;
|
|
75
|
-
loadedPaths.add(fullPath);
|
|
76
|
-
try {
|
|
77
|
-
const mod = await import(/* @vite-ignore */ fullPath);
|
|
78
|
-
registerPlugin(mod.default || mod);
|
|
79
|
-
} catch (e) {
|
|
80
|
-
console.error(`[
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
// user-supplied plugins — resolve relative to cwd
|
|
86
|
-
for (const p of paths) {
|
|
87
|
-
if (loadedPaths.has(p)) continue;
|
|
88
|
-
loadedPaths.add(p);
|
|
89
|
-
try {
|
|
90
|
-
const resolved = pathToFileURL(join(process.cwd(), p)).href;
|
|
91
|
-
const mod = await import(/* @vite-ignore */ resolved);
|
|
92
|
-
registerPlugin(mod.default || mod);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
console.error(`[
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return plugins.length;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function listPlugins() { return plugins.map(p => p?.name || "(unnamed)"); }
|
|
101
|
-
|
|
102
|
-
export function getSweepOp(name) { return sweepOps.get(name); }
|
|
103
|
-
export function getViewportPreset(name) { return viewportPresets.get(name); }
|
|
104
|
-
export function listViewportPresets() { return Object.fromEntries(viewportPresets); }
|
|
105
|
-
export function getFlagHelp() { return Object.fromEntries(flagHelp); }
|
|
106
|
-
|
|
107
|
-
export async function runBeforeShoot(ctx) {
|
|
108
|
-
for (const p of plugins) {
|
|
109
|
-
if (typeof p.beforeShoot === "function") {
|
|
110
|
-
try { await p.beforeShoot(ctx); } catch (e) { console.error(`[plugin ${p.name}] beforeShoot: ${e.message}`); }
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function runAfterShoot(ctx, meta) {
|
|
116
|
-
for (const p of plugins) {
|
|
117
|
-
if (typeof p.afterShoot === "function") {
|
|
118
|
-
try { await p.afterShoot(ctx, meta); } catch (e) { console.error(`[plugin ${p.name}] afterShoot: ${e.message}`); }
|
|
119
|
-
}
|
|
120
|
-
}
|
|
1
|
+
// pursr — plugin API.
|
|
2
|
+
//
|
|
3
|
+
// A plugin is a plain ES module that exports a default object with one
|
|
4
|
+
// or more hook handlers. The host loads plugins from:
|
|
5
|
+
// 1. The built-in `plugins/` directory (shipped with the package).
|
|
6
|
+
// 2. A path passed to `loadPlugins([...paths])`.
|
|
7
|
+
// 3. Any package named `pursr-plugin-*` in node_modules.
|
|
8
|
+
//
|
|
9
|
+
// Hook reference:
|
|
10
|
+
//
|
|
11
|
+
// name: "my-plugin" // optional, for logs
|
|
12
|
+
// viewport: { <presetName>: { width, height, dpr, label } }
|
|
13
|
+
// sweepOp: { <opName>: async (ctx, opts) => Result }
|
|
14
|
+
// beforeShoot: async (ctx) => void // mutate ctx.flags / ctx.viewport
|
|
15
|
+
// afterShoot: async (ctx, meta) => void // augment sidecar
|
|
16
|
+
// flagHelp: { "my-flag": "what it does" }
|
|
17
|
+
//
|
|
18
|
+
// ctx fields available to hooks:
|
|
19
|
+
// url, out, viewport, flags, browser, page
|
|
20
|
+
//
|
|
21
|
+
// Example plugin (plugins/my-plugin.js):
|
|
22
|
+
//
|
|
23
|
+
// export default {
|
|
24
|
+
// name: "my-plugin",
|
|
25
|
+
// viewport: {
|
|
26
|
+
// "my-laptop": { width: 1440, height: 900, dpr: 2, label: "MBP 14" },
|
|
27
|
+
// },
|
|
28
|
+
// sweepOp: {
|
|
29
|
+
// "lighthouse": async (ctx, opts) => {
|
|
30
|
+
// // ... run lighthouse, return { out, meta }
|
|
31
|
+
// },
|
|
32
|
+
// },
|
|
33
|
+
// beforeShoot: async (ctx) => {
|
|
34
|
+
// // e.g. set extra cookies, click a button, etc.
|
|
35
|
+
// },
|
|
36
|
+
// };
|
|
37
|
+
|
|
38
|
+
const plugins = [];
|
|
39
|
+
const registeredRefs = new Set();
|
|
40
|
+
const sweepOps = new Map();
|
|
41
|
+
const viewportPresets = new Map();
|
|
42
|
+
const flagHelp = new Map();
|
|
43
|
+
|
|
44
|
+
export function registerPlugin(p) {
|
|
45
|
+
if (!p || typeof p !== "object") return;
|
|
46
|
+
if (registeredRefs.has(p)) return;
|
|
47
|
+
registeredRefs.add(p);
|
|
48
|
+
plugins.push(p);
|
|
49
|
+
if (p.name) console.error(`[pursr] loaded plugin: ${p.name}`);
|
|
50
|
+
if (p.viewport) {
|
|
51
|
+
for (const [k, v] of Object.entries(p.viewport)) viewportPresets.set(k, v);
|
|
52
|
+
}
|
|
53
|
+
if (p.sweepOp) {
|
|
54
|
+
for (const [k, v] of Object.entries(p.sweepOp)) sweepOps.set(k, v);
|
|
55
|
+
}
|
|
56
|
+
if (p.flagHelp) {
|
|
57
|
+
for (const [k, v] of Object.entries(p.flagHelp)) flagHelp.set(k, v);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function loadPlugins(paths = []) {
|
|
62
|
+
if (typeof paths === "string") paths = [paths];
|
|
63
|
+
const loadedPaths = new Set();
|
|
64
|
+
// auto-load built-in plugins from plugins/ directory
|
|
65
|
+
const { readdirSync, existsSync } = await import("node:fs");
|
|
66
|
+
const { join, dirname } = await import("node:path");
|
|
67
|
+
const { fileURLToPath, pathToFileURL } = await import("node:url");
|
|
68
|
+
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
69
|
+
const pluginDir = join(selfDir, "..", "plugins");
|
|
70
|
+
if (existsSync(pluginDir)) {
|
|
71
|
+
for (const f of readdirSync(pluginDir)) {
|
|
72
|
+
if (f.endsWith(".js")) {
|
|
73
|
+
const fullPath = pathToFileURL(join(pluginDir, f)).href;
|
|
74
|
+
if (loadedPaths.has(fullPath)) continue;
|
|
75
|
+
loadedPaths.add(fullPath);
|
|
76
|
+
try {
|
|
77
|
+
const mod = await import(/* @vite-ignore */ fullPath);
|
|
78
|
+
registerPlugin(mod.default || mod);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(`[pursr] failed to load built-in plugin ${f}: ${e.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// user-supplied plugins — resolve relative to cwd
|
|
86
|
+
for (const p of paths) {
|
|
87
|
+
if (loadedPaths.has(p)) continue;
|
|
88
|
+
loadedPaths.add(p);
|
|
89
|
+
try {
|
|
90
|
+
const resolved = pathToFileURL(join(process.cwd(), p)).href;
|
|
91
|
+
const mod = await import(/* @vite-ignore */ resolved);
|
|
92
|
+
registerPlugin(mod.default || mod);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error(`[pursr] failed to load plugin ${p}: ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return plugins.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listPlugins() { return plugins.map(p => p?.name || "(unnamed)"); }
|
|
101
|
+
|
|
102
|
+
export function getSweepOp(name) { return sweepOps.get(name); }
|
|
103
|
+
export function getViewportPreset(name) { return viewportPresets.get(name); }
|
|
104
|
+
export function listViewportPresets() { return Object.fromEntries(viewportPresets); }
|
|
105
|
+
export function getFlagHelp() { return Object.fromEntries(flagHelp); }
|
|
106
|
+
|
|
107
|
+
export async function runBeforeShoot(ctx) {
|
|
108
|
+
for (const p of plugins) {
|
|
109
|
+
if (typeof p.beforeShoot === "function") {
|
|
110
|
+
try { await p.beforeShoot(ctx); } catch (e) { console.error(`[plugin ${p.name}] beforeShoot: ${e.message}`); }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runAfterShoot(ctx, meta) {
|
|
116
|
+
for (const p of plugins) {
|
|
117
|
+
if (typeof p.afterShoot === "function") {
|
|
118
|
+
try { await p.afterShoot(ctx, meta); } catch (e) { console.error(`[plugin ${p.name}] afterShoot: ${e.message}`); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
121
|
}
|
package/src/probe.js
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
// Probe: open a viewport, navigate, return basic info. No screenshot.
|
|
2
|
-
|
|
3
|
-
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
5
|
-
import { gotoOrThrow } from "./overlays.js";
|
|
6
|
-
import { requireArg } from "./util.js";
|
|
7
|
-
|
|
8
|
-
export async function runProbe(url) {
|
|
9
|
-
requireArg("url", url, "string");
|
|
10
|
-
const browser = await launch();
|
|
11
|
-
try {
|
|
12
|
-
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
13
|
-
let navError = null, status = null, title = null;
|
|
14
|
-
try {
|
|
15
|
-
const r = await gotoOrThrow(page, url);
|
|
16
|
-
status = r.status; title = r.title;
|
|
17
|
-
} catch (e) { navError = e.message; }
|
|
18
|
-
return { url, status, title, navError, viewport: DEFAULT_VIEWPORT };
|
|
19
|
-
} finally { try { await browser.close(); } catch {} }
|
|
1
|
+
// Probe: open a viewport, navigate, return basic info. No screenshot.
|
|
2
|
+
|
|
3
|
+
import { launch, newPage } from "./runway.js";
|
|
4
|
+
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
5
|
+
import { gotoOrThrow } from "./overlays.js";
|
|
6
|
+
import { requireArg } from "./util.js";
|
|
7
|
+
|
|
8
|
+
export async function runProbe(url) {
|
|
9
|
+
requireArg("url", url, "string");
|
|
10
|
+
const browser = await launch();
|
|
11
|
+
try {
|
|
12
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
13
|
+
let navError = null, status = null, title = null;
|
|
14
|
+
try {
|
|
15
|
+
const r = await gotoOrThrow(page, url);
|
|
16
|
+
status = r.status; title = r.title;
|
|
17
|
+
} catch (e) { navError = e.message; }
|
|
18
|
+
return { url, status, title, navError, viewport: DEFAULT_VIEWPORT };
|
|
19
|
+
} finally { try { await browser.close(); } catch {} }
|
|
20
20
|
}
|
package/src/report.js
CHANGED
|
@@ -1,176 +1,176 @@
|
|
|
1
|
-
//
|
|
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
|
-
|
|
1
|
+
// pursr - 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
176
|
export { renderSweepPdf as default };
|