@govtechsg/oobee 0.10.83 → 0.10.84
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/README.md +6 -1
- package/dist/constants/common.js +13 -1
- package/dist/crawlers/crawlDomain.js +220 -120
- package/dist/crawlers/crawlIntelligentSitemap.js +22 -7
- package/dist/crawlers/runCustom.js +8 -2
- package/dist/mergeAxeResults/itemReferences.js +55 -0
- package/dist/mergeAxeResults/jsonArtifacts.js +335 -0
- package/dist/mergeAxeResults/scanPages.js +159 -0
- package/dist/mergeAxeResults/sentryTelemetry.js +152 -0
- package/dist/mergeAxeResults/types.js +1 -0
- package/dist/mergeAxeResults/writeCsv.js +125 -0
- package/dist/mergeAxeResults/writeScanDetailsCsv.js +35 -0
- package/dist/mergeAxeResults/writeSitemap.js +10 -0
- package/dist/mergeAxeResults.js +24 -929
- package/dist/proxyService.js +90 -5
- package/dist/utils.js +20 -7
- package/package.json +6 -6
- package/src/constants/common.ts +13 -1
- package/src/crawlers/crawlDomain.ts +248 -137
- package/src/crawlers/crawlIntelligentSitemap.ts +22 -8
- package/src/crawlers/runCustom.ts +10 -2
- package/src/mergeAxeResults/itemReferences.ts +62 -0
- package/src/mergeAxeResults/jsonArtifacts.ts +451 -0
- package/src/mergeAxeResults/scanPages.ts +207 -0
- package/src/mergeAxeResults/sentryTelemetry.ts +183 -0
- package/src/mergeAxeResults/types.ts +99 -0
- package/src/mergeAxeResults/writeCsv.ts +145 -0
- package/src/mergeAxeResults/writeScanDetailsCsv.ts +51 -0
- package/src/mergeAxeResults/writeSitemap.ts +13 -0
- package/src/mergeAxeResults.ts +82 -1318
- package/src/proxyService.ts +96 -4
- package/src/utils.ts +19 -7
package/dist/mergeAxeResults.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
+
/* eslint-disable consistent-return */
|
|
2
|
+
/* eslint-disable no-console */
|
|
1
3
|
import fs, { ensureDirSync } from 'fs-extra';
|
|
2
4
|
import printMessage from 'print-message';
|
|
3
5
|
import path from 'path';
|
|
4
6
|
import ejs from 'ejs';
|
|
5
7
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import {
|
|
7
|
-
import { AsyncParser } from '@json2csv/node';
|
|
8
|
-
import zlib from 'zlib';
|
|
9
|
-
import { Base64Encode } from 'base64-stream';
|
|
10
|
-
import { pipeline } from 'stream/promises';
|
|
11
|
-
import * as Sentry from '@sentry/node';
|
|
12
|
-
import constants, { BrowserTypes, ScannerTypes, sentryConfig, setSentryUser, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
|
|
8
|
+
import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
|
|
13
9
|
import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
|
|
14
|
-
import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage,
|
|
10
|
+
import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage, register, } from './utils.js';
|
|
15
11
|
import { consoleLogger } from './logs.js';
|
|
16
12
|
import itemTypeDescription from './constants/itemTypeDescription.js';
|
|
17
13
|
import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js';
|
|
14
|
+
import { buildHtmlGroups, convertItemsToReferences } from './mergeAxeResults/itemReferences.js';
|
|
15
|
+
import { compressJsonFileStreaming, writeJsonAndBase64Files, writeJsonFileAndCompressedJsonFile, } from './mergeAxeResults/jsonArtifacts.js';
|
|
16
|
+
import writeCsv from './mergeAxeResults/writeCsv.js';
|
|
17
|
+
import writeScanDetailsCsv from './mergeAxeResults/writeScanDetailsCsv.js';
|
|
18
|
+
import writeSitemap from './mergeAxeResults/writeSitemap.js';
|
|
19
|
+
import populateScanPagesDetail from './mergeAxeResults/scanPages.js';
|
|
20
|
+
import sendWcagBreakdownToSentry from './mergeAxeResults/sentryTelemetry.js';
|
|
18
21
|
const filename = fileURLToPath(import.meta.url);
|
|
19
22
|
const dirname = path.dirname(filename);
|
|
20
23
|
const BUFFER_LIMIT = 100 * 1024 * 1024; // 100MB size
|
|
@@ -64,126 +67,6 @@ const parseContentToJson = async (rPath) => {
|
|
|
64
67
|
return undefined;
|
|
65
68
|
}
|
|
66
69
|
};
|
|
67
|
-
const writeCsv = async (allIssues, storagePath) => {
|
|
68
|
-
const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
|
|
69
|
-
const formatPageViolation = pageNum => {
|
|
70
|
-
if (pageNum < 0)
|
|
71
|
-
return 'Document';
|
|
72
|
-
return `Page ${pageNum}`;
|
|
73
|
-
};
|
|
74
|
-
// transform allIssues into the form:
|
|
75
|
-
// [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
|
|
76
|
-
const getRulesByCategory = (allIssues) => {
|
|
77
|
-
return Object.entries(allIssues.items)
|
|
78
|
-
.filter(([category]) => category !== 'passed')
|
|
79
|
-
.reduce((prev, [category, value]) => {
|
|
80
|
-
const rulesEntries = Object.entries(value.rules);
|
|
81
|
-
rulesEntries.forEach(([, ruleInfo]) => {
|
|
82
|
-
prev.push([category, ruleInfo]);
|
|
83
|
-
});
|
|
84
|
-
return prev;
|
|
85
|
-
}, [])
|
|
86
|
-
.sort((a, b) => {
|
|
87
|
-
// sort rules according to severity, then ruleId
|
|
88
|
-
const compareCategory = -a[0].localeCompare(b[0]);
|
|
89
|
-
return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
|
|
90
|
-
});
|
|
91
|
-
};
|
|
92
|
-
const flattenRule = catAndRule => {
|
|
93
|
-
const [severity, rule] = catAndRule;
|
|
94
|
-
const results = [];
|
|
95
|
-
const { rule: issueId, description: issueDescription, axeImpact, conformance, pagesAffected, helpUrl: learnMore, } = rule;
|
|
96
|
-
// format clauses as a string
|
|
97
|
-
const wcagConformance = conformance.join(',');
|
|
98
|
-
pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
|
|
99
|
-
pagesAffected.forEach(affectedPage => {
|
|
100
|
-
const { url, items } = affectedPage;
|
|
101
|
-
items.forEach(item => {
|
|
102
|
-
const { html, page, message, xpath } = item;
|
|
103
|
-
const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
|
|
104
|
-
const violation = html || formatPageViolation(page); // page is a number, not a string
|
|
105
|
-
const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
|
|
106
|
-
results.push({
|
|
107
|
-
customFlowLabel: allIssues.customFlowLabel || '',
|
|
108
|
-
deviceChosen: allIssues.deviceChosen || '',
|
|
109
|
-
scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
|
|
110
|
-
severity: severity || '',
|
|
111
|
-
issueId: issueId || '',
|
|
112
|
-
issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
|
|
113
|
-
wcagConformance: wcagConformance || '',
|
|
114
|
-
url: url || '',
|
|
115
|
-
pageTitle: affectedPage.pageTitle || 'No page title',
|
|
116
|
-
context: context || '',
|
|
117
|
-
howToFix: howToFix || '',
|
|
118
|
-
axeImpact: axeImpact || '',
|
|
119
|
-
xpath: xpath || '',
|
|
120
|
-
learnMore: learnMore || '',
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
if (results.length === 0)
|
|
125
|
-
return {};
|
|
126
|
-
return results;
|
|
127
|
-
};
|
|
128
|
-
const opts = {
|
|
129
|
-
transforms: [getRulesByCategory, flattenRule],
|
|
130
|
-
fields: [
|
|
131
|
-
'customFlowLabel',
|
|
132
|
-
'deviceChosen',
|
|
133
|
-
'scanCompletedAt',
|
|
134
|
-
'severity',
|
|
135
|
-
'issueId',
|
|
136
|
-
'issueDescription',
|
|
137
|
-
'wcagConformance',
|
|
138
|
-
'url',
|
|
139
|
-
'pageTitle',
|
|
140
|
-
'context',
|
|
141
|
-
'howToFix',
|
|
142
|
-
'axeImpact',
|
|
143
|
-
'xpath',
|
|
144
|
-
'learnMore',
|
|
145
|
-
],
|
|
146
|
-
includeEmptyRows: true,
|
|
147
|
-
};
|
|
148
|
-
// Create the parse stream (it's asynchronous)
|
|
149
|
-
const parser = new AsyncParser(opts);
|
|
150
|
-
const parseStream = parser.parse(allIssues);
|
|
151
|
-
// Pipe JSON2CSV output into the file, but don't end automatically
|
|
152
|
-
parseStream.pipe(csvOutput, { end: false });
|
|
153
|
-
// Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
|
|
154
|
-
parseStream.on('end', () => {
|
|
155
|
-
if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
|
|
156
|
-
csvOutput.write('\n');
|
|
157
|
-
allIssues.pagesNotScanned.forEach(page => {
|
|
158
|
-
const skippedPage = {
|
|
159
|
-
customFlowLabel: allIssues.customFlowLabel || '',
|
|
160
|
-
deviceChosen: allIssues.deviceChosen || '',
|
|
161
|
-
scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
|
|
162
|
-
severity: 'error',
|
|
163
|
-
issueId: 'error-pages-skipped',
|
|
164
|
-
issueDescription: page.metadata
|
|
165
|
-
? page.metadata
|
|
166
|
-
: 'An unknown error caused the page to be skipped',
|
|
167
|
-
wcagConformance: '',
|
|
168
|
-
url: page.url || page || '',
|
|
169
|
-
pageTitle: 'Error',
|
|
170
|
-
context: '',
|
|
171
|
-
howToFix: '',
|
|
172
|
-
axeImpact: '',
|
|
173
|
-
xpath: '',
|
|
174
|
-
learnMore: '',
|
|
175
|
-
};
|
|
176
|
-
csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
// Now close the CSV file
|
|
180
|
-
csvOutput.end();
|
|
181
|
-
});
|
|
182
|
-
parseStream.on('error', err => {
|
|
183
|
-
console.error('Error parsing CSV:', err);
|
|
184
|
-
csvOutput.end();
|
|
185
|
-
});
|
|
186
|
-
};
|
|
187
70
|
const compileHtmlWithEJS = async (allIssues, storagePath, htmlFilename = 'report') => {
|
|
188
71
|
const htmlFilePath = `${path.join(storagePath, htmlFilename)}.html`;
|
|
189
72
|
const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/report.ejs'), 'utf-8');
|
|
@@ -239,7 +122,7 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
|
|
|
239
122
|
// Create lighter version with item references for embedding in HTML
|
|
240
123
|
const scanItemsWithHtmlGroupRefs = convertItemsToReferences(allIssues);
|
|
241
124
|
// Write the lighter items to a file and get the base64 path
|
|
242
|
-
const { jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath, base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath } = await writeJsonFileAndCompressedJsonFile(scanItemsWithHtmlGroupRefs.items, storagePath, 'scanItems-light');
|
|
125
|
+
const { jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath, base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath, } = await writeJsonFileAndCompressedJsonFile(scanItemsWithHtmlGroupRefs.items, storagePath, 'scanItems-light');
|
|
243
126
|
return new Promise((resolve, reject) => {
|
|
244
127
|
const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
|
|
245
128
|
encoding: 'utf8',
|
|
@@ -262,7 +145,7 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
|
|
|
262
145
|
};
|
|
263
146
|
outputStream.write(prefixData);
|
|
264
147
|
// For Proxied AI environments only
|
|
265
|
-
outputStream.write(`let proxyUrl = "${process.env.PROXY_API_BASE_URL ||
|
|
148
|
+
outputStream.write(`let proxyUrl = "${process.env.PROXY_API_BASE_URL || ''}"\n`);
|
|
266
149
|
// Initialize GenAI feature flag
|
|
267
150
|
outputStream.write(`
|
|
268
151
|
// Fetch GenAI feature flag from backend
|
|
@@ -290,12 +173,12 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
|
|
|
290
173
|
console.warn('Skipping fetch GenAI feature as it is local report');
|
|
291
174
|
}
|
|
292
175
|
\n`);
|
|
293
|
-
outputStream.write(
|
|
176
|
+
outputStream.write('</script>\n<script type="text/plain" id="scanDataRaw">');
|
|
294
177
|
scanDetailsReadStream.pipe(outputStream, { end: false });
|
|
295
178
|
scanDetailsReadStream.on('end', async () => {
|
|
296
|
-
outputStream.write(
|
|
179
|
+
outputStream.write('</script>\n<script>\n');
|
|
297
180
|
outputStream.write("var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n");
|
|
298
|
-
outputStream.write(
|
|
181
|
+
outputStream.write('</script>\n');
|
|
299
182
|
// Write scanItems in 2MB chunks using a stream to avoid loading entire file into memory
|
|
300
183
|
try {
|
|
301
184
|
let chunkIndex = 1;
|
|
@@ -307,7 +190,7 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
|
|
|
307
190
|
outputStream.write(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
|
|
308
191
|
chunkIndex++;
|
|
309
192
|
}
|
|
310
|
-
outputStream.write(
|
|
193
|
+
outputStream.write('<script>\n');
|
|
311
194
|
outputStream.write(`
|
|
312
195
|
var scanItemsPromise = (async () => {
|
|
313
196
|
console.log('Loading scanItems...');
|
|
@@ -356,12 +239,6 @@ const writeSummaryHTML = async (allIssues, storagePath, htmlFilename = 'summary'
|
|
|
356
239
|
const html = template(allIssues);
|
|
357
240
|
fs.writeFileSync(`${storagePath}/${htmlFilename}.html`, html);
|
|
358
241
|
};
|
|
359
|
-
const writeSitemap = async (pagesScanned, storagePath) => {
|
|
360
|
-
const sitemapPath = path.join(storagePath, 'sitemap.txt');
|
|
361
|
-
const content = pagesScanned.map(p => p.url).join('\n');
|
|
362
|
-
await fs.writeFile(sitemapPath, content, { encoding: 'utf-8' });
|
|
363
|
-
consoleLogger.info(`Sitemap written to ${sitemapPath}`);
|
|
364
|
-
};
|
|
365
242
|
const cleanUpJsonFiles = async (filesToDelete) => {
|
|
366
243
|
consoleLogger.info('Cleaning up JSON files...');
|
|
367
244
|
filesToDelete.forEach(file => {
|
|
@@ -369,379 +246,6 @@ const cleanUpJsonFiles = async (filesToDelete) => {
|
|
|
369
246
|
consoleLogger.info(`Deleted ${file}`);
|
|
370
247
|
});
|
|
371
248
|
};
|
|
372
|
-
function* serializeObject(obj, depth = 0, indent = ' ') {
|
|
373
|
-
const currentIndent = indent.repeat(depth);
|
|
374
|
-
const nextIndent = indent.repeat(depth + 1);
|
|
375
|
-
if (obj instanceof Date) {
|
|
376
|
-
yield JSON.stringify(obj.toISOString());
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
if (Array.isArray(obj)) {
|
|
380
|
-
yield '[\n';
|
|
381
|
-
for (let i = 0; i < obj.length; i++) {
|
|
382
|
-
if (i > 0)
|
|
383
|
-
yield ',\n';
|
|
384
|
-
yield nextIndent;
|
|
385
|
-
yield* serializeObject(obj[i], depth + 1, indent);
|
|
386
|
-
}
|
|
387
|
-
yield `\n${currentIndent}]`;
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (obj !== null && typeof obj === 'object') {
|
|
391
|
-
yield '{\n';
|
|
392
|
-
const keys = Object.keys(obj);
|
|
393
|
-
for (let i = 0; i < keys.length; i++) {
|
|
394
|
-
const key = keys[i];
|
|
395
|
-
if (i > 0)
|
|
396
|
-
yield ',\n';
|
|
397
|
-
yield `${nextIndent}${JSON.stringify(key)}: `;
|
|
398
|
-
yield* serializeObject(obj[key], depth + 1, indent);
|
|
399
|
-
}
|
|
400
|
-
yield `\n${currentIndent}}`;
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
if (obj === null || typeof obj === 'function' || typeof obj === 'undefined') {
|
|
404
|
-
yield 'null';
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
yield JSON.stringify(obj);
|
|
408
|
-
}
|
|
409
|
-
function writeLargeJsonToFile(obj, filePath) {
|
|
410
|
-
return new Promise((resolve, reject) => {
|
|
411
|
-
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
412
|
-
writeStream.on('error', error => {
|
|
413
|
-
consoleLogger.error('Stream error:', error);
|
|
414
|
-
reject(error);
|
|
415
|
-
});
|
|
416
|
-
writeStream.on('finish', () => {
|
|
417
|
-
consoleLogger.info(`JSON file written successfully: ${filePath}`);
|
|
418
|
-
resolve(true);
|
|
419
|
-
});
|
|
420
|
-
const generator = serializeObject(obj);
|
|
421
|
-
function write() {
|
|
422
|
-
let next;
|
|
423
|
-
while (!(next = generator.next()).done) {
|
|
424
|
-
if (!writeStream.write(next.value)) {
|
|
425
|
-
writeStream.once('drain', write);
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
writeStream.end();
|
|
430
|
-
}
|
|
431
|
-
write();
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
const writeLargeScanItemsJsonToFile = async (obj, filePath) => {
|
|
435
|
-
return new Promise((resolve, reject) => {
|
|
436
|
-
const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
|
|
437
|
-
const writeQueue = [];
|
|
438
|
-
let isWriting = false;
|
|
439
|
-
const processNextWrite = async () => {
|
|
440
|
-
if (isWriting || writeQueue.length === 0)
|
|
441
|
-
return;
|
|
442
|
-
isWriting = true;
|
|
443
|
-
const data = writeQueue.shift();
|
|
444
|
-
try {
|
|
445
|
-
if (!writeStream.write(data)) {
|
|
446
|
-
await new Promise(resolve => {
|
|
447
|
-
writeStream.once('drain', () => {
|
|
448
|
-
resolve();
|
|
449
|
-
});
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
catch (error) {
|
|
454
|
-
writeStream.destroy(error);
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
isWriting = false;
|
|
458
|
-
processNextWrite();
|
|
459
|
-
};
|
|
460
|
-
const queueWrite = (data) => {
|
|
461
|
-
writeQueue.push(data);
|
|
462
|
-
processNextWrite();
|
|
463
|
-
};
|
|
464
|
-
writeStream.on('error', error => {
|
|
465
|
-
consoleLogger.error(`Error writing object to JSON file: ${error}`);
|
|
466
|
-
reject(error);
|
|
467
|
-
});
|
|
468
|
-
writeStream.on('finish', () => {
|
|
469
|
-
consoleLogger.info(`JSON file written successfully: ${filePath}`);
|
|
470
|
-
resolve(true);
|
|
471
|
-
});
|
|
472
|
-
try {
|
|
473
|
-
queueWrite('{\n');
|
|
474
|
-
const keys = Object.keys(obj);
|
|
475
|
-
keys.forEach((key, i) => {
|
|
476
|
-
const value = obj[key];
|
|
477
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
478
|
-
queueWrite(` "${key}": ${JSON.stringify(value)}`);
|
|
479
|
-
}
|
|
480
|
-
else {
|
|
481
|
-
queueWrite(` "${key}": {\n`);
|
|
482
|
-
const { rules, ...otherProperties } = value;
|
|
483
|
-
// Write other properties
|
|
484
|
-
Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
|
|
485
|
-
const propValueString = propValue === null ||
|
|
486
|
-
typeof propValue === 'function' ||
|
|
487
|
-
typeof propValue === 'undefined'
|
|
488
|
-
? 'null'
|
|
489
|
-
: JSON.stringify(propValue);
|
|
490
|
-
queueWrite(` "${propKey}": ${propValueString}`);
|
|
491
|
-
if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
|
|
492
|
-
queueWrite(',\n');
|
|
493
|
-
}
|
|
494
|
-
else {
|
|
495
|
-
queueWrite('\n');
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
if (rules && Array.isArray(rules)) {
|
|
499
|
-
queueWrite(' "rules": [\n');
|
|
500
|
-
rules.forEach((rule, j) => {
|
|
501
|
-
queueWrite(' {\n');
|
|
502
|
-
const { pagesAffected, ...otherRuleProperties } = rule;
|
|
503
|
-
Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
|
|
504
|
-
const ruleValueString = ruleValue === null ||
|
|
505
|
-
typeof ruleValue === 'function' ||
|
|
506
|
-
typeof ruleValue === 'undefined'
|
|
507
|
-
? 'null'
|
|
508
|
-
: JSON.stringify(ruleValue);
|
|
509
|
-
queueWrite(` "${ruleKey}": ${ruleValueString}`);
|
|
510
|
-
if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
|
|
511
|
-
queueWrite(',\n');
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
queueWrite('\n');
|
|
515
|
-
}
|
|
516
|
-
});
|
|
517
|
-
if (pagesAffected && Array.isArray(pagesAffected)) {
|
|
518
|
-
queueWrite(' "pagesAffected": [\n');
|
|
519
|
-
pagesAffected.forEach((page, p) => {
|
|
520
|
-
const pageJson = JSON.stringify(page, null, 2)
|
|
521
|
-
.split('\n')
|
|
522
|
-
.map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
|
|
523
|
-
.join('\n');
|
|
524
|
-
queueWrite(pageJson);
|
|
525
|
-
if (p < pagesAffected.length - 1) {
|
|
526
|
-
queueWrite(',\n');
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
queueWrite('\n');
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
|
-
queueWrite(' ]');
|
|
533
|
-
}
|
|
534
|
-
queueWrite('\n }');
|
|
535
|
-
if (j < rules.length - 1) {
|
|
536
|
-
queueWrite(',\n');
|
|
537
|
-
}
|
|
538
|
-
else {
|
|
539
|
-
queueWrite('\n');
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
queueWrite(' ]');
|
|
543
|
-
}
|
|
544
|
-
queueWrite('\n }');
|
|
545
|
-
}
|
|
546
|
-
if (i < keys.length - 1) {
|
|
547
|
-
queueWrite(',\n');
|
|
548
|
-
}
|
|
549
|
-
else {
|
|
550
|
-
queueWrite('\n');
|
|
551
|
-
}
|
|
552
|
-
});
|
|
553
|
-
queueWrite('}\n');
|
|
554
|
-
// Ensure all queued writes are processed before ending
|
|
555
|
-
const checkQueueAndEnd = () => {
|
|
556
|
-
if (writeQueue.length === 0 && !isWriting) {
|
|
557
|
-
writeStream.end();
|
|
558
|
-
}
|
|
559
|
-
else {
|
|
560
|
-
setTimeout(checkQueueAndEnd, 100);
|
|
561
|
-
}
|
|
562
|
-
};
|
|
563
|
-
checkQueueAndEnd();
|
|
564
|
-
}
|
|
565
|
-
catch (err) {
|
|
566
|
-
writeStream.destroy(err);
|
|
567
|
-
reject(err);
|
|
568
|
-
}
|
|
569
|
-
});
|
|
570
|
-
};
|
|
571
|
-
async function compressJsonFileStreaming(inputPath, outputPath) {
|
|
572
|
-
// Create the read and write streams
|
|
573
|
-
const readStream = fs.createReadStream(inputPath);
|
|
574
|
-
const writeStream = fs.createWriteStream(outputPath);
|
|
575
|
-
// Create a gzip transform stream
|
|
576
|
-
const gzip = zlib.createGzip();
|
|
577
|
-
// Create a Base64 transform stream
|
|
578
|
-
const base64Encode = new Base64Encode();
|
|
579
|
-
// Pipe the streams:
|
|
580
|
-
// read -> gzip -> base64 -> write
|
|
581
|
-
await pipeline(readStream, gzip, base64Encode, writeStream);
|
|
582
|
-
consoleLogger.info(`File successfully compressed and saved to ${outputPath}`);
|
|
583
|
-
}
|
|
584
|
-
const writeJsonFileAndCompressedJsonFile = async (data, storagePath, filename) => {
|
|
585
|
-
try {
|
|
586
|
-
consoleLogger.info(`Writing JSON to ${filename}.json`);
|
|
587
|
-
const jsonFilePath = path.join(storagePath, `${filename}.json`);
|
|
588
|
-
if (filename === 'scanItems') {
|
|
589
|
-
await writeLargeScanItemsJsonToFile(data, jsonFilePath);
|
|
590
|
-
}
|
|
591
|
-
else {
|
|
592
|
-
await writeLargeJsonToFile(data, jsonFilePath);
|
|
593
|
-
}
|
|
594
|
-
consoleLogger.info(`Reading ${filename}.json, gzipping and base64 encoding it into ${filename}.json.gz.b64`);
|
|
595
|
-
const base64FilePath = path.join(storagePath, `${filename}.json.gz.b64`);
|
|
596
|
-
await compressJsonFileStreaming(jsonFilePath, base64FilePath);
|
|
597
|
-
consoleLogger.info(`Finished compression and base64 encoding for ${filename}`);
|
|
598
|
-
return {
|
|
599
|
-
jsonFilePath,
|
|
600
|
-
base64FilePath,
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
catch (error) {
|
|
604
|
-
consoleLogger.error(`Error compressing and encoding ${filename}`);
|
|
605
|
-
throw error;
|
|
606
|
-
}
|
|
607
|
-
};
|
|
608
|
-
const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma) => {
|
|
609
|
-
const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
|
|
610
|
-
let isFirstChunk = true;
|
|
611
|
-
for await (const chunk of readStream) {
|
|
612
|
-
if (isFirstChunk) {
|
|
613
|
-
isFirstChunk = false;
|
|
614
|
-
writeStream.write(chunk);
|
|
615
|
-
}
|
|
616
|
-
else {
|
|
617
|
-
writeStream.write(chunk);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (appendComma) {
|
|
621
|
-
writeStream.write(',');
|
|
622
|
-
}
|
|
623
|
-
};
|
|
624
|
-
const writeJsonAndBase64Files = async (allIssues, storagePath) => {
|
|
625
|
-
const { items, ...rest } = allIssues;
|
|
626
|
-
const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } = await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
|
|
627
|
-
const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
|
|
628
|
-
// Add pagesAffectedCount to each rule in items and sort them in descending order of pagesAffectedCount
|
|
629
|
-
['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
|
|
630
|
-
if (items[category].rules && Array.isArray(items[category].rules)) {
|
|
631
|
-
items[category].rules.forEach(rule => {
|
|
632
|
-
rule.pagesAffectedCount = Array.isArray(rule.pagesAffected) ? rule.pagesAffected.length : 0;
|
|
633
|
-
});
|
|
634
|
-
// Sort in descending order of pagesAffectedCount
|
|
635
|
-
items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
// Refactor scanIssuesSummary to reuse the structure by stripping out pagesAffected
|
|
639
|
-
const scanIssuesSummary = {
|
|
640
|
-
// Replace rule descriptions with short descriptions from the map
|
|
641
|
-
mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
642
|
-
...ruleInfo,
|
|
643
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
644
|
-
})),
|
|
645
|
-
goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
646
|
-
...ruleInfo,
|
|
647
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
648
|
-
})),
|
|
649
|
-
needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
650
|
-
...ruleInfo,
|
|
651
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
652
|
-
})),
|
|
653
|
-
passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
654
|
-
...ruleInfo,
|
|
655
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
656
|
-
})),
|
|
657
|
-
};
|
|
658
|
-
// Write out the scanIssuesSummary JSON using the new structure
|
|
659
|
-
const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
|
|
660
|
-
// scanItemsSummary
|
|
661
|
-
// the below mutates the original items object, since it is expensive to clone
|
|
662
|
-
items.mustFix.rules.forEach(rule => {
|
|
663
|
-
rule.pagesAffected.forEach(page => {
|
|
664
|
-
page.itemsCount = page.items.length;
|
|
665
|
-
});
|
|
666
|
-
});
|
|
667
|
-
items.goodToFix.rules.forEach(rule => {
|
|
668
|
-
rule.pagesAffected.forEach(page => {
|
|
669
|
-
page.itemsCount = page.items.length;
|
|
670
|
-
});
|
|
671
|
-
});
|
|
672
|
-
items.needsReview.rules.forEach(rule => {
|
|
673
|
-
rule.pagesAffected.forEach(page => {
|
|
674
|
-
page.itemsCount = page.items.length;
|
|
675
|
-
});
|
|
676
|
-
});
|
|
677
|
-
items.passed.rules.forEach(rule => {
|
|
678
|
-
rule.pagesAffected.forEach(page => {
|
|
679
|
-
page.itemsCount = page.items.length;
|
|
680
|
-
});
|
|
681
|
-
});
|
|
682
|
-
items.mustFix.totalRuleIssues = items.mustFix.rules.length;
|
|
683
|
-
items.goodToFix.totalRuleIssues = items.goodToFix.rules.length;
|
|
684
|
-
items.needsReview.totalRuleIssues = items.needsReview.rules.length;
|
|
685
|
-
items.passed.totalRuleIssues = items.passed.rules.length;
|
|
686
|
-
const { pagesScanned, topTenPagesWithMostIssues, pagesNotScanned, wcagLinks, wcagPassPercentage, progressPercentage, issuesPercentage, totalPagesScanned, totalPagesNotScanned, topTenIssues, } = rest;
|
|
687
|
-
const summaryItems = {
|
|
688
|
-
mustFix: {
|
|
689
|
-
totalItems: items.mustFix?.totalItems || 0,
|
|
690
|
-
totalRuleIssues: items.mustFix?.totalRuleIssues || 0,
|
|
691
|
-
},
|
|
692
|
-
goodToFix: {
|
|
693
|
-
totalItems: items.goodToFix?.totalItems || 0,
|
|
694
|
-
totalRuleIssues: items.goodToFix?.totalRuleIssues || 0,
|
|
695
|
-
},
|
|
696
|
-
needsReview: {
|
|
697
|
-
totalItems: items.needsReview?.totalItems || 0,
|
|
698
|
-
totalRuleIssues: items.needsReview?.totalRuleIssues || 0,
|
|
699
|
-
},
|
|
700
|
-
topTenPagesWithMostIssues,
|
|
701
|
-
wcagLinks,
|
|
702
|
-
wcagPassPercentage,
|
|
703
|
-
progressPercentage,
|
|
704
|
-
issuesPercentage,
|
|
705
|
-
totalPagesScanned,
|
|
706
|
-
totalPagesNotScanned,
|
|
707
|
-
topTenIssues,
|
|
708
|
-
};
|
|
709
|
-
const { jsonFilePath: scanItemsSummaryJsonFilePath, base64FilePath: scanItemsSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
|
|
710
|
-
const { jsonFilePath: scanPagesDetailJsonFilePath, base64FilePath: scanPagesDetailBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail }, storagePath, 'scanPagesDetail');
|
|
711
|
-
const { jsonFilePath: scanPagesSummaryJsonFilePath, base64FilePath: scanPagesSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary }, storagePath, 'scanPagesSummary');
|
|
712
|
-
return {
|
|
713
|
-
scanDataJsonFilePath,
|
|
714
|
-
scanDataBase64FilePath,
|
|
715
|
-
scanItemsJsonFilePath,
|
|
716
|
-
scanItemsBase64FilePath,
|
|
717
|
-
scanItemsSummaryJsonFilePath,
|
|
718
|
-
scanItemsSummaryBase64FilePath,
|
|
719
|
-
scanIssuesSummaryJsonFilePath,
|
|
720
|
-
scanIssuesSummaryBase64FilePath,
|
|
721
|
-
scanPagesDetailJsonFilePath,
|
|
722
|
-
scanPagesDetailBase64FilePath,
|
|
723
|
-
scanPagesSummaryJsonFilePath,
|
|
724
|
-
scanPagesSummaryBase64FilePath,
|
|
725
|
-
scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
|
|
726
|
-
scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
|
|
727
|
-
};
|
|
728
|
-
};
|
|
729
|
-
const writeScanDetailsCsv = async (scanDataFilePath, scanItemsFilePath, scanItemsSummaryFilePath, storagePath) => {
|
|
730
|
-
const filePath = path.join(storagePath, 'scanDetails.csv');
|
|
731
|
-
const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
732
|
-
const directoryPath = path.dirname(filePath);
|
|
733
|
-
if (!fs.existsSync(directoryPath)) {
|
|
734
|
-
fs.mkdirSync(directoryPath, { recursive: true });
|
|
735
|
-
}
|
|
736
|
-
csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
|
|
737
|
-
await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
|
|
738
|
-
await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
|
|
739
|
-
await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
|
|
740
|
-
await new Promise((resolve, reject) => {
|
|
741
|
-
csvWriteStream.end(resolve);
|
|
742
|
-
csvWriteStream.on('error', reject);
|
|
743
|
-
});
|
|
744
|
-
};
|
|
745
249
|
const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary', browser, _userDataDirectory) => {
|
|
746
250
|
const htmlFilePath = `${storagePath}/${filename}.html`;
|
|
747
251
|
const fileDestinationPath = `${storagePath}/${filename}.pdf`;
|
|
@@ -776,16 +280,6 @@ const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary',
|
|
|
776
280
|
};
|
|
777
281
|
// Tracking WCAG occurrences
|
|
778
282
|
const wcagOccurrencesMap = new Map();
|
|
779
|
-
// Format WCAG tag in requested format: wcag111a_Occurrences
|
|
780
|
-
const formatWcagTag = async (wcagId) => {
|
|
781
|
-
// Get dynamic WCAG criteria map
|
|
782
|
-
const wcagCriteriaMap = await getWcagCriteriaMap();
|
|
783
|
-
if (wcagCriteriaMap[wcagId]) {
|
|
784
|
-
const { level } = wcagCriteriaMap[wcagId];
|
|
785
|
-
return `${wcagId}${level}_Occurrences`;
|
|
786
|
-
}
|
|
787
|
-
return null;
|
|
788
|
-
};
|
|
789
283
|
const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
790
284
|
const { url, pageTitle, filePath } = pageResults;
|
|
791
285
|
const totalIssuesInPage = new Set();
|
|
@@ -843,73 +337,16 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
|
843
337
|
items: [...items],
|
|
844
338
|
};
|
|
845
339
|
}
|
|
846
|
-
else {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
};
|
|
853
|
-
}
|
|
340
|
+
else if (!(url in currRuleFromAllIssues.pagesAffected)) {
|
|
341
|
+
currRuleFromAllIssues.pagesAffected[url] = {
|
|
342
|
+
pageTitle,
|
|
343
|
+
items: [...items],
|
|
344
|
+
...(filePath && { filePath }),
|
|
345
|
+
};
|
|
854
346
|
}
|
|
855
347
|
});
|
|
856
348
|
});
|
|
857
349
|
};
|
|
858
|
-
/**
|
|
859
|
-
* Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
|
|
860
|
-
* Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
|
|
861
|
-
*/
|
|
862
|
-
const buildHtmlGroups = (rule, items, pageUrl) => {
|
|
863
|
-
if (!rule.htmlGroups) {
|
|
864
|
-
rule.htmlGroups = {};
|
|
865
|
-
}
|
|
866
|
-
items.forEach(item => {
|
|
867
|
-
// Use composite key of html + xpath for precise matching
|
|
868
|
-
const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
|
|
869
|
-
if (!rule.htmlGroups[htmlKey]) {
|
|
870
|
-
// Create new group with the first occurrence
|
|
871
|
-
rule.htmlGroups[htmlKey] = {
|
|
872
|
-
html: item.html || '',
|
|
873
|
-
xpath: item.xpath || '',
|
|
874
|
-
message: item.message || '',
|
|
875
|
-
screenshotPath: item.screenshotPath || '',
|
|
876
|
-
displayNeedsReview: item.displayNeedsReview,
|
|
877
|
-
pageUrls: [],
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
if (!rule.htmlGroups[htmlKey].pageUrls.includes(pageUrl)) {
|
|
881
|
-
rule.htmlGroups[htmlKey].pageUrls.push(pageUrl);
|
|
882
|
-
}
|
|
883
|
-
});
|
|
884
|
-
};
|
|
885
|
-
/**
|
|
886
|
-
* Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
|
|
887
|
-
* Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
|
|
888
|
-
* Those refs are specifically for htmlGroups lookup (html + xpath).
|
|
889
|
-
*/
|
|
890
|
-
export const convertItemsToReferences = (allIssues) => {
|
|
891
|
-
const cloned = JSON.parse(JSON.stringify(allIssues));
|
|
892
|
-
['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
|
|
893
|
-
if (!cloned.items[category]?.rules)
|
|
894
|
-
return;
|
|
895
|
-
cloned.items[category].rules.forEach((rule) => {
|
|
896
|
-
if (!rule.pagesAffected || !rule.htmlGroups)
|
|
897
|
-
return;
|
|
898
|
-
rule.pagesAffected.forEach((page) => {
|
|
899
|
-
if (!page.items)
|
|
900
|
-
return;
|
|
901
|
-
page.items = page.items.map((item) => {
|
|
902
|
-
if (typeof item === 'string')
|
|
903
|
-
return item; // Already a reference
|
|
904
|
-
// Use composite key matching buildHtmlGroups
|
|
905
|
-
const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
|
|
906
|
-
return htmlKey;
|
|
907
|
-
});
|
|
908
|
-
});
|
|
909
|
-
});
|
|
910
|
-
});
|
|
911
|
-
return cloned;
|
|
912
|
-
};
|
|
913
350
|
const getTopTenIssues = allIssues => {
|
|
914
351
|
const categories = ['mustFix', 'goodToFix'];
|
|
915
352
|
const rulesWithCounts = [];
|
|
@@ -1048,348 +485,6 @@ const moveElemScreenshots = (randomToken, storagePath) => {
|
|
|
1048
485
|
fs.moveSync(currentScreenshotsPath, resultsScreenshotsPath);
|
|
1049
486
|
}
|
|
1050
487
|
};
|
|
1051
|
-
/**
|
|
1052
|
-
* Build allIssues.scanPagesDetail and allIssues.scanPagesSummary
|
|
1053
|
-
* by analyzing pagesScanned (including mustFix/goodToFix/etc.).
|
|
1054
|
-
*/
|
|
1055
|
-
function populateScanPagesDetail(allIssues) {
|
|
1056
|
-
// --------------------------------------------
|
|
1057
|
-
// 1) Gather your "scanned" pages from allIssues
|
|
1058
|
-
// --------------------------------------------
|
|
1059
|
-
const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
|
|
1060
|
-
// --------------------------------------------
|
|
1061
|
-
// 2) Define category constants (optional, just for clarity)
|
|
1062
|
-
// --------------------------------------------
|
|
1063
|
-
const mustFixCategory = 'mustFix';
|
|
1064
|
-
const goodToFixCategory = 'goodToFix';
|
|
1065
|
-
const needsReviewCategory = 'needsReview';
|
|
1066
|
-
const passedCategory = 'passed';
|
|
1067
|
-
// --------------------------------------------
|
|
1068
|
-
// 4) We'll accumulate pages in a map keyed by URL
|
|
1069
|
-
// --------------------------------------------
|
|
1070
|
-
const pagesMap = {};
|
|
1071
|
-
// --------------------------------------------
|
|
1072
|
-
// 5) Build pagesMap by iterating over each category in allIssues.items
|
|
1073
|
-
// --------------------------------------------
|
|
1074
|
-
Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
|
|
1075
|
-
if (!categoryData?.rules)
|
|
1076
|
-
return; // no rules in this category? skip
|
|
1077
|
-
categoryData.rules.forEach(rule => {
|
|
1078
|
-
const { rule: ruleId, conformance = [] } = rule;
|
|
1079
|
-
rule.pagesAffected.forEach(p => {
|
|
1080
|
-
const { url, pageTitle, items = [] } = p;
|
|
1081
|
-
const itemsCount = items.length;
|
|
1082
|
-
// Ensure the page is in pagesMap
|
|
1083
|
-
if (!pagesMap[url]) {
|
|
1084
|
-
pagesMap[url] = {
|
|
1085
|
-
pageTitle,
|
|
1086
|
-
url,
|
|
1087
|
-
totalOccurrencesFailedIncludingNeedsReview: 0,
|
|
1088
|
-
totalOccurrencesFailedExcludingNeedsReview: 0,
|
|
1089
|
-
totalOccurrencesNeedsReview: 0,
|
|
1090
|
-
totalOccurrencesPassed: 0,
|
|
1091
|
-
typesOfIssues: {},
|
|
1092
|
-
};
|
|
1093
|
-
}
|
|
1094
|
-
// Ensure the rule is present for this page
|
|
1095
|
-
if (!pagesMap[url].typesOfIssues[ruleId]) {
|
|
1096
|
-
pagesMap[url].typesOfIssues[ruleId] = {
|
|
1097
|
-
ruleId,
|
|
1098
|
-
wcagConformance: conformance,
|
|
1099
|
-
occurrencesMustFix: 0,
|
|
1100
|
-
occurrencesGoodToFix: 0,
|
|
1101
|
-
occurrencesNeedsReview: 0,
|
|
1102
|
-
occurrencesPassed: 0,
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
// Depending on the category, increment the relevant occurrence counts
|
|
1106
|
-
if (categoryName === mustFixCategory) {
|
|
1107
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
|
|
1108
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1109
|
-
pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
|
|
1110
|
-
}
|
|
1111
|
-
else if (categoryName === goodToFixCategory) {
|
|
1112
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
|
|
1113
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1114
|
-
pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
|
|
1115
|
-
}
|
|
1116
|
-
else if (categoryName === needsReviewCategory) {
|
|
1117
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
|
|
1118
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1119
|
-
pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
|
|
1120
|
-
}
|
|
1121
|
-
else if (categoryName === passedCategory) {
|
|
1122
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
|
|
1123
|
-
pagesMap[url].totalOccurrencesPassed += itemsCount;
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
});
|
|
1127
|
-
});
|
|
1128
|
-
// --------------------------------------------
|
|
1129
|
-
// 6) Separate scanned pages into “affected” vs. “notAffected”
|
|
1130
|
-
// --------------------------------------------
|
|
1131
|
-
const pagesInMap = Object.values(pagesMap); // All pages that have some record in pagesMap
|
|
1132
|
-
const pagesInMapUrls = new Set(Object.keys(pagesMap));
|
|
1133
|
-
// (a) Pages with only passed (no mustFix/goodToFix/needsReview)
|
|
1134
|
-
const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
|
|
1135
|
-
// (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
|
|
1136
|
-
const pagesNoEntries = allScannedPages
|
|
1137
|
-
.filter(sp => !pagesInMapUrls.has(sp.url))
|
|
1138
|
-
.map(sp => ({
|
|
1139
|
-
pageTitle: sp.pageTitle,
|
|
1140
|
-
url: sp.url,
|
|
1141
|
-
totalOccurrencesFailedIncludingNeedsReview: 0,
|
|
1142
|
-
totalOccurrencesFailedExcludingNeedsReview: 0,
|
|
1143
|
-
totalOccurrencesNeedsReview: 0,
|
|
1144
|
-
totalOccurrencesPassed: 0,
|
|
1145
|
-
typesOfIssues: {},
|
|
1146
|
-
}));
|
|
1147
|
-
// Combine these into "notAffected"
|
|
1148
|
-
const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
|
|
1149
|
-
// "affected" pages => have at least 1 mustFix/goodToFix/needsReview
|
|
1150
|
-
const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
|
|
1151
|
-
// --------------------------------------------
|
|
1152
|
-
// 7) Transform both arrays to the final shape
|
|
1153
|
-
// --------------------------------------------
|
|
1154
|
-
function transformPageData(page) {
|
|
1155
|
-
const typesOfIssuesArray = Object.values(page.typesOfIssues);
|
|
1156
|
-
// Compute sums for each failing category
|
|
1157
|
-
const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
|
|
1158
|
-
const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
|
|
1159
|
-
const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
|
|
1160
|
-
// Build categoriesPresent based on nonzero failing counts
|
|
1161
|
-
const categoriesPresent = [];
|
|
1162
|
-
if (mustFixSum > 0)
|
|
1163
|
-
categoriesPresent.push('mustFix');
|
|
1164
|
-
if (goodToFixSum > 0)
|
|
1165
|
-
categoriesPresent.push('goodToFix');
|
|
1166
|
-
if (needsReviewSum > 0)
|
|
1167
|
-
categoriesPresent.push('needsReview');
|
|
1168
|
-
// Count how many rules have failing issues
|
|
1169
|
-
const failedRuleIds = new Set();
|
|
1170
|
-
typesOfIssuesArray.forEach(r => {
|
|
1171
|
-
if ((r.occurrencesMustFix || 0) > 0 ||
|
|
1172
|
-
(r.occurrencesGoodToFix || 0) > 0 ||
|
|
1173
|
-
(r.occurrencesNeedsReview || 0) > 0) {
|
|
1174
|
-
failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
const failedRuleCount = failedRuleIds.size;
|
|
1178
|
-
// Possibly these two for future convenience
|
|
1179
|
-
const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0).length;
|
|
1180
|
-
const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(r => (r.occurrencesNeedsReview || 0) > 0 &&
|
|
1181
|
-
(r.occurrencesMustFix || 0) === 0 &&
|
|
1182
|
-
(r.occurrencesGoodToFix || 0) === 0).length;
|
|
1183
|
-
// Aggregate wcagConformance for rules that actually fail
|
|
1184
|
-
const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
|
|
1185
|
-
const nonPassedCount = (curr.occurrencesMustFix || 0) +
|
|
1186
|
-
(curr.occurrencesGoodToFix || 0) +
|
|
1187
|
-
(curr.occurrencesNeedsReview || 0);
|
|
1188
|
-
if (nonPassedCount > 0) {
|
|
1189
|
-
return acc.concat(curr.wcagConformance || []);
|
|
1190
|
-
}
|
|
1191
|
-
return acc;
|
|
1192
|
-
}, []);
|
|
1193
|
-
// Remove duplicates
|
|
1194
|
-
const conformance = Array.from(new Set(allConformance));
|
|
1195
|
-
return {
|
|
1196
|
-
pageTitle: page.pageTitle,
|
|
1197
|
-
url: page.url,
|
|
1198
|
-
totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
|
|
1199
|
-
totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
|
|
1200
|
-
totalOccurrencesMustFix: mustFixSum,
|
|
1201
|
-
totalOccurrencesGoodToFix: goodToFixSum,
|
|
1202
|
-
totalOccurrencesNeedsReview: needsReviewSum,
|
|
1203
|
-
totalOccurrencesPassed: page.totalOccurrencesPassed,
|
|
1204
|
-
typesOfIssuesExclusiveToNeedsReviewCount,
|
|
1205
|
-
typesOfIssuesCount: failedRuleCount,
|
|
1206
|
-
typesOfIssuesExcludingNeedsReviewCount,
|
|
1207
|
-
categoriesPresent,
|
|
1208
|
-
conformance,
|
|
1209
|
-
// Keep full detail for "scanPagesDetail"
|
|
1210
|
-
typesOfIssues: typesOfIssuesArray,
|
|
1211
|
-
};
|
|
1212
|
-
}
|
|
1213
|
-
// Transform raw pages
|
|
1214
|
-
const pagesAffected = pagesAffectedRaw.map(transformPageData);
|
|
1215
|
-
const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
|
|
1216
|
-
// --------------------------------------------
|
|
1217
|
-
// 8) Sort pages by typesOfIssuesCount (descending) for both arrays
|
|
1218
|
-
// --------------------------------------------
|
|
1219
|
-
pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
|
|
1220
|
-
pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
|
|
1221
|
-
// --------------------------------------------
|
|
1222
|
-
// 9) Compute scanned/ skipped counts
|
|
1223
|
-
// --------------------------------------------
|
|
1224
|
-
const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
|
|
1225
|
-
const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
|
|
1226
|
-
? allIssues.pagesNotScanned.length
|
|
1227
|
-
: 0;
|
|
1228
|
-
// --------------------------------------------
|
|
1229
|
-
// 10) Build scanPagesDetail (with full "typesOfIssues")
|
|
1230
|
-
// --------------------------------------------
|
|
1231
|
-
allIssues.scanPagesDetail = {
|
|
1232
|
-
pagesAffected,
|
|
1233
|
-
pagesNotAffected,
|
|
1234
|
-
scannedPagesCount,
|
|
1235
|
-
pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
|
|
1236
|
-
pagesNotScannedCount,
|
|
1237
|
-
};
|
|
1238
|
-
// --------------------------------------------
|
|
1239
|
-
// 11) Build scanPagesSummary (strip out "typesOfIssues")
|
|
1240
|
-
// --------------------------------------------
|
|
1241
|
-
function stripTypesOfIssues(page) {
|
|
1242
|
-
const { typesOfIssues, ...rest } = page;
|
|
1243
|
-
return rest;
|
|
1244
|
-
}
|
|
1245
|
-
const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
|
|
1246
|
-
const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
|
|
1247
|
-
allIssues.scanPagesSummary = {
|
|
1248
|
-
pagesAffected: summaryPagesAffected,
|
|
1249
|
-
pagesNotAffected: summaryPagesNotAffected,
|
|
1250
|
-
scannedPagesCount,
|
|
1251
|
-
pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
|
|
1252
|
-
pagesNotScannedCount,
|
|
1253
|
-
};
|
|
1254
|
-
}
|
|
1255
|
-
// Send WCAG criteria breakdown to Sentry
|
|
1256
|
-
export const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson, scanInfo, allIssues, pagesScannedCount = 0) => {
|
|
1257
|
-
try {
|
|
1258
|
-
// Initialize Sentry
|
|
1259
|
-
Sentry.init(sentryConfig);
|
|
1260
|
-
// Set user ID for Sentry tracking
|
|
1261
|
-
const userData = getUserDataTxt();
|
|
1262
|
-
if (userData && userData.userId) {
|
|
1263
|
-
setSentryUser(userData.userId);
|
|
1264
|
-
}
|
|
1265
|
-
// Prepare tags for the event
|
|
1266
|
-
const tags = {};
|
|
1267
|
-
const wcagCriteriaBreakdown = {};
|
|
1268
|
-
// Tag app version
|
|
1269
|
-
tags.version = appVersion;
|
|
1270
|
-
// Get dynamic WCAG criteria map once
|
|
1271
|
-
const wcagCriteriaMap = await getWcagCriteriaMap();
|
|
1272
|
-
// Categorize all WCAG criteria for reporting
|
|
1273
|
-
const wcagIds = Array.from(new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]));
|
|
1274
|
-
const categorizedWcag = await categorizeWcagCriteria(wcagIds);
|
|
1275
|
-
// First ensure all WCAG criteria are included in the tags with a value of 0
|
|
1276
|
-
// This ensures criteria with no violations are still reported
|
|
1277
|
-
for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
|
|
1278
|
-
const formattedTag = await formatWcagTag(wcagId);
|
|
1279
|
-
if (formattedTag) {
|
|
1280
|
-
// Initialize with zero
|
|
1281
|
-
tags[formattedTag] = '0';
|
|
1282
|
-
// Store in breakdown object with category information
|
|
1283
|
-
wcagCriteriaBreakdown[formattedTag] = {
|
|
1284
|
-
count: 0,
|
|
1285
|
-
category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
|
|
1286
|
-
};
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
// Now override with actual counts from the scan
|
|
1290
|
-
for (const [wcagId, count] of wcagBreakdown.entries()) {
|
|
1291
|
-
const formattedTag = await formatWcagTag(wcagId);
|
|
1292
|
-
if (formattedTag) {
|
|
1293
|
-
// Add as a tag with the count as value
|
|
1294
|
-
tags[formattedTag] = String(count);
|
|
1295
|
-
// Update count in breakdown object
|
|
1296
|
-
if (wcagCriteriaBreakdown[formattedTag]) {
|
|
1297
|
-
wcagCriteriaBreakdown[formattedTag].count = count;
|
|
1298
|
-
}
|
|
1299
|
-
else {
|
|
1300
|
-
// If somehow this wasn't in our initial map
|
|
1301
|
-
wcagCriteriaBreakdown[formattedTag] = {
|
|
1302
|
-
count,
|
|
1303
|
-
category: categorizedWcag[wcagId] || 'mustFix',
|
|
1304
|
-
};
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
// Calculate category counts based on actual issue counts from the report
|
|
1309
|
-
// rather than occurrence counts from wcagBreakdown
|
|
1310
|
-
const categoryCounts = {
|
|
1311
|
-
mustFix: 0,
|
|
1312
|
-
goodToFix: 0,
|
|
1313
|
-
needsReview: 0,
|
|
1314
|
-
};
|
|
1315
|
-
if (allIssues) {
|
|
1316
|
-
// Use the actual report data for the counts
|
|
1317
|
-
categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
|
|
1318
|
-
categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
|
|
1319
|
-
categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
|
|
1320
|
-
}
|
|
1321
|
-
else {
|
|
1322
|
-
// Fallback to the old way if allIssues not provided
|
|
1323
|
-
Object.values(wcagCriteriaBreakdown).forEach(item => {
|
|
1324
|
-
if (item.count > 0 && categoryCounts[item.category] !== undefined) {
|
|
1325
|
-
categoryCounts[item.category] += 1; // Count rules, not occurrences
|
|
1326
|
-
}
|
|
1327
|
-
});
|
|
1328
|
-
}
|
|
1329
|
-
// Add category counts as tags
|
|
1330
|
-
tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
|
|
1331
|
-
tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
|
|
1332
|
-
tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
|
|
1333
|
-
// Also add occurrence counts for reference
|
|
1334
|
-
if (allIssues) {
|
|
1335
|
-
tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
|
|
1336
|
-
tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
|
|
1337
|
-
tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
|
|
1338
|
-
// Add number of pages scanned tag
|
|
1339
|
-
tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
|
|
1340
|
-
}
|
|
1341
|
-
else if (pagesScannedCount > 0) {
|
|
1342
|
-
// Still add the pages scanned count even if we don't have allIssues
|
|
1343
|
-
tags['Pages-Scanned-Count'] = String(pagesScannedCount);
|
|
1344
|
-
}
|
|
1345
|
-
// Send the event to Sentry
|
|
1346
|
-
await Sentry.captureEvent({
|
|
1347
|
-
message: 'Accessibility Scan Completed',
|
|
1348
|
-
level: 'info',
|
|
1349
|
-
tags: {
|
|
1350
|
-
...tags,
|
|
1351
|
-
event_type: 'accessibility_scan',
|
|
1352
|
-
scanType: scanInfo.scanType,
|
|
1353
|
-
browser: scanInfo.browser,
|
|
1354
|
-
entryUrl: scanInfo.entryUrl,
|
|
1355
|
-
},
|
|
1356
|
-
user: {
|
|
1357
|
-
...(scanInfo.email && scanInfo.name
|
|
1358
|
-
? {
|
|
1359
|
-
email: scanInfo.email,
|
|
1360
|
-
username: scanInfo.name,
|
|
1361
|
-
}
|
|
1362
|
-
: {}),
|
|
1363
|
-
...(userData && userData.userId ? { id: userData.userId } : {}),
|
|
1364
|
-
},
|
|
1365
|
-
extra: {
|
|
1366
|
-
additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : '{}',
|
|
1367
|
-
wcagBreakdown: wcagCriteriaBreakdown,
|
|
1368
|
-
reportCounts: allIssues
|
|
1369
|
-
? {
|
|
1370
|
-
mustFix: {
|
|
1371
|
-
issues: allIssues.items.mustFix.rules?.length ?? 0,
|
|
1372
|
-
occurrences: allIssues.items.mustFix.totalItems ?? 0,
|
|
1373
|
-
},
|
|
1374
|
-
goodToFix: {
|
|
1375
|
-
issues: allIssues.items.goodToFix.rules?.length ?? 0,
|
|
1376
|
-
occurrences: allIssues.items.goodToFix.totalItems ?? 0,
|
|
1377
|
-
},
|
|
1378
|
-
needsReview: {
|
|
1379
|
-
issues: allIssues.items.needsReview.rules?.length ?? 0,
|
|
1380
|
-
occurrences: allIssues.items.needsReview.totalItems ?? 0,
|
|
1381
|
-
},
|
|
1382
|
-
}
|
|
1383
|
-
: undefined,
|
|
1384
|
-
},
|
|
1385
|
-
});
|
|
1386
|
-
// Wait for events to be sent
|
|
1387
|
-
await Sentry.flush(2000);
|
|
1388
|
-
}
|
|
1389
|
-
catch (error) {
|
|
1390
|
-
console.error('Error sending WCAG breakdown to Sentry:', error);
|
|
1391
|
-
}
|
|
1392
|
-
};
|
|
1393
488
|
const formatAboutStartTime = (dateString) => {
|
|
1394
489
|
const utcStartTimeDate = new Date(dateString);
|
|
1395
490
|
const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
|
|
@@ -1675,5 +770,5 @@ generateJsonFiles = false) => {
|
|
|
1675
770
|
console.log('Report generated successfully');
|
|
1676
771
|
return ruleIdJson;
|
|
1677
772
|
};
|
|
1678
|
-
export { writeHTML, compressJsonFileStreaming, flattenAndSortResults, populateScanPagesDetail, getWcagPassPercentage, getProgressPercentage, getIssuesPercentage, itemTypeDescription, oobeeAiHtmlETL, oobeeAiRules, formatAboutStartTime, };
|
|
773
|
+
export { writeHTML, compressJsonFileStreaming, convertItemsToReferences, flattenAndSortResults, populateScanPagesDetail, sendWcagBreakdownToSentry, getWcagPassPercentage, getProgressPercentage, getIssuesPercentage, itemTypeDescription, oobeeAiHtmlETL, oobeeAiRules, formatAboutStartTime, };
|
|
1679
774
|
export default generateArtifacts;
|