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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// pursor — Auto-heal Selector Chain.
|
|
2
|
+
//
|
|
3
|
+
// In sweep plans, a selector can be an array of fallback strategies:
|
|
4
|
+
// "click": { "selector": ["text=Login", "button[type=submit]", "#login-btn"] }
|
|
5
|
+
//
|
|
6
|
+
// resolveHealedSelector tries each one in order, returns the first match.
|
|
7
|
+
// Also supports named matchers (text=, role=, aria=, placeholder=, css=)
|
|
8
|
+
// and plain CSS selectors.
|
|
9
|
+
|
|
10
|
+
import { resolveLocator } from "./selector.js";
|
|
11
|
+
import { CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a selector that may be a chain of fallbacks.
|
|
15
|
+
*
|
|
16
|
+
* @param {import("playwright-core").Page} page
|
|
17
|
+
* @param {string|string[]} selector - Single selector or array of fallbacks
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @param {number} [opts.timeout] - Per-selector timeout
|
|
20
|
+
* @param {boolean} [opts.returnAll] - Return ALL matching locators (first found, rest as fallbacks)
|
|
21
|
+
* @returns {Promise<{ locator: import("playwright-core").Locator, selector: string, index: number }|null>}
|
|
22
|
+
*
|
|
23
|
+
* Example:
|
|
24
|
+
* const result = await resolveHealedSelector(page, ["text=Login", "button[type=submit]", "#login-btn"]);
|
|
25
|
+
* if (result) await result.locator.first().click();
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveHealedSelector(page, selector, opts = {}) {
|
|
28
|
+
if (!selector) throw new Error("empty selector");
|
|
29
|
+
if (!page) throw new Error("page required");
|
|
30
|
+
|
|
31
|
+
const chains = Array.isArray(selector) ? selector : [selector];
|
|
32
|
+
const timeout = opts.timeout || CLICK_TIMEOUT_MS;
|
|
33
|
+
let lastError = null;
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < chains.length; i++) {
|
|
36
|
+
const sel = String(chains[i]).trim();
|
|
37
|
+
if (!sel) continue;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Use existing resolveLocator for text=/role=/aria=/placeholder= prefixes
|
|
41
|
+
const locator = await resolveLocator(page, sel);
|
|
42
|
+
const count = await locator.count();
|
|
43
|
+
if (count > 0) {
|
|
44
|
+
// Quick visibility check without awaiting each element individually
|
|
45
|
+
const visible = await locator.first().isVisible().catch(() => false);
|
|
46
|
+
if (visible) {
|
|
47
|
+
return { locator, selector: sel, index: i, count };
|
|
48
|
+
}
|
|
49
|
+
// Found but not visible — try next if available
|
|
50
|
+
lastError = new Error(`Found "${sel}" (x${count}) but not visible`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
lastError = e;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If nothing matched, throw with helpful message about what was tried
|
|
60
|
+
if (lastError) throw new Error(`Selector chain exhausted: tried [${chains.join(", ")}]. Last error: ${lastError.message}`);
|
|
61
|
+
throw new Error(`Selector chain exhausted: tried [${chains.join(", ")}]. No match found.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Simplify: extract a single selector string for logging / display
|
|
66
|
+
* from a selector chain.
|
|
67
|
+
*/
|
|
68
|
+
export function displaySelector(selector) {
|
|
69
|
+
return Array.isArray(selector) ? selector[0] : selector;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap a step's click/hover/type/wait selector calls with auto-heal support.
|
|
74
|
+
* Mutates the step action object in-place: resolves string → {selector, ...} to
|
|
75
|
+
* the first matching selector.
|
|
76
|
+
*/
|
|
77
|
+
export async function healStepAction(page, action) {
|
|
78
|
+
if (!action || !action.selector) return action;
|
|
79
|
+
const result = await resolveHealedSelector(page, action.selector);
|
|
80
|
+
// Replace chain with the single resolved selector for logging
|
|
81
|
+
action._resolvedSelector = result.selector;
|
|
82
|
+
action._healAttempts = Array.isArray(action.selector) ? action.selector.length : 1;
|
|
83
|
+
action.selector = result.selector;
|
|
84
|
+
return action;
|
|
85
|
+
}
|
package/src/selector.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Selector parsing + resolution. Reused by click/type/wait/hover/seq.
|
|
2
|
+
|
|
3
|
+
export function parseTextSelector(rest) {
|
|
4
|
+
// text==Exact[0] → exact match, nth 0
|
|
5
|
+
// text~regex → regex match
|
|
6
|
+
// text=Hello → substring match
|
|
7
|
+
let m = rest.match(/^text~\/?(.+?)\/?(\[(\d+)\])?$/);
|
|
8
|
+
if (m) {
|
|
9
|
+
const source = m[1].replace(/\\\//g, "/");
|
|
10
|
+
try {
|
|
11
|
+
return { text: new RegExp(source, "i"), exact: false, regex: true, nth: m[3] !== undefined ? Number(m[3]) : undefined };
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
}
|
|
14
|
+
m = rest.match(/^text(={1,2})(.*?)(\[(\d+)\])?$/);
|
|
15
|
+
if (!m) return null;
|
|
16
|
+
const exact = m[1] === "==";
|
|
17
|
+
const nth = m[3] !== undefined ? Number(m[4]) : undefined;
|
|
18
|
+
return { text: m[2], exact, regex: false, nth };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function resolveLocator(page, selector) {
|
|
22
|
+
if (!selector) throw new Error("empty selector");
|
|
23
|
+
if (selector.startsWith("text=")) {
|
|
24
|
+
const p = parseTextSelector(selector);
|
|
25
|
+
if (!p) throw new Error(`bad text= selector: ${selector}`);
|
|
26
|
+
let loc = p.exact ? page.getByText(p.text, { exact: true })
|
|
27
|
+
: p.regex ? page.getByText(p.text)
|
|
28
|
+
: page.getByText(p.text);
|
|
29
|
+
if (p.nth !== undefined) loc = loc.nth(p.nth - 1);
|
|
30
|
+
return loc;
|
|
31
|
+
}
|
|
32
|
+
if (selector.startsWith("role=")) {
|
|
33
|
+
const [role, name] = selector.slice(5).split("|", 2);
|
|
34
|
+
return page.getByRole(role.trim(), name ? { name: name.trim() } : undefined);
|
|
35
|
+
}
|
|
36
|
+
if (selector.startsWith("aria=")) return page.getByLabel(selector.slice(5));
|
|
37
|
+
if (selector.startsWith("placeholder=")) return page.getByPlaceholder(selector.slice("placeholder=".length));
|
|
38
|
+
return page.locator(selector);
|
|
39
|
+
}
|
package/src/shoot.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The core capture function: open a viewport, navigate, apply all the
|
|
2
|
+
// overlays + camera + frame-stable waits, then screenshot.
|
|
3
|
+
|
|
4
|
+
import { launch, newPage } from "./runway.js";
|
|
5
|
+
import { resolveViewport } from "./viewport.js";
|
|
6
|
+
import {
|
|
7
|
+
gotoOrThrow, settle, overlayCursor, overlayGrid, hideHud,
|
|
8
|
+
isolateLayer, freezeAnimation, waitForStableFrame, applyCamera,
|
|
9
|
+
} from "./overlays.js";
|
|
10
|
+
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
11
|
+
import { runBeforeShoot, runAfterShoot } from "./plugin.js";
|
|
12
|
+
|
|
13
|
+
export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
|
|
14
|
+
requireArg("url", url, "string");
|
|
15
|
+
const viewport = resolveViewport(flags);
|
|
16
|
+
const ownBrowser = !extBrowser;
|
|
17
|
+
const browser = extBrowser || await launch();
|
|
18
|
+
const cleanups = [];
|
|
19
|
+
try {
|
|
20
|
+
return await (async () => {
|
|
21
|
+
const page = await newPage(browser, viewport);
|
|
22
|
+
const r = await gotoOrThrow(page, url);
|
|
23
|
+
await settle(page);
|
|
24
|
+
|
|
25
|
+
// Build a ctx object so plugins can mutate it
|
|
26
|
+
const ctx = { url, out, viewport, flags, browser, page };
|
|
27
|
+
|
|
28
|
+
await runBeforeShoot(ctx);
|
|
29
|
+
|
|
30
|
+
cleanups.push(await freezeAnimation(page, asBool(flags["no-animation"], false)));
|
|
31
|
+
cleanups.push(await overlayCursor(page, flags.cursor || "default"));
|
|
32
|
+
if (asBool(flags.grid, false)) cleanups.push(await overlayGrid(page, { tileSize: flags["grid-tile"], color: flags["grid-color"] }));
|
|
33
|
+
if (asBool(flags["no-hud"], false)) cleanups.push(await hideHud(page));
|
|
34
|
+
cleanups.push(await isolateLayer(page, flags.layer || "all"));
|
|
35
|
+
if (typeof prepare === "function") { const c = await prepare(page); if (typeof c === "function") cleanups.push(c); }
|
|
36
|
+
|
|
37
|
+
if (flags["wait-frame"]) await waitForStableFrame(page, asNum(flags["wait-frame"], 600));
|
|
38
|
+
|
|
39
|
+
if (flags.zoom || flags.panX || flags.panY) {
|
|
40
|
+
await applyCamera(page, { zoom: asNum(flags.zoom, 1), panX: asNum(flags.panX, 0), panY: asNum(flags.panY, 0) });
|
|
41
|
+
await page.waitForTimeout(400);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
|
|
45
|
+
const meta = { url, out, ts: nowIso(), status: r.status, title: r.title, viewport, flags: { ...flags } };
|
|
46
|
+
await runAfterShoot(ctx, meta);
|
|
47
|
+
return meta;
|
|
48
|
+
})().catch(e => ({ url, out, ts: nowIso(), error: e.message, viewport, flags: { ...flags } }));
|
|
49
|
+
} finally {
|
|
50
|
+
// Run cleanups (remove injected overlay styles)
|
|
51
|
+
for (const fn of cleanups) {
|
|
52
|
+
try { await fn(); } catch {}
|
|
53
|
+
}
|
|
54
|
+
if (ownBrowser) await browser.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runShootWithSidecar(args) {
|
|
59
|
+
const meta = await runShoot(args);
|
|
60
|
+
await writeSidecar(meta);
|
|
61
|
+
return meta;
|
|
62
|
+
}
|
package/src/shot.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Simple screenshot (no flags / overlays).
|
|
2
|
+
|
|
3
|
+
import { launch, newPage } from "./runway.js";
|
|
4
|
+
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
5
|
+
import { gotoOrThrow, settle } from "./overlays.js";
|
|
6
|
+
import { requireArg } from "./util.js";
|
|
7
|
+
|
|
8
|
+
export async function runShot(url, out, opts = {}) {
|
|
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
|
+
await settle(page);
|
|
15
|
+
await page.screenshot({ path: out, fullPage: !!opts.fullPage });
|
|
16
|
+
return { ...r, url, out, fullPage: !!opts.fullPage };
|
|
17
|
+
} finally { try { await browser.close(); } catch {} }
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// pursor — sweep plan schema validator.
|
|
2
|
+
//
|
|
3
|
+
// Validates a parsed JSON object against the sweep plan grammar used by
|
|
4
|
+
// runSweep(). Returns { valid, errors } with a flat list of human-readable
|
|
5
|
+
// error messages. This is intentionally lightweight (no JSON-schema
|
|
6
|
+
// dependency) but covers the practical cases that bite users: missing
|
|
7
|
+
// op keys, wrong types, unknown ops, out-of-range numbers.
|
|
8
|
+
|
|
9
|
+
const KNOWN_OPS = new Set([
|
|
10
|
+
"shoot", "hover", "frames", "diff", "audit", "dom", "seq", "eval",
|
|
11
|
+
"every-viewport", "baseline-save", "baseline-approve", "diff-baseline",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const KNOWN_SWEEP_PLUGIN_OPS = new Set(); // populated at runtime by plugins
|
|
15
|
+
|
|
16
|
+
export function registerSweepOp(name) { KNOWN_SWEEP_PLUGIN_OPS.add(name); }
|
|
17
|
+
|
|
18
|
+
function isPlainObject(x) { return x != null && typeof x === "object" && !Array.isArray(x); }
|
|
19
|
+
|
|
20
|
+
function err(errors, path, msg) { errors.push(`${path}: ${msg}`); }
|
|
21
|
+
|
|
22
|
+
function validateStep(step, i, errors, ctx) {
|
|
23
|
+
const base = `steps[${i}]`;
|
|
24
|
+
if (!isPlainObject(step)) { err(errors, base, "must be an object"); return; }
|
|
25
|
+
const keys = Object.keys(step).filter(k => k !== "name");
|
|
26
|
+
if (step.name !== undefined && typeof step.name !== "string") err(errors, `${base}.name`, "must be a string");
|
|
27
|
+
if (!keys.length) { err(errors, base, `must define exactly one operation key (one of: ${[...KNOWN_OPS].join(", ")})`); return; }
|
|
28
|
+
if (keys.length > 1) err(errors, base, `defines multiple operation keys (${keys.join(", ")}); only one allowed`);
|
|
29
|
+
const op = keys[0];
|
|
30
|
+
if (!KNOWN_OPS.has(op) && !KNOWN_SWEEP_PLUGIN_OPS.has(op)) err(errors, `${base}.${op}`, `unknown op; expected one of ${[...KNOWN_OPS].join(", ")}`);
|
|
31
|
+
const v = step[op];
|
|
32
|
+
if (!isPlainObject(v)) { err(errors, `${base}.${op}`, "must be an object"); return; }
|
|
33
|
+
// Per-op checks
|
|
34
|
+
if (op === "shoot") {
|
|
35
|
+
if (v.url !== undefined && typeof v.url !== "string") err(errors, `${base}.shoot.url`, "must be a string");
|
|
36
|
+
} else if (op === "hover") {
|
|
37
|
+
if (typeof v.selector !== "string") err(errors, `${base}.hover.selector`, "required string");
|
|
38
|
+
} else if (op === "frames") {
|
|
39
|
+
if (v.count !== undefined && !(Number.isFinite(Number(v.count)) && Number(v.count) >= 1 && Number(v.count) <= 120))
|
|
40
|
+
err(errors, `${base}.frames.count`, "must be a number between 1 and 120");
|
|
41
|
+
if (v.intervalMs !== undefined && !(Number.isFinite(Number(v.intervalMs)) && Number(v.intervalMs) >= 16))
|
|
42
|
+
err(errors, `${base}.frames.intervalMs`, "must be >= 16");
|
|
43
|
+
} else if (op === "diff") {
|
|
44
|
+
if (typeof v.ref !== "string") err(errors, `${base}.diff.ref`, "required string (step name or filename)");
|
|
45
|
+
if (v.threshold !== undefined && !(Number.isFinite(Number(v.threshold)) && Number(v.threshold) >= 0 && Number(v.threshold) <= 1))
|
|
46
|
+
err(errors, `${base}.diff.threshold`, "must be 0..1");
|
|
47
|
+
} else if (op === "audit") {
|
|
48
|
+
if (v.tags !== undefined && typeof v.tags !== "string") err(errors, `${base}.audit.tags`, "must be a string (comma-separated)");
|
|
49
|
+
} else if (op === "baseline-save" || op === "baseline-approve") {
|
|
50
|
+
if (v.id !== undefined && typeof v.id !== "string") err(errors, `${base}.${op}.id`, "must be a string");
|
|
51
|
+
} else if (op === "diff-baseline") {
|
|
52
|
+
if (v.id !== undefined && typeof v.id !== "string") err(errors, `${base}.diff-baseline.id`, "must be a string");
|
|
53
|
+
}
|
|
54
|
+
// Step name uniqueness (soft check)
|
|
55
|
+
if (ctx.seenNames.has(step.name)) err(errors, `${base}.name`, `duplicate step name "${step.name}"`);
|
|
56
|
+
ctx.seenNames.add(step.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validateSweepPlan(plan) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
if (!isPlainObject(plan)) { return { valid: false, errors: ["plan: must be an object"] }; }
|
|
62
|
+
if (!Array.isArray(plan.steps)) { return { valid: false, errors: ["plan.steps: required array"] }; }
|
|
63
|
+
if (!plan.steps.length) err(errors, "plan.steps", "must be non-empty");
|
|
64
|
+
if (plan.base !== undefined && typeof plan.base !== "string") err(errors, "plan.base", "must be a string");
|
|
65
|
+
if (plan.outDir !== undefined && typeof plan.outDir !== "string") err(errors, "plan.outDir", "must be a string");
|
|
66
|
+
if (plan.name !== undefined && typeof plan.name !== "string") err(errors, "plan.name", "must be a string");
|
|
67
|
+
const ctx = { seenNames: new Set() };
|
|
68
|
+
plan.steps.forEach((s, i) => validateStep(s, i, errors, ctx));
|
|
69
|
+
return { valid: errors.length === 0, errors };
|
|
70
|
+
}
|
package/src/sweep.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Sweep: batch capture plan. Each step is one of:
|
|
2
|
+
// { name, shoot: { url?, preset?, ...flags } }
|
|
3
|
+
// { name, hover: { url?, selector } }
|
|
4
|
+
// { name, frames: { url?, count, intervalMs } }
|
|
5
|
+
// { name, diff: { url?, ref, threshold? } }
|
|
6
|
+
// { name, <customOp>: { ... } } // from a registered plugin
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join, resolve as pathResolve } from "node:path";
|
|
10
|
+
import { runShootWithSidecar } from "./shoot.js";
|
|
11
|
+
import { runFrames } from "./frames.js";
|
|
12
|
+
import { runDiff } from "./diff.js";
|
|
13
|
+
import { launch, newPage } from "./runway.js";
|
|
14
|
+
import { resolveViewport } from "./viewport.js";
|
|
15
|
+
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
16
|
+
import { resolveLocator } from "./selector.js";
|
|
17
|
+
import { resolveHealedSelector } from "./selector-heal.js";
|
|
18
|
+
import { asNum, nowIso, findStepPng, renderSweepHtml } from "./util.js";
|
|
19
|
+
import { getSweepOp } from "./plugin.js";
|
|
20
|
+
import { writeCiOutput } from "./ci-output.js";
|
|
21
|
+
|
|
22
|
+
export async function runSweep(planPath, outDirArg) {
|
|
23
|
+
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
24
|
+
// Validate plan shape early for actionable errors.
|
|
25
|
+
const { validateSweepPlan } = await import("./sweep-schema.js");
|
|
26
|
+
const v = validateSweepPlan(plan);
|
|
27
|
+
if (!v.valid) {
|
|
28
|
+
const msg = v.errors.length === 1
|
|
29
|
+
? v.errors[0]
|
|
30
|
+
: v.errors.map((e, i) => " " + (i + 1) + ". " + e).join("\n");
|
|
31
|
+
throw new Error("sweep plan validation failed:\n" + msg);
|
|
32
|
+
}
|
|
33
|
+
const dir = outDirArg ?? plan.outDir ?? join(plan.outBase || ".", `sweep-${plan.name || "plan"}`);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
const summary = { plan: pathResolve(planPath), outDir: dir, name: plan.name || null, steps: [], ts: nowIso() };
|
|
36
|
+
const browser = await launch();
|
|
37
|
+
try {
|
|
38
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
39
|
+
const s = plan.steps[i] || {};
|
|
40
|
+
const stepName = s.name || `step-${i}`;
|
|
41
|
+
const stepOut = join(dir, `${String(i).padStart(2, "0")}-${stepName}.png`);
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
const stepKeys = Object.keys(s).filter(k => k !== "name");
|
|
44
|
+
if (!stepKeys.length) throw new Error(`step ${i} has no operation key (only "name"?)`);
|
|
45
|
+
if (stepKeys.length > 1) throw new Error(`step ${i} has ${stepKeys.length} operation keys (${stepKeys.join(", ")}). Only one allowed per step.`);
|
|
46
|
+
const opKey = stepKeys[0];
|
|
47
|
+
const entry = { i, name: stepName, op: opKey };
|
|
48
|
+
try {
|
|
49
|
+
if (s.shoot) {
|
|
50
|
+
const url = s.shoot.url || (plan.base ? plan.base : null);
|
|
51
|
+
if (!url) throw new Error("shoot: missing url (and no plan.base)");
|
|
52
|
+
const flags = Object.fromEntries(Object.entries(s.shoot).filter(([k]) => k !== "url").map(([k, v]) => [k, v]));
|
|
53
|
+
const meta = await runShootWithSidecar({ url, out: stepOut, flags, browser });
|
|
54
|
+
entry.meta = meta;
|
|
55
|
+
} else if (s.hover) {
|
|
56
|
+
const url = s.hover.url || (plan.base ? plan.base : null);
|
|
57
|
+
if (!url) throw new Error("hover: missing url (and no plan.base)");
|
|
58
|
+
const selector = s.hover.selector;
|
|
59
|
+
if (!selector) throw new Error("hover: missing selector");
|
|
60
|
+
const viewport = resolveViewport(s.hover);
|
|
61
|
+
const page = await newPage(browser, viewport);
|
|
62
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
63
|
+
// Auto-heal: selector can be string or array of fallbacks
|
|
64
|
+
const healed = await resolveHealedSelector(page, selector);
|
|
65
|
+
await healed.locator.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
66
|
+
await page.waitForTimeout(asNum(s.hover.settleMs, 300));
|
|
67
|
+
await page.screenshot({ path: stepOut, fullPage: false });
|
|
68
|
+
await page.close();
|
|
69
|
+
entry.meta = { ...r, url, out: stepOut, selector, viewport };
|
|
70
|
+
} else if (s.frames) {
|
|
71
|
+
const url = s.frames.url || (plan.base ? plan.base : null);
|
|
72
|
+
if (!url) throw new Error("frames: missing url (and no plan.base)");
|
|
73
|
+
const subDir = join(dir, `${String(i).padStart(2, "0")}-${stepName}-frames`);
|
|
74
|
+
const meta = await runFrames({ url, count: asNum(s.frames.count, 8), intervalMs: asNum(s.frames.intervalMs, 200), outDir: subDir, flags: s.frames, browser });
|
|
75
|
+
entry.meta = meta;
|
|
76
|
+
} else if (s.diff) {
|
|
77
|
+
const refName = s.diff.ref;
|
|
78
|
+
if (!refName) throw new Error("diff: missing ref (step name or filename)");
|
|
79
|
+
const refPath = findStepPng(dir, refName);
|
|
80
|
+
if (!refPath) throw new Error(`diff: ref not found in dir: ${refName}`);
|
|
81
|
+
const url = s.diff.url || (plan.base ? plan.base : null);
|
|
82
|
+
if (!url) throw new Error("diff: missing url (and no plan.base)");
|
|
83
|
+
const meta = await runDiff(url, refPath, stepOut, asNum(s.diff.threshold, 0.1), browser);
|
|
84
|
+
entry.meta = meta;
|
|
85
|
+
} else if (opKey && getSweepOp(opKey)) {
|
|
86
|
+
const customOp = getSweepOp(opKey);
|
|
87
|
+
if (typeof customOp !== "function") throw new Error(`custom op "${opKey}" is not a function`);
|
|
88
|
+
const ctx = { url: plan.base, out: stepOut, browser, page: null };
|
|
89
|
+
const result = await customOp(ctx, s[opKey] || {});
|
|
90
|
+
entry.meta = result != null ? result : { out: stepOut };
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(`step "${opKey}": unknown or unregistered operation`);
|
|
93
|
+
}
|
|
94
|
+
entry.ms = Date.now() - t0; entry.ok = true;
|
|
95
|
+
} catch (e) {
|
|
96
|
+
entry.ok = false; entry.error = e.message; entry.ms = Date.now() - t0;
|
|
97
|
+
}
|
|
98
|
+
summary.steps.push(entry);
|
|
99
|
+
}
|
|
100
|
+
} finally { try { await browser.close(); } catch {} }
|
|
101
|
+
writeFileSync(join(dir, "sweep.json"), JSON.stringify(summary, null, 2));
|
|
102
|
+
writeFileSync(join(dir, "index.html"), renderSweepHtml(summary));
|
|
103
|
+
try { writeCiOutput(summary, dir); } catch (e) { console.error("[pursor] CI output error:", e.message); }
|
|
104
|
+
return summary;
|
|
105
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Tiny utility module: arg reading, flag parsing, output path picking,
|
|
2
|
+
// sidecar writing.
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join, basename } from "node:path";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export function outDir() {
|
|
10
|
+
// Use $XDG_PICTURES_DIR, ~/Pictures, or fallback to system tmp
|
|
11
|
+
const base = process.env.XDG_PICTURES_DIR || join(homedir(), "Pictures");
|
|
12
|
+
const dir = process.platform === "win32" ? join(base, "gen") : join(base, "gen");
|
|
13
|
+
try {
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
} catch (e) {
|
|
17
|
+
const fallback = join(tmpdir(), "pursor");
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(fallback, { recursive: true });
|
|
20
|
+
} catch {}
|
|
21
|
+
// Surface a single warning so silent fallback is debuggable.
|
|
22
|
+
if (process.env.PURSOR_DEBUG) console.error("[pursor] outDir fallback to", fallback, "(", e?.message, ")");
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Validate required arg — throws with caller-friendly message. Returns value. */
|
|
28
|
+
export function requireArg(name, value, kind) {
|
|
29
|
+
if (value === undefined || value === null) throw new Error(`missing required argument: ${name}`);
|
|
30
|
+
if (kind === "string" && typeof value !== "string") throw new Error(`${name}: expected string, got ${typeof value}`);
|
|
31
|
+
if (kind === "number" && (typeof value !== "number" || !Number.isFinite(value))) throw new Error(`${name}: expected finite number, got ${typeof value}`);
|
|
32
|
+
if (kind === "string" && value.trim() === "") throw new Error(`${name}: must not be empty`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makeOut(name) {
|
|
37
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
38
|
+
return join(outDir(), `pursor-${ts}-${name}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function nowIso() { return new Date().toISOString(); }
|
|
42
|
+
|
|
43
|
+
export function shortHash(buf) {
|
|
44
|
+
if (!buf || !Buffer.isBuffer(buf)) return "".padStart(10, "0");
|
|
45
|
+
return createHash("sha1").update(buf).digest("hex").slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readArg(arg) {
|
|
49
|
+
if (arg === undefined || arg === null) return undefined;
|
|
50
|
+
if (typeof arg !== "string" || !arg.startsWith("@")) return arg;
|
|
51
|
+
const path = arg.slice(1);
|
|
52
|
+
if (!existsSync(path)) throw new Error(`@file not found: ${path}`);
|
|
53
|
+
return readFileSync(path, "utf8").replace(/\r?\n$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseFlags(argv) {
|
|
57
|
+
const flags = {};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (!a || !a.startsWith("--")) continue;
|
|
61
|
+
const eq = a.indexOf("=");
|
|
62
|
+
let key, val;
|
|
63
|
+
if (eq >= 0) { key = a.slice(2, eq); val = a.slice(eq + 1); }
|
|
64
|
+
else {
|
|
65
|
+
key = a.slice(2);
|
|
66
|
+
const next = i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
67
|
+
val = (next !== undefined && !next.startsWith("--")) ? argv[++i] : true;
|
|
68
|
+
}
|
|
69
|
+
flags[key] = val;
|
|
70
|
+
}
|
|
71
|
+
return flags;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function asNum(v, dflt) {
|
|
75
|
+
if (v === undefined || v === null) return dflt;
|
|
76
|
+
const n = Number(v);
|
|
77
|
+
return Number.isFinite(n) ? n : dflt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function asBool(v, dflt) {
|
|
81
|
+
if (v === true) return true;
|
|
82
|
+
if (v === false || v === undefined || v === null) return dflt;
|
|
83
|
+
const s = String(v).toLowerCase();
|
|
84
|
+
if (s === "1" || s === "true" || s === "yes" || s === "on") return true;
|
|
85
|
+
if (s === "0" || s === "false" || s === "no" || s === "off") return false;
|
|
86
|
+
return dflt;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Pick a positional output path from argv, skipping --flags and their values.
|
|
90
|
+
// Returns the path or undefined.
|
|
91
|
+
export function pickOutPath(argv) {
|
|
92
|
+
for (let i = 0; i < argv.length; i++) {
|
|
93
|
+
const a = argv[i];
|
|
94
|
+
if (!a) continue;
|
|
95
|
+
if (a.startsWith("--")) { if (!a.includes("=")) i++; continue; }
|
|
96
|
+
if (a.startsWith("@")) continue;
|
|
97
|
+
if (/[\\\/]/.test(a) || a.endsWith(".png")) return a.endsWith(".png") ? a : a + ".png";
|
|
98
|
+
return undefined; // first positional non-path token is not an out path
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function writeSidecar(meta) {
|
|
104
|
+
if (!meta?.out) return null;
|
|
105
|
+
try {
|
|
106
|
+
if (existsSync(meta.out)) {
|
|
107
|
+
const buf = readFileSync(meta.out);
|
|
108
|
+
meta.size = buf.length;
|
|
109
|
+
meta.hash = shortHash(buf);
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
const sidecar = meta.out.replace(/\.png$/i, ".json");
|
|
113
|
+
writeFileSync(sidecar, JSON.stringify(meta, null, 2));
|
|
114
|
+
return sidecar;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function findStepPng(dir, stepName) {
|
|
118
|
+
const target = String(stepName || "").replace(/.png$/i, "").trim();
|
|
119
|
+
const files = readdirSyncFiles(dir);
|
|
120
|
+
if (!files.length) return null;
|
|
121
|
+
if (!target) return null;
|
|
122
|
+
// exact basename match first (handles refs like "baseline" or "00-baseline")
|
|
123
|
+
for (const f of files) {
|
|
124
|
+
const base = basename(f, ".png");
|
|
125
|
+
if (base === target) return join(dir, f);
|
|
126
|
+
}
|
|
127
|
+
// match the "NN-" prefix-stripped basename (e.g. "03-baseline" referenced as "baseline")
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
const base = basename(f, ".png");
|
|
130
|
+
const m = base.match(/^\d+-(.+)$/);
|
|
131
|
+
if (m && m[1] === target) return join(dir, f);
|
|
132
|
+
}
|
|
133
|
+
// loose suffix match
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
const base = basename(f, ".png");
|
|
136
|
+
if (base.endsWith("-" + target)) return join(dir, f);
|
|
137
|
+
}
|
|
138
|
+
// substring match last resort
|
|
139
|
+
for (const f of files) {
|
|
140
|
+
const base = basename(f, ".png");
|
|
141
|
+
if (base.includes(target)) return join(dir, f);
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readdirSyncFiles(dir) {
|
|
147
|
+
try { return readdirSync(dir).filter(f => f.endsWith(".png")); } catch { return []; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
151
|
+
export function escapeHtml(s) {
|
|
152
|
+
return String(s ?? "").replace(/[&<>"']/g, c => ESCAPE_MAP[c]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function stripLarge(obj) {
|
|
156
|
+
if (!obj || typeof obj !== "object") return obj;
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
159
|
+
if (k === "viewport" || k === "flags") continue;
|
|
160
|
+
if (typeof v === "string" && v.length > 400) { out[k] = v.slice(0, 400) + "…"; continue; }
|
|
161
|
+
out[k] = v;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderSweepHtml(summary) {
|
|
167
|
+
if (!summary?.steps?.length) return `<!doctype html><html><meta charset="utf-8"><title>pursor sweep — empty</title><body><p>No steps.</p></body></html>`;
|
|
168
|
+
const rows = summary.steps.map(s => {
|
|
169
|
+
const png = s.meta && s.meta.out ? basename(s.meta.out) : null;
|
|
170
|
+
const errCell = s.ok ? "" : `<div class="err">${escapeHtml(s.error || "")}</div>`;
|
|
171
|
+
const meta = s.meta ? `<pre>${escapeHtml(JSON.stringify(stripLarge(s.meta), null, 2))}</pre>` : "";
|
|
172
|
+
return `<article class="step ${s.ok ? "ok" : "fail"}"><header><span class="i">#${s.i}</span><span class="name">${escapeHtml(s.name)}</span><span class="op">${escapeHtml(s.op || "")}</span><span class="ms">${s.ms}ms</span><span class="status">${s.ok ? "OK" : "FAIL"}</span></header>${png ? `<img src="${png}" loading="lazy" alt="${escapeHtml(s.name)}" />` : ""}${errCell}${meta}</article>`;
|
|
173
|
+
}).join("\n");
|
|
174
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>pursor sweep — ${escapeHtml(summary.name || "")}</title>
|
|
175
|
+
<style>:root { color-scheme: light dark; } body { font: 14px/1.4 -apple-system, system-ui, sans-serif; margin: 0; background:#0b0b0b; color:#eee; } header.bar { padding: 12px 20px; background:#181818; border-bottom: 1px solid #2a2a2a; position: sticky; top:0; } header.bar h1 { font-size: 16px; margin: 0; } header.bar .meta { font-size: 12px; opacity: .7; } main { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; padding: 12px; } article.step { background:#161616; border:1px solid #2a2a2a; border-radius: 8px; overflow: hidden; } article.step.fail { border-color: #b04; } article.step header { display: flex; gap: 6px; padding: 8px 10px; font-size: 12px; background: #1c1c1c; align-items: center; } article.step header .i { color:#888; } article.step header .name { font-weight: 600; } article.step header .op { color:#9ad; font-family: monospace; } article.step header .ms { color:#888; margin-left: auto; } article.step header .status { padding: 1px 6px; border-radius: 4px; background:#234; color:#adf; font-size: 11px; } article.step.fail header .status { background:#421; color:#fbb; } article.step img { display: block; width: 100%; height: auto; background:#000; } article.step pre { margin: 0; padding: 8px 10px; font-size: 11px; max-height: 180px; overflow: auto; background:#111; color:#aaa; border-top: 1px solid #222; } article.step .err { padding: 8px 10px; background: #2a0e0e; color: #fbb; font-size: 12px; }</style></head>
|
|
176
|
+
<body><header class="bar"><h1>pursor sweep: ${escapeHtml(summary.name || "(unnamed)")}</h1><div class="meta">${summary.steps.length} steps · ${escapeHtml(summary.outDir)} · ${escapeHtml(summary.ts)}</div></header><main>${rows}</main></body></html>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function renderEveryViewportHtml(summary) {
|
|
180
|
+
if (!summary?.captures?.length) return '<!doctype html><html><meta charset="utf-8"><title>every-viewport — empty</title><body><p>No captures.</p></body></html>';
|
|
181
|
+
const rows = summary.captures.map(c => {
|
|
182
|
+
const png = c.meta?.out ? basename(c.meta.out) : null;
|
|
183
|
+
const err = c.error ? '<div class="err">' + escapeHtml(c.error) + '</div>' : '';
|
|
184
|
+
const img = png ? '<img src="' + png + '" loading="lazy" alt="' + escapeHtml(c.name) + '" />' : '';
|
|
185
|
+
return '<article class="step ' + (c.ok ? "ok" : "fail") + '"><header><span class="name">' + escapeHtml(c.name) + '</span><span class="ms">' + c.ms + 'ms</span><span class="status">' + (c.ok ? "OK" : "FAIL") + '</span></header>' + img + err + '</article>';
|
|
186
|
+
}).join("\n");
|
|
187
|
+
return '<!doctype html><html><head><meta charset="utf-8"><title>every-viewport — ' + escapeHtml(summary.url || "") + '</title><style>:root{color-scheme:light dark}body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:0;background:#0b0b0b;color:#eee}header.bar{padding:12px 20px;background:#181818;border-bottom:1px solid #2a2a2a;position:sticky;top:0}header.bar h1{font-size:16px;margin:0}header.bar .meta{font-size:12px;opacity:.7}main{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:12px;padding:12px}article.step{background:#161616;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}article.step.fail{border-color:#b04}article.step header{display:flex;gap:6px;padding:8px 10px;font-size:12px;background:#1c1c1c;align-items:center}article.step header .name{font-weight:600}article.step header .ms{color:#888;margin-left:auto}article.step header .status{padding:1px 6px;border-radius:4px;background:#234;color:#adf;font-size:11px}article.step.fail header .status{background:#421;color:#fbb}article.step img{display:block;width:100%;height:auto;background:#000}article.step .err{padding:8px 10px;background:#2a0e0e;color:#fbb;font-size:12px}</style></head><body><header class="bar"><h1>every-viewport: ' + escapeHtml(summary.url || "") + '</h1><div class="meta">' + summary.captures.length + ' viewports · ' + escapeHtml(summary.outDir) + ' · ' + escapeHtml(summary.ts) + '</div></header><main>' + rows + '</main></body></html>';
|
|
188
|
+
}
|
package/src/viewport.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// viewport presets + flag -> viewport resolution.
|
|
2
|
+
|
|
3
|
+
export const VIEWPORTS = {
|
|
4
|
+
"desktop-1280": { width: 1280, height: 800, dpr: 1, label: "Desktop 1280x800" },
|
|
5
|
+
"desktop-1440": { width: 1440, height: 900, dpr: 1, label: "Desktop 1440x900" },
|
|
6
|
+
"desktop-1920": { width: 1920, height: 1080, dpr: 1, label: "Desktop 1920x1080" },
|
|
7
|
+
"desktop-2560": { width: 2560, height: 1440, dpr: 1, label: "QHD 2560x1440" },
|
|
8
|
+
"ultrawide-3440":{ width: 3440, height: 1440, dpr: 1, label: "Ultrawide 3440x1440" },
|
|
9
|
+
"tablet-768": { width: 768, height: 1024, dpr: 2, label: "Tablet portrait 768x1024 @2x" },
|
|
10
|
+
"tablet-1024": { width: 1024, height: 768, dpr: 2, label: "Tablet landscape 1024x768 @2x" },
|
|
11
|
+
"mobile-375": { width: 375, height: 812, dpr: 3, label: "iPhone X 375x812 @3x" },
|
|
12
|
+
"mobile-414": { width: 414, height: 896, dpr: 3, label: "iPhone XR 414x896 @3x" },
|
|
13
|
+
"mobile-360": { width: 360, height: 800, dpr: 2, label: "Android 360x800 @2x" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_VIEWPORT = VIEWPORTS["desktop-1280"];
|
|
17
|
+
|
|
18
|
+
import { listViewportPresets } from "./plugin.js";
|
|
19
|
+
import { asNum } from "./util.js";
|
|
20
|
+
|
|
21
|
+
export function listViewports() {
|
|
22
|
+
return Object.entries({ ...VIEWPORTS, ...listViewportPresets() }).map(([k, v]) => ({ name: k, ...v }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveViewport(flags = {}) {
|
|
26
|
+
const all = { ...VIEWPORTS, ...listViewportPresets() };
|
|
27
|
+
const name = flags.preset || flags.viewport;
|
|
28
|
+
if (name && all[name]) return { ...all[name], name };
|
|
29
|
+
if (flags.width || flags.height) {
|
|
30
|
+
return {
|
|
31
|
+
name: "custom",
|
|
32
|
+
label: `Custom ${flags.width}x${flags.height}`,
|
|
33
|
+
width: asNum(flags.width, DEFAULT_VIEWPORT.width),
|
|
34
|
+
height: asNum(flags.height, DEFAULT_VIEWPORT.height),
|
|
35
|
+
dpr: asNum(flags.dpr, 1),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ...DEFAULT_VIEWPORT, name: "desktop-1280" };
|
|
39
|
+
}
|