pursr 0.6.0 → 0.7.1

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/baseline.js CHANGED
@@ -1,126 +1,127 @@
1
- // pursor — baseline storage for visual regression.
2
- //
3
- // Baselines live under $PURSOR_BASELINES_DIR || ~/.pursor/baselines/<project>/
4
- // Each baseline is keyed by a stable id derived from the URL + viewport +
5
- // flag set (a short hash), so re-running a sweep deterministically points
6
- // to the same baseline slot.
7
- //
8
- // Layout:
9
- // ~/.pursor/baselines/<project>/<id>/<step>.png
10
- // ~/.pursor/baselines/<project>/<id>/manifest.json (url, viewport, flags, ts)
11
- //
12
- // Public API:
13
- // resolveBaselinePath({ project, id, step }) -> { dir, file, manifest? }
14
- // saveBaseline({ project, id, step, png, meta }) -> manifest path
15
- // loadBaseline({ project, id, step }) -> { png, manifest } | null
16
- // listBaselines(project) -> [{ id, step, ts, url }]
17
- // approveBaseline({ project, id, step, fromPng }) -> manifest path
18
- // diffKey({ url, viewport, flags }) -> string (stable id)
19
- //
20
- // CLI subcommands added: pursor baseline save/approve/list/diff-key
21
-
22
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
23
- import { join, dirname, basename } from "node:path";
24
- import { homedir } from "node:os";
25
- import { createHash } from "node:crypto";
26
- import { shortHash, nowIso } from "./util.js";
27
-
28
- function baseDir(project) {
29
- const root = process.env.PURSOR_BASELINES_DIR || join(homedir(), ".pursor", "baselines");
30
- const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
31
- return join(root, proj);
32
- }
33
-
34
- export function diffKey({ url = "", viewport = {}, flags = {} } = {}) {
35
- // Stable hash: project-agnostic, viewport+flag+url sensitive.
36
- const v = `${viewport.width || 0}x${viewport.height || 0}@${viewport.dpr || 1}${viewport.name ? ":" + viewport.name : ""}`;
37
- const fl = Object.keys(flags).sort().filter(k => k !== "out").map(k => `${k}=${flags[k]}`).join("&");
38
- const src = `${url}|${v}|${fl}`;
39
- return createHash("sha1").update(src).digest("hex").slice(0, 16);
40
- }
41
-
42
- export function resolveBaselinePath({ project, id, step }) {
43
- const dir = join(baseDir(project), id);
44
- const file = join(dir, `${String(step).replace(/[^a-z0-9._-]+/gi, "_")}.png`);
45
- const manifestPath = join(dir, "manifest.json");
46
- return { dir, file, manifestPath, manifest: existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null };
47
- }
48
-
49
- export function saveBaseline({ project, id, step, png, meta }) {
50
- if (!png) throw new Error("saveBaseline: missing png path");
51
- if (!existsSync(png)) throw new Error(`saveBaseline: png not found: ${png}`);
52
- const { dir, file, manifestPath } = resolveBaselinePath({ project, id, step });
53
- mkdirSync(dir, { recursive: true });
54
- // Copy file (renameSync would also work but keep semantics explicit)
55
- const buf = readFileSync(png);
56
- writeFileSync(file, buf);
57
- const manifest = {
58
- project: project || "default",
59
- id,
60
- step: String(step),
61
- file,
62
- size: buf.length,
63
- sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
64
- url: meta?.url || null,
65
- viewport: meta?.viewport || null,
66
- flags: meta?.flags || null,
67
- ts: nowIso(),
68
- };
69
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
70
- return { ...manifest, manifestPath };
71
- }
72
-
73
- export function loadBaseline({ project, id, step }) {
74
- const { file, manifest } = resolveBaselinePath({ project, id, step });
75
- if (!existsSync(file)) return null;
76
- return {
77
- png: file,
78
- size: statSync(file).size,
79
- hash: shortHash(readFileSync(file)),
80
- manifest: manifest || null,
81
- };
82
- }
83
-
84
- export function listBaselines(project) {
85
- const root = baseDir(project);
86
- if (!existsSync(root)) return [];
87
- const out = [];
88
- for (const id of readdirSync(root)) {
89
- const dir = join(root, id);
90
- if (!statSync(dir).isDirectory()) continue;
91
- const manifestPath = join(dir, "manifest.json");
92
- const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
93
- if (!manifest) continue;
94
- out.push({
95
- id,
96
- step: manifest.step,
97
- url: manifest.url,
98
- ts: manifest.ts,
99
- sha1: manifest.sha1,
100
- });
101
- }
102
- return out.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
103
- }
104
-
105
- export function approveBaseline({ project, id, step, fromPng }) {
106
- if (!fromPng) throw new Error("approveBaseline: missing fromPng");
107
- if (!existsSync(fromPng)) throw new Error(`approveBaseline: fromPng not found: ${fromPng}`);
108
- const buf = readFileSync(fromPng);
109
- const { file, dir, manifestPath } = resolveBaselinePath({ project, id, step });
110
- mkdirSync(dir, { recursive: true });
111
- writeFileSync(file, buf);
112
- const prev = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {};
113
- const manifest = {
114
- ...prev,
115
- project: project || prev.project || "default",
116
- id,
117
- step: String(step),
118
- file,
119
- size: buf.length,
120
- sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
121
- ts: nowIso(),
122
- approvedFrom: fromPng,
123
- };
124
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
125
- return manifest;
1
+ // pursr — baseline storage for visual regression.
2
+ //
3
+ // Baselines live under $PURSR_BASELINES_DIR || ~/./baselines/<project>/
4
+ // Each baseline is keyed by a stable id derived from the URL + viewport +
5
+ // flag set (a short hash), so re-running a sweep deterministically points
6
+ // to the same baseline slot.
7
+ //
8
+ // Layout:
9
+ // ~/.pursr/baselines/<project>/<id>/<step>.png
10
+ // ~/.pursr/baselines/<project>/<id>/manifest.json (url, viewport, flags, ts)
11
+ //
12
+ // Public API:
13
+ // resolveBaselinePath({ project, id, step }) -> { dir, file, manifest? }
14
+ // saveBaseline({ project, id, step, png, meta }) -> manifest path
15
+ // loadBaseline({ project, id, step }) -> { png, manifest } | null
16
+ // listBaselines(project) -> [{ id, step, ts, url }]
17
+ // approveBaseline({ project, id, step, fromPng }) -> manifest path
18
+ // diffKey({ url, viewport, flags }) -> string (stable id)
19
+ //
20
+ // CLI subcommands added: baseline save/approve/list/diff-key
21
+
22
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
23
+ import { join, dirname, basename } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { createHash } from "node:crypto";
26
+ import { shortHash, nowIso } from "./util.js";
27
+ import { __PURSR_GET } from "./util.js";
28
+
29
+ function baseDir(project) {
30
+ const root = __PURSR_GET("PURSR_BASELINES_DIR") || join(homedir(), ".pursr", "baselines");
31
+ const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
32
+ return join(root, proj);
33
+ }
34
+
35
+ export function diffKey({ url = "", viewport = {}, flags = {} } = {}) {
36
+ // Stable hash: project-agnostic, viewport+flag+url sensitive.
37
+ const v = `${viewport.width || 0}x${viewport.height || 0}@${viewport.dpr || 1}${viewport.name ? ":" + viewport.name : ""}`;
38
+ const fl = Object.keys(flags).sort().filter(k => k !== "out").map(k => `${k}=${flags[k]}`).join("&");
39
+ const src = `${url}|${v}|${fl}`;
40
+ return createHash("sha1").update(src).digest("hex").slice(0, 16);
41
+ }
42
+
43
+ export function resolveBaselinePath({ project, id, step }) {
44
+ const dir = join(baseDir(project), id);
45
+ const file = join(dir, `${String(step).replace(/[^a-z0-9._-]+/gi, "_")}.png`);
46
+ const manifestPath = join(dir, "manifest.json");
47
+ return { dir, file, manifestPath, manifest: existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null };
48
+ }
49
+
50
+ export function saveBaseline({ project, id, step, png, meta }) {
51
+ if (!png) throw new Error("saveBaseline: missing png path");
52
+ if (!existsSync(png)) throw new Error(`saveBaseline: png not found: ${png}`);
53
+ const { dir, file, manifestPath } = resolveBaselinePath({ project, id, step });
54
+ mkdirSync(dir, { recursive: true });
55
+ // Copy file (renameSync would also work but keep semantics explicit)
56
+ const buf = readFileSync(png);
57
+ writeFileSync(file, buf);
58
+ const manifest = {
59
+ project: project || "default",
60
+ id,
61
+ step: String(step),
62
+ file,
63
+ size: buf.length,
64
+ sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
65
+ url: meta?.url || null,
66
+ viewport: meta?.viewport || null,
67
+ flags: meta?.flags || null,
68
+ ts: nowIso(),
69
+ };
70
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
71
+ return { ...manifest, manifestPath };
72
+ }
73
+
74
+ export function loadBaseline({ project, id, step }) {
75
+ const { file, manifest } = resolveBaselinePath({ project, id, step });
76
+ if (!existsSync(file)) return null;
77
+ return {
78
+ png: file,
79
+ size: statSync(file).size,
80
+ hash: shortHash(readFileSync(file)),
81
+ manifest: manifest || null,
82
+ };
83
+ }
84
+
85
+ export function listBaselines(project) {
86
+ const root = baseDir(project);
87
+ if (!existsSync(root)) return [];
88
+ const out = [];
89
+ for (const id of readdirSync(root)) {
90
+ const dir = join(root, id);
91
+ if (!statSync(dir).isDirectory()) continue;
92
+ const manifestPath = join(dir, "manifest.json");
93
+ const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
94
+ if (!manifest) continue;
95
+ out.push({
96
+ id,
97
+ step: manifest.step,
98
+ url: manifest.url,
99
+ ts: manifest.ts,
100
+ sha1: manifest.sha1,
101
+ });
102
+ }
103
+ return out.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
104
+ }
105
+
106
+ export function approveBaseline({ project, id, step, fromPng }) {
107
+ if (!fromPng) throw new Error("approveBaseline: missing fromPng");
108
+ if (!existsSync(fromPng)) throw new Error(`approveBaseline: fromPng not found: ${fromPng}`);
109
+ const buf = readFileSync(fromPng);
110
+ const { file, dir, manifestPath } = resolveBaselinePath({ project, id, step });
111
+ mkdirSync(dir, { recursive: true });
112
+ writeFileSync(file, buf);
113
+ const prev = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : {};
114
+ const manifest = {
115
+ ...prev,
116
+ project: project || prev.project || "default",
117
+ id,
118
+ step: String(step),
119
+ file,
120
+ size: buf.length,
121
+ sha1: createHash("sha1").update(buf).digest("hex").slice(0, 16),
122
+ ts: nowIso(),
123
+ approvedFrom: fromPng,
124
+ };
125
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
126
+ return manifest;
126
127
  }
package/src/ci-output.js CHANGED
@@ -1,156 +1,156 @@
1
- // pursor — CI Output Formatters.
2
- //
3
- // Converts a sweep summary into:
4
- // - JUnit XML (sweep.junit.xml) — CI pipeline integration
5
- // - GitHub Actions annotations (sweep.github.json)
6
- // - Markdown summary (sweep.md)
7
- //
8
- // Call writeCiOutput(summary) at the end of a sweep to write all three
9
- // sidecars alongside sweep.json.
10
-
11
- import { writeFileSync, mkdirSync } from "node:fs";
12
- import { join } from "node:path";
13
- import { escapeHtml } from "./util.js";
14
-
15
- // ─── JUnit XML ───────────────────────────────────────────────────────────
16
- // Produces a minimal JUnit-compatible XML that GitLab CI, Jenkins, CircleCI,
17
- // and other CI runners understand.
18
-
19
- function renderJUnitXml(summary) {
20
- const { steps, name, ts, outDir } = summary;
21
- const suiteName = escapeXml(name || "pursor-sweep");
22
- const tests = steps.length;
23
- const failures = steps.filter(s => !s.ok).length;
24
- const time = (steps.reduce((s, t) => s + (t.ms || 0), 0) / 1000).toFixed(3);
25
-
26
- const testcases = steps.map(s => {
27
- const className = escapeXml(`${s.op || "step"}` || "unknown");
28
- const testName = escapeXml(s.name || `step-${s.i}`);
29
- const fileAttr = s.meta?.out ? ` file="${escapeXml(s.meta.out)}"` : "";
30
- let extra = "";
31
- if (!s.ok) {
32
- const msg = escapeXml(s.error || "unknown error");
33
- const meta = s.meta ? escapeXml(JSON.stringify(s.meta, null, 2).slice(0, 500)) : "";
34
- extra = `\n <failure message="${msg}">${meta ? `\n <![CDATA[${meta}]]>\n ` : ""}</failure>`;
35
- }
36
- return ` <testcase classname="${className}" name="${testName}" time="${((s.ms || 0) / 1000).toFixed(3)}"${fileAttr}>${extra}
37
- </testcase>`;
38
- }).join("\n");
39
-
40
- return `<?xml version="1.0" encoding="UTF-8"?>
41
- <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="0" time="${time}" timestamp="${escapeXml(summary.ts || "")}">
42
- ${testcases}
43
- </testsuite>`;
44
- }
45
-
46
- function escapeXml(s) {
47
- return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
48
- }
49
-
50
- // ─── GitHub Actions (JSON annotations) ───────────────────────────────────
51
- // Each annotation: { filename, fileLine, annotation_level, message, title }
52
-
53
- function renderGitHubAnnotations(summary) {
54
- const annotations = [];
55
- for (const s of summary.steps) {
56
- if (s.ok) continue;
57
- annotations.push({
58
- filename: s.meta?.out || s.name || `step-${s.i}`,
59
- fileLine: 1,
60
- annotation_level: "failure",
61
- title: `pursor: ${s.name || `step-${s.i}`}`,
62
- message: s.error || "Step failed",
63
- raw_details: s.meta ? JSON.stringify(s.meta) : "",
64
- });
65
- }
66
- // Also add warnings for diffs with high diffPct
67
- for (const s of summary.steps) {
68
- if (s.ok && s.meta?.numDiff > 0 && s.meta?.diffPct > 1) {
69
- annotations.push({
70
- filename: s.meta.out || s.name || `step-${s.i}`,
71
- fileLine: 1,
72
- annotation_level: "warning",
73
- title: `pursor: diff > 1%`,
74
- message: `${s.name}: ${s.meta.diffPct}% pixels differ from reference`,
75
- raw_details: JSON.stringify(s.meta),
76
- });
77
- }
78
- }
79
- return { annotations };
80
- }
81
-
82
- // ─── Markdown Summary ────────────────────────────────────────────────────
83
-
84
- function renderMarkdownSummary(summary) {
85
- const lines = [
86
- `# pursor Sweep: ${summary.name || "(unnamed)"}`,
87
- ``,
88
- `**Plan:** \`${summary.plan || "?"}\``,
89
- `**Date:** ${summary.ts || "?"}`,
90
- `**Steps:** ${summary.steps.length}`,
91
- ``,
92
- `## Results`,
93
- ``,
94
- `| # | Name | Op | Status | Time |`,
95
- `|---|------|----|--------|------|`,
96
- ];
97
-
98
- for (const s of summary.steps) {
99
- const status = s.ok ? "✅ OK" : "❌ FAIL";
100
- const time = `${s.ms || 0}ms`;
101
- lines.push(`| ${s.i} | ${escapeMd(s.name || "")} | \`${s.op || ""}\` | ${status} | ${time} |`);
102
- }
103
-
104
- // Failures detail
105
- const failed = summary.steps.filter(s => !s.ok);
106
- if (failed.length) {
107
- lines.push(``, `## Failures`, ``);
108
- for (const s of failed) {
109
- lines.push(`### ${s.i}. ${s.name || "unknown"}`);
110
- lines.push(``);
111
- lines.push(`**Error:** \`${escapeMd(s.error || "unknown")}\``);
112
- if (s.meta?.out) lines.push(`**Output:** \`${s.meta.out}\``);
113
- lines.push(``);
114
- }
115
- }
116
-
117
- // Diffs with non-zero diff
118
- const diffs = summary.steps.filter(s => s.ok && s.meta?.numDiff > 0);
119
- if (diffs.length) {
120
- lines.push(``, `## Diffs`, ``);
121
- for (const s of diffs) {
122
- lines.push(`| ${s.name} | ${s.meta.diffPct}% | ${s.meta.numDiff}/${s.meta.totalPx} pixels |`);
123
- }
124
- lines.push(``);
125
- }
126
-
127
- lines.push(`_Generated by pursor_`);
128
- return lines.join("\n");
129
- }
130
-
131
- function escapeMd(s) {
132
- return String(s ?? "").replace(/([_*[\]()~`>#+\-=|{}!.])/g, "\\$1");
133
- }
134
-
135
- // ─── Writer ──────────────────────────────────────────────────────────────
136
-
137
- /**
138
- * Write CI-format output files alongside the sweep summary.
139
- *
140
- * @param {object} summary - Sweep summary object from runSweep()
141
- * @param {string} dir - Output directory (same as sweep.json)
142
- * @returns {{ junit: string, github: string, markdown: string }} Written paths
143
- */
144
- export function writeCiOutput(summary, dir) {
145
- if (!dir) dir = summary.outDir || ".";
146
- mkdirSync(dir, { recursive: true });
147
- const junitPath = join(dir, "sweep.junit.xml");
148
- const githubPath = join(dir, "sweep.github.json");
149
- const mdPath = join(dir, "sweep.md");
150
-
151
- writeFileSync(junitPath, renderJUnitXml(summary), "utf8");
152
- writeFileSync(githubPath, JSON.stringify(renderGitHubAnnotations(summary), null, 2), "utf8");
153
- writeFileSync(mdPath, renderMarkdownSummary(summary), "utf8");
154
-
155
- return { junit: junitPath, github: githubPath, markdown: mdPath };
156
- }
1
+ // pursr — CI Output Formatters.
2
+ //
3
+ // Converts a sweep summary into:
4
+ // pursr - JUnit XML (sweep.junit.xml) — CI pipeline integration
5
+ // pursr - GitHub Actions annotations (sweep.github.json)
6
+ // pursr - Markdown summary (sweep.md)
7
+ //
8
+ // Call writeCiOutput(summary) at the end of a sweep to write all three
9
+ // sidecars alongside sweep.json.
10
+
11
+ import { writeFileSync, mkdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { escapeHtml } from "./util.js";
14
+
15
+ // ─── JUnit XML ───────────────────────────────────────────────────────────
16
+ // Produces a minimal JUnit-compatible XML that GitLab CI, Jenkins, CircleCI,
17
+ // and other CI runners understand.
18
+
19
+ function renderJUnitXml(summary) {
20
+ const { steps, name, ts, outDir } = summary;
21
+ const suiteName = escapeXml(name || "pursr-sweep");
22
+ const tests = steps.length;
23
+ const failures = steps.filter(s => !s.ok).length;
24
+ const time = (steps.reduce((s, t) => s + (t.ms || 0), 0) / 1000).toFixed(3);
25
+
26
+ const testcases = steps.map(s => {
27
+ const className = escapeXml(`${s.op || "step"}` || "unknown");
28
+ const testName = escapeXml(s.name || `step-${s.i}`);
29
+ const fileAttr = s.meta?.out ? ` file="${escapeXml(s.meta.out)}"` : "";
30
+ let extra = "";
31
+ if (!s.ok) {
32
+ const msg = escapeXml(s.error || "unknown error");
33
+ const meta = s.meta ? escapeXml(JSON.stringify(s.meta, null, 2).slice(0, 500)) : "";
34
+ extra = `\n <failure message="${msg}">${meta ? `\n <![CDATA[${meta}]]>\n ` : ""}</failure>`;
35
+ }
36
+ return ` <testcase classname="${className}" name="${testName}" time="${((s.ms || 0) / 1000).toFixed(3)}"${fileAttr}>${extra}
37
+ </testcase>`;
38
+ }).join("\n");
39
+
40
+ return `<?xml version="1.0" encoding="UTF-8"?>
41
+ <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="0" time="${time}" timestamp="${escapeXml(summary.ts || "")}">
42
+ ${testcases}
43
+ </testsuite>`;
44
+ }
45
+
46
+ function escapeXml(s) {
47
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
48
+ }
49
+
50
+ // ─── GitHub Actions (JSON annotations) ───────────────────────────────────
51
+ // Each annotation: { filename, fileLine, annotation_level, message, title }
52
+
53
+ function renderGitHubAnnotations(summary) {
54
+ const annotations = [];
55
+ for (const s of summary.steps) {
56
+ if (s.ok) continue;
57
+ annotations.push({
58
+ filename: s.meta?.out || s.name || `step-${s.i}`,
59
+ fileLine: 1,
60
+ annotation_level: "failure",
61
+ title: `pursr: ${s.name || `step-${s.i}`}`,
62
+ message: s.error || "Step failed",
63
+ raw_details: s.meta ? JSON.stringify(s.meta) : "",
64
+ });
65
+ }
66
+ // Also add warnings for diffs with high diffPct
67
+ for (const s of summary.steps) {
68
+ if (s.ok && s.meta?.numDiff > 0 && s.meta?.diffPct > 1) {
69
+ annotations.push({
70
+ filename: s.meta.out || s.name || `step-${s.i}`,
71
+ fileLine: 1,
72
+ annotation_level: "warning",
73
+ title: `pursr: diff > 1%`,
74
+ message: `${s.name}: ${s.meta.diffPct}% pixels differ from reference`,
75
+ raw_details: JSON.stringify(s.meta),
76
+ });
77
+ }
78
+ }
79
+ return { annotations };
80
+ }
81
+
82
+ // ─── Markdown Summary ────────────────────────────────────────────────────
83
+
84
+ function renderMarkdownSummary(summary) {
85
+ const lines = [
86
+ `# pursr Sweep: ${summary.name || "(unnamed)"}`,
87
+ ``,
88
+ `**Plan:** \`${summary.plan || "?"}\``,
89
+ `**Date:** ${summary.ts || "?"}`,
90
+ `**Steps:** ${summary.steps.length}`,
91
+ ``,
92
+ `## Results`,
93
+ ``,
94
+ `| # | Name | Op | Status | Time |`,
95
+ `|---|------|----|--------|------|`,
96
+ ];
97
+
98
+ for (const s of summary.steps) {
99
+ const status = s.ok ? "✅ OK" : "❌ FAIL";
100
+ const time = `${s.ms || 0}ms`;
101
+ lines.push(`| ${s.i} | ${escapeMd(s.name || "")} | \`${s.op || ""}\` | ${status} | ${time} |`);
102
+ }
103
+
104
+ // Failures detail
105
+ const failed = summary.steps.filter(s => !s.ok);
106
+ if (failed.length) {
107
+ lines.push(``, `## Failures`, ``);
108
+ for (const s of failed) {
109
+ lines.push(`### ${s.i}. ${s.name || "unknown"}`);
110
+ lines.push(``);
111
+ lines.push(`**Error:** \`${escapeMd(s.error || "unknown")}\``);
112
+ if (s.meta?.out) lines.push(`**Output:** \`${s.meta.out}\``);
113
+ lines.push(``);
114
+ }
115
+ }
116
+
117
+ // Diffs with non-zero diff
118
+ const diffs = summary.steps.filter(s => s.ok && s.meta?.numDiff > 0);
119
+ if (diffs.length) {
120
+ lines.push(``, `## Diffs`, ``);
121
+ for (const s of diffs) {
122
+ lines.push(`| ${s.name} | ${s.meta.diffPct}% | ${s.meta.numDiff}/${s.meta.totalPx} pixels |`);
123
+ }
124
+ lines.push(``);
125
+ }
126
+
127
+ lines.push(`_Generated by pursr_`);
128
+ return lines.join("\n");
129
+ }
130
+
131
+ function escapeMd(s) {
132
+ return String(s ?? "").replace(/([_*[\]()~`>#+\-=|{}!.])/g, "\\$1");
133
+ }
134
+
135
+ // ─── Writer ──────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Write CI-format output files alongside the sweep summary.
139
+ *
140
+ * @param {object} summary - Sweep summary object from runSweep()
141
+ * @param {string} dir - Output directory (same as sweep.json)
142
+ * @returns {{ junit: string, github: string, markdown: string }} Written paths
143
+ */
144
+ export function writeCiOutput(summary, dir) {
145
+ if (!dir) dir = summary.outDir || ".";
146
+ mkdirSync(dir, { recursive: true });
147
+ const junitPath = join(dir, "sweep.junit.xml");
148
+ const githubPath = join(dir, "sweep.github.json");
149
+ const mdPath = join(dir, "sweep.md");
150
+
151
+ writeFileSync(junitPath, renderJUnitXml(summary), "utf8");
152
+ writeFileSync(githubPath, JSON.stringify(renderGitHubAnnotations(summary), null, 2), "utf8");
153
+ writeFileSync(mdPath, renderMarkdownSummary(summary), "utf8");
154
+
155
+ return { junit: junitPath, github: githubPath, markdown: mdPath };
156
+ }
package/src/diff.js CHANGED
@@ -3,9 +3,9 @@
3
3
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { launch, newPage } from "./runway.js";
6
- import { DEFAULT_VIEWPORT } from "./viewport.js";
7
- import { gotoOrThrow, settle } from "./overlays.js";
8
- import { requireArg } from "./util.js";
6
+ import { resolveViewport } from "./viewport.js";
7
+ import { gotoOrThrow, settle, applyCamera, waitForStableFrame } from "./overlays.js";
8
+ import { asNum, requireArg } from "./util.js";
9
9
  import { aiDiffSidecar } from "./ai-diff.js";
10
10
 
11
11
  const DIFF_DEFAULT_THRESHOLD = 0.1;
@@ -20,7 +20,7 @@ async function loadPixelmatch() {
20
20
  catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
21
21
  }
22
22
 
23
- export async function runDiff(url, refPath, out, threshold, browser) {
23
+ export async function runDiff(url, refPath, out, threshold, flags = {}, browser) {
24
24
  requireArg("url", url, "string");
25
25
  requireArg("refPath", refPath, "string");
26
26
  const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
@@ -28,10 +28,21 @@ export async function runDiff(url, refPath, out, threshold, browser) {
28
28
  const PNG = await loadPngjs();
29
29
  const pixelmatch = await loadPixelmatch();
30
30
  const ownBrowser = !browser;
31
+ const viewport = resolveViewport(flags || {});
31
32
  browser = browser || await launch();
32
33
  try {
33
- const page = await newPage(browser, DEFAULT_VIEWPORT);
34
+ const page = await newPage(browser, viewport);
34
35
  const r = await gotoOrThrow(page, url); await settle(page);
36
+ if (flags && (flags["wait-frame"] || flags["no-animation"])) {
37
+ await waitForStableFrame(page, asNum(flags["wait-frame"], 600));
38
+ }
39
+ if (flags && (flags.zoom || flags.panX || flags.panY)) {
40
+ await applyCamera(page, {
41
+ zoom: asNum(flags.zoom, 1),
42
+ panX: asNum(flags.panX, 0),
43
+ panY: asNum(flags.panY, 0),
44
+ });
45
+ }
35
46
  const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
36
47
  await page.screenshot({ path: currentPath, fullPage: false });
37
48
  const refPng = PNG.sync.read(readFileSync(refPath));
@@ -53,8 +64,8 @@ export async function runDiff(url, refPath, out, threshold, browser) {
53
64
  * summary of the visual differences. The AI summary is written to <out>.ai.json
54
65
  * and also returned on the result object.
55
66
  */
56
- export async function runDiffWithAi(url, refPath, out, threshold, aiOpts, browser) {
57
- const r = await runDiff(url, refPath, out, threshold, browser);
67
+ export async function runDiffWithAi(url, refPath, out, threshold, flags, aiOpts, browser) {
68
+ const r = await runDiff(url, refPath, out, threshold, flags, browser);
58
69
  if (r.error) return r;
59
70
  try {
60
71
  const curPath = r.currentPath;