@govtechsg/oobee 0.10.83 → 0.10.85
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/cli.js +7 -6
- package/dist/constants/common.js +13 -1
- package/dist/crawlers/crawlDomain.js +220 -120
- package/dist/crawlers/crawlIntelligentSitemap.js +22 -7
- package/dist/crawlers/custom/utils.js +81 -40
- package/dist/crawlers/runCustom.js +13 -5
- 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 +64 -950
- package/dist/proxyService.js +90 -5
- package/dist/utils.js +20 -7
- package/package.json +6 -6
- package/src/cli.ts +20 -15
- package/src/constants/common.ts +13 -1
- package/src/crawlers/crawlDomain.ts +248 -137
- package/src/crawlers/crawlIntelligentSitemap.ts +22 -8
- package/src/crawlers/custom/utils.ts +103 -48
- package/src/crawlers/runCustom.ts +18 -5
- 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 +125 -1344
- package/src/proxyService.ts +96 -4
- package/src/utils.ts +19 -7
package/src/mergeAxeResults.ts
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
/* eslint-disable consistent-return */
|
|
2
2
|
/* eslint-disable no-console */
|
|
3
|
-
import os from 'os';
|
|
4
3
|
import fs, { ensureDirSync } from 'fs-extra';
|
|
5
4
|
import printMessage from 'print-message';
|
|
6
5
|
import path from 'path';
|
|
7
6
|
import ejs from 'ejs';
|
|
8
7
|
import { fileURLToPath } from 'url';
|
|
9
|
-
import { chromium } from 'playwright';
|
|
10
|
-
import { createWriteStream } from 'fs';
|
|
11
|
-
import { AsyncParser, ParserOptions } from '@json2csv/node';
|
|
12
|
-
import zlib from 'zlib';
|
|
13
|
-
import { Base64Encode } from 'base64-stream';
|
|
14
|
-
import { pipeline } from 'stream/promises';
|
|
15
|
-
import * as Sentry from '@sentry/node';
|
|
16
8
|
import constants, {
|
|
17
9
|
BrowserTypes,
|
|
18
10
|
ScannerTypes,
|
|
19
|
-
sentryConfig,
|
|
20
|
-
setSentryUser,
|
|
21
11
|
WCAGclauses,
|
|
22
12
|
a11yRuleShortDescriptionMap,
|
|
23
13
|
disabilityBadgesMap,
|
|
@@ -34,114 +24,32 @@ import {
|
|
|
34
24
|
retryFunction,
|
|
35
25
|
zipResults,
|
|
36
26
|
getIssuesPercentage,
|
|
37
|
-
getWcagCriteriaMap,
|
|
38
|
-
categorizeWcagCriteria,
|
|
39
|
-
getUserDataTxt,
|
|
40
27
|
register,
|
|
41
28
|
} from './utils.js';
|
|
42
|
-
import { consoleLogger
|
|
29
|
+
import { consoleLogger } from './logs.js';
|
|
43
30
|
import itemTypeDescription from './constants/itemTypeDescription.js';
|
|
44
31
|
import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
export type HtmlGroupItem = {
|
|
67
|
-
html: string;
|
|
68
|
-
xpath: string;
|
|
69
|
-
message: string;
|
|
70
|
-
screenshotPath: string;
|
|
71
|
-
displayNeedsReview?: boolean;
|
|
72
|
-
pageUrls: string[];
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export type HtmlGroups = {
|
|
76
|
-
[htmlKey: string]: HtmlGroupItem;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export type RuleInfo = {
|
|
80
|
-
totalItems: number;
|
|
81
|
-
pagesAffected: PageInfo[];
|
|
82
|
-
pagesAffectedCount: number;
|
|
83
|
-
rule: string;
|
|
84
|
-
description: string;
|
|
85
|
-
axeImpact: string;
|
|
86
|
-
conformance: string[];
|
|
87
|
-
helpUrl: string;
|
|
88
|
-
htmlGroups?: HtmlGroups;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
type Category = {
|
|
92
|
-
description: string;
|
|
93
|
-
totalItems: number;
|
|
94
|
-
totalRuleIssues: number;
|
|
95
|
-
rules: RuleInfo[];
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
type AllIssues = {
|
|
99
|
-
storagePath: string;
|
|
100
|
-
oobeeAi: {
|
|
101
|
-
htmlETL: any;
|
|
102
|
-
rules: string[];
|
|
103
|
-
};
|
|
104
|
-
siteName: string;
|
|
105
|
-
startTime: Date;
|
|
106
|
-
endTime: Date;
|
|
107
|
-
urlScanned: string;
|
|
108
|
-
scanType: string;
|
|
109
|
-
deviceChosen: string;
|
|
110
|
-
formatAboutStartTime: (dateString: any) => string;
|
|
111
|
-
isCustomFlow: boolean;
|
|
112
|
-
pagesScanned: PageInfo[];
|
|
113
|
-
pagesNotScanned: PageInfo[];
|
|
114
|
-
totalPagesScanned: number;
|
|
115
|
-
totalPagesNotScanned: number;
|
|
116
|
-
totalItems: number;
|
|
117
|
-
topFiveMostIssues: Array<any>;
|
|
118
|
-
topTenPagesWithMostIssues: Array<any>;
|
|
119
|
-
topTenIssues: Array<any>;
|
|
120
|
-
wcagViolations: string[];
|
|
121
|
-
customFlowLabel: string;
|
|
122
|
-
oobeeAppVersion: string;
|
|
123
|
-
items: {
|
|
124
|
-
mustFix: Category;
|
|
125
|
-
goodToFix: Category;
|
|
126
|
-
needsReview: Category;
|
|
127
|
-
passed: Category;
|
|
128
|
-
};
|
|
129
|
-
cypressScanAboutMetadata: {
|
|
130
|
-
browser?: string;
|
|
131
|
-
viewport?: { width: number; height: number };
|
|
132
|
-
};
|
|
133
|
-
wcagLinks: { [key: string]: string };
|
|
134
|
-
wcagClauses: { [key: string]: string };
|
|
135
|
-
[key: string]: any;
|
|
136
|
-
advancedScanOptionsSummaryItems: { [key: string]: boolean };
|
|
137
|
-
scanPagesDetail: {
|
|
138
|
-
pagesAffected: any[];
|
|
139
|
-
pagesNotAffected: any[];
|
|
140
|
-
scannedPagesCount: number;
|
|
141
|
-
pagesNotScanned: any[];
|
|
142
|
-
pagesNotScannedCount: number;
|
|
143
|
-
};
|
|
144
|
-
};
|
|
32
|
+
import { buildHtmlGroups, convertItemsToReferences } from './mergeAxeResults/itemReferences.js';
|
|
33
|
+
import {
|
|
34
|
+
compressJsonFileStreaming,
|
|
35
|
+
writeJsonAndBase64Files,
|
|
36
|
+
writeJsonFileAndCompressedJsonFile,
|
|
37
|
+
} from './mergeAxeResults/jsonArtifacts.js';
|
|
38
|
+
import writeCsv from './mergeAxeResults/writeCsv.js';
|
|
39
|
+
import writeScanDetailsCsv from './mergeAxeResults/writeScanDetailsCsv.js';
|
|
40
|
+
import writeSitemap from './mergeAxeResults/writeSitemap.js';
|
|
41
|
+
import populateScanPagesDetail from './mergeAxeResults/scanPages.js';
|
|
42
|
+
import sendWcagBreakdownToSentry from './mergeAxeResults/sentryTelemetry.js';
|
|
43
|
+
import type { AllIssues, PageInfo, RuleInfo } from './mergeAxeResults/types.js';
|
|
44
|
+
|
|
45
|
+
export type {
|
|
46
|
+
AllIssues,
|
|
47
|
+
HtmlGroupItem,
|
|
48
|
+
HtmlGroups,
|
|
49
|
+
ItemsInfo,
|
|
50
|
+
PageInfo,
|
|
51
|
+
RuleInfo,
|
|
52
|
+
} from './mergeAxeResults/types.js';
|
|
145
53
|
|
|
146
54
|
const filename = fileURLToPath(import.meta.url);
|
|
147
55
|
const dirname = path.dirname(filename);
|
|
@@ -182,7 +90,9 @@ const parseContentToJson = async (rPath: string) => {
|
|
|
182
90
|
}
|
|
183
91
|
|
|
184
92
|
consoleLogger.error(`[parseContentToJson] Failed to parse file: ${rPath}`);
|
|
185
|
-
consoleLogger.error(
|
|
93
|
+
consoleLogger.error(
|
|
94
|
+
`[parseContentToJson] ${parseError?.name || 'Error'}: ${parseError?.message || parseError}`,
|
|
95
|
+
);
|
|
186
96
|
if (position !== null) {
|
|
187
97
|
consoleLogger.error(`[parseContentToJson] JSON parse position: ${position}`);
|
|
188
98
|
}
|
|
@@ -195,144 +105,6 @@ const parseContentToJson = async (rPath: string) => {
|
|
|
195
105
|
}
|
|
196
106
|
};
|
|
197
107
|
|
|
198
|
-
const writeCsv = async (allIssues, storagePath) => {
|
|
199
|
-
const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
|
|
200
|
-
const formatPageViolation = pageNum => {
|
|
201
|
-
if (pageNum < 0) return 'Document';
|
|
202
|
-
return `Page ${pageNum}`;
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// transform allIssues into the form:
|
|
206
|
-
// [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
|
|
207
|
-
const getRulesByCategory = (allIssues: AllIssues) => {
|
|
208
|
-
return Object.entries(allIssues.items)
|
|
209
|
-
.filter(([category]) => category !== 'passed')
|
|
210
|
-
.reduce((prev: [string, RuleInfo][], [category, value]) => {
|
|
211
|
-
const rulesEntries = Object.entries(value.rules);
|
|
212
|
-
rulesEntries.forEach(([, ruleInfo]) => {
|
|
213
|
-
prev.push([category, ruleInfo]);
|
|
214
|
-
});
|
|
215
|
-
return prev;
|
|
216
|
-
}, [])
|
|
217
|
-
.sort((a, b) => {
|
|
218
|
-
// sort rules according to severity, then ruleId
|
|
219
|
-
const compareCategory = -a[0].localeCompare(b[0]);
|
|
220
|
-
return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
|
|
221
|
-
});
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
const flattenRule = catAndRule => {
|
|
225
|
-
const [severity, rule] = catAndRule;
|
|
226
|
-
const results = [];
|
|
227
|
-
const {
|
|
228
|
-
rule: issueId,
|
|
229
|
-
description: issueDescription,
|
|
230
|
-
axeImpact,
|
|
231
|
-
conformance,
|
|
232
|
-
pagesAffected,
|
|
233
|
-
helpUrl: learnMore,
|
|
234
|
-
} = rule;
|
|
235
|
-
|
|
236
|
-
// format clauses as a string
|
|
237
|
-
const wcagConformance = conformance.join(',');
|
|
238
|
-
|
|
239
|
-
pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
|
|
240
|
-
|
|
241
|
-
pagesAffected.forEach(affectedPage => {
|
|
242
|
-
const { url, items } = affectedPage;
|
|
243
|
-
items.forEach(item => {
|
|
244
|
-
const { html, page, message, xpath } = item;
|
|
245
|
-
const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
|
|
246
|
-
const violation = html || formatPageViolation(page); // page is a number, not a string
|
|
247
|
-
const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
|
|
248
|
-
|
|
249
|
-
results.push({
|
|
250
|
-
customFlowLabel: allIssues.customFlowLabel || '',
|
|
251
|
-
deviceChosen: allIssues.deviceChosen || '',
|
|
252
|
-
scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
|
|
253
|
-
severity: severity || '',
|
|
254
|
-
issueId: issueId || '',
|
|
255
|
-
issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
|
|
256
|
-
wcagConformance: wcagConformance || '',
|
|
257
|
-
url: url || '',
|
|
258
|
-
pageTitle: affectedPage.pageTitle || 'No page title',
|
|
259
|
-
context: context || '',
|
|
260
|
-
howToFix: howToFix || '',
|
|
261
|
-
axeImpact: axeImpact || '',
|
|
262
|
-
xpath: xpath || '',
|
|
263
|
-
learnMore: learnMore || '',
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
if (results.length === 0) return {};
|
|
268
|
-
return results;
|
|
269
|
-
};
|
|
270
|
-
|
|
271
|
-
const opts: ParserOptions<any, any> = {
|
|
272
|
-
transforms: [getRulesByCategory, flattenRule],
|
|
273
|
-
fields: [
|
|
274
|
-
'customFlowLabel',
|
|
275
|
-
'deviceChosen',
|
|
276
|
-
'scanCompletedAt',
|
|
277
|
-
'severity',
|
|
278
|
-
'issueId',
|
|
279
|
-
'issueDescription',
|
|
280
|
-
'wcagConformance',
|
|
281
|
-
'url',
|
|
282
|
-
'pageTitle',
|
|
283
|
-
'context',
|
|
284
|
-
'howToFix',
|
|
285
|
-
'axeImpact',
|
|
286
|
-
'xpath',
|
|
287
|
-
'learnMore',
|
|
288
|
-
],
|
|
289
|
-
includeEmptyRows: true,
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
// Create the parse stream (it's asynchronous)
|
|
293
|
-
const parser = new AsyncParser(opts);
|
|
294
|
-
const parseStream = parser.parse(allIssues);
|
|
295
|
-
|
|
296
|
-
// Pipe JSON2CSV output into the file, but don't end automatically
|
|
297
|
-
parseStream.pipe(csvOutput, { end: false });
|
|
298
|
-
|
|
299
|
-
// Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
|
|
300
|
-
parseStream.on('end', () => {
|
|
301
|
-
if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
|
|
302
|
-
csvOutput.write('\n');
|
|
303
|
-
allIssues.pagesNotScanned.forEach(page => {
|
|
304
|
-
const skippedPage = {
|
|
305
|
-
customFlowLabel: allIssues.customFlowLabel || '',
|
|
306
|
-
deviceChosen: allIssues.deviceChosen || '',
|
|
307
|
-
scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
|
|
308
|
-
severity: 'error',
|
|
309
|
-
issueId: 'error-pages-skipped',
|
|
310
|
-
issueDescription: page.metadata
|
|
311
|
-
? page.metadata
|
|
312
|
-
: 'An unknown error caused the page to be skipped',
|
|
313
|
-
wcagConformance: '',
|
|
314
|
-
url: page.url || page || '',
|
|
315
|
-
pageTitle: 'Error',
|
|
316
|
-
context: '',
|
|
317
|
-
howToFix: '',
|
|
318
|
-
axeImpact: '',
|
|
319
|
-
xpath: '',
|
|
320
|
-
learnMore: '',
|
|
321
|
-
};
|
|
322
|
-
csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Now close the CSV file
|
|
327
|
-
csvOutput.end();
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
parseStream.on('error', err => {
|
|
331
|
-
console.error('Error parsing CSV:', err);
|
|
332
|
-
csvOutput.end();
|
|
333
|
-
});
|
|
334
|
-
};
|
|
335
|
-
|
|
336
108
|
const compileHtmlWithEJS = async (
|
|
337
109
|
allIssues: AllIssues,
|
|
338
110
|
storagePath: string,
|
|
@@ -416,12 +188,14 @@ const writeHTML = async (
|
|
|
416
188
|
const scanItemsWithHtmlGroupRefs = convertItemsToReferences(allIssues);
|
|
417
189
|
|
|
418
190
|
// Write the lighter items to a file and get the base64 path
|
|
419
|
-
const {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
191
|
+
const {
|
|
192
|
+
jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath,
|
|
193
|
+
base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath,
|
|
194
|
+
} = await writeJsonFileAndCompressedJsonFile(
|
|
195
|
+
scanItemsWithHtmlGroupRefs.items,
|
|
196
|
+
storagePath,
|
|
197
|
+
'scanItems-light',
|
|
198
|
+
);
|
|
425
199
|
|
|
426
200
|
return new Promise<void>((resolve, reject) => {
|
|
427
201
|
const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
|
|
@@ -448,7 +222,7 @@ const writeHTML = async (
|
|
|
448
222
|
outputStream.write(prefixData);
|
|
449
223
|
|
|
450
224
|
// For Proxied AI environments only
|
|
451
|
-
outputStream.write(`let proxyUrl = "${process.env.PROXY_API_BASE_URL ||
|
|
225
|
+
outputStream.write(`let proxyUrl = "${process.env.PROXY_API_BASE_URL || ''}"\n`);
|
|
452
226
|
|
|
453
227
|
// Initialize GenAI feature flag
|
|
454
228
|
outputStream.write(`
|
|
@@ -478,17 +252,15 @@ const writeHTML = async (
|
|
|
478
252
|
}
|
|
479
253
|
\n`);
|
|
480
254
|
|
|
481
|
-
outputStream.write(
|
|
482
|
-
"</script>\n<script type=\"text/plain\" id=\"scanDataRaw\">"
|
|
483
|
-
);
|
|
255
|
+
outputStream.write('</script>\n<script type="text/plain" id="scanDataRaw">');
|
|
484
256
|
scanDetailsReadStream.pipe(outputStream, { end: false });
|
|
485
257
|
|
|
486
258
|
scanDetailsReadStream.on('end', async () => {
|
|
487
|
-
outputStream.write(
|
|
259
|
+
outputStream.write('</script>\n<script>\n');
|
|
488
260
|
outputStream.write(
|
|
489
|
-
"var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n"
|
|
261
|
+
"var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n",
|
|
490
262
|
);
|
|
491
|
-
outputStream.write(
|
|
263
|
+
outputStream.write('</script>\n');
|
|
492
264
|
|
|
493
265
|
// Write scanItems in 2MB chunks using a stream to avoid loading entire file into memory
|
|
494
266
|
try {
|
|
@@ -499,11 +271,13 @@ const writeHTML = async (
|
|
|
499
271
|
});
|
|
500
272
|
|
|
501
273
|
for await (const chunk of scanItemsStream) {
|
|
502
|
-
outputStream.write(
|
|
274
|
+
outputStream.write(
|
|
275
|
+
`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`,
|
|
276
|
+
);
|
|
503
277
|
chunkIndex++;
|
|
504
278
|
}
|
|
505
279
|
|
|
506
|
-
outputStream.write(
|
|
280
|
+
outputStream.write('<script>\n');
|
|
507
281
|
outputStream.write(`
|
|
508
282
|
var scanItemsPromise = (async () => {
|
|
509
283
|
console.log('Loading scanItems...');
|
|
@@ -560,13 +334,6 @@ const writeSummaryHTML = async (
|
|
|
560
334
|
fs.writeFileSync(`${storagePath}/${htmlFilename}.html`, html);
|
|
561
335
|
};
|
|
562
336
|
|
|
563
|
-
const writeSitemap = async (pagesScanned: PageInfo[], storagePath: string) => {
|
|
564
|
-
const sitemapPath = path.join(storagePath, 'sitemap.txt');
|
|
565
|
-
const content = pagesScanned.map(p => p.url).join('\n');
|
|
566
|
-
await fs.writeFile(sitemapPath, content, { encoding: 'utf-8' });
|
|
567
|
-
consoleLogger.info(`Sitemap written to ${sitemapPath}`);
|
|
568
|
-
};
|
|
569
|
-
|
|
570
337
|
const cleanUpJsonFiles = async (filesToDelete: string[]) => {
|
|
571
338
|
consoleLogger.info('Cleaning up JSON files...');
|
|
572
339
|
filesToDelete.forEach(file => {
|
|
@@ -575,513 +342,6 @@ const cleanUpJsonFiles = async (filesToDelete: string[]) => {
|
|
|
575
342
|
});
|
|
576
343
|
};
|
|
577
344
|
|
|
578
|
-
function* serializeObject(obj: any, depth = 0, indent = ' ') {
|
|
579
|
-
const currentIndent = indent.repeat(depth);
|
|
580
|
-
const nextIndent = indent.repeat(depth + 1);
|
|
581
|
-
|
|
582
|
-
if (obj instanceof Date) {
|
|
583
|
-
yield JSON.stringify(obj.toISOString());
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
if (Array.isArray(obj)) {
|
|
588
|
-
yield '[\n';
|
|
589
|
-
for (let i = 0; i < obj.length; i++) {
|
|
590
|
-
if (i > 0) yield ',\n';
|
|
591
|
-
yield nextIndent;
|
|
592
|
-
yield* serializeObject(obj[i], depth + 1, indent);
|
|
593
|
-
}
|
|
594
|
-
yield `\n${currentIndent}]`;
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (obj !== null && typeof obj === 'object') {
|
|
599
|
-
yield '{\n';
|
|
600
|
-
const keys = Object.keys(obj);
|
|
601
|
-
for (let i = 0; i < keys.length; i++) {
|
|
602
|
-
const key = keys[i];
|
|
603
|
-
if (i > 0) yield ',\n';
|
|
604
|
-
yield `${nextIndent}${JSON.stringify(key)}: `;
|
|
605
|
-
yield* serializeObject(obj[key], depth + 1, indent);
|
|
606
|
-
}
|
|
607
|
-
yield `\n${currentIndent}}`;
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (obj === null || typeof obj === 'function' || typeof obj === 'undefined') {
|
|
612
|
-
yield 'null';
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
yield JSON.stringify(obj);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function writeLargeJsonToFile(obj: object, filePath: string) {
|
|
620
|
-
return new Promise((resolve, reject) => {
|
|
621
|
-
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
622
|
-
|
|
623
|
-
writeStream.on('error', error => {
|
|
624
|
-
consoleLogger.error('Stream error:', error);
|
|
625
|
-
reject(error);
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
writeStream.on('finish', () => {
|
|
629
|
-
consoleLogger.info(`JSON file written successfully: ${filePath}`);
|
|
630
|
-
resolve(true);
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
const generator = serializeObject(obj);
|
|
634
|
-
|
|
635
|
-
function write() {
|
|
636
|
-
let next: any;
|
|
637
|
-
while (!(next = generator.next()).done) {
|
|
638
|
-
if (!writeStream.write(next.value)) {
|
|
639
|
-
writeStream.once('drain', write);
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
writeStream.end();
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
write();
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
|
|
651
|
-
return new Promise((resolve, reject) => {
|
|
652
|
-
const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
|
|
653
|
-
const writeQueue: string[] = [];
|
|
654
|
-
let isWriting = false;
|
|
655
|
-
|
|
656
|
-
const processNextWrite = async () => {
|
|
657
|
-
if (isWriting || writeQueue.length === 0) return;
|
|
658
|
-
|
|
659
|
-
isWriting = true;
|
|
660
|
-
const data = writeQueue.shift()!;
|
|
661
|
-
|
|
662
|
-
try {
|
|
663
|
-
if (!writeStream.write(data)) {
|
|
664
|
-
await new Promise<void>(resolve => {
|
|
665
|
-
writeStream.once('drain', () => {
|
|
666
|
-
resolve();
|
|
667
|
-
});
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
} catch (error) {
|
|
671
|
-
writeStream.destroy(error as Error);
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
isWriting = false;
|
|
676
|
-
processNextWrite();
|
|
677
|
-
};
|
|
678
|
-
|
|
679
|
-
const queueWrite = (data: string) => {
|
|
680
|
-
writeQueue.push(data);
|
|
681
|
-
processNextWrite();
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
writeStream.on('error', error => {
|
|
685
|
-
consoleLogger.error(`Error writing object to JSON file: ${error}`);
|
|
686
|
-
reject(error);
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
writeStream.on('finish', () => {
|
|
690
|
-
consoleLogger.info(`JSON file written successfully: ${filePath}`);
|
|
691
|
-
resolve(true);
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
try {
|
|
695
|
-
queueWrite('{\n');
|
|
696
|
-
const keys = Object.keys(obj);
|
|
697
|
-
|
|
698
|
-
keys.forEach((key, i) => {
|
|
699
|
-
const value = obj[key];
|
|
700
|
-
|
|
701
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
702
|
-
queueWrite(` "${key}": ${JSON.stringify(value)}`);
|
|
703
|
-
} else {
|
|
704
|
-
queueWrite(` "${key}": {\n`);
|
|
705
|
-
|
|
706
|
-
const { rules, ...otherProperties } = value;
|
|
707
|
-
|
|
708
|
-
// Write other properties
|
|
709
|
-
Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
|
|
710
|
-
const propValueString =
|
|
711
|
-
propValue === null ||
|
|
712
|
-
typeof propValue === 'function' ||
|
|
713
|
-
typeof propValue === 'undefined'
|
|
714
|
-
? 'null'
|
|
715
|
-
: JSON.stringify(propValue);
|
|
716
|
-
queueWrite(` "${propKey}": ${propValueString}`);
|
|
717
|
-
if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
|
|
718
|
-
queueWrite(',\n');
|
|
719
|
-
} else {
|
|
720
|
-
queueWrite('\n');
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
if (rules && Array.isArray(rules)) {
|
|
725
|
-
queueWrite(' "rules": [\n');
|
|
726
|
-
|
|
727
|
-
rules.forEach((rule, j) => {
|
|
728
|
-
queueWrite(' {\n');
|
|
729
|
-
const { pagesAffected, ...otherRuleProperties } = rule;
|
|
730
|
-
|
|
731
|
-
Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
|
|
732
|
-
const ruleValueString =
|
|
733
|
-
ruleValue === null ||
|
|
734
|
-
typeof ruleValue === 'function' ||
|
|
735
|
-
typeof ruleValue === 'undefined'
|
|
736
|
-
? 'null'
|
|
737
|
-
: JSON.stringify(ruleValue);
|
|
738
|
-
queueWrite(` "${ruleKey}": ${ruleValueString}`);
|
|
739
|
-
if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
|
|
740
|
-
queueWrite(',\n');
|
|
741
|
-
} else {
|
|
742
|
-
queueWrite('\n');
|
|
743
|
-
}
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
if (pagesAffected && Array.isArray(pagesAffected)) {
|
|
747
|
-
queueWrite(' "pagesAffected": [\n');
|
|
748
|
-
|
|
749
|
-
pagesAffected.forEach((page, p) => {
|
|
750
|
-
const pageJson = JSON.stringify(page, null, 2)
|
|
751
|
-
.split('\n')
|
|
752
|
-
.map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
|
|
753
|
-
.join('\n');
|
|
754
|
-
|
|
755
|
-
queueWrite(pageJson);
|
|
756
|
-
|
|
757
|
-
if (p < pagesAffected.length - 1) {
|
|
758
|
-
queueWrite(',\n');
|
|
759
|
-
} else {
|
|
760
|
-
queueWrite('\n');
|
|
761
|
-
}
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
queueWrite(' ]');
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
queueWrite('\n }');
|
|
768
|
-
if (j < rules.length - 1) {
|
|
769
|
-
queueWrite(',\n');
|
|
770
|
-
} else {
|
|
771
|
-
queueWrite('\n');
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
|
|
775
|
-
queueWrite(' ]');
|
|
776
|
-
}
|
|
777
|
-
queueWrite('\n }');
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if (i < keys.length - 1) {
|
|
781
|
-
queueWrite(',\n');
|
|
782
|
-
} else {
|
|
783
|
-
queueWrite('\n');
|
|
784
|
-
}
|
|
785
|
-
});
|
|
786
|
-
|
|
787
|
-
queueWrite('}\n');
|
|
788
|
-
|
|
789
|
-
// Ensure all queued writes are processed before ending
|
|
790
|
-
const checkQueueAndEnd = () => {
|
|
791
|
-
if (writeQueue.length === 0 && !isWriting) {
|
|
792
|
-
writeStream.end();
|
|
793
|
-
} else {
|
|
794
|
-
setTimeout(checkQueueAndEnd, 100);
|
|
795
|
-
}
|
|
796
|
-
};
|
|
797
|
-
|
|
798
|
-
checkQueueAndEnd();
|
|
799
|
-
} catch (err) {
|
|
800
|
-
writeStream.destroy(err as Error);
|
|
801
|
-
reject(err);
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
};
|
|
805
|
-
|
|
806
|
-
async function compressJsonFileStreaming(inputPath: string, outputPath: string) {
|
|
807
|
-
// Create the read and write streams
|
|
808
|
-
const readStream = fs.createReadStream(inputPath);
|
|
809
|
-
const writeStream = fs.createWriteStream(outputPath);
|
|
810
|
-
|
|
811
|
-
// Create a gzip transform stream
|
|
812
|
-
const gzip = zlib.createGzip();
|
|
813
|
-
|
|
814
|
-
// Create a Base64 transform stream
|
|
815
|
-
const base64Encode = new Base64Encode();
|
|
816
|
-
|
|
817
|
-
// Pipe the streams:
|
|
818
|
-
// read -> gzip -> base64 -> write
|
|
819
|
-
await pipeline(readStream, gzip, base64Encode, writeStream);
|
|
820
|
-
consoleLogger.info(`File successfully compressed and saved to ${outputPath}`);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
const writeJsonFileAndCompressedJsonFile = async (
|
|
824
|
-
data: object,
|
|
825
|
-
storagePath: string,
|
|
826
|
-
filename: string,
|
|
827
|
-
): Promise<{ jsonFilePath: string; base64FilePath: string }> => {
|
|
828
|
-
try {
|
|
829
|
-
consoleLogger.info(`Writing JSON to ${filename}.json`);
|
|
830
|
-
const jsonFilePath = path.join(storagePath, `${filename}.json`);
|
|
831
|
-
if (filename === 'scanItems') {
|
|
832
|
-
await writeLargeScanItemsJsonToFile(data, jsonFilePath);
|
|
833
|
-
} else {
|
|
834
|
-
await writeLargeJsonToFile(data, jsonFilePath);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
consoleLogger.info(
|
|
838
|
-
`Reading ${filename}.json, gzipping and base64 encoding it into ${filename}.json.gz.b64`,
|
|
839
|
-
);
|
|
840
|
-
const base64FilePath = path.join(storagePath, `${filename}.json.gz.b64`);
|
|
841
|
-
await compressJsonFileStreaming(jsonFilePath, base64FilePath);
|
|
842
|
-
|
|
843
|
-
consoleLogger.info(`Finished compression and base64 encoding for ${filename}`);
|
|
844
|
-
return {
|
|
845
|
-
jsonFilePath,
|
|
846
|
-
base64FilePath,
|
|
847
|
-
};
|
|
848
|
-
} catch (error) {
|
|
849
|
-
consoleLogger.error(`Error compressing and encoding ${filename}`);
|
|
850
|
-
throw error;
|
|
851
|
-
}
|
|
852
|
-
};
|
|
853
|
-
|
|
854
|
-
const streamEncodedDataToFile = async (
|
|
855
|
-
inputFilePath: string,
|
|
856
|
-
writeStream: fs.WriteStream,
|
|
857
|
-
appendComma: boolean,
|
|
858
|
-
) => {
|
|
859
|
-
const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
|
|
860
|
-
let isFirstChunk = true;
|
|
861
|
-
|
|
862
|
-
for await (const chunk of readStream) {
|
|
863
|
-
if (isFirstChunk) {
|
|
864
|
-
isFirstChunk = false;
|
|
865
|
-
writeStream.write(chunk);
|
|
866
|
-
} else {
|
|
867
|
-
writeStream.write(chunk);
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
if (appendComma) {
|
|
872
|
-
writeStream.write(',');
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
const writeJsonAndBase64Files = async (
|
|
877
|
-
allIssues: AllIssues,
|
|
878
|
-
storagePath: string,
|
|
879
|
-
): Promise<{
|
|
880
|
-
scanDataJsonFilePath: string;
|
|
881
|
-
scanDataBase64FilePath: string;
|
|
882
|
-
scanItemsJsonFilePath: string;
|
|
883
|
-
scanItemsBase64FilePath: string;
|
|
884
|
-
scanItemsSummaryJsonFilePath: string;
|
|
885
|
-
scanItemsSummaryBase64FilePath: string;
|
|
886
|
-
scanIssuesSummaryJsonFilePath: string;
|
|
887
|
-
scanIssuesSummaryBase64FilePath: string;
|
|
888
|
-
scanPagesDetailJsonFilePath: string;
|
|
889
|
-
scanPagesDetailBase64FilePath: string;
|
|
890
|
-
scanPagesSummaryJsonFilePath: string;
|
|
891
|
-
scanPagesSummaryBase64FilePath: string;
|
|
892
|
-
scanDataJsonFileSize: number;
|
|
893
|
-
scanItemsJsonFileSize: number;
|
|
894
|
-
}> => {
|
|
895
|
-
const { items, ...rest } = allIssues;
|
|
896
|
-
const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
|
|
897
|
-
await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
|
|
898
|
-
const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
|
|
899
|
-
await writeJsonFileAndCompressedJsonFile(
|
|
900
|
-
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
|
|
901
|
-
storagePath,
|
|
902
|
-
'scanItems',
|
|
903
|
-
);
|
|
904
|
-
|
|
905
|
-
// Add pagesAffectedCount to each rule in items and sort them in descending order of pagesAffectedCount
|
|
906
|
-
['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
|
|
907
|
-
if (items[category].rules && Array.isArray(items[category].rules)) {
|
|
908
|
-
items[category].rules.forEach(rule => {
|
|
909
|
-
rule.pagesAffectedCount = Array.isArray(rule.pagesAffected) ? rule.pagesAffected.length : 0;
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
// Sort in descending order of pagesAffectedCount
|
|
913
|
-
items[category].rules.sort(
|
|
914
|
-
(a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0),
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
// Refactor scanIssuesSummary to reuse the structure by stripping out pagesAffected
|
|
920
|
-
const scanIssuesSummary = {
|
|
921
|
-
|
|
922
|
-
// Replace rule descriptions with short descriptions from the map
|
|
923
|
-
mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
924
|
-
...ruleInfo,
|
|
925
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
926
|
-
})),
|
|
927
|
-
goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
928
|
-
...ruleInfo,
|
|
929
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
930
|
-
})),
|
|
931
|
-
needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
932
|
-
...ruleInfo,
|
|
933
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
934
|
-
})),
|
|
935
|
-
passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ({
|
|
936
|
-
...ruleInfo,
|
|
937
|
-
description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
|
|
938
|
-
})),
|
|
939
|
-
};
|
|
940
|
-
|
|
941
|
-
// Write out the scanIssuesSummary JSON using the new structure
|
|
942
|
-
const {
|
|
943
|
-
jsonFilePath: scanIssuesSummaryJsonFilePath,
|
|
944
|
-
base64FilePath: scanIssuesSummaryBase64FilePath,
|
|
945
|
-
} = await writeJsonFileAndCompressedJsonFile(
|
|
946
|
-
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary },
|
|
947
|
-
storagePath,
|
|
948
|
-
'scanIssuesSummary',
|
|
949
|
-
);
|
|
950
|
-
|
|
951
|
-
// scanItemsSummary
|
|
952
|
-
// the below mutates the original items object, since it is expensive to clone
|
|
953
|
-
items.mustFix.rules.forEach(rule => {
|
|
954
|
-
rule.pagesAffected.forEach(page => {
|
|
955
|
-
page.itemsCount = page.items.length;
|
|
956
|
-
});
|
|
957
|
-
});
|
|
958
|
-
items.goodToFix.rules.forEach(rule => {
|
|
959
|
-
rule.pagesAffected.forEach(page => {
|
|
960
|
-
page.itemsCount = page.items.length;
|
|
961
|
-
});
|
|
962
|
-
});
|
|
963
|
-
items.needsReview.rules.forEach(rule => {
|
|
964
|
-
rule.pagesAffected.forEach(page => {
|
|
965
|
-
page.itemsCount = page.items.length;
|
|
966
|
-
});
|
|
967
|
-
});
|
|
968
|
-
items.passed.rules.forEach(rule => {
|
|
969
|
-
rule.pagesAffected.forEach(page => {
|
|
970
|
-
page.itemsCount = page.items.length;
|
|
971
|
-
});
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
items.mustFix.totalRuleIssues = items.mustFix.rules.length;
|
|
975
|
-
items.goodToFix.totalRuleIssues = items.goodToFix.rules.length;
|
|
976
|
-
items.needsReview.totalRuleIssues = items.needsReview.rules.length;
|
|
977
|
-
items.passed.totalRuleIssues = items.passed.rules.length;
|
|
978
|
-
|
|
979
|
-
const {
|
|
980
|
-
pagesScanned,
|
|
981
|
-
topTenPagesWithMostIssues,
|
|
982
|
-
pagesNotScanned,
|
|
983
|
-
wcagLinks,
|
|
984
|
-
wcagPassPercentage,
|
|
985
|
-
progressPercentage,
|
|
986
|
-
issuesPercentage,
|
|
987
|
-
totalPagesScanned,
|
|
988
|
-
totalPagesNotScanned,
|
|
989
|
-
topTenIssues,
|
|
990
|
-
} = rest;
|
|
991
|
-
|
|
992
|
-
const summaryItems = {
|
|
993
|
-
mustFix: {
|
|
994
|
-
totalItems: items.mustFix?.totalItems || 0,
|
|
995
|
-
totalRuleIssues: items.mustFix?.totalRuleIssues || 0,
|
|
996
|
-
},
|
|
997
|
-
goodToFix: {
|
|
998
|
-
totalItems: items.goodToFix?.totalItems || 0,
|
|
999
|
-
totalRuleIssues: items.goodToFix?.totalRuleIssues || 0,
|
|
1000
|
-
},
|
|
1001
|
-
needsReview: {
|
|
1002
|
-
totalItems: items.needsReview?.totalItems || 0,
|
|
1003
|
-
totalRuleIssues: items.needsReview?.totalRuleIssues || 0,
|
|
1004
|
-
},
|
|
1005
|
-
topTenPagesWithMostIssues,
|
|
1006
|
-
wcagLinks,
|
|
1007
|
-
wcagPassPercentage,
|
|
1008
|
-
progressPercentage,
|
|
1009
|
-
issuesPercentage,
|
|
1010
|
-
totalPagesScanned,
|
|
1011
|
-
totalPagesNotScanned,
|
|
1012
|
-
topTenIssues,
|
|
1013
|
-
};
|
|
1014
|
-
|
|
1015
|
-
const {
|
|
1016
|
-
jsonFilePath: scanItemsSummaryJsonFilePath,
|
|
1017
|
-
base64FilePath: scanItemsSummaryBase64FilePath,
|
|
1018
|
-
} = await writeJsonFileAndCompressedJsonFile(
|
|
1019
|
-
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems },
|
|
1020
|
-
storagePath,
|
|
1021
|
-
'scanItemsSummary',
|
|
1022
|
-
);
|
|
1023
|
-
|
|
1024
|
-
const {
|
|
1025
|
-
jsonFilePath: scanPagesDetailJsonFilePath,
|
|
1026
|
-
base64FilePath: scanPagesDetailBase64FilePath,
|
|
1027
|
-
} = await writeJsonFileAndCompressedJsonFile(
|
|
1028
|
-
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail },
|
|
1029
|
-
storagePath,
|
|
1030
|
-
'scanPagesDetail',
|
|
1031
|
-
);
|
|
1032
|
-
|
|
1033
|
-
const {
|
|
1034
|
-
jsonFilePath: scanPagesSummaryJsonFilePath,
|
|
1035
|
-
base64FilePath: scanPagesSummaryBase64FilePath,
|
|
1036
|
-
} = await writeJsonFileAndCompressedJsonFile(
|
|
1037
|
-
{ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary },
|
|
1038
|
-
storagePath,
|
|
1039
|
-
'scanPagesSummary',
|
|
1040
|
-
);
|
|
1041
|
-
|
|
1042
|
-
return {
|
|
1043
|
-
scanDataJsonFilePath,
|
|
1044
|
-
scanDataBase64FilePath,
|
|
1045
|
-
scanItemsJsonFilePath,
|
|
1046
|
-
scanItemsBase64FilePath,
|
|
1047
|
-
scanItemsSummaryJsonFilePath,
|
|
1048
|
-
scanItemsSummaryBase64FilePath,
|
|
1049
|
-
scanIssuesSummaryJsonFilePath,
|
|
1050
|
-
scanIssuesSummaryBase64FilePath,
|
|
1051
|
-
scanPagesDetailJsonFilePath,
|
|
1052
|
-
scanPagesDetailBase64FilePath,
|
|
1053
|
-
scanPagesSummaryJsonFilePath,
|
|
1054
|
-
scanPagesSummaryBase64FilePath,
|
|
1055
|
-
scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
|
|
1056
|
-
scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
|
|
1057
|
-
};
|
|
1058
|
-
};
|
|
1059
|
-
|
|
1060
|
-
const writeScanDetailsCsv = async (
|
|
1061
|
-
scanDataFilePath: string,
|
|
1062
|
-
scanItemsFilePath: string,
|
|
1063
|
-
scanItemsSummaryFilePath: string,
|
|
1064
|
-
storagePath: string,
|
|
1065
|
-
) => {
|
|
1066
|
-
const filePath = path.join(storagePath, 'scanDetails.csv');
|
|
1067
|
-
const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
|
|
1068
|
-
const directoryPath = path.dirname(filePath);
|
|
1069
|
-
|
|
1070
|
-
if (!fs.existsSync(directoryPath)) {
|
|
1071
|
-
fs.mkdirSync(directoryPath, { recursive: true });
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
|
|
1075
|
-
await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
|
|
1076
|
-
await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
|
|
1077
|
-
await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
|
|
1078
|
-
|
|
1079
|
-
await new Promise((resolve, reject) => {
|
|
1080
|
-
csvWriteStream.end(resolve);
|
|
1081
|
-
csvWriteStream.on('error', reject);
|
|
1082
|
-
});
|
|
1083
|
-
};
|
|
1084
|
-
|
|
1085
345
|
const writeSummaryPdf = async (
|
|
1086
346
|
storagePath: string,
|
|
1087
347
|
pagesScanned: number,
|
|
@@ -1089,62 +349,67 @@ const writeSummaryPdf = async (
|
|
|
1089
349
|
browser: string,
|
|
1090
350
|
_userDataDirectory: string,
|
|
1091
351
|
) => {
|
|
1092
|
-
|
|
1093
|
-
|
|
352
|
+
let browserInstance;
|
|
353
|
+
let context;
|
|
354
|
+
let page;
|
|
1094
355
|
|
|
1095
|
-
|
|
356
|
+
try {
|
|
357
|
+
const htmlFilePath = path.join(storagePath, `${filename}.html`);
|
|
358
|
+
const fileDestinationPath = path.join(storagePath, `${filename}.pdf`);
|
|
359
|
+
const htmlFileUrl = `file://${htmlFilePath}`;
|
|
1096
360
|
|
|
1097
|
-
|
|
1098
|
-
...launchOptions,
|
|
1099
|
-
headless: true, // force headless for PDF
|
|
1100
|
-
});
|
|
361
|
+
const launchOptions = getPlaywrightLaunchOptions(browser);
|
|
1101
362
|
|
|
1102
|
-
|
|
363
|
+
browserInstance = await constants.launcher.launch({
|
|
364
|
+
...launchOptions,
|
|
365
|
+
headless: true,
|
|
366
|
+
});
|
|
1103
367
|
|
|
1104
|
-
|
|
1105
|
-
const page = await context.newPage();
|
|
368
|
+
register(browserInstance as unknown as { close: () => Promise<void> });
|
|
1106
369
|
|
|
1107
|
-
|
|
1108
|
-
|
|
370
|
+
context = await browserInstance.newContext();
|
|
371
|
+
page = await context.newPage();
|
|
1109
372
|
|
|
1110
|
-
|
|
373
|
+
await page.goto(htmlFileUrl, {
|
|
374
|
+
waitUntil: 'domcontentloaded',
|
|
375
|
+
timeout: 120000,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
await page.emulateMedia({ media: 'print' });
|
|
1111
379
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
380
|
+
await page.pdf({
|
|
381
|
+
margin: { bottom: '32px' },
|
|
382
|
+
path: fileDestinationPath,
|
|
383
|
+
format: 'A4',
|
|
384
|
+
displayHeaderFooter: true,
|
|
385
|
+
footerTemplate: `
|
|
1118
386
|
<div style="margin-top:50px;color:#26241b;font-family:Open Sans;text-align: center;width: 100%;font-weight:400">
|
|
1119
387
|
<span style="color:#26241b;font-size: 14px;font-weight:400">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
|
|
1120
388
|
</div>
|
|
1121
389
|
`,
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
await page.close();
|
|
1125
|
-
await context.close().catch(() => {});
|
|
1126
|
-
await browserInstance.close().catch(() => {});
|
|
390
|
+
});
|
|
1127
391
|
|
|
1128
|
-
|
|
1129
|
-
|
|
392
|
+
if (pagesScanned < 2000) {
|
|
393
|
+
fs.unlinkSync(htmlFilePath);
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
consoleLogger.info(`Error at writeSummaryPDF ${err instanceof Error ? err.stack : err}`);
|
|
397
|
+
} finally {
|
|
398
|
+
await page?.close().catch(err => {
|
|
399
|
+
consoleLogger.info(`Error at page close writeSummaryPDF ${err}`);
|
|
400
|
+
});
|
|
401
|
+
await context?.close().catch(err => {
|
|
402
|
+
consoleLogger.info(`Error at context close writeSummaryPDF ${err}`);
|
|
403
|
+
});
|
|
404
|
+
await browserInstance?.close().catch(err => {
|
|
405
|
+
consoleLogger.info(`Error at browserInstance close writeSummaryPDF ${err}`);
|
|
406
|
+
});
|
|
1130
407
|
}
|
|
1131
408
|
};
|
|
1132
409
|
|
|
1133
410
|
// Tracking WCAG occurrences
|
|
1134
411
|
const wcagOccurrencesMap = new Map<string, number>();
|
|
1135
412
|
|
|
1136
|
-
// Format WCAG tag in requested format: wcag111a_Occurrences
|
|
1137
|
-
const formatWcagTag = async (wcagId: string): Promise<string | null> => {
|
|
1138
|
-
// Get dynamic WCAG criteria map
|
|
1139
|
-
const wcagCriteriaMap = await getWcagCriteriaMap();
|
|
1140
|
-
|
|
1141
|
-
if (wcagCriteriaMap[wcagId]) {
|
|
1142
|
-
const { level } = wcagCriteriaMap[wcagId];
|
|
1143
|
-
return `${wcagId}${level}_Occurrences`;
|
|
1144
|
-
}
|
|
1145
|
-
return null;
|
|
1146
|
-
};
|
|
1147
|
-
|
|
1148
413
|
const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
1149
414
|
const { url, pageTitle, filePath } = pageResults;
|
|
1150
415
|
|
|
@@ -1206,10 +471,10 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
|
1206
471
|
const currRuleFromAllIssues = currCategoryFromAllIssues.rules[rule];
|
|
1207
472
|
|
|
1208
473
|
currRuleFromAllIssues.totalItems += count;
|
|
1209
|
-
|
|
474
|
+
|
|
1210
475
|
// Build htmlGroups for pre-computed Group by HTML Element
|
|
1211
476
|
buildHtmlGroups(currRuleFromAllIssues, items, url);
|
|
1212
|
-
|
|
477
|
+
|
|
1213
478
|
if (isCustomFlow) {
|
|
1214
479
|
const { pageIndex, pageImagePath, metadata } = pageResults;
|
|
1215
480
|
currRuleFromAllIssues.pagesAffected[pageIndex] = {
|
|
@@ -1219,85 +484,17 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
|
1219
484
|
metadata,
|
|
1220
485
|
items: [...items],
|
|
1221
486
|
};
|
|
1222
|
-
} else {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
|
|
487
|
+
} else if (!(url in currRuleFromAllIssues.pagesAffected)) {
|
|
488
|
+
currRuleFromAllIssues.pagesAffected[url] = {
|
|
489
|
+
pageTitle,
|
|
490
|
+
items: [...items],
|
|
491
|
+
...(filePath && { filePath }),
|
|
492
|
+
};
|
|
1231
493
|
}
|
|
1232
494
|
});
|
|
1233
495
|
});
|
|
1234
496
|
};
|
|
1235
497
|
|
|
1236
|
-
/**
|
|
1237
|
-
* Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
|
|
1238
|
-
* Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
|
|
1239
|
-
*/
|
|
1240
|
-
const buildHtmlGroups = (
|
|
1241
|
-
rule: RuleInfo,
|
|
1242
|
-
items: ItemsInfo[],
|
|
1243
|
-
pageUrl: string
|
|
1244
|
-
) => {
|
|
1245
|
-
if (!rule.htmlGroups) {
|
|
1246
|
-
rule.htmlGroups = {};
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
items.forEach(item => {
|
|
1250
|
-
// Use composite key of html + xpath for precise matching
|
|
1251
|
-
const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
|
|
1252
|
-
|
|
1253
|
-
if (!rule.htmlGroups![htmlKey]) {
|
|
1254
|
-
// Create new group with the first occurrence
|
|
1255
|
-
rule.htmlGroups![htmlKey] = {
|
|
1256
|
-
html: item.html || '',
|
|
1257
|
-
xpath: item.xpath || '',
|
|
1258
|
-
message: item.message || '',
|
|
1259
|
-
screenshotPath: item.screenshotPath || '',
|
|
1260
|
-
displayNeedsReview: item.displayNeedsReview,
|
|
1261
|
-
pageUrls: [],
|
|
1262
|
-
};
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
if (!rule.htmlGroups![htmlKey].pageUrls.includes(pageUrl)) {
|
|
1266
|
-
rule.htmlGroups![htmlKey].pageUrls.push(pageUrl);
|
|
1267
|
-
}
|
|
1268
|
-
});
|
|
1269
|
-
};
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
|
|
1273
|
-
* Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
|
|
1274
|
-
* Those refs are specifically for htmlGroups lookup (html + xpath).
|
|
1275
|
-
*/
|
|
1276
|
-
export const convertItemsToReferences = (allIssues: AllIssues): AllIssues => {
|
|
1277
|
-
const cloned = JSON.parse(JSON.stringify(allIssues));
|
|
1278
|
-
|
|
1279
|
-
['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
|
|
1280
|
-
if (!cloned.items[category]?.rules) return;
|
|
1281
|
-
|
|
1282
|
-
cloned.items[category].rules.forEach((rule: any) => {
|
|
1283
|
-
if (!rule.pagesAffected || !rule.htmlGroups) return;
|
|
1284
|
-
|
|
1285
|
-
rule.pagesAffected.forEach((page: any) => {
|
|
1286
|
-
if (!page.items) return;
|
|
1287
|
-
|
|
1288
|
-
page.items = page.items.map((item: any) => {
|
|
1289
|
-
if (typeof item === 'string') return item; // Already a reference
|
|
1290
|
-
// Use composite key matching buildHtmlGroups
|
|
1291
|
-
const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
|
|
1292
|
-
return htmlKey;
|
|
1293
|
-
});
|
|
1294
|
-
});
|
|
1295
|
-
});
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
|
-
return cloned;
|
|
1299
|
-
};
|
|
1300
|
-
|
|
1301
498
|
const getTopTenIssues = allIssues => {
|
|
1302
499
|
const categories = ['mustFix', 'goodToFix'];
|
|
1303
500
|
const rulesWithCounts = [];
|
|
@@ -1401,7 +598,12 @@ function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<s
|
|
|
1401
598
|
});
|
|
1402
599
|
}
|
|
1403
600
|
|
|
1404
|
-
const extractRuleAiData = (
|
|
601
|
+
const extractRuleAiData = (
|
|
602
|
+
ruleId: string,
|
|
603
|
+
totalItems: number,
|
|
604
|
+
items: any[],
|
|
605
|
+
callback?: () => void,
|
|
606
|
+
) => {
|
|
1405
607
|
let snippets = [];
|
|
1406
608
|
|
|
1407
609
|
if (oobeeAiRules.includes(ruleId)) {
|
|
@@ -1421,8 +623,7 @@ const extractRuleAiData = (ruleId: string, totalItems: number, items: any[], cal
|
|
|
1421
623
|
};
|
|
1422
624
|
|
|
1423
625
|
// This is for telemetry purposes called within mergeAxeResults.ts
|
|
1424
|
-
export
|
|
1425
|
-
const createRuleIdJson = allIssues => {
|
|
626
|
+
export const createRuleIdJson = allIssues => {
|
|
1426
627
|
const compiledRuleJson = {};
|
|
1427
628
|
|
|
1428
629
|
['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
|
|
@@ -1445,9 +646,11 @@ export const createBasicFormHTMLSnippet = filteredResults => {
|
|
|
1445
646
|
|
|
1446
647
|
['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
|
|
1447
648
|
if (filteredResults[category] && filteredResults[category].rules) {
|
|
1448
|
-
Object.entries(filteredResults[category].rules).forEach(
|
|
1449
|
-
|
|
1450
|
-
|
|
649
|
+
Object.entries(filteredResults[category].rules).forEach(
|
|
650
|
+
([ruleId, ruleVal]: [string, any]) => {
|
|
651
|
+
compiledRuleJson[ruleId] = extractRuleAiData(ruleId, ruleVal.totalItems, ruleVal.items);
|
|
652
|
+
},
|
|
653
|
+
);
|
|
1451
654
|
}
|
|
1452
655
|
});
|
|
1453
656
|
|
|
@@ -1462,429 +665,6 @@ const moveElemScreenshots = (randomToken: string, storagePath: string) => {
|
|
|
1462
665
|
}
|
|
1463
666
|
};
|
|
1464
667
|
|
|
1465
|
-
/**
|
|
1466
|
-
* Build allIssues.scanPagesDetail and allIssues.scanPagesSummary
|
|
1467
|
-
* by analyzing pagesScanned (including mustFix/goodToFix/etc.).
|
|
1468
|
-
*/
|
|
1469
|
-
function populateScanPagesDetail(allIssues: AllIssues): void {
|
|
1470
|
-
// --------------------------------------------
|
|
1471
|
-
// 1) Gather your "scanned" pages from allIssues
|
|
1472
|
-
// --------------------------------------------
|
|
1473
|
-
const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
|
|
1474
|
-
|
|
1475
|
-
// --------------------------------------------
|
|
1476
|
-
// 2) Define category constants (optional, just for clarity)
|
|
1477
|
-
// --------------------------------------------
|
|
1478
|
-
const mustFixCategory = 'mustFix';
|
|
1479
|
-
const goodToFixCategory = 'goodToFix';
|
|
1480
|
-
const needsReviewCategory = 'needsReview';
|
|
1481
|
-
const passedCategory = 'passed';
|
|
1482
|
-
|
|
1483
|
-
// --------------------------------------------
|
|
1484
|
-
// 3) Set up type declarations (if you want them local to this function)
|
|
1485
|
-
// --------------------------------------------
|
|
1486
|
-
type RuleData = {
|
|
1487
|
-
ruleId: string;
|
|
1488
|
-
wcagConformance: string[];
|
|
1489
|
-
occurrencesMustFix: number;
|
|
1490
|
-
occurrencesGoodToFix: number;
|
|
1491
|
-
occurrencesNeedsReview: number;
|
|
1492
|
-
occurrencesPassed: number;
|
|
1493
|
-
};
|
|
1494
|
-
|
|
1495
|
-
type PageData = {
|
|
1496
|
-
pageTitle: string;
|
|
1497
|
-
url: string;
|
|
1498
|
-
// Summaries
|
|
1499
|
-
totalOccurrencesFailedIncludingNeedsReview: number; // mustFix + goodToFix + needsReview
|
|
1500
|
-
totalOccurrencesFailedExcludingNeedsReview: number; // mustFix + goodToFix
|
|
1501
|
-
totalOccurrencesNeedsReview: number; // needsReview
|
|
1502
|
-
totalOccurrencesPassed: number; // passed only
|
|
1503
|
-
typesOfIssues: Record<string, RuleData>;
|
|
1504
|
-
};
|
|
1505
|
-
|
|
1506
|
-
// --------------------------------------------
|
|
1507
|
-
// 4) We'll accumulate pages in a map keyed by URL
|
|
1508
|
-
// --------------------------------------------
|
|
1509
|
-
const pagesMap: Record<string, PageData> = {};
|
|
1510
|
-
|
|
1511
|
-
// --------------------------------------------
|
|
1512
|
-
// 5) Build pagesMap by iterating over each category in allIssues.items
|
|
1513
|
-
// --------------------------------------------
|
|
1514
|
-
Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
|
|
1515
|
-
if (!categoryData?.rules) return; // no rules in this category? skip
|
|
1516
|
-
|
|
1517
|
-
categoryData.rules.forEach(rule => {
|
|
1518
|
-
const { rule: ruleId, conformance = [] } = rule;
|
|
1519
|
-
|
|
1520
|
-
rule.pagesAffected.forEach(p => {
|
|
1521
|
-
const { url, pageTitle, items = [] } = p;
|
|
1522
|
-
const itemsCount = items.length;
|
|
1523
|
-
|
|
1524
|
-
// Ensure the page is in pagesMap
|
|
1525
|
-
if (!pagesMap[url]) {
|
|
1526
|
-
pagesMap[url] = {
|
|
1527
|
-
pageTitle,
|
|
1528
|
-
url,
|
|
1529
|
-
totalOccurrencesFailedIncludingNeedsReview: 0,
|
|
1530
|
-
totalOccurrencesFailedExcludingNeedsReview: 0,
|
|
1531
|
-
totalOccurrencesNeedsReview: 0,
|
|
1532
|
-
totalOccurrencesPassed: 0,
|
|
1533
|
-
typesOfIssues: {},
|
|
1534
|
-
};
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// Ensure the rule is present for this page
|
|
1538
|
-
if (!pagesMap[url].typesOfIssues[ruleId]) {
|
|
1539
|
-
pagesMap[url].typesOfIssues[ruleId] = {
|
|
1540
|
-
ruleId,
|
|
1541
|
-
wcagConformance: conformance,
|
|
1542
|
-
occurrencesMustFix: 0,
|
|
1543
|
-
occurrencesGoodToFix: 0,
|
|
1544
|
-
occurrencesNeedsReview: 0,
|
|
1545
|
-
occurrencesPassed: 0,
|
|
1546
|
-
};
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
// Depending on the category, increment the relevant occurrence counts
|
|
1550
|
-
if (categoryName === mustFixCategory) {
|
|
1551
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
|
|
1552
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1553
|
-
pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
|
|
1554
|
-
} else if (categoryName === goodToFixCategory) {
|
|
1555
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
|
|
1556
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1557
|
-
pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
|
|
1558
|
-
} else if (categoryName === needsReviewCategory) {
|
|
1559
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
|
|
1560
|
-
pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
|
|
1561
|
-
pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
|
|
1562
|
-
} else if (categoryName === passedCategory) {
|
|
1563
|
-
pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
|
|
1564
|
-
pagesMap[url].totalOccurrencesPassed += itemsCount;
|
|
1565
|
-
}
|
|
1566
|
-
});
|
|
1567
|
-
});
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
// --------------------------------------------
|
|
1571
|
-
// 6) Separate scanned pages into “affected” vs. “notAffected”
|
|
1572
|
-
// --------------------------------------------
|
|
1573
|
-
const pagesInMap = Object.values(pagesMap); // All pages that have some record in pagesMap
|
|
1574
|
-
const pagesInMapUrls = new Set(Object.keys(pagesMap));
|
|
1575
|
-
|
|
1576
|
-
// (a) Pages with only passed (no mustFix/goodToFix/needsReview)
|
|
1577
|
-
const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
|
|
1578
|
-
|
|
1579
|
-
// (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
|
|
1580
|
-
const pagesNoEntries = allScannedPages
|
|
1581
|
-
.filter(sp => !pagesInMapUrls.has(sp.url))
|
|
1582
|
-
.map(sp => ({
|
|
1583
|
-
pageTitle: sp.pageTitle,
|
|
1584
|
-
url: sp.url,
|
|
1585
|
-
totalOccurrencesFailedIncludingNeedsReview: 0,
|
|
1586
|
-
totalOccurrencesFailedExcludingNeedsReview: 0,
|
|
1587
|
-
totalOccurrencesNeedsReview: 0,
|
|
1588
|
-
totalOccurrencesPassed: 0,
|
|
1589
|
-
typesOfIssues: {},
|
|
1590
|
-
}));
|
|
1591
|
-
|
|
1592
|
-
// Combine these into "notAffected"
|
|
1593
|
-
const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
|
|
1594
|
-
|
|
1595
|
-
// "affected" pages => have at least 1 mustFix/goodToFix/needsReview
|
|
1596
|
-
const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
|
|
1597
|
-
|
|
1598
|
-
// --------------------------------------------
|
|
1599
|
-
// 7) Transform both arrays to the final shape
|
|
1600
|
-
// --------------------------------------------
|
|
1601
|
-
function transformPageData(page: PageData) {
|
|
1602
|
-
const typesOfIssuesArray = Object.values(page.typesOfIssues);
|
|
1603
|
-
|
|
1604
|
-
// Compute sums for each failing category
|
|
1605
|
-
const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
|
|
1606
|
-
const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
|
|
1607
|
-
const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
|
|
1608
|
-
|
|
1609
|
-
// Build categoriesPresent based on nonzero failing counts
|
|
1610
|
-
const categoriesPresent: string[] = [];
|
|
1611
|
-
if (mustFixSum > 0) categoriesPresent.push('mustFix');
|
|
1612
|
-
if (goodToFixSum > 0) categoriesPresent.push('goodToFix');
|
|
1613
|
-
if (needsReviewSum > 0) categoriesPresent.push('needsReview');
|
|
1614
|
-
|
|
1615
|
-
// Count how many rules have failing issues
|
|
1616
|
-
const failedRuleIds = new Set<string>();
|
|
1617
|
-
typesOfIssuesArray.forEach(r => {
|
|
1618
|
-
if (
|
|
1619
|
-
(r.occurrencesMustFix || 0) > 0 ||
|
|
1620
|
-
(r.occurrencesGoodToFix || 0) > 0 ||
|
|
1621
|
-
(r.occurrencesNeedsReview || 0) > 0
|
|
1622
|
-
) {
|
|
1623
|
-
failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
|
|
1624
|
-
}
|
|
1625
|
-
});
|
|
1626
|
-
const failedRuleCount = failedRuleIds.size;
|
|
1627
|
-
|
|
1628
|
-
// Possibly these two for future convenience
|
|
1629
|
-
const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(
|
|
1630
|
-
r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0,
|
|
1631
|
-
).length;
|
|
1632
|
-
|
|
1633
|
-
const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(
|
|
1634
|
-
r =>
|
|
1635
|
-
(r.occurrencesNeedsReview || 0) > 0 &&
|
|
1636
|
-
(r.occurrencesMustFix || 0) === 0 &&
|
|
1637
|
-
(r.occurrencesGoodToFix || 0) === 0,
|
|
1638
|
-
).length;
|
|
1639
|
-
|
|
1640
|
-
// Aggregate wcagConformance for rules that actually fail
|
|
1641
|
-
const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
|
|
1642
|
-
const nonPassedCount =
|
|
1643
|
-
(curr.occurrencesMustFix || 0) +
|
|
1644
|
-
(curr.occurrencesGoodToFix || 0) +
|
|
1645
|
-
(curr.occurrencesNeedsReview || 0);
|
|
1646
|
-
|
|
1647
|
-
if (nonPassedCount > 0) {
|
|
1648
|
-
return acc.concat(curr.wcagConformance || []);
|
|
1649
|
-
}
|
|
1650
|
-
return acc;
|
|
1651
|
-
}, [] as string[]);
|
|
1652
|
-
// Remove duplicates
|
|
1653
|
-
const conformance = Array.from(new Set(allConformance));
|
|
1654
|
-
|
|
1655
|
-
return {
|
|
1656
|
-
pageTitle: page.pageTitle,
|
|
1657
|
-
url: page.url,
|
|
1658
|
-
totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
|
|
1659
|
-
totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
|
|
1660
|
-
totalOccurrencesMustFix: mustFixSum,
|
|
1661
|
-
totalOccurrencesGoodToFix: goodToFixSum,
|
|
1662
|
-
totalOccurrencesNeedsReview: needsReviewSum,
|
|
1663
|
-
totalOccurrencesPassed: page.totalOccurrencesPassed,
|
|
1664
|
-
typesOfIssuesExclusiveToNeedsReviewCount,
|
|
1665
|
-
typesOfIssuesCount: failedRuleCount,
|
|
1666
|
-
typesOfIssuesExcludingNeedsReviewCount,
|
|
1667
|
-
categoriesPresent,
|
|
1668
|
-
conformance,
|
|
1669
|
-
// Keep full detail for "scanPagesDetail"
|
|
1670
|
-
typesOfIssues: typesOfIssuesArray,
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
// Transform raw pages
|
|
1675
|
-
const pagesAffected = pagesAffectedRaw.map(transformPageData);
|
|
1676
|
-
const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
|
|
1677
|
-
|
|
1678
|
-
// --------------------------------------------
|
|
1679
|
-
// 8) Sort pages by typesOfIssuesCount (descending) for both arrays
|
|
1680
|
-
// --------------------------------------------
|
|
1681
|
-
pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
|
|
1682
|
-
pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
|
|
1683
|
-
|
|
1684
|
-
// --------------------------------------------
|
|
1685
|
-
// 9) Compute scanned/ skipped counts
|
|
1686
|
-
// --------------------------------------------
|
|
1687
|
-
const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
|
|
1688
|
-
const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
|
|
1689
|
-
? allIssues.pagesNotScanned.length
|
|
1690
|
-
: 0;
|
|
1691
|
-
|
|
1692
|
-
// --------------------------------------------
|
|
1693
|
-
// 10) Build scanPagesDetail (with full "typesOfIssues")
|
|
1694
|
-
// --------------------------------------------
|
|
1695
|
-
allIssues.scanPagesDetail = {
|
|
1696
|
-
pagesAffected,
|
|
1697
|
-
pagesNotAffected,
|
|
1698
|
-
scannedPagesCount,
|
|
1699
|
-
pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
|
|
1700
|
-
pagesNotScannedCount,
|
|
1701
|
-
};
|
|
1702
|
-
|
|
1703
|
-
// --------------------------------------------
|
|
1704
|
-
// 11) Build scanPagesSummary (strip out "typesOfIssues")
|
|
1705
|
-
// --------------------------------------------
|
|
1706
|
-
function stripTypesOfIssues(page: ReturnType<typeof transformPageData>) {
|
|
1707
|
-
const { typesOfIssues, ...rest } = page;
|
|
1708
|
-
return rest;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
|
|
1712
|
-
const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
|
|
1713
|
-
|
|
1714
|
-
allIssues.scanPagesSummary = {
|
|
1715
|
-
pagesAffected: summaryPagesAffected,
|
|
1716
|
-
pagesNotAffected: summaryPagesNotAffected,
|
|
1717
|
-
scannedPagesCount,
|
|
1718
|
-
pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
|
|
1719
|
-
pagesNotScannedCount,
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Send WCAG criteria breakdown to Sentry
|
|
1724
|
-
export const sendWcagBreakdownToSentry = async (
|
|
1725
|
-
appVersion: string,
|
|
1726
|
-
wcagBreakdown: Map<string, number>,
|
|
1727
|
-
ruleIdJson: any,
|
|
1728
|
-
scanInfo: {
|
|
1729
|
-
entryUrl: string;
|
|
1730
|
-
scanType: string;
|
|
1731
|
-
browser: string;
|
|
1732
|
-
email?: string;
|
|
1733
|
-
name?: string;
|
|
1734
|
-
},
|
|
1735
|
-
allIssues?: AllIssues,
|
|
1736
|
-
pagesScannedCount: number = 0,
|
|
1737
|
-
) => {
|
|
1738
|
-
try {
|
|
1739
|
-
// Initialize Sentry
|
|
1740
|
-
Sentry.init(sentryConfig);
|
|
1741
|
-
// Set user ID for Sentry tracking
|
|
1742
|
-
const userData = getUserDataTxt();
|
|
1743
|
-
if (userData && userData.userId) {
|
|
1744
|
-
setSentryUser(userData.userId);
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
// Prepare tags for the event
|
|
1748
|
-
const tags: Record<string, string> = {};
|
|
1749
|
-
const wcagCriteriaBreakdown: Record<string, any> = {};
|
|
1750
|
-
|
|
1751
|
-
// Tag app version
|
|
1752
|
-
tags.version = appVersion;
|
|
1753
|
-
|
|
1754
|
-
// Get dynamic WCAG criteria map once
|
|
1755
|
-
const wcagCriteriaMap = await getWcagCriteriaMap();
|
|
1756
|
-
|
|
1757
|
-
// Categorize all WCAG criteria for reporting
|
|
1758
|
-
const wcagIds = Array.from(
|
|
1759
|
-
new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]),
|
|
1760
|
-
);
|
|
1761
|
-
const categorizedWcag = await categorizeWcagCriteria(wcagIds);
|
|
1762
|
-
|
|
1763
|
-
// First ensure all WCAG criteria are included in the tags with a value of 0
|
|
1764
|
-
// This ensures criteria with no violations are still reported
|
|
1765
|
-
for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
|
|
1766
|
-
const formattedTag = await formatWcagTag(wcagId);
|
|
1767
|
-
if (formattedTag) {
|
|
1768
|
-
// Initialize with zero
|
|
1769
|
-
tags[formattedTag] = '0';
|
|
1770
|
-
|
|
1771
|
-
// Store in breakdown object with category information
|
|
1772
|
-
wcagCriteriaBreakdown[formattedTag] = {
|
|
1773
|
-
count: 0,
|
|
1774
|
-
category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
|
|
1775
|
-
};
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
// Now override with actual counts from the scan
|
|
1780
|
-
for (const [wcagId, count] of wcagBreakdown.entries()) {
|
|
1781
|
-
const formattedTag = await formatWcagTag(wcagId);
|
|
1782
|
-
if (formattedTag) {
|
|
1783
|
-
// Add as a tag with the count as value
|
|
1784
|
-
tags[formattedTag] = String(count);
|
|
1785
|
-
|
|
1786
|
-
// Update count in breakdown object
|
|
1787
|
-
if (wcagCriteriaBreakdown[formattedTag]) {
|
|
1788
|
-
wcagCriteriaBreakdown[formattedTag].count = count;
|
|
1789
|
-
} else {
|
|
1790
|
-
// If somehow this wasn't in our initial map
|
|
1791
|
-
wcagCriteriaBreakdown[formattedTag] = {
|
|
1792
|
-
count,
|
|
1793
|
-
category: categorizedWcag[wcagId] || 'mustFix',
|
|
1794
|
-
};
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// Calculate category counts based on actual issue counts from the report
|
|
1800
|
-
// rather than occurrence counts from wcagBreakdown
|
|
1801
|
-
const categoryCounts = {
|
|
1802
|
-
mustFix: 0,
|
|
1803
|
-
goodToFix: 0,
|
|
1804
|
-
needsReview: 0,
|
|
1805
|
-
};
|
|
1806
|
-
|
|
1807
|
-
if (allIssues) {
|
|
1808
|
-
// Use the actual report data for the counts
|
|
1809
|
-
categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
|
|
1810
|
-
categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
|
|
1811
|
-
categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
|
|
1812
|
-
} else {
|
|
1813
|
-
// Fallback to the old way if allIssues not provided
|
|
1814
|
-
Object.values(wcagCriteriaBreakdown).forEach(item => {
|
|
1815
|
-
if (item.count > 0 && categoryCounts[item.category] !== undefined) {
|
|
1816
|
-
categoryCounts[item.category] += 1; // Count rules, not occurrences
|
|
1817
|
-
}
|
|
1818
|
-
});
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
// Add category counts as tags
|
|
1822
|
-
tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
|
|
1823
|
-
tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
|
|
1824
|
-
tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
|
|
1825
|
-
|
|
1826
|
-
// Also add occurrence counts for reference
|
|
1827
|
-
if (allIssues) {
|
|
1828
|
-
tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
|
|
1829
|
-
tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
|
|
1830
|
-
tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
|
|
1831
|
-
|
|
1832
|
-
// Add number of pages scanned tag
|
|
1833
|
-
tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
|
|
1834
|
-
} else if (pagesScannedCount > 0) {
|
|
1835
|
-
// Still add the pages scanned count even if we don't have allIssues
|
|
1836
|
-
tags['Pages-Scanned-Count'] = String(pagesScannedCount);
|
|
1837
|
-
}
|
|
1838
|
-
|
|
1839
|
-
// Send the event to Sentry
|
|
1840
|
-
await Sentry.captureEvent({
|
|
1841
|
-
message: 'Accessibility Scan Completed',
|
|
1842
|
-
level: 'info',
|
|
1843
|
-
tags: {
|
|
1844
|
-
...tags,
|
|
1845
|
-
event_type: 'accessibility_scan',
|
|
1846
|
-
scanType: scanInfo.scanType,
|
|
1847
|
-
browser: scanInfo.browser,
|
|
1848
|
-
entryUrl: scanInfo.entryUrl,
|
|
1849
|
-
},
|
|
1850
|
-
user: {
|
|
1851
|
-
...(scanInfo.email && scanInfo.name
|
|
1852
|
-
? {
|
|
1853
|
-
email: scanInfo.email,
|
|
1854
|
-
username: scanInfo.name,
|
|
1855
|
-
}
|
|
1856
|
-
: {}),
|
|
1857
|
-
...(userData && userData.userId ? { id: userData.userId } : {}),
|
|
1858
|
-
},
|
|
1859
|
-
extra: {
|
|
1860
|
-
additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : '{}',
|
|
1861
|
-
wcagBreakdown: wcagCriteriaBreakdown,
|
|
1862
|
-
reportCounts: allIssues
|
|
1863
|
-
? {
|
|
1864
|
-
mustFix: {
|
|
1865
|
-
issues: allIssues.items.mustFix.rules?.length ?? 0,
|
|
1866
|
-
occurrences: allIssues.items.mustFix.totalItems ?? 0,
|
|
1867
|
-
},
|
|
1868
|
-
goodToFix: {
|
|
1869
|
-
issues: allIssues.items.goodToFix.rules?.length ?? 0,
|
|
1870
|
-
occurrences: allIssues.items.goodToFix.totalItems ?? 0,
|
|
1871
|
-
},
|
|
1872
|
-
needsReview: {
|
|
1873
|
-
issues: allIssues.items.needsReview.rules?.length ?? 0,
|
|
1874
|
-
occurrences: allIssues.items.needsReview.totalItems ?? 0,
|
|
1875
|
-
},
|
|
1876
|
-
}
|
|
1877
|
-
: undefined,
|
|
1878
|
-
},
|
|
1879
|
-
});
|
|
1880
|
-
|
|
1881
|
-
// Wait for events to be sent
|
|
1882
|
-
await Sentry.flush(2000);
|
|
1883
|
-
} catch (error) {
|
|
1884
|
-
console.error('Error sending WCAG breakdown to Sentry:', error);
|
|
1885
|
-
}
|
|
1886
|
-
};
|
|
1887
|
-
|
|
1888
668
|
const formatAboutStartTime = (dateString: string) => {
|
|
1889
669
|
const utcStartTimeDate = new Date(dateString);
|
|
1890
670
|
const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
|
|
@@ -1939,7 +719,6 @@ const generateArtifacts = async (
|
|
|
1939
719
|
zip: string = undefined, // optional
|
|
1940
720
|
generateJsonFiles = false,
|
|
1941
721
|
) => {
|
|
1942
|
-
|
|
1943
722
|
consoleLogger.info('Generating report artifacts');
|
|
1944
723
|
|
|
1945
724
|
const storagePath = getStoragePath(randomToken);
|
|
@@ -2037,7 +816,7 @@ const generateArtifacts = async (
|
|
|
2037
816
|
await pushResults(pageResults, allIssues, isCustomFlow);
|
|
2038
817
|
}),
|
|
2039
818
|
).catch(flattenIssuesError => {
|
|
2040
|
-
|
|
819
|
+
consoleLogger.error(
|
|
2041
820
|
`[generateArtifacts] Error flattening issues: ${flattenIssuesError?.stack || flattenIssuesError}`,
|
|
2042
821
|
);
|
|
2043
822
|
});
|
|
@@ -2176,22 +955,22 @@ const generateArtifacts = async (
|
|
|
2176
955
|
scanItemsBase64FilePath,
|
|
2177
956
|
);
|
|
2178
957
|
|
|
2179
|
-
if (!generateJsonFiles) {
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
}
|
|
958
|
+
if (!generateJsonFiles) {
|
|
959
|
+
await cleanUpJsonFiles([
|
|
960
|
+
scanDataJsonFilePath,
|
|
961
|
+
scanDataBase64FilePath,
|
|
962
|
+
scanItemsJsonFilePath,
|
|
963
|
+
scanItemsBase64FilePath,
|
|
964
|
+
scanItemsSummaryJsonFilePath,
|
|
965
|
+
scanItemsSummaryBase64FilePath,
|
|
966
|
+
scanIssuesSummaryJsonFilePath,
|
|
967
|
+
scanIssuesSummaryBase64FilePath,
|
|
968
|
+
scanPagesDetailJsonFilePath,
|
|
969
|
+
scanPagesDetailBase64FilePath,
|
|
970
|
+
scanPagesSummaryJsonFilePath,
|
|
971
|
+
scanPagesSummaryBase64FilePath,
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
2195
974
|
|
|
2196
975
|
const browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
|
|
2197
976
|
|
|
@@ -2300,8 +1079,10 @@ if (!generateJsonFiles) {
|
|
|
2300
1079
|
export {
|
|
2301
1080
|
writeHTML,
|
|
2302
1081
|
compressJsonFileStreaming,
|
|
1082
|
+
convertItemsToReferences,
|
|
2303
1083
|
flattenAndSortResults,
|
|
2304
1084
|
populateScanPagesDetail,
|
|
1085
|
+
sendWcagBreakdownToSentry,
|
|
2305
1086
|
getWcagPassPercentage,
|
|
2306
1087
|
getProgressPercentage,
|
|
2307
1088
|
getIssuesPercentage,
|
|
@@ -2311,4 +1092,4 @@ export {
|
|
|
2311
1092
|
formatAboutStartTime,
|
|
2312
1093
|
};
|
|
2313
1094
|
|
|
2314
|
-
export default generateArtifacts;
|
|
1095
|
+
export default generateArtifacts;
|