pursor 0.2.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 +21 -0
- package/README.md +595 -0
- package/bin/pursor-mcp.mjs +21 -0
- package/bin/pursor.mjs +191 -0
- package/package.json +73 -0
- package/plans/m5.4-polish.json +22 -0
- package/plugins/plugin-audit.js +57 -0
- package/plugins/plugin-demo.js +63 -0
- package/src/baseline.js +126 -0
- package/src/ci-output.js +156 -0
- package/src/diff.js +48 -0
- package/src/dom-snapshot.js +192 -0
- package/src/eval.js +18 -0
- package/src/every-viewport.js +51 -0
- package/src/frames.js +34 -0
- package/src/hover.js +26 -0
- package/src/index.js +90 -0
- package/src/interact.js +138 -0
- package/src/mcp-resources.js +111 -0
- package/src/mcp.js +436 -0
- package/src/overlays.js +170 -0
- package/src/plugin-audit.js +260 -0
- package/src/plugin.js +121 -0
- package/src/probe.js +20 -0
- package/src/runway.js +63 -0
- package/src/selector-heal.js +85 -0
- package/src/selector.js +39 -0
- package/src/shoot.js +62 -0
- package/src/shot.js +18 -0
- package/src/sweep-schema.js +70 -0
- package/src/sweep.js +105 -0
- package/src/util.js +188 -0
- package/src/viewport.js +39 -0
package/src/ci-output.js
ADDED
|
@@ -0,0 +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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pixelmatch diff against a reference PNG.
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
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";
|
|
9
|
+
|
|
10
|
+
const DIFF_DEFAULT_THRESHOLD = 0.1;
|
|
11
|
+
|
|
12
|
+
async function loadPngjs() {
|
|
13
|
+
try { return (await import("pngjs")).PNG; }
|
|
14
|
+
catch { throw new Error("pngjs not found. Install: npm i pngjs"); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function loadPixelmatch() {
|
|
18
|
+
try { return (await import("pixelmatch")).default; }
|
|
19
|
+
catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runDiff(url, refPath, out, threshold, browser) {
|
|
23
|
+
requireArg("url", url, "string");
|
|
24
|
+
requireArg("refPath", refPath, "string");
|
|
25
|
+
const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
|
|
26
|
+
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
27
|
+
const PNG = await loadPngjs();
|
|
28
|
+
const pixelmatch = await loadPixelmatch();
|
|
29
|
+
const ownBrowser = !browser;
|
|
30
|
+
browser = browser || await launch();
|
|
31
|
+
try {
|
|
32
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
33
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
34
|
+
const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
|
|
35
|
+
await page.screenshot({ path: currentPath, fullPage: false });
|
|
36
|
+
const refPng = PNG.sync.read(readFileSync(refPath));
|
|
37
|
+
const curPng = PNG.sync.read(readFileSync(currentPath));
|
|
38
|
+
if (refPng.width !== curPng.width || refPng.height !== curPng.height) {
|
|
39
|
+
return { ...r, url, refPath, currentPath, error: "size mismatch", refSize: { w: refPng.width, h: refPng.height }, currentSize: { w: curPng.width, h: curPng.height } };
|
|
40
|
+
}
|
|
41
|
+
const diffPng = new PNG({ width: refPng.width, height: refPng.height });
|
|
42
|
+
const numDiff = pixelmatch(refPng.data, curPng.data, diffPng.data, refPng.width, refPng.height, { threshold: t });
|
|
43
|
+
const totalPx = refPng.width * refPng.height;
|
|
44
|
+
const diffPct = (numDiff / totalPx) * 100;
|
|
45
|
+
if (out) writeFileSync(out, PNG.sync.write(diffPng));
|
|
46
|
+
return { ...r, url, refPath, currentPath, out: out || null, threshold: t, refSize: { w: refPng.width, h: refPng.height }, totalPx, numDiff, diffPct: Number(diffPct.toFixed(3)), equal: numDiff === 0 };
|
|
47
|
+
} finally { if (ownBrowser) try { await browser.close(); } catch {} }
|
|
48
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// pursor — DOM Snapshot + CSSOM + Selector Map.
|
|
2
|
+
//
|
|
3
|
+
// Every capture can optionally produce a .dom.json sidecar containing:
|
|
4
|
+
// - serialized DOM (document.documentElement.outerHTML)
|
|
5
|
+
// - computed styles for every visible element
|
|
6
|
+
// - selector map (id → role → accessible name → xpath → css selector)
|
|
7
|
+
// - viewport-relative bounding rects
|
|
8
|
+
//
|
|
9
|
+
// Useful for visual regression debugging without a browser —
|
|
10
|
+
// compare DOM structure directly.
|
|
11
|
+
|
|
12
|
+
import { launch, newPage } from "./runway.js";
|
|
13
|
+
import { resolveViewport } from "./viewport.js";
|
|
14
|
+
import { gotoOrThrow, settle } from "./overlays.js";
|
|
15
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { nowIso, requireArg } from "./util.js";
|
|
18
|
+
|
|
19
|
+
// ─── Injected page script ──────────────────────────────────────────────
|
|
20
|
+
// Runs inside the browser to collect all DOM + CSSOM data in one pass.
|
|
21
|
+
|
|
22
|
+
const SNAPSHOT_PAGE_SCRIPT = `(() => {
|
|
23
|
+
const results = {
|
|
24
|
+
url: location.href,
|
|
25
|
+
title: document.title,
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
|
|
28
|
+
dom: null, // outerHTML
|
|
29
|
+
selectorMap: [], // element entries
|
|
30
|
+
styles: {}, // cssText keyed by selector
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// --- build xpath for an element ---
|
|
34
|
+
function getXPath(el) {
|
|
35
|
+
if (el === document.body) return '/html/body';
|
|
36
|
+
if (el === document.documentElement) return '/html';
|
|
37
|
+
let path = '';
|
|
38
|
+
let current = el;
|
|
39
|
+
while (current && current !== document.documentElement) {
|
|
40
|
+
let idx = 1;
|
|
41
|
+
let sib = current;
|
|
42
|
+
while ((sib = sib.previousElementSibling) !== null) {
|
|
43
|
+
if (sib.tagName === current.tagName) idx++;
|
|
44
|
+
}
|
|
45
|
+
path = '/' + current.tagName.toLowerCase() + '[' + idx + ']' + path;
|
|
46
|
+
current = current.parentElement;
|
|
47
|
+
}
|
|
48
|
+
return '/html' + path;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- CSS selector for an element ---
|
|
52
|
+
function getCSSSelector(el) {
|
|
53
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
54
|
+
let path = [];
|
|
55
|
+
let c = el;
|
|
56
|
+
while (c && c !== document.documentElement) {
|
|
57
|
+
let sel = c.tagName.toLowerCase();
|
|
58
|
+
if (c.id) { path.unshift('#' + CSS.escape(c.id)); break; }
|
|
59
|
+
if (c.className && typeof c.className === 'string') {
|
|
60
|
+
const cls = c.className.trim().split(/\\s+/).filter(Boolean).map(cl => '.' + CSS.escape(cl)).join('');
|
|
61
|
+
if (cls) sel += cls;
|
|
62
|
+
}
|
|
63
|
+
// add nth-child if needed
|
|
64
|
+
const parent = c.parentElement;
|
|
65
|
+
if (parent) {
|
|
66
|
+
const siblings = Array.from(parent.children).filter(ch => ch.tagName === c.tagName);
|
|
67
|
+
if (siblings.length > 1) {
|
|
68
|
+
const idx = siblings.indexOf(c) + 1;
|
|
69
|
+
sel += ':nth-of-type(' + idx + ')';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
path.unshift(sel);
|
|
73
|
+
c = c.parentElement;
|
|
74
|
+
}
|
|
75
|
+
return path.join(' > ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- collect element data ---
|
|
79
|
+
const allElements = document.querySelectorAll('*');
|
|
80
|
+
for (const el of allElements) {
|
|
81
|
+
const tag = el.tagName.toLowerCase();
|
|
82
|
+
// skip non-visible / empty elements (but keep <canvas>, <img>, <video>, <svg>, input, textarea)
|
|
83
|
+
const keep = ['canvas','img','video','svg','input','textarea','select','button','a','p','h1','h2','h3','h4','h5','h6','li','td','th','blockquote','code','pre','figure','figcaption'];
|
|
84
|
+
const rect = el.getBoundingClientRect();
|
|
85
|
+
const visible = rect.width > 0 && rect.height > 0 && el.offsetParent !== null;
|
|
86
|
+
if (!visible && !keep.includes(tag)) continue;
|
|
87
|
+
if (['script','style','link','meta','head'].includes(tag)) continue;
|
|
88
|
+
|
|
89
|
+
const id = el.id || null;
|
|
90
|
+
const role = el.getAttribute('role');
|
|
91
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
92
|
+
const text = ((el.textContent || '').trim().slice(0, 200)) || null;
|
|
93
|
+
const placeholder = el.getAttribute('placeholder');
|
|
94
|
+
const alt = el.getAttribute('alt');
|
|
95
|
+
const href = el.getAttribute('href');
|
|
96
|
+
const src = el.getAttribute('src');
|
|
97
|
+
|
|
98
|
+
const entry = {
|
|
99
|
+
tag,
|
|
100
|
+
id,
|
|
101
|
+
css: getCSSSelector(el),
|
|
102
|
+
xpath: getXPath(el),
|
|
103
|
+
role: role || null,
|
|
104
|
+
ariaLabel: ariaLabel || null,
|
|
105
|
+
text,
|
|
106
|
+
placeholder: placeholder || null,
|
|
107
|
+
alt: alt || null,
|
|
108
|
+
href: href || null,
|
|
109
|
+
src: src || null,
|
|
110
|
+
rect: visible ? { x: round(rect.x), y: round(rect.y), w: round(rect.width), h: round(rect.height) } : null,
|
|
111
|
+
visible,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// get computed role from accessibility tree
|
|
115
|
+
try { entry.ariaRole = el.computedRole || el.getAttribute('role') || null; } catch {}
|
|
116
|
+
|
|
117
|
+
results.selectorMap.push(entry);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function round(n) { return Math.round(n * 10) / 10; }
|
|
121
|
+
|
|
122
|
+
// --- get all computed stylesheets ---
|
|
123
|
+
for (let i = 0; i < document.styleSheets.length; i++) {
|
|
124
|
+
try {
|
|
125
|
+
const ss = document.styleSheets[i];
|
|
126
|
+
const rules = ss.cssRules || ss.rules;
|
|
127
|
+
if (!rules) continue;
|
|
128
|
+
for (let j = 0; j < rules.length; j++) {
|
|
129
|
+
const r = rules[j];
|
|
130
|
+
if (r && r.cssText && r.selectorText) {
|
|
131
|
+
if (!results.styles[r.selectorText]) results.styles[r.selectorText] = [];
|
|
132
|
+
if (results.styles[r.selectorText].length < 5) { // cap per selector
|
|
133
|
+
results.styles[r.selectorText].push(r.cssText);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- serialize DOM ---
|
|
141
|
+
results.dom = document.documentElement.outerHTML;
|
|
142
|
+
|
|
143
|
+
return results;
|
|
144
|
+
})()`;
|
|
145
|
+
|
|
146
|
+
// ─── Public API ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Capture full DOM snapshot of a URL.
|
|
150
|
+
* Returns the snapshot data AND writes it to out path.
|
|
151
|
+
*/
|
|
152
|
+
export async function captureDomSnapshot({ url, out, flags = {} }) {
|
|
153
|
+
requireArg("url", url, "string");
|
|
154
|
+
const viewport = resolveViewport(flags);
|
|
155
|
+
const browser = await launch();
|
|
156
|
+
try {
|
|
157
|
+
const page = await newPage(browser, viewport);
|
|
158
|
+
const r = await gotoOrThrow(page, url);
|
|
159
|
+
await settle(page);
|
|
160
|
+
// Give dynamic content a moment
|
|
161
|
+
await page.waitForTimeout(500);
|
|
162
|
+
const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
|
|
163
|
+
snapshot.navStatus = r.status;
|
|
164
|
+
snapshot.navTitle = r.title;
|
|
165
|
+
|
|
166
|
+
// Write output
|
|
167
|
+
if (out) {
|
|
168
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
169
|
+
writeFileSync(out, JSON.stringify(snapshot, null, 2));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return snapshot;
|
|
173
|
+
} finally {
|
|
174
|
+
try { await browser.close(); } catch {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Attach DOM snapshot as sidecar to an existing shoot result.
|
|
180
|
+
* Call after runShoot — reuses the active page.
|
|
181
|
+
*/
|
|
182
|
+
export async function captureDomSnapshotSidecar(page, out) {
|
|
183
|
+
if (!page || !out) return null;
|
|
184
|
+
try {
|
|
185
|
+
const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
|
|
186
|
+
const domPath = out.replace(/\.png$/i, ".dom.json");
|
|
187
|
+
writeFileSync(domPath, JSON.stringify(snapshot, null, 2));
|
|
188
|
+
return domPath;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/eval.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Evaluate a JS string in the page, optionally screenshot after.
|
|
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 runEval(url, js, out) {
|
|
9
|
+
requireArg("url", url, "string");
|
|
10
|
+
const browser = await launch();
|
|
11
|
+
try {
|
|
12
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
13
|
+
const r = await gotoOrThrow(page, url);
|
|
14
|
+
const result = await page.evaluate(js);
|
|
15
|
+
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
16
|
+
return { ...r, url, out, result };
|
|
17
|
+
} finally { try { await browser.close(); } catch {} }
|
|
18
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Every-viewport: capture one screenshot at every registered viewport
|
|
2
|
+
// preset in a single command. No JSON plan needed.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// pursor every-viewport https://example.com
|
|
6
|
+
// pursor every-viewport https://example.com --out ./report
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { launch } from "./runway.js";
|
|
11
|
+
import { listViewports } from "./viewport.js";
|
|
12
|
+
import { runShoot } from "./shoot.js";
|
|
13
|
+
import { asNum, nowIso, renderEveryViewportHtml } from "./util.js";
|
|
14
|
+
|
|
15
|
+
export async function runEveryViewport({ url, outDir, viewports, browser: extBrowser }) {
|
|
16
|
+
const ownBrowser = !extBrowser;
|
|
17
|
+
const browser = extBrowser || await launch();
|
|
18
|
+
const dir = outDir || join(".", `every-viewport-${Date.now()}`);
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
const all = listViewports();
|
|
21
|
+
const wanted = viewports?.length ? all.filter(v => viewports.includes(v.name)) : all;
|
|
22
|
+
const captures = [];
|
|
23
|
+
try {
|
|
24
|
+
// Bounded concurrency: 3 viewports at a time. Each runShoot reuses the
|
|
25
|
+
// shared browser, so we cap the pool to avoid exhausting Chromium.
|
|
26
|
+
const POOL = 3;
|
|
27
|
+
let cursor = 0;
|
|
28
|
+
async function worker() {
|
|
29
|
+
while (cursor < wanted.length) {
|
|
30
|
+
const idx = cursor++;
|
|
31
|
+
const vp = wanted[idx];
|
|
32
|
+
const out = join(dir, `${vp.name}.png`);
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
try {
|
|
35
|
+
const meta = await runShoot({ url, out, flags: { preset: vp.name }, browser });
|
|
36
|
+
captures.push({ name: vp.name, out, ok: true, ms: Date.now() - t0, meta });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
captures.push({ name: vp.name, out, ok: false, ms: Date.now() - t0, error: e.message });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const workers = Array.from({ length: Math.min(POOL, wanted.length) }, () => worker());
|
|
43
|
+
await Promise.all(workers);
|
|
44
|
+
} finally {
|
|
45
|
+
if (ownBrowser) try { await browser.close(); } catch {}
|
|
46
|
+
}
|
|
47
|
+
const summary = { url, outDir: dir, captures, ts: nowIso(), ok: captures.every(c => c.ok) };
|
|
48
|
+
writeFileSync(join(dir, "every-viewport.json"), JSON.stringify(summary, null, 2));
|
|
49
|
+
writeFileSync(join(dir, "index.html"), renderEveryViewportHtml(summary));
|
|
50
|
+
return summary;
|
|
51
|
+
}
|
package/src/frames.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Frames: capture N screenshots at intervalMs.
|
|
2
|
+
|
|
3
|
+
import { launch, newPage } from "./runway.js";
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
|
+
import { gotoOrThrow, settle } from "./overlays.js";
|
|
6
|
+
import { asNum, nowIso, shortHash, requireArg } from "./util.js";
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export async function runFrames({ url, count, intervalMs, outDir, flags = {}, browser: extBrowser }) {
|
|
11
|
+
requireArg("url", url, "string");
|
|
12
|
+
const n = Math.max(1, Math.min(120, asNum(count, 8)));
|
|
13
|
+
const stepMs = Math.max(16, asNum(intervalMs, 250));
|
|
14
|
+
const dir = outDir;
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
const viewport = resolveViewport(flags);
|
|
17
|
+
const ownBrowser = !extBrowser;
|
|
18
|
+
const browser = extBrowser || await launch();
|
|
19
|
+
const meta = { url, outDir: dir, count: n, intervalMs: stepMs, viewport, files: [], ts: nowIso() };
|
|
20
|
+
try {
|
|
21
|
+
const page = await newPage(browser, viewport);
|
|
22
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
23
|
+
meta.status = r.status; meta.title = r.title;
|
|
24
|
+
for (let i = 0; i < n; i++) {
|
|
25
|
+
const f = join(dir, `frame-${String(i).padStart(3, "0")}.png`);
|
|
26
|
+
await page.screenshot({ path: f, fullPage: false });
|
|
27
|
+
const buf = readFileSync(f);
|
|
28
|
+
meta.files.push({ i, out: f, size: buf.length, hash: shortHash(buf) });
|
|
29
|
+
if (i + 1 < n) await page.waitForTimeout(stepMs);
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(join(dir, "frames.json"), JSON.stringify(meta, null, 2));
|
|
32
|
+
return meta;
|
|
33
|
+
} finally { if (ownBrowser) try { await browser.close(); } catch {} }
|
|
34
|
+
}
|
package/src/hover.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Hover capture: navigate, hover a selector, screenshot.
|
|
2
|
+
|
|
3
|
+
import { launch, newPage } from "./runway.js";
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
|
+
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
6
|
+
import { resolveLocator } from "./selector.js";
|
|
7
|
+
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
8
|
+
|
|
9
|
+
export async function runHover({ url, selector, out, flags = {} }) {
|
|
10
|
+
requireArg("url", url, "string");
|
|
11
|
+
requireArg("selector", selector, "string");
|
|
12
|
+
const viewport = resolveViewport(flags);
|
|
13
|
+
const browser = await launch();
|
|
14
|
+
try {
|
|
15
|
+
const page = await newPage(browser, viewport);
|
|
16
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
17
|
+
const loc = await resolveLocator(page, selector);
|
|
18
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
19
|
+
await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
20
|
+
await page.waitForTimeout(asNum(flags["hover-ms"], 250));
|
|
21
|
+
if (out) await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
|
|
22
|
+
const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
|
|
23
|
+
if (out) await writeSidecar(meta);
|
|
24
|
+
return meta;
|
|
25
|
+
} finally { try { await browser.close(); } catch {} }
|
|
26
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// pursor — public library API.
|
|
2
|
+
//
|
|
3
|
+
// This is the entry point for consumers who want to embed pursor
|
|
4
|
+
// inside their own scripts (instead of using the CLI). The CLI in
|
|
5
|
+
// bin/pursor.mjs is a thin wrapper around the same exports.
|
|
6
|
+
//
|
|
7
|
+
// All capture / sweep helpers return a `Result` object: the path to the
|
|
8
|
+
// PNG, a sidecar JSON metadata object, and timing info. They never throw
|
|
9
|
+
// on capture-time errors — those are reported in the result so the
|
|
10
|
+
// caller can decide how to react.
|
|
11
|
+
//
|
|
12
|
+
// The plugin system lives in src/plugin.js — see that file for how to
|
|
13
|
+
// write a custom plugin (viewport, sweep-op, before/after hooks).
|
|
14
|
+
|
|
15
|
+
import { runShoot } from "./shoot.js";
|
|
16
|
+
import { runSweep } from "./sweep.js";
|
|
17
|
+
import { runEveryViewport } from "./every-viewport.js";
|
|
18
|
+
import { runFrames } from "./frames.js";
|
|
19
|
+
import { runHover } from "./hover.js";
|
|
20
|
+
import { runDiff } from "./diff.js";
|
|
21
|
+
import { runProbe } from "./probe.js";
|
|
22
|
+
import { runShot } from "./shot.js";
|
|
23
|
+
import { runEval } from "./eval.js";
|
|
24
|
+
import { runClick, runType, runWait, runSeq } from "./interact.js";
|
|
25
|
+
import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
|
|
26
|
+
import { applyCamera, waitForStableFrame } from "./overlays.js";
|
|
27
|
+
import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
|
|
28
|
+
import { launch, newPage } from "./runway.js";
|
|
29
|
+
import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
|
|
30
|
+
import { resolveLocator, parseTextSelector } from "./selector.js";
|
|
31
|
+
import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
|
|
32
|
+
import { runAudit } from "./plugin-audit.js";
|
|
33
|
+
import { resolveHealedSelector, healStepAction } from "./selector-heal.js";
|
|
34
|
+
import { writeCiOutput } from "./ci-output.js";
|
|
35
|
+
import { PursorMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
|
|
36
|
+
import { createRequire } from "node:module";
|
|
37
|
+
import { saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath } from "./baseline.js";
|
|
38
|
+
import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
|
|
39
|
+
import { listResources, readResource, recordResource } from "./mcp-resources.js";
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// Derive VERSION from package.json to prevent drift
|
|
43
|
+
const __require = createRequire(import.meta.url);
|
|
44
|
+
const pkg = __require("../package.json");
|
|
45
|
+
const VERSION = pkg.version;
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
// CLI-style actions
|
|
49
|
+
runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
|
|
50
|
+
runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
|
|
51
|
+
// v3: audit, DOM snapshot, MCP
|
|
52
|
+
runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
|
|
53
|
+
// viewport + camera helpers
|
|
54
|
+
listViewports, resolveViewport, VIEWPORTS,
|
|
55
|
+
applyCamera, waitForStableFrame,
|
|
56
|
+
// plugin system
|
|
57
|
+
loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
|
|
58
|
+
// low-level helpers (for plugin authors)
|
|
59
|
+
launch, newPage,
|
|
60
|
+
parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
|
|
61
|
+
resolveLocator, parseTextSelector,
|
|
62
|
+
// v3: selector healing, CI output, MCP server
|
|
63
|
+
resolveHealedSelector, healStepAction,
|
|
64
|
+
writeCiOutput,
|
|
65
|
+
PursorMCPServer, loadMcpConfig, MCP_VERSION,
|
|
66
|
+
// v4: baselines, sweep validation, MCP resources
|
|
67
|
+
saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
|
|
68
|
+
validateSweepPlan, registerSweepOp,
|
|
69
|
+
listResources, readResource, recordResource,
|
|
70
|
+
VERSION,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default {
|
|
74
|
+
runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
|
|
75
|
+
runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
|
|
76
|
+
runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
|
|
77
|
+
listViewports, resolveViewport, VIEWPORTS,
|
|
78
|
+
applyCamera, waitForStableFrame,
|
|
79
|
+
loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
|
|
80
|
+
launch, newPage,
|
|
81
|
+
parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
|
|
82
|
+
resolveLocator, parseTextSelector,
|
|
83
|
+
resolveHealedSelector, healStepAction,
|
|
84
|
+
writeCiOutput,
|
|
85
|
+
PursorMCPServer, loadMcpConfig, MCP_VERSION,
|
|
86
|
+
saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
|
|
87
|
+
validateSweepPlan, registerSweepOp,
|
|
88
|
+
listResources, readResource, recordResource,
|
|
89
|
+
VERSION,
|
|
90
|
+
};
|