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.
@@ -0,0 +1,170 @@
1
+ // Shared capture-side helpers: page-side CSS overlays, camera control,
2
+ // frame stability wait.
3
+
4
+ export const NAV_TIMEOUT_MS = 90_000;
5
+ export const SETTLE_MS = 1200;
6
+ export const CLICK_TIMEOUT_MS = 15_000;
7
+ export const WAIT_DEFAULT_TIMEOUT_MS = 30_000;
8
+
9
+ export async function gotoOrThrow(page, url, opts = {}) {
10
+ const timeout = opts.timeoutMs || NAV_TIMEOUT_MS;
11
+ const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
12
+ if (!resp) throw new Error(`No response for ${url}`);
13
+ const status = resp.status();
14
+ if (status >= 400) throw new Error(`HTTP ${status} for ${url}`);
15
+ return { status, title: await page.title() };
16
+ }
17
+
18
+ export async function settle(page) {
19
+ await page.waitForTimeout(SETTLE_MS);
20
+ }
21
+
22
+ // --- overlays ---
23
+
24
+ export async function overlayCursor(page, kind) {
25
+ if (!kind || kind === "default" || kind === "none") return async () => {};
26
+ const map = { pointer: "pointer", grab: "grab", grabbing: "grabbing", crosshair: "crosshair" };
27
+ const css = map[kind] || "pointer";
28
+ const marker = `/*purr_visual_cursor_${css}*/`;
29
+ await page.addStyleTag({ content: `${marker}\n*, *::before, *::after { cursor: ${css} !important; }` });
30
+ return async () => {
31
+ await page.evaluate((m) => document.querySelectorAll("style").forEach(s => { if (s.textContent && s.textContent.includes(m)) s.remove(); }), marker).catch(() => {});
32
+ };
33
+ }
34
+
35
+ export async function overlayGrid(page, opts = {}) {
36
+ const tile = Math.max(8, Math.min(512, Number(opts.tileSize) || 64));
37
+ // Sanitize color: allow only CSS-safe color tokens (hex, rgba, named)
38
+ const raw = (opts.color || "rgba(255, 0, 255, 0.35)").trim();
39
+ // Reject anything with braces, semicolons, quotes, or HTML — CSS injection guard
40
+ const color = /^[a-zA-Z#()\d\s,%.]+$/.test(raw) ? raw : "rgba(255, 0, 255, 0.35)";
41
+ await page.evaluate(({ tile, color }) => {
42
+ let s = document.getElementById("__purr_visual_grid__");
43
+ if (s) s.remove();
44
+ s = document.createElement("style");
45
+ s.id = "__purr_visual_grid__";
46
+ s.textContent = `body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 99999; background-image: linear-gradient(to right, ${color} 1px, transparent 1px), linear-gradient(to bottom, ${color} 1px, transparent 1px); background-size: ${tile}px ${tile}px, ${tile}px ${tile}px; }`;
47
+ document.documentElement.appendChild(s);
48
+ }, { tile, color });
49
+ return async () => {
50
+ await page.evaluate(() => document.getElementById("__purr_visual_grid__")?.remove()).catch(() => {});
51
+ };
52
+ }
53
+
54
+ export async function hideHud(page) {
55
+ const marker = "/*purr_visual_hide_hud*/";
56
+ const css = marker + "\n" + [
57
+ "header, footer, nav { display: none !important; }",
58
+ ".hud-topbar, .bottom-nav { display: none !important; }",
59
+ "[data-pursor-hud=\"hide\"] { display: none !important; }",
60
+ ].join("\n");
61
+ await page.addStyleTag({ content: css });
62
+ return async () => {
63
+ await page.evaluate((m) => document.querySelectorAll("style").forEach(s => { if (s.textContent && s.textContent.includes(m)) s.remove(); }), marker).catch(() => {});
64
+ };
65
+ }
66
+
67
+ export async function isolateLayer(page, layer) {
68
+ if (!layer || layer === "all" || layer === "none") return async () => {};
69
+ let css = "";
70
+ // entity = canvas only (game worlds typically render into <canvas>)
71
+ if (layer === "entity") css = "[class*=\"bottom\"], [class*=\"hud\"], [class*=\"nav\"], [class*=\"bar\"], [class*=\"companion\"] { display: none !important; }";
72
+ else if (layer === "terrain") css = "canvas { display: none !important; }";
73
+ else if (layer === "hud") css = "[class*=\"hud\"], [class*=\"nav\"], [class*=\"bar\"] { display: none !important; }";
74
+ else if (layer === "ui") css = "canvas, header, footer, main > nav { display: none !important; }";
75
+ else throw new Error("unknown layer: " + layer);
76
+ const marker = `/*purr_visual_layer_${layer}*/`;
77
+ await page.addStyleTag({ content: `${marker}\n${css}` });
78
+ return async () => {
79
+ await page.evaluate((m) => document.querySelectorAll("style").forEach(s => { if (s.textContent && s.textContent.includes(m)) s.remove(); }), marker).catch(() => {});
80
+ };
81
+ }
82
+
83
+ export async function freezeAnimation(page, freeze) {
84
+ if (!freeze) return async () => {};
85
+ const marker = "/*purr_visual_freeze*/";
86
+ await page.addStyleTag({ content: `${marker}\n*, *::before, *::after { animation-play-state: paused !important; animation-delay: 0s !important; transition: none !important; }` });
87
+ return async () => {
88
+ await page.evaluate((m) => document.querySelectorAll("style").forEach(s => { if (s.textContent && s.textContent.includes(m)) s.remove(); }), marker).catch(() => {});
89
+ };
90
+ }
91
+
92
+ export async function waitForStableFrame(page, ms) {
93
+ if (!ms || ms <= 0) return;
94
+ const hard = 8000;
95
+ const t0 = Date.now();
96
+ let lastHash = null, lastChange = Date.now();
97
+ while (Date.now() - t0 < hard) {
98
+ let h;
99
+ try {
100
+ h = await page.evaluate(() => {
101
+ const c = document.querySelector("canvas");
102
+ if (!c) return null;
103
+ // Prefer WebGL context (common in game canvases), fall back to 2D
104
+ let ctx, type;
105
+ try {
106
+ ctx = c.getContext("webgl2", { willReadFrequently: false }) || c.getContext("webgl");
107
+ if (ctx) type = "webgl";
108
+ } catch {}
109
+ if (!ctx) {
110
+ try { ctx = c.getContext("2d", { willReadFrequently: true }); type = "2d"; } catch {}
111
+ }
112
+ if (!ctx) return null;
113
+ if (type === "webgl") {
114
+ // Create a small readback for WebGL with complete framebuffer
115
+ const fb = ctx.createFramebuffer();
116
+ ctx.bindFramebuffer(ctx.FRAMEBUFFER, fb);
117
+ const rb = ctx.createRenderbuffer();
118
+ ctx.bindRenderbuffer(ctx.RENDERBUFFER, rb);
119
+ ctx.renderbufferStorage(ctx.RENDERBUFFER, ctx.RGBA4, 4, 4);
120
+ ctx.framebufferRenderbuffer(ctx.FRAMEBUFFER, ctx.COLOR_ATTACHMENT0, ctx.RENDERBUFFER, rb);
121
+ const d = new Uint8Array(64);
122
+ ctx.readPixels(0, 0, 4, 4, ctx.RGBA, ctx.UNSIGNED_BYTE, d);
123
+ let acc = 0; for (let i = 0; i < d.length; i += 4) acc = (acc * 31 + d[i]) | 0;
124
+ return acc.toString(36);
125
+ } else {
126
+ const d = ctx.getImageData(Math.floor(c.width / 2), Math.floor(c.height / 2), 32, 32).data;
127
+ let acc = 0; for (let i = 0; i < d.length; i += 64) acc = (acc * 31 + d[i]) | 0;
128
+ return acc.toString(36);
129
+ }
130
+ });
131
+ } catch {
132
+ // page detached mid-poll — give up
133
+ return;
134
+ }
135
+ if (h && h === lastHash) { if (Date.now() - lastChange >= ms) return; }
136
+ else { lastHash = h; lastChange = Date.now(); }
137
+ await page.waitForTimeout(120);
138
+ }
139
+ }
140
+
141
+ // --- camera ---
142
+
143
+ export async function applyCamera(page, opts = {}) {
144
+ if (!opts) return;
145
+ const zoom = Number(opts.zoom) || 1;
146
+ const panX = Number(opts.panX) || 0;
147
+ const panY = Number(opts.panY) || 0;
148
+ const center = await page.evaluate(() => {
149
+ const c = document.querySelector("canvas");
150
+ if (!c) return null;
151
+ const r = c.getBoundingClientRect();
152
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
153
+ });
154
+ if (!center) return;
155
+ if (zoom !== 1) {
156
+ const factor = Math.log2(zoom) * 8;
157
+ const delta = -120 * (factor > 0 ? 1 : -1);
158
+ for (let i = 0; i < Math.max(1, Math.abs(Math.round(factor))); i++) {
159
+ await page.mouse.move(center.x, center.y);
160
+ await page.mouse.wheel(0, delta);
161
+ await page.waitForTimeout(50);
162
+ }
163
+ }
164
+ if (panX || panY) {
165
+ await page.mouse.move(center.x, center.y);
166
+ await page.mouse.down();
167
+ await page.mouse.move(center.x + panX, center.y + panY, { steps: 10 });
168
+ await page.mouse.up();
169
+ }
170
+ }
@@ -0,0 +1,260 @@
1
+ // pursor — axe-core accessibility audit.
2
+ //
3
+ // Injects axe-core into the page, runs a WCAG audit, returns violations.
4
+ // Optionally highlights violated elements with a red overlay and
5
+ // generates a full audit report.
6
+ //
7
+ // Used internally via runAudit() and also exposed as a sweep-op
8
+ // so it works in batch plans.
9
+ //
10
+ // Dependencies: axe-core (npm i axe-core)
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, readFileSync, existsSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { asNum, nowIso } from "./util.js";
18
+
19
+ // ─── Injected audit runner ──────────────────────────────────────────────
20
+
21
+ const AUDIT_RUNNER = (axeSource) => `
22
+ ${axeSource}
23
+ (function() {
24
+ return new Promise((resolve, reject) => {
25
+ const config = typeof window.__PURR_AUDIT_CONFIG__ !== 'undefined' ? window.__PURR_AUDIT_CONFIG__ : {};
26
+ var tags = config.tags || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'];
27
+ axe.run(
28
+ { runOnly: { type: 'tag', values: tags } },
29
+ function(err, results) {
30
+ if (err) { reject(err.message); return; }
31
+ // Also collect element references for highlighting
32
+ resolve(JSON.parse(JSON.stringify(results)));
33
+ }
34
+ );
35
+ });
36
+ })()
37
+ `;
38
+
39
+ // ─── Highlight overlay script ──────────────────────────────────────────
40
+
41
+ const HIGHLIGHT_VIOLATIONS = (violationsJson) => `
42
+ (function() {
43
+ var violations = ${violationsJson};
44
+ if (!violations || !violations.length) return;
45
+ var style = document.createElement('style');
46
+ style.id = '__purr_audit_highlight__';
47
+ style.textContent = \`
48
+ [data-purr-audit-violation] {
49
+ outline: 3px solid rgba(255, 0, 0, 0.85) !important;
50
+ background: rgba(255, 0, 0, 0.12) !important;
51
+ position: relative !important;
52
+ }
53
+ [data-purr-audit-violation]::after {
54
+ content: attr(data-purr-audit-violation);
55
+ position: absolute;
56
+ top: -20px;
57
+ left: 0;
58
+ background: #d00;
59
+ color: #fff;
60
+ font: 10px/14px monospace;
61
+ padding: 1px 5px;
62
+ border-radius: 3px;
63
+ z-index: 999999;
64
+ white-space: nowrap;
65
+ }
66
+ \`;
67
+ document.documentElement.appendChild(style);
68
+ for (var v of violations) {
69
+ for (var n of v.nodes || []) {
70
+ for (var t of n.target || []) {
71
+ try {
72
+ var sel = Array.isArray(t) ? t.join(' ') : t;
73
+ var els = document.querySelectorAll(sel);
74
+ for (var e of els) {
75
+ e.setAttribute('data-purr-audit-violation', v.id + ': ' + v.impact);
76
+ }
77
+ } catch(e) {}
78
+ }
79
+ }
80
+ }
81
+ })()
82
+ `;
83
+
84
+ // ─── Load axe-core ──────────────────────────────────────────────────────
85
+
86
+ let _axeSource = null;
87
+ async function getAxeSource() {
88
+ if (_axeSource) return _axeSource;
89
+ // Try node_modules/axe-core
90
+ const paths = [
91
+ join(process.cwd(), "node_modules", "axe-core", "axe.min.js"),
92
+ join(process.cwd(), "node_modules", "axe-core", "axe.js"),
93
+ new URL("..", import.meta.url).pathname && join(dirname(new URL(import.meta.url).pathname), "node_modules", "axe-core", "axe.min.js"),
94
+ join(dirname(process.execPath), "node_modules", "axe-core", "axe.min.js"),
95
+ ];
96
+ for (const p of paths) {
97
+ if (p && existsSync(p)) {
98
+ _axeSource = readFileSync(p, "utf8");
99
+ return _axeSource;
100
+ }
101
+ }
102
+ throw new Error("axe-core not found. Install: npm i axe-core");
103
+ }
104
+
105
+ // ─── Group helper ───────────────────────────────────────────────────────
106
+
107
+ /** Count violations by WCAG impact level */
108
+ function summarizeViolations(violations) {
109
+ const byImpact = { critical: 0, serious: 0, moderate: 0, minor: 0 };
110
+ const byTag = {};
111
+ for (const v of violations) {
112
+ byImpact[v.impact] = (byImpact[v.impact] || 0) + v.nodes.length;
113
+ for (const t of v.tags || []) {
114
+ if (!byTag[t]) byTag[t] = 0;
115
+ byTag[t] += v.nodes.length;
116
+ }
117
+ }
118
+ return { total: violations.length, totalNodes: violations.reduce((s, v) => s + v.nodes.length, 0), byImpact, byTag };
119
+ }
120
+
121
+ // ─── Public API ─────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Run axe-core audit on a URL.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.url - Target URL
128
+ * @param {string[]} [opts.tags] - WCAG tags (default: wcag2a, wcag2aa, wcag21a, wcag21aa, best-practice)
129
+ * @param {string} [opts.outDir] - Output directory
130
+ * @param {boolean} [opts.screenshot=true] - Capture highlighted screenshot
131
+ * @param {object} [opts.flags] - Extra capture flags
132
+ * @returns {Promise<object>} Audit result
133
+ */
134
+ export async function runAudit({ url, tags, outDir, screenshot = true, flags = {} }) {
135
+ const dir = outDir || join(process.cwd(), `audit-${Date.now()}`);
136
+ mkdirSync(dir, { recursive: true });
137
+
138
+ const viewport = resolveViewport(flags);
139
+ const browser = await launch();
140
+ const axeSource = await getAxeSource();
141
+
142
+ try {
143
+ const page = await newPage(browser, viewport);
144
+ const r = await gotoOrThrow(page, url);
145
+ await settle(page);
146
+ // Give dynamic content time to fully render
147
+ await page.waitForTimeout(800);
148
+
149
+ // Inject axe config
150
+ await page.evaluate((t) => {
151
+ window.__PURR_AUDIT_CONFIG__ = { tags: t || ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] };
152
+ }, tags || undefined);
153
+
154
+ // Inject and run axe
155
+ const rawResults = await page.evaluate(AUDIT_RUNNER, axeSource);
156
+
157
+ const result = {
158
+ url,
159
+ title: r.title,
160
+ ts: nowIso(),
161
+ viewport: { width: viewport.width, height: viewport.height, dpr: viewport.dpr },
162
+ violationSummary: summarizeViolations(rawResults.violations || []),
163
+ passes: (rawResults.passes || []).length,
164
+ incomplete: (rawResults.incomplete || []).length,
165
+ inapplicable: (rawResults.inapplicable || []).length,
166
+ violations: rawResults.violations || [],
167
+ };
168
+
169
+ // Write audit report
170
+ const auditPath = join(dir, "audit.json");
171
+ writeFileSync(auditPath, JSON.stringify(result, null, 2));
172
+
173
+ // Write summary Markdown
174
+ const mdPath = join(dir, "audit-summary.md");
175
+ writeFileSync(mdPath, renderAuditMarkdown(result));
176
+
177
+ // Highlighted screenshot
178
+ if (screenshot && result.violations.length > 0) {
179
+ const violationsJson = JSON.stringify(result.violations.map(v => ({
180
+ id: v.id,
181
+ impact: v.impact,
182
+ nodes: (v.nodes || []).map(n => ({ target: n.target })),
183
+ })));
184
+ await page.evaluate(HIGHLIGHT_VIOLATIONS, violationsJson);
185
+ await page.waitForTimeout(200);
186
+ const shotPath = join(dir, "audit-highlighted.png");
187
+ await page.screenshot({ path: shotPath, fullPage: true });
188
+ result.highlightedScreenshot = shotPath;
189
+ } else if (screenshot) {
190
+ const shotPath = join(dir, "audit-clean.png");
191
+ await page.screenshot({ path: shotPath, fullPage: true });
192
+ result.cleanScreenshot = shotPath;
193
+ }
194
+
195
+ return result;
196
+ } finally {
197
+ try { await browser.close(); } catch {}
198
+ }
199
+ }
200
+
201
+ // ─── Markdown report ────────────────────────────────────────────────────
202
+
203
+ function renderAuditMarkdown(result) {
204
+ const s = result.violationSummary || {};
205
+ const lines = [
206
+ `# Accessibility Audit: ${result.url}`,
207
+ ``,
208
+ `**Date:** ${result.ts}`,
209
+ `**Viewport:** ${result.viewport.width}x${result.viewport.height} @${result.viewport.dpr}x`,
210
+ ``,
211
+ `## Summary`,
212
+ ``,
213
+ `| Severity | Count |`,
214
+ `|----------|-------|`,
215
+ `| 🔴 Critical | ${s.byImpact?.critical || 0} nodes`,
216
+ `| 🟠 Serious | ${s.byImpact?.serious || 0} nodes`,
217
+ `| 🟡 Moderate | ${s.byImpact?.moderate || 0} nodes`,
218
+ `| ⚪ Minor | ${s.byImpact?.minor || 0} nodes`,
219
+ `| **Total violations** | **${s.total} rules, ${s.totalNodes} nodes** |`,
220
+ ``,
221
+ `| Check | Count |`,
222
+ `|-------|-------|`,
223
+ `| Passes | ${result.passes || 0} |`,
224
+ `| Incomplete | ${result.incomplete || 0} |`,
225
+ `| Inapplicable | ${result.inapplicable || 0} |`,
226
+ ``,
227
+ ];
228
+
229
+ if (result.violations.length) {
230
+ lines.push(`## Violations`);
231
+ lines.push(``);
232
+ for (const v of result.violations) {
233
+ lines.push(`### ${v.id} — ${v.impact}`);
234
+ lines.push(``);
235
+ lines.push(`**Help:** ${v.helpUrl || v.help || 'N/A'}`);
236
+ lines.push(``);
237
+ lines.push(`**Tags:** ${(v.tags || []).join(', ')}`);
238
+ lines.push(``);
239
+ lines.push(`**Affected nodes:** ${(v.nodes || []).length}`);
240
+ lines.push(``);
241
+ for (const n of (v.nodes || []).slice(0, 10)) { // top 10 per violation
242
+ const target = (n.target || []).join(', ');
243
+ const snippet = (n.html || '').slice(0, 200);
244
+ const failureSummary = n.failureSummary || '';
245
+ lines.push(`- \`${target}\``);
246
+ if (snippet) lines.push(` - \`${snippet.replace(/`/g, '')}\``);
247
+ if (failureSummary) lines.push(` - ${failureSummary.split('\\n')[0]}`);
248
+ }
249
+ if ((v.nodes || []).length > 10) lines.push(` - … and ${v.nodes.length - 10} more nodes`);
250
+ lines.push(``);
251
+ }
252
+ }
253
+
254
+ if (result.highlightedScreenshot) lines.push(`![Highlighted screenshot](${result.highlightedScreenshot})`);
255
+ if (result.cleanScreenshot) lines.push(`![Clean screenshot](${result.cleanScreenshot})`);
256
+
257
+ lines.push(``);
258
+ lines.push(`_Generated by pursor audit_`);
259
+ return lines.join('\n');
260
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,121 @@
1
+ // pursor — plugin API.
2
+ //
3
+ // A plugin is a plain ES module that exports a default object with one
4
+ // or more hook handlers. The host loads plugins from:
5
+ // 1. The built-in `plugins/` directory (shipped with the package).
6
+ // 2. A path passed to `loadPlugins([...paths])`.
7
+ // 3. Any package named `pursor-plugin-*` in node_modules.
8
+ //
9
+ // Hook reference:
10
+ //
11
+ // name: "my-plugin" // optional, for logs
12
+ // viewport: { <presetName>: { width, height, dpr, label } }
13
+ // sweepOp: { <opName>: async (ctx, opts) => Result }
14
+ // beforeShoot: async (ctx) => void // mutate ctx.flags / ctx.viewport
15
+ // afterShoot: async (ctx, meta) => void // augment sidecar
16
+ // flagHelp: { "my-flag": "what it does" }
17
+ //
18
+ // ctx fields available to hooks:
19
+ // url, out, viewport, flags, browser, page
20
+ //
21
+ // Example plugin (plugins/my-plugin.js):
22
+ //
23
+ // export default {
24
+ // name: "my-plugin",
25
+ // viewport: {
26
+ // "my-laptop": { width: 1440, height: 900, dpr: 2, label: "MBP 14" },
27
+ // },
28
+ // sweepOp: {
29
+ // "lighthouse": async (ctx, opts) => {
30
+ // // ... run lighthouse, return { out, meta }
31
+ // },
32
+ // },
33
+ // beforeShoot: async (ctx) => {
34
+ // // e.g. set extra cookies, click a button, etc.
35
+ // },
36
+ // };
37
+
38
+ const plugins = [];
39
+ const registeredRefs = new Set();
40
+ const sweepOps = new Map();
41
+ const viewportPresets = new Map();
42
+ const flagHelp = new Map();
43
+
44
+ export function registerPlugin(p) {
45
+ if (!p || typeof p !== "object") return;
46
+ if (registeredRefs.has(p)) return;
47
+ registeredRefs.add(p);
48
+ plugins.push(p);
49
+ if (p.name) console.error(`[pursor] loaded plugin: ${p.name}`);
50
+ if (p.viewport) {
51
+ for (const [k, v] of Object.entries(p.viewport)) viewportPresets.set(k, v);
52
+ }
53
+ if (p.sweepOp) {
54
+ for (const [k, v] of Object.entries(p.sweepOp)) sweepOps.set(k, v);
55
+ }
56
+ if (p.flagHelp) {
57
+ for (const [k, v] of Object.entries(p.flagHelp)) flagHelp.set(k, v);
58
+ }
59
+ }
60
+
61
+ export async function loadPlugins(paths = []) {
62
+ if (typeof paths === "string") paths = [paths];
63
+ const loadedPaths = new Set();
64
+ // auto-load built-in plugins from plugins/ directory
65
+ const { readdirSync, existsSync } = await import("node:fs");
66
+ const { join, dirname } = await import("node:path");
67
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
68
+ const selfDir = dirname(fileURLToPath(import.meta.url));
69
+ const pluginDir = join(selfDir, "..", "plugins");
70
+ if (existsSync(pluginDir)) {
71
+ for (const f of readdirSync(pluginDir)) {
72
+ if (f.endsWith(".js")) {
73
+ const fullPath = pathToFileURL(join(pluginDir, f)).href;
74
+ if (loadedPaths.has(fullPath)) continue;
75
+ loadedPaths.add(fullPath);
76
+ try {
77
+ const mod = await import(/* @vite-ignore */ fullPath);
78
+ registerPlugin(mod.default || mod);
79
+ } catch (e) {
80
+ console.error(`[pursor] failed to load built-in plugin ${f}: ${e.message}`);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ // user-supplied plugins — resolve relative to cwd
86
+ for (const p of paths) {
87
+ if (loadedPaths.has(p)) continue;
88
+ loadedPaths.add(p);
89
+ try {
90
+ const resolved = pathToFileURL(join(process.cwd(), p)).href;
91
+ const mod = await import(/* @vite-ignore */ resolved);
92
+ registerPlugin(mod.default || mod);
93
+ } catch (e) {
94
+ console.error(`[pursor] failed to load plugin ${p}: ${e.message}`);
95
+ }
96
+ }
97
+ return plugins.length;
98
+ }
99
+
100
+ export function listPlugins() { return plugins.map(p => p?.name || "(unnamed)"); }
101
+
102
+ export function getSweepOp(name) { return sweepOps.get(name); }
103
+ export function getViewportPreset(name) { return viewportPresets.get(name); }
104
+ export function listViewportPresets() { return Object.fromEntries(viewportPresets); }
105
+ export function getFlagHelp() { return Object.fromEntries(flagHelp); }
106
+
107
+ export async function runBeforeShoot(ctx) {
108
+ for (const p of plugins) {
109
+ if (typeof p.beforeShoot === "function") {
110
+ try { await p.beforeShoot(ctx); } catch (e) { console.error(`[plugin ${p.name}] beforeShoot: ${e.message}`); }
111
+ }
112
+ }
113
+ }
114
+
115
+ export async function runAfterShoot(ctx, meta) {
116
+ for (const p of plugins) {
117
+ if (typeof p.afterShoot === "function") {
118
+ try { await p.afterShoot(ctx, meta); } catch (e) { console.error(`[plugin ${p.name}] afterShoot: ${e.message}`); }
119
+ }
120
+ }
121
+ }
package/src/probe.js ADDED
@@ -0,0 +1,20 @@
1
+ // Probe: open a viewport, navigate, return basic info. No screenshot.
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 runProbe(url) {
9
+ requireArg("url", url, "string");
10
+ const browser = await launch();
11
+ try {
12
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
13
+ let navError = null, status = null, title = null;
14
+ try {
15
+ const r = await gotoOrThrow(page, url);
16
+ status = r.status; title = r.title;
17
+ } catch (e) { navError = e.message; }
18
+ return { url, status, title, navError, viewport: DEFAULT_VIEWPORT };
19
+ } finally { try { await browser.close(); } catch {} }
20
+ }
package/src/runway.js ADDED
@@ -0,0 +1,63 @@
1
+ // Browser launcher: auto-detect Playwright + system Chrome.
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ let _chromium = null;
8
+ async function getChromium() {
9
+ if (_chromium) return _chromium;
10
+ // Try local node_modules first
11
+ try { return _chromium = (await import("playwright-core")).chromium; } catch {}
12
+ // Try Codex cua_node runtime (current Codex Desktop bundles it).
13
+ // The cua_node folder has a hash subdir (e.g. 789504f803e82e2b), so we
14
+ // walk it to find the playwright-core entry.
15
+ const cuaRoot = join(homedir(), "AppData", "Local", "OpenAI", "Codex", "runtimes", "cua_node");
16
+ if (existsSync(cuaRoot)) {
17
+ const { readdirSync } = await import("node:fs");
18
+ const subdirs = (() => { try { return readdirSync(cuaRoot); } catch { return []; } })();
19
+ for (const sub of subdirs) {
20
+ const cand = join(cuaRoot, sub, "bin", "node_modules", "playwright-core", "index.mjs");
21
+ if (existsSync(cand)) {
22
+ try {
23
+ const url = "file:///" + cand.replace(/\\/g, "/");
24
+ return _chromium = (await import(url)).chromium;
25
+ } catch {}
26
+ }
27
+ }
28
+ }
29
+ throw new Error("playwright-core not found. Install it: npm i -D playwright-core");
30
+ }
31
+
32
+ const CHROME_PATHS = [
33
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
34
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
35
+ "/usr/bin/google-chrome",
36
+ "/usr/bin/chromium-browser",
37
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
38
+ ];
39
+ function findChrome() {
40
+ for (const p of CHROME_PATHS) if (existsSync(p)) return p;
41
+ return null;
42
+ }
43
+
44
+ const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
45
+
46
+ export async function launch() {
47
+ const chromium = await getChromium();
48
+ const exec = findChrome();
49
+ if (!exec) throw new Error("system Chrome not found in standard paths");
50
+ return await chromium.launch({ headless: true, executablePath: exec, args: BROWSER_ARGS });
51
+ }
52
+
53
+ export async function newPage(browser, viewport) {
54
+ const ctx = await browser.newContext({
55
+ viewport: { width: viewport.width, height: viewport.height },
56
+ deviceScaleFactor: viewport.dpr || 1,
57
+ reducedMotion: "no-preference",
58
+ colorScheme: "light",
59
+ hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
60
+ isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
61
+ });
62
+ return await ctx.newPage();
63
+ }