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
package/src/overlays.js
ADDED
|
@@ -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(``);
|
|
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,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
|
+
}
|