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.
- package/LICENSE +21 -0
- package/README.md +440 -0
- package/assets/icon.svg +21 -0
- package/assets/logo.svg +29 -0
- package/assets/social-preview.svg +77 -0
- package/bin/pursr-mcp.mjs +21 -0
- package/bin/pursr.mjs +227 -0
- package/package.json +90 -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/auth.js +92 -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/har.js +159 -0
- package/src/hover.js +26 -0
- package/src/index.js +95 -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 +66 -0
- package/src/selector-heal.js +85 -0
- package/src/selector.js +39 -0
- package/src/shoot.js +74 -0
- package/src/shot.js +18 -0
- package/src/sweep-schema.js +70 -0
- package/src/sweep.js +119 -0
- package/src/util.js +188 -0
- package/src/viewport.js +39 -0
|
@@ -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(``);
|
|
255
|
+
if (result.cleanScreenshot) lines.push(``);
|
|
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
|
+
}
|
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,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
|
+
}
|