ally-a11y 1.0.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/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility scanner using axe-core with Puppeteer or Playwright
|
|
3
|
+
*/
|
|
4
|
+
import puppeteer from 'puppeteer';
|
|
5
|
+
import { AxePuppeteer } from '@axe-core/puppeteer';
|
|
6
|
+
import axe from 'axe-core';
|
|
7
|
+
import { glob } from 'glob';
|
|
8
|
+
import { resolve, extname } from 'path';
|
|
9
|
+
import { stat } from 'fs/promises';
|
|
10
|
+
import { createBrowser, } from './browser.js';
|
|
11
|
+
const SUPPORTED_EXTENSIONS = ['.html', '.htm'];
|
|
12
|
+
const COMPONENT_EXTENSIONS = ['.jsx', '.tsx', '.vue', '.svelte'];
|
|
13
|
+
// Map standard names to axe-core tags
|
|
14
|
+
export const standardToTags = {
|
|
15
|
+
'wcag2a': ['wcag2a'],
|
|
16
|
+
'wcag2aa': ['wcag2a', 'wcag2aa'],
|
|
17
|
+
'wcag2aaa': ['wcag2a', 'wcag2aa', 'wcag2aaa'],
|
|
18
|
+
'wcag21a': ['wcag2a', 'wcag21a'],
|
|
19
|
+
'wcag21aa': ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
|
|
20
|
+
'wcag21aaa': ['wcag2a', 'wcag2aa', 'wcag2aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa'],
|
|
21
|
+
'wcag22aa': ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
|
|
22
|
+
'section508': ['section508'],
|
|
23
|
+
'best-practice': ['best-practice'],
|
|
24
|
+
};
|
|
25
|
+
// Default standard
|
|
26
|
+
export const DEFAULT_STANDARD = 'wcag22aa';
|
|
27
|
+
// SVG filter matrices for color blindness simulation
|
|
28
|
+
// These use feColorMatrix values adapted for CSS filter
|
|
29
|
+
const COLOR_BLINDNESS_FILTERS = {
|
|
30
|
+
// Protanopia (red-blind)
|
|
31
|
+
protanopia: `
|
|
32
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
33
|
+
<filter id="protanopia">
|
|
34
|
+
<feColorMatrix type="matrix" values="
|
|
35
|
+
0.567 0.433 0 0 0
|
|
36
|
+
0.558 0.442 0 0 0
|
|
37
|
+
0 0.242 0.758 0 0
|
|
38
|
+
0 0 0 1 0
|
|
39
|
+
"/>
|
|
40
|
+
</filter>
|
|
41
|
+
</svg>
|
|
42
|
+
`,
|
|
43
|
+
// Deuteranopia (green-blind)
|
|
44
|
+
deuteranopia: `
|
|
45
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
46
|
+
<filter id="deuteranopia">
|
|
47
|
+
<feColorMatrix type="matrix" values="
|
|
48
|
+
0.625 0.375 0 0 0
|
|
49
|
+
0.7 0.3 0 0 0
|
|
50
|
+
0 0.3 0.7 0 0
|
|
51
|
+
0 0 0 1 0
|
|
52
|
+
"/>
|
|
53
|
+
</filter>
|
|
54
|
+
</svg>
|
|
55
|
+
`,
|
|
56
|
+
// Tritanopia (blue-blind)
|
|
57
|
+
tritanopia: `
|
|
58
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
59
|
+
<filter id="tritanopia">
|
|
60
|
+
<feColorMatrix type="matrix" values="
|
|
61
|
+
0.95 0.05 0 0 0
|
|
62
|
+
0 0.433 0.567 0 0
|
|
63
|
+
0 0.475 0.525 0 0
|
|
64
|
+
0 0 0 1 0
|
|
65
|
+
"/>
|
|
66
|
+
</filter>
|
|
67
|
+
</svg>
|
|
68
|
+
`,
|
|
69
|
+
};
|
|
70
|
+
/** Default page load timeout in milliseconds */
|
|
71
|
+
export const DEFAULT_TIMEOUT = 30000;
|
|
72
|
+
/** Default batch size for parallel scanning */
|
|
73
|
+
export const DEFAULT_BATCH_SIZE = 4;
|
|
74
|
+
/**
|
|
75
|
+
* Split an array into chunks of a specified size
|
|
76
|
+
*/
|
|
77
|
+
function chunk(arr, size) {
|
|
78
|
+
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Run axe-core analysis on a Playwright page by injecting axe-core source
|
|
82
|
+
*/
|
|
83
|
+
async function runAxeOnPlaywrightPage(page, tags) {
|
|
84
|
+
// Get axe-core source code
|
|
85
|
+
const axeSource = axe.source;
|
|
86
|
+
// Get underlying page for direct evaluate access
|
|
87
|
+
const underlyingPage = page.getUnderlyingPage();
|
|
88
|
+
// Inject axe-core source
|
|
89
|
+
await underlyingPage.evaluate(axeSource);
|
|
90
|
+
// Run axe with tags - pass tags as a single argument object
|
|
91
|
+
const axeResults = await underlyingPage.evaluate((runTags) => {
|
|
92
|
+
const tagsArray = runTags;
|
|
93
|
+
return window.axe.run({
|
|
94
|
+
runOnly: {
|
|
95
|
+
type: 'tag',
|
|
96
|
+
values: tagsArray,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}, tags);
|
|
100
|
+
return axeResults;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Map raw axe violations to our Violation format
|
|
104
|
+
* Shared by both Puppeteer and Playwright code paths
|
|
105
|
+
* Handles axe-core Result type (which may have null impact)
|
|
106
|
+
*/
|
|
107
|
+
function mapAxeViolations(axeViolations) {
|
|
108
|
+
return axeViolations.map((v) => ({
|
|
109
|
+
id: v.id,
|
|
110
|
+
impact: (v.impact || 'minor'),
|
|
111
|
+
description: v.description,
|
|
112
|
+
help: v.help,
|
|
113
|
+
helpUrl: v.helpUrl,
|
|
114
|
+
tags: v.tags,
|
|
115
|
+
nodes: v.nodes.map((n) => ({
|
|
116
|
+
html: n.html,
|
|
117
|
+
target: n.target.map(t => typeof t === 'string' ? t : t.join(' ')),
|
|
118
|
+
failureSummary: n.failureSummary || '',
|
|
119
|
+
})),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Convert axe results to our Violation format
|
|
124
|
+
*/
|
|
125
|
+
function convertAxeResults(results) {
|
|
126
|
+
const violations = mapAxeViolations(results.violations);
|
|
127
|
+
return {
|
|
128
|
+
violations,
|
|
129
|
+
passes: results.passes.length,
|
|
130
|
+
incomplete: results.incomplete.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export class AccessibilityScanner {
|
|
134
|
+
browser = null;
|
|
135
|
+
browserAdapter = null;
|
|
136
|
+
timeout;
|
|
137
|
+
browserType;
|
|
138
|
+
usePlaywright;
|
|
139
|
+
constructor(timeout = DEFAULT_TIMEOUT, browserType = 'chromium') {
|
|
140
|
+
this.timeout = timeout;
|
|
141
|
+
this.browserType = browserType;
|
|
142
|
+
// Use Playwright for Firefox and WebKit, Puppeteer for Chromium (default)
|
|
143
|
+
this.usePlaywright = browserType !== 'chromium';
|
|
144
|
+
}
|
|
145
|
+
async init() {
|
|
146
|
+
if (this.usePlaywright) {
|
|
147
|
+
// Use browser abstraction for Playwright browsers
|
|
148
|
+
this.browserAdapter = createBrowser(this.browserType);
|
|
149
|
+
await this.browserAdapter.launch();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// Use Puppeteer directly for Chromium (faster, always available)
|
|
153
|
+
this.browser = await puppeteer.launch({
|
|
154
|
+
headless: true,
|
|
155
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async close() {
|
|
160
|
+
if (this.browserAdapter) {
|
|
161
|
+
await this.browserAdapter.close();
|
|
162
|
+
this.browserAdapter = null;
|
|
163
|
+
}
|
|
164
|
+
if (this.browser) {
|
|
165
|
+
await this.browser.close();
|
|
166
|
+
this.browser = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get the browser type being used
|
|
171
|
+
*/
|
|
172
|
+
getBrowserType() {
|
|
173
|
+
return this.browserType;
|
|
174
|
+
}
|
|
175
|
+
async scanHtmlFile(filePath, standard = DEFAULT_STANDARD) {
|
|
176
|
+
const absolutePath = resolve(filePath);
|
|
177
|
+
const fileUrl = `file://${absolutePath}`;
|
|
178
|
+
const tags = standardToTags[standard];
|
|
179
|
+
if (this.usePlaywright && this.browserAdapter) {
|
|
180
|
+
// Playwright path
|
|
181
|
+
const page = await this.browserAdapter.newPage();
|
|
182
|
+
try {
|
|
183
|
+
await page.goto(fileUrl, { waitUntil: 'load', timeout: this.timeout });
|
|
184
|
+
await page.waitForSelector('body');
|
|
185
|
+
const results = await runAxeOnPlaywrightPage(page, tags);
|
|
186
|
+
const { violations, passes, incomplete } = convertAxeResults(results);
|
|
187
|
+
return {
|
|
188
|
+
url: fileUrl,
|
|
189
|
+
file: filePath,
|
|
190
|
+
timestamp: new Date().toISOString(),
|
|
191
|
+
violations,
|
|
192
|
+
passes,
|
|
193
|
+
incomplete,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await page.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (this.browser) {
|
|
201
|
+
// Puppeteer path
|
|
202
|
+
const page = await this.browser.newPage();
|
|
203
|
+
try {
|
|
204
|
+
await page.goto(fileUrl, { waitUntil: 'load', timeout: this.timeout });
|
|
205
|
+
await page.waitForSelector('body');
|
|
206
|
+
const results = await new AxePuppeteer(page)
|
|
207
|
+
.withTags(tags)
|
|
208
|
+
.analyze();
|
|
209
|
+
const violations = mapAxeViolations(results.violations);
|
|
210
|
+
return {
|
|
211
|
+
url: fileUrl,
|
|
212
|
+
file: filePath,
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
violations,
|
|
215
|
+
passes: results.passes.length,
|
|
216
|
+
incomplete: results.incomplete.length,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await page.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
throw new Error('Scanner not initialized. Call init() first.');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async scanUrl(url, standard = DEFAULT_STANDARD) {
|
|
228
|
+
const tags = standardToTags[standard];
|
|
229
|
+
if (this.usePlaywright && this.browserAdapter) {
|
|
230
|
+
// Playwright path
|
|
231
|
+
const page = await this.browserAdapter.newPage();
|
|
232
|
+
try {
|
|
233
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
|
|
234
|
+
const results = await runAxeOnPlaywrightPage(page, tags);
|
|
235
|
+
const { violations, passes, incomplete } = convertAxeResults(results);
|
|
236
|
+
return {
|
|
237
|
+
url,
|
|
238
|
+
timestamp: new Date().toISOString(),
|
|
239
|
+
violations,
|
|
240
|
+
passes,
|
|
241
|
+
incomplete,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await page.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
else if (this.browser) {
|
|
249
|
+
// Puppeteer path
|
|
250
|
+
const page = await this.browser.newPage();
|
|
251
|
+
try {
|
|
252
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
|
|
253
|
+
const results = await new AxePuppeteer(page)
|
|
254
|
+
.withTags(tags)
|
|
255
|
+
.analyze();
|
|
256
|
+
const violations = mapAxeViolations(results.violations);
|
|
257
|
+
return {
|
|
258
|
+
url,
|
|
259
|
+
timestamp: new Date().toISOString(),
|
|
260
|
+
violations,
|
|
261
|
+
passes: results.passes.length,
|
|
262
|
+
incomplete: results.incomplete.length,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await page.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
throw new Error('Scanner not initialized. Call init() first.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async scanHtmlString(html, identifier = 'inline', standard = DEFAULT_STANDARD) {
|
|
274
|
+
const tags = standardToTags[standard];
|
|
275
|
+
if (this.usePlaywright && this.browserAdapter) {
|
|
276
|
+
// Playwright path
|
|
277
|
+
const page = await this.browserAdapter.newPage();
|
|
278
|
+
try {
|
|
279
|
+
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
|
280
|
+
const results = await runAxeOnPlaywrightPage(page, tags);
|
|
281
|
+
const { violations, passes, incomplete } = convertAxeResults(results);
|
|
282
|
+
return {
|
|
283
|
+
url: identifier,
|
|
284
|
+
file: identifier,
|
|
285
|
+
timestamp: new Date().toISOString(),
|
|
286
|
+
violations,
|
|
287
|
+
passes,
|
|
288
|
+
incomplete,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
await page.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (this.browser) {
|
|
296
|
+
// Puppeteer path
|
|
297
|
+
const page = await this.browser.newPage();
|
|
298
|
+
try {
|
|
299
|
+
await page.setContent(html, { waitUntil: 'domcontentloaded' });
|
|
300
|
+
const results = await new AxePuppeteer(page)
|
|
301
|
+
.withTags(tags)
|
|
302
|
+
.analyze();
|
|
303
|
+
const violations = mapAxeViolations(results.violations);
|
|
304
|
+
return {
|
|
305
|
+
url: identifier,
|
|
306
|
+
file: identifier,
|
|
307
|
+
timestamp: new Date().toISOString(),
|
|
308
|
+
violations,
|
|
309
|
+
passes: results.passes.length,
|
|
310
|
+
incomplete: results.incomplete.length,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await page.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
throw new Error('Scanner not initialized. Call init() first.');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Scan multiple HTML files in parallel batches for improved performance
|
|
323
|
+
* @param files Array of file paths to scan
|
|
324
|
+
* @param standard WCAG standard to use for scanning
|
|
325
|
+
* @param batchSize Number of files to scan concurrently (default: 4)
|
|
326
|
+
* @param onProgress Optional callback for progress updates
|
|
327
|
+
* @returns Array of scan results (successful) and errors (failed)
|
|
328
|
+
*/
|
|
329
|
+
async scanHtmlFilesParallel(files, standard = DEFAULT_STANDARD, batchSize = DEFAULT_BATCH_SIZE, onProgress) {
|
|
330
|
+
if (!this.browser && !this.browserAdapter) {
|
|
331
|
+
throw new Error('Scanner not initialized. Call init() first.');
|
|
332
|
+
}
|
|
333
|
+
const results = [];
|
|
334
|
+
const errors = [];
|
|
335
|
+
const batches = chunk(files, batchSize);
|
|
336
|
+
let completedCount = 0;
|
|
337
|
+
for (const batch of batches) {
|
|
338
|
+
// Scan files in this batch concurrently
|
|
339
|
+
const batchPromises = batch.map(async (file) => {
|
|
340
|
+
try {
|
|
341
|
+
const result = await this.scanHtmlFile(file, standard);
|
|
342
|
+
return { result };
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
error: {
|
|
347
|
+
path: file,
|
|
348
|
+
error: error instanceof Error ? error.message : String(error),
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Wait for all files in the batch to complete
|
|
354
|
+
const batchResults = await Promise.all(batchPromises);
|
|
355
|
+
// Aggregate results and errors
|
|
356
|
+
for (const item of batchResults) {
|
|
357
|
+
if (item.result) {
|
|
358
|
+
results.push(item.result);
|
|
359
|
+
}
|
|
360
|
+
else if (item.error) {
|
|
361
|
+
errors.push(item.error);
|
|
362
|
+
}
|
|
363
|
+
completedCount++;
|
|
364
|
+
// Report progress after each file completes
|
|
365
|
+
if (onProgress) {
|
|
366
|
+
onProgress(completedCount, files.length, item.result?.file || item.error?.path);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { results, errors };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Extract all anchor links from a URL
|
|
374
|
+
* This method provides type-safe access to browser pages for link extraction
|
|
375
|
+
*/
|
|
376
|
+
async extractLinks(url) {
|
|
377
|
+
if (this.usePlaywright && this.browserAdapter) {
|
|
378
|
+
const page = await this.browserAdapter.newPage();
|
|
379
|
+
try {
|
|
380
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
|
|
381
|
+
const underlyingPage = page.getUnderlyingPage();
|
|
382
|
+
return await underlyingPage.evaluate(() => {
|
|
383
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
384
|
+
.map(a => a.href)
|
|
385
|
+
.filter(href => href && !href.startsWith('javascript:'));
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
await page.close();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else if (this.browser) {
|
|
393
|
+
const page = await this.browser.newPage();
|
|
394
|
+
try {
|
|
395
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
|
|
396
|
+
return await page.evaluate(() => {
|
|
397
|
+
return Array.from(document.querySelectorAll('a[href]'))
|
|
398
|
+
.map(a => a.href)
|
|
399
|
+
.filter(href => href && !href.startsWith('javascript:'));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await page.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Take a screenshot of a URL with color blindness simulation applied
|
|
410
|
+
*/
|
|
411
|
+
async simulateColorBlindness(url, type, outputPath) {
|
|
412
|
+
if (this.usePlaywright && this.browserAdapter) {
|
|
413
|
+
// Playwright path
|
|
414
|
+
const page = await this.browserAdapter.newPage();
|
|
415
|
+
try {
|
|
416
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
417
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: this.timeout });
|
|
418
|
+
const svgFilter = COLOR_BLINDNESS_FILTERS[type];
|
|
419
|
+
await page.addStyleTag({
|
|
420
|
+
content: `
|
|
421
|
+
html::before {
|
|
422
|
+
content: '';
|
|
423
|
+
position: fixed;
|
|
424
|
+
top: 0;
|
|
425
|
+
left: 0;
|
|
426
|
+
width: 0;
|
|
427
|
+
height: 0;
|
|
428
|
+
background-image: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}');
|
|
429
|
+
}
|
|
430
|
+
html {
|
|
431
|
+
filter: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}#${type}');
|
|
432
|
+
}
|
|
433
|
+
`,
|
|
434
|
+
});
|
|
435
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
436
|
+
await page.screenshot({ path: outputPath, fullPage: true });
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
await page.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (this.browser) {
|
|
443
|
+
// Puppeteer path
|
|
444
|
+
const page = await this.browser.newPage();
|
|
445
|
+
try {
|
|
446
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
447
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: this.timeout });
|
|
448
|
+
const svgFilter = COLOR_BLINDNESS_FILTERS[type];
|
|
449
|
+
await page.addStyleTag({
|
|
450
|
+
content: `
|
|
451
|
+
html::before {
|
|
452
|
+
content: '';
|
|
453
|
+
position: fixed;
|
|
454
|
+
top: 0;
|
|
455
|
+
left: 0;
|
|
456
|
+
width: 0;
|
|
457
|
+
height: 0;
|
|
458
|
+
background-image: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}');
|
|
459
|
+
}
|
|
460
|
+
html {
|
|
461
|
+
filter: url('data:image/svg+xml,${encodeURIComponent(svgFilter.trim())}#${type}');
|
|
462
|
+
}
|
|
463
|
+
`,
|
|
464
|
+
});
|
|
465
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
466
|
+
await page.screenshot({ path: outputPath, fullPage: true });
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
await page.close();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
throw new Error('Scanner not initialized. Call init() first.');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Default ignore patterns applied to all scans */
|
|
478
|
+
const DEFAULT_IGNORE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/.git/**'];
|
|
479
|
+
export async function findHtmlFiles(targetPath, extraIgnore = []) {
|
|
480
|
+
// Check if targetPath is a file (not a directory)
|
|
481
|
+
try {
|
|
482
|
+
const stats = await stat(targetPath);
|
|
483
|
+
if (stats.isFile()) {
|
|
484
|
+
// If it's a file with a supported extension, return it directly
|
|
485
|
+
const ext = extname(targetPath).toLowerCase();
|
|
486
|
+
if (SUPPORTED_EXTENSIONS.includes(ext)) {
|
|
487
|
+
return [resolve(targetPath)];
|
|
488
|
+
}
|
|
489
|
+
// File exists but not a supported extension
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Path doesn't exist or can't be accessed, fall through to glob
|
|
495
|
+
}
|
|
496
|
+
// It's a directory, use glob to find files
|
|
497
|
+
const patterns = SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
498
|
+
const files = await glob(patterns, {
|
|
499
|
+
cwd: targetPath,
|
|
500
|
+
ignore: [...DEFAULT_IGNORE, ...extraIgnore],
|
|
501
|
+
absolute: true,
|
|
502
|
+
});
|
|
503
|
+
return files;
|
|
504
|
+
}
|
|
505
|
+
export async function findComponentFiles(targetPath, extraIgnore = []) {
|
|
506
|
+
// Check if targetPath is a file (not a directory)
|
|
507
|
+
try {
|
|
508
|
+
const stats = await stat(targetPath);
|
|
509
|
+
if (stats.isFile()) {
|
|
510
|
+
// If it's a file with a supported extension, return it directly
|
|
511
|
+
const ext = extname(targetPath).toLowerCase();
|
|
512
|
+
if (COMPONENT_EXTENSIONS.includes(ext)) {
|
|
513
|
+
return [resolve(targetPath)];
|
|
514
|
+
}
|
|
515
|
+
// File exists but not a supported extension
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// Path doesn't exist or can't be accessed, fall through to glob
|
|
521
|
+
}
|
|
522
|
+
// It's a directory, use glob to find files
|
|
523
|
+
const patterns = COMPONENT_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
524
|
+
const files = await glob(patterns, {
|
|
525
|
+
cwd: targetPath,
|
|
526
|
+
ignore: [...DEFAULT_IGNORE, ...extraIgnore],
|
|
527
|
+
absolute: true,
|
|
528
|
+
});
|
|
529
|
+
return files;
|
|
530
|
+
}
|
|
531
|
+
export function calculateScore(results) {
|
|
532
|
+
if (results.length === 0)
|
|
533
|
+
return 100;
|
|
534
|
+
const weights = {
|
|
535
|
+
critical: 25,
|
|
536
|
+
serious: 15,
|
|
537
|
+
moderate: 5,
|
|
538
|
+
minor: 1,
|
|
539
|
+
};
|
|
540
|
+
let totalPenalty = 0;
|
|
541
|
+
let totalPasses = 0;
|
|
542
|
+
for (const result of results) {
|
|
543
|
+
totalPasses += result.passes;
|
|
544
|
+
for (const violation of result.violations) {
|
|
545
|
+
const weight = weights[violation.impact] || 1;
|
|
546
|
+
const nodeCount = Math.min(violation.nodes.length, 10); // Cap at 10 to avoid extreme penalties
|
|
547
|
+
totalPenalty += weight * nodeCount;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Score starts at 100 and decreases based on violations
|
|
551
|
+
// Maximum penalty is capped at 100
|
|
552
|
+
const score = Math.max(0, 100 - Math.min(totalPenalty, 100));
|
|
553
|
+
return Math.round(score);
|
|
554
|
+
}
|
|
555
|
+
export function generateSummary(results) {
|
|
556
|
+
const bySeverity = {
|
|
557
|
+
critical: 0,
|
|
558
|
+
serious: 0,
|
|
559
|
+
moderate: 0,
|
|
560
|
+
minor: 0,
|
|
561
|
+
};
|
|
562
|
+
const issueCount = new Map();
|
|
563
|
+
let totalViolations = 0;
|
|
564
|
+
for (const result of results) {
|
|
565
|
+
for (const violation of result.violations) {
|
|
566
|
+
totalViolations++;
|
|
567
|
+
bySeverity[violation.impact] = (bySeverity[violation.impact] || 0) + 1;
|
|
568
|
+
const existing = issueCount.get(violation.id);
|
|
569
|
+
if (existing) {
|
|
570
|
+
existing.count++;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
issueCount.set(violation.id, {
|
|
574
|
+
count: 1,
|
|
575
|
+
description: violation.help,
|
|
576
|
+
severity: violation.impact,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Sort by count and take top 5
|
|
582
|
+
const topIssues = Array.from(issueCount.entries())
|
|
583
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
584
|
+
.slice(0, 5)
|
|
585
|
+
.map(([id, data]) => ({
|
|
586
|
+
id,
|
|
587
|
+
count: data.count,
|
|
588
|
+
description: data.description,
|
|
589
|
+
severity: data.severity,
|
|
590
|
+
}));
|
|
591
|
+
return {
|
|
592
|
+
totalViolations,
|
|
593
|
+
bySeverity,
|
|
594
|
+
score: calculateScore(results),
|
|
595
|
+
topIssues,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
export function createReport(results) {
|
|
599
|
+
return {
|
|
600
|
+
version: '1.0.0',
|
|
601
|
+
scanDate: new Date().toISOString(),
|
|
602
|
+
totalFiles: results.length,
|
|
603
|
+
results,
|
|
604
|
+
summary: generateSummary(results),
|
|
605
|
+
};
|
|
606
|
+
}
|