pursr 0.6.0 → 0.7.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 +20 -20
- package/README.md +9 -9
- package/assets/icon.svg +20 -20
- package/assets/logo.svg +28 -28
- package/assets/social-preview.svg +76 -76
- package/bin/pursr-mcp.mjs +10 -9
- package/bin/pursr.mjs +11 -11
- package/package.json +4 -4
- package/plans/m5.4-polish.json +21 -21
- package/plugins/plugin-audit.js +57 -57
- package/plugins/plugin-demo.js +63 -63
- package/src/ai-diff.js +7 -6
- package/src/auth.js +92 -91
- package/src/baseline.js +126 -125
- package/src/ci-output.js +156 -156
- package/src/dom-snapshot.js +192 -192
- package/src/eval.js +17 -17
- package/src/every-viewport.js +51 -51
- package/src/frames.js +33 -33
- package/src/har.js +158 -158
- package/src/hover.js +25 -25
- package/src/index.js +6 -6
- package/src/interact.js +137 -137
- package/src/mcp-resources.js +111 -110
- package/src/mcp.js +436 -435
- package/src/overlays.js +169 -169
- package/src/plugin-audit.js +260 -260
- package/src/plugin.js +120 -120
- package/src/probe.js +19 -19
- package/src/report.js +175 -175
- package/src/runway.js +65 -65
- package/src/selector-heal.js +85 -85
- package/src/selector.js +38 -38
- package/src/shoot.js +73 -73
- package/src/shot.js +17 -17
- package/src/snap.js +128 -128
- package/src/sweep-schema.js +69 -69
- package/src/sweep.js +1 -1
- package/src/util.js +204 -188
- package/src/viewport.js +38 -38
- package/src/watch.js +134 -134
package/src/plugin-audit.js
CHANGED
|
@@ -1,260 +1,260 @@
|
|
|
1
|
-
//
|
|
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
|
|
259
|
-
return lines.join('\n');
|
|
260
|
-
}
|
|
1
|
+
// pursr — 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 pursr audit_`);
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
}
|