pursr 0.4.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,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,66 @@
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, opts = {}) {
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
+ storageState: opts.storageState || undefined,
62
+ });
63
+ const page = await ctx.newPage();
64
+ page._pursorContext = ctx;
65
+ return page;
66
+ }
@@ -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
+ }
@@ -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,74 @@
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
+ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
13
+ import { loadAuthState } from "./auth.js";
14
+
15
+ export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
16
+ requireArg("url", url, "string");
17
+ const viewport = resolveViewport(flags);
18
+ const ownBrowser = !extBrowser;
19
+ const browser = extBrowser || await launch();
20
+ const cleanups = [];
21
+ try {
22
+ return await (async () => {
23
+ const page = await newPage(browser, viewport, {
24
+ storageState: flags["auth-state"] ? loadAuthState({ project: flags["auth-project"] || "default", name: flags["auth-state"] }) : undefined,
25
+ });
26
+ const r = await gotoOrThrow(page, url);
27
+ await settle(page);
28
+
29
+ // Build a ctx object so plugins can mutate it
30
+ const ctx = { url, out, viewport, flags, browser, page };
31
+
32
+ await runBeforeShoot(ctx);
33
+
34
+ cleanups.push(await freezeAnimation(page, asBool(flags["no-animation"], false)));
35
+ cleanups.push(await overlayCursor(page, flags.cursor || "default"));
36
+ if (asBool(flags.grid, false)) cleanups.push(await overlayGrid(page, { tileSize: flags["grid-tile"], color: flags["grid-color"] }));
37
+ if (asBool(flags["no-hud"], false)) cleanups.push(await hideHud(page));
38
+ cleanups.push(await isolateLayer(page, flags.layer || "all"));
39
+ if (typeof prepare === "function") { const c = await prepare(page); if (typeof c === "function") cleanups.push(c); }
40
+
41
+ if (flags["wait-frame"]) await waitForStableFrame(page, asNum(flags["wait-frame"], 600));
42
+
43
+ if (flags.zoom || flags.panX || flags.panY) {
44
+ await applyCamera(page, { zoom: asNum(flags.zoom, 1), panX: asNum(flags.panX, 0), panY: asNum(flags.panY, 0) });
45
+ await page.waitForTimeout(400);
46
+ }
47
+
48
+ // Optional HAR capture
49
+ const harState = flags.har ? await startHarCapture(page) : null;
50
+ await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
51
+ const meta = { url, out, ts: nowIso(), status: r.status, title: r.title, viewport, flags: { ...flags } };
52
+ if (harState) {
53
+ const har = stopHarCapture(page);
54
+ const harFile = await writeHar(har, String(flags.har));
55
+ meta.har = harFile;
56
+ meta.harEntryCount = har?._meta?.entryCount || 0;
57
+ }
58
+ await runAfterShoot(ctx, meta);
59
+ return meta;
60
+ })().catch(e => ({ url, out, ts: nowIso(), error: e.message, viewport, flags: { ...flags } }));
61
+ } finally {
62
+ // Run cleanups (remove injected overlay styles)
63
+ for (const fn of cleanups) {
64
+ try { await fn(); } catch {}
65
+ }
66
+ if (ownBrowser) await browser.close();
67
+ }
68
+ }
69
+
70
+ export async function runShootWithSidecar(args) {
71
+ const meta = await runShoot(args);
72
+ await writeSidecar(meta);
73
+ return meta;
74
+ }
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
+ }