pursr 0.5.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 +549 -471
- 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 +44 -12
- package/package.json +95 -92
- 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 +125 -0
- package/src/auth.js +92 -91
- package/src/baseline.js +126 -125
- package/src/ci-output.js +156 -156
- package/src/diff.js +76 -48
- 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 +15 -7
- 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 +176 -0
- 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/diff.js
CHANGED
|
@@ -1,48 +1,76 @@
|
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
requireArg("
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
import { aiDiffSidecar } from "./ai-diff.js";
|
|
10
|
+
|
|
11
|
+
const DIFF_DEFAULT_THRESHOLD = 0.1;
|
|
12
|
+
|
|
13
|
+
async function loadPngjs() {
|
|
14
|
+
try { return (await import("pngjs")).PNG; }
|
|
15
|
+
catch { throw new Error("pngjs not found. Install: npm i pngjs"); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function loadPixelmatch() {
|
|
19
|
+
try { return (await import("pixelmatch")).default; }
|
|
20
|
+
catch { throw new Error("pixelmatch not found. Install: npm i pixelmatch"); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runDiff(url, refPath, out, threshold, browser) {
|
|
24
|
+
requireArg("url", url, "string");
|
|
25
|
+
requireArg("refPath", refPath, "string");
|
|
26
|
+
const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
|
|
27
|
+
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
28
|
+
const PNG = await loadPngjs();
|
|
29
|
+
const pixelmatch = await loadPixelmatch();
|
|
30
|
+
const ownBrowser = !browser;
|
|
31
|
+
browser = browser || await launch();
|
|
32
|
+
try {
|
|
33
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
34
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
35
|
+
const currentPath = out ? out.replace(/\.png$/i, "-current.png") : join(dirname(refPath), "current.png");
|
|
36
|
+
await page.screenshot({ path: currentPath, fullPage: false });
|
|
37
|
+
const refPng = PNG.sync.read(readFileSync(refPath));
|
|
38
|
+
const curPng = PNG.sync.read(readFileSync(currentPath));
|
|
39
|
+
if (refPng.width !== curPng.width || refPng.height !== curPng.height) {
|
|
40
|
+
return { ...r, url, refPath, currentPath, error: "size mismatch", refSize: { w: refPng.width, h: refPng.height }, currentSize: { w: curPng.width, h: curPng.height } };
|
|
41
|
+
}
|
|
42
|
+
const diffPng = new PNG({ width: refPng.width, height: refPng.height });
|
|
43
|
+
const numDiff = pixelmatch(refPng.data, curPng.data, diffPng.data, refPng.width, refPng.height, { threshold: t });
|
|
44
|
+
const totalPx = refPng.width * refPng.height;
|
|
45
|
+
const diffPct = (numDiff / totalPx) * 100;
|
|
46
|
+
if (out) writeFileSync(out, PNG.sync.write(diffPng));
|
|
47
|
+
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 };
|
|
48
|
+
} finally { if (ownBrowser) try { await browser.close(); } catch {} }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Like runDiff, but additionally calls a vision LLM to produce a human-readable
|
|
53
|
+
* summary of the visual differences. The AI summary is written to <out>.ai.json
|
|
54
|
+
* and also returned on the result object.
|
|
55
|
+
*/
|
|
56
|
+
export async function runDiffWithAi(url, refPath, out, threshold, aiOpts, browser) {
|
|
57
|
+
const r = await runDiff(url, refPath, out, threshold, browser);
|
|
58
|
+
if (r.error) return r;
|
|
59
|
+
try {
|
|
60
|
+
const curPath = r.currentPath;
|
|
61
|
+
const sidecar = await aiDiffSidecar({
|
|
62
|
+
refPath, curPath, url,
|
|
63
|
+
model: aiOpts && aiOpts.model,
|
|
64
|
+
baseUrl: aiOpts && aiOpts.baseUrl,
|
|
65
|
+
apiKey: aiOpts && aiOpts.apiKey,
|
|
66
|
+
maxTokens: aiOpts && aiOpts.maxTokens,
|
|
67
|
+
});
|
|
68
|
+
r.ai = sidecar;
|
|
69
|
+
const sidecarPath = (out || curPath).replace(/.png$/i, "") + ".ai.json";
|
|
70
|
+
fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), "utf8");
|
|
71
|
+
r.aiFile = sidecarPath;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
r.ai = { error: e.message };
|
|
74
|
+
}
|
|
75
|
+
return r;
|
|
76
|
+
}
|
package/src/dom-snapshot.js
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
//
|
|
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
|
-
}
|
|
1
|
+
// pursr — DOM Snapshot + CSSOM + Selector Map.
|
|
2
|
+
//
|
|
3
|
+
// Every capture can optionally produce a .dom.json sidecar containing:
|
|
4
|
+
// pursr - serialized DOM (document.documentElement.outerHTML)
|
|
5
|
+
// pursr - computed styles for every visible element
|
|
6
|
+
// pursr - selector map (id → role → accessible name → xpath → css selector)
|
|
7
|
+
// pursr - 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
CHANGED
|
@@ -1,18 +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 {} }
|
|
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
18
|
}
|
package/src/every-viewport.js
CHANGED
|
@@ -1,51 +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
|
-
//
|
|
6
|
-
//
|
|
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
|
-
}
|
|
1
|
+
// Every-viewport: capture one screenshot at every registered viewport
|
|
2
|
+
// preset in a single command. No JSON plan needed.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// pursr every-viewport https://example.com
|
|
6
|
+
// pursr 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
|
+
}
|