pursr 0.5.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/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 };