@govtechsg/oobee 0.10.21 → 0.10.28

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.
Files changed (39) hide show
  1. package/.github/workflows/docker-test.yml +1 -1
  2. package/DETAILS.md +40 -25
  3. package/Dockerfile +41 -47
  4. package/LICENSE-3RD-PARTY-REPORT.txt +448 -0
  5. package/LICENSE-3RD-PARTY.txt +19913 -0
  6. package/README.md +10 -2
  7. package/__mocks__/mock-report.html +1503 -1360
  8. package/package.json +8 -4
  9. package/scripts/decodeUnzipParse.js +29 -0
  10. package/scripts/install_oobee_dependencies.command +2 -2
  11. package/scripts/install_oobee_dependencies.ps1 +3 -3
  12. package/src/cli.ts +3 -2
  13. package/src/constants/cliFunctions.ts +16 -2
  14. package/src/constants/common.ts +29 -5
  15. package/src/constants/constants.ts +28 -26
  16. package/src/constants/questions.ts +4 -1
  17. package/src/crawlers/commonCrawlerFunc.ts +114 -152
  18. package/src/crawlers/crawlDomain.ts +25 -25
  19. package/src/crawlers/crawlIntelligentSitemap.ts +7 -1
  20. package/src/crawlers/crawlLocalFile.ts +1 -1
  21. package/src/crawlers/crawlSitemap.ts +1 -1
  22. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +546 -472
  23. package/src/crawlers/customAxeFunctions.ts +1 -1
  24. package/src/index.ts +0 -2
  25. package/src/mergeAxeResults.ts +569 -219
  26. package/src/screenshotFunc/pdfScreenshotFunc.ts +3 -3
  27. package/src/static/ejs/partials/components/wcagCompliance.ejs +10 -29
  28. package/src/static/ejs/partials/footer.ejs +10 -13
  29. package/src/static/ejs/partials/scripts/categorySummary.ejs +2 -2
  30. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
  31. package/src/static/ejs/partials/scripts/reportSearch.ejs +1 -0
  32. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +54 -52
  33. package/src/static/ejs/partials/styles/styles.ejs +4 -0
  34. package/src/static/ejs/partials/summaryMain.ejs +15 -42
  35. package/src/static/ejs/report.ejs +21 -12
  36. package/src/utils.ts +10 -2
  37. package/src/xPathToCss.ts +186 -0
  38. package/a11y-scan-results.zip +0 -0
  39. package/src/types/xpath-to-css.d.ts +0 -3
@@ -9,9 +9,11 @@ import { fileURLToPath } from 'url';
9
9
  import { chromium } from 'playwright';
10
10
  import { createWriteStream } from 'fs';
11
11
  import { AsyncParser, ParserOptions } from '@json2csv/node';
12
- import { v4 as uuidv4 } from 'uuid';
12
+ import zlib from 'zlib';
13
+ import { Base64Encode } from 'base64-stream';
14
+ import { pipeline } from 'stream/promises';
13
15
  import constants, { ScannerTypes } from './constants/constants.js';
14
- import { urlWithoutAuth } from './constants/common.js';
16
+ import { urlWithoutAuth, prepareData } from './constants/common.js';
15
17
  import {
16
18
  createScreenshotsFolder,
17
19
  getStoragePath,
@@ -34,6 +36,7 @@ export type ItemsInfo = {
34
36
 
35
37
  type PageInfo = {
36
38
  items: ItemsInfo[];
39
+ itemsCount?: number;
37
40
  pageTitle: string;
38
41
  url?: string;
39
42
  pageImagePath?: string;
@@ -51,6 +54,13 @@ export type RuleInfo = {
51
54
  helpUrl: string;
52
55
  };
53
56
 
57
+ type Category = {
58
+ description: string;
59
+ totalItems: number;
60
+ totalRuleIssues: number;
61
+ rules: RuleInfo[];
62
+ };
63
+
54
64
  type AllIssues = {
55
65
  storagePath: string;
56
66
  oobeeAi: {
@@ -61,6 +71,7 @@ type AllIssues = {
61
71
  endTime: Date;
62
72
  urlScanned: string;
63
73
  scanType: string;
74
+ deviceChosen: string;
64
75
  formatAboutStartTime: (dateString: any) => string;
65
76
  isCustomFlow: boolean;
66
77
  viewport: string;
@@ -70,14 +81,16 @@ type AllIssues = {
70
81
  totalPagesNotScanned: number;
71
82
  totalItems: number;
72
83
  topFiveMostIssues: Array<any>;
84
+ topTenPagesWithMostIssues: Array<any>;
85
+ topTenIssues: Array<any>;
73
86
  wcagViolations: string[];
74
87
  customFlowLabel: string;
75
88
  phAppVersion: string;
76
89
  items: {
77
- mustFix: { description: string; totalItems: number; rules: RuleInfo[] };
78
- goodToFix: { description: string; totalItems: number; rules: RuleInfo[] };
79
- needsReview: { description: string; totalItems: number; rules: RuleInfo[] };
80
- passed: { description: string; totalItems: number; rules: RuleInfo[] };
90
+ mustFix: Category;
91
+ goodToFix: Category;
92
+ needsReview: Category;
93
+ passed: Category;
81
94
  };
82
95
  cypressScanAboutMetadata: string;
83
96
  wcagLinks: { [key: string]: string };
@@ -135,7 +148,7 @@ const writeCsv = async (allIssues, storagePath) => {
135
148
  return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
136
149
  });
137
150
  };
138
- // seems to go into
151
+
139
152
  const flattenRule = catAndRule => {
140
153
  const [severity, rule] = catAndRule;
141
154
  const results = [];
@@ -154,39 +167,49 @@ const writeCsv = async (allIssues, storagePath) => {
154
167
  pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
155
168
  // format clauses as a string
156
169
  const wcagConformance = clausesArr.join(',');
170
+
157
171
  pagesAffected.forEach(affectedPage => {
158
172
  const { url, items } = affectedPage;
159
173
  items.forEach(item => {
160
174
  const { html, page, message, xpath } = item;
161
- const howToFix = message.replace(/(\r\n|\n|\r)/g, ' '); // remove newlines
175
+ const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
162
176
  const violation = html || formatPageViolation(page); // page is a number, not a string
163
177
  const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
164
178
 
165
179
  results.push({
166
- severity,
167
- issueId,
168
- issueDescription,
169
- wcagConformance,
170
- url,
171
- context,
172
- howToFix,
173
- axeImpact,
174
- xpath,
175
- learnMore,
180
+ customFlowLabel: allIssues.customFlowLabel || '',
181
+ deviceChosen: allIssues.deviceChosen || '',
182
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
183
+ severity: severity || '',
184
+ issueId: issueId || '',
185
+ issueDescription: issueDescription || '',
186
+ wcagConformance: wcagConformance || '',
187
+ url: url || '',
188
+ pageTitle: affectedPage.pageTitle || 'No page title',
189
+ context: context || '',
190
+ howToFix: howToFix || '',
191
+ axeImpact: axeImpact || '',
192
+ xpath: xpath || '',
193
+ learnMore: learnMore || '',
176
194
  });
177
195
  });
178
196
  });
179
197
  if (results.length === 0) return {};
180
198
  return results;
181
199
  };
200
+
182
201
  const opts: ParserOptions<any, any> = {
183
202
  transforms: [getRulesByCategory, flattenRule],
184
203
  fields: [
204
+ 'customFlowLabel',
205
+ 'deviceChosen',
206
+ 'scanCompletedAt',
185
207
  'severity',
186
208
  'issueId',
187
209
  'issueDescription',
188
210
  'wcagConformance',
189
211
  'url',
212
+ 'pageTitle',
190
213
  'context',
191
214
  'howToFix',
192
215
  'axeImpact',
@@ -195,17 +218,23 @@ const writeCsv = async (allIssues, storagePath) => {
195
218
  ],
196
219
  includeEmptyRows: true,
197
220
  };
221
+
198
222
  const parser = new AsyncParser(opts);
199
223
  parser.parse(allIssues).pipe(csvOutput);
200
224
  };
201
225
 
202
- const compileHtmlWithEJS = async (allIssues, storagePath, htmlFilename = 'report') => {
226
+ const compileHtmlWithEJS = async (
227
+ allIssues: AllIssues,
228
+ storagePath: string,
229
+ htmlFilename = 'report',
230
+ ) => {
203
231
  const htmlFilePath = `${path.join(storagePath, htmlFilename)}.html`;
204
232
  const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/report.ejs'), 'utf-8');
205
233
  const template = ejs.compile(ejsString, {
206
234
  filename: path.join(dirname, './static/ejs/report.ejs'),
207
235
  });
208
- const html = template(allIssues);
236
+
237
+ const html = template({...allIssues, storagePath: JSON.stringify(storagePath)});
209
238
  await fs.writeFile(htmlFilePath, html);
210
239
 
211
240
  let htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });
@@ -213,28 +242,8 @@ const compileHtmlWithEJS = async (allIssues, storagePath, htmlFilename = 'report
213
242
  const headIndex = htmlContent.indexOf('</head>');
214
243
  const injectScript = `
215
244
  <script>
216
- try {
217
- const base64DecodeChunkedWithDecoder = (data, chunkSize = ${BUFFER_LIMIT}) => {
218
- const encodedChunks = data.split('|');
219
- const decoder = new TextDecoder();
220
- const jsonParts = [];
221
-
222
- encodedChunks.forEach(chunk => {
223
- for (let i = 0; i < chunk.length; i += chunkSize) {
224
- const chunkPart = chunk.slice(i, i + chunkSize);
225
- const decodedBytes = Uint8Array.from(atob(chunkPart), c => c.charCodeAt(0));
226
- jsonParts.push(decoder.decode(decodedBytes, { stream: true }));
227
- }
228
- });
229
-
230
- return JSON.parse(jsonParts.join(''));
231
-
232
- };
233
-
234
245
  // IMPORTANT! DO NOT REMOVE ME: Decode the encoded data
235
- } catch (error) {
236
- console.error("Error decoding base64 data:", error);
237
- }
246
+
238
247
  </script>
239
248
  `;
240
249
 
@@ -276,40 +285,32 @@ const splitHtmlAndCreateFiles = async (htmlFilePath, storagePath) => {
276
285
  }
277
286
  };
278
287
 
279
- const writeHTML = async (allIssues, storagePath, htmlFilename = 'report') => {
288
+ const writeHTML = async (
289
+ allIssues: AllIssues,
290
+ storagePath: string,
291
+ htmlFilename = 'report',
292
+ scanDetailsFilePath: string,
293
+ scanItemsFilePath: string,
294
+ ) => {
280
295
  const htmlFilePath = await compileHtmlWithEJS(allIssues, storagePath, htmlFilename);
281
- const inputFilePath = path.resolve(storagePath, 'scanDetails.csv');
282
- const outputFilePath = `${storagePath}/${htmlFilename}.html`;
283
-
284
296
  const { topFilePath, bottomFilePath } = await splitHtmlAndCreateFiles(htmlFilePath, storagePath);
285
-
286
297
  const prefixData = fs.readFileSync(path.join(storagePath, 'report-partial-top.htm.txt'), 'utf-8');
287
298
  const suffixData = fs.readFileSync(
288
299
  path.join(storagePath, 'report-partial-bottom.htm.txt'),
289
300
  'utf-8',
290
301
  );
291
302
 
292
- const outputStream = fs.createWriteStream(outputFilePath, { flags: 'a' });
293
-
294
- outputStream.write(prefixData);
295
-
296
- const inputStream = fs.createReadStream(inputFilePath, {
297
- encoding: 'utf-8',
303
+ const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
304
+ encoding: 'utf8',
305
+ highWaterMark: BUFFER_LIMIT,
306
+ });
307
+ const scanItemsReadStream = fs.createReadStream(scanItemsFilePath, {
308
+ encoding: 'utf8',
298
309
  highWaterMark: BUFFER_LIMIT,
299
310
  });
300
311
 
301
- let isFirstLine = true;
302
- let lineEndingDetected = false;
303
- let isFirstField = true;
304
- let isWritingFirstDataLine = true;
305
- let buffer = '';
306
-
307
- function flushBuffer() {
308
- if (buffer.length > 0) {
309
- outputStream.write(buffer);
310
- buffer = '';
311
- }
312
- }
312
+ const outputFilePath = `${storagePath}/${htmlFilename}.html`;
313
+ const outputStream = fs.createWriteStream(outputFilePath, { flags: 'a' });
313
314
 
314
315
  const cleanupFiles = async () => {
315
316
  try {
@@ -319,75 +320,54 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report') => {
319
320
  }
320
321
  };
321
322
 
322
- inputStream.on('data', chunk => {
323
- let chunkIndex = 0;
324
-
325
- while (chunkIndex < chunk.length) {
326
- const char = chunk[chunkIndex];
327
-
328
- if (isFirstLine) {
329
- if (char === '\n' || char === '\r') {
330
- lineEndingDetected = true;
331
- } else if (lineEndingDetected) {
332
- if (char !== '\n' && char !== '\r') {
333
- isFirstLine = false;
334
-
335
- if (isWritingFirstDataLine) {
336
- buffer += "scanData = base64DecodeChunkedWithDecoder('";
337
- isWritingFirstDataLine = false;
338
- }
339
- buffer += char;
340
- }
341
- lineEndingDetected = false;
342
- }
343
- } else {
344
- if (char === ',') {
345
- buffer += "')\n\n";
346
- buffer += "scanItems = base64DecodeChunkedWithDecoder('";
347
- isFirstField = false;
348
- } else if (char === '\n' || char === '\r') {
349
- if (!isFirstField) {
350
- buffer += "')\n";
351
- }
352
- } else {
353
- buffer += char;
354
- }
355
-
356
- if (buffer.length >= BUFFER_LIMIT) {
357
- flushBuffer();
358
- }
359
- }
323
+ outputStream.write(prefixData);
360
324
 
361
- chunkIndex++;
362
- }
325
+ // outputStream.write("scanData = decompressJsonObject('");
326
+ outputStream.write(
327
+ "let scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse('",
328
+ );
329
+ scanDetailsReadStream.pipe(outputStream, { end: false });
330
+
331
+ scanDetailsReadStream.on('end', () => {
332
+ // outputStream.write("')\n\n");
333
+ outputStream.write("'); })();\n\n");
334
+ // outputStream.write("(scanItems = decompressJsonObject('");
335
+ outputStream.write(
336
+ "let scanItemsPromise = (async () => { console.log('Loading scanItems...'); scanItems = await decodeUnzipParse('",
337
+ );
338
+ scanItemsReadStream.pipe(outputStream, { end: false });
363
339
  });
364
340
 
365
- inputStream.on('end', async () => {
366
- if (!isFirstField) {
367
- buffer += "')\n";
368
- }
369
- flushBuffer();
341
+ scanDetailsReadStream.on('error', err => {
342
+ console.error('Read stream error:', err);
343
+ outputStream.end();
344
+ });
370
345
 
346
+ scanItemsReadStream.on('end', () => {
347
+ // outputStream.write("')\n\n");
348
+ outputStream.write("'); })();\n\n");
371
349
  outputStream.write(suffixData);
372
350
  outputStream.end();
373
- console.log('Content appended successfully.');
374
-
375
- await cleanupFiles();
376
351
  });
377
352
 
378
- inputStream.on('error', async err => {
379
- console.error('Error reading input file:', err);
353
+ scanItemsReadStream.on('error', err => {
354
+ console.error('Read stream error:', err);
380
355
  outputStream.end();
381
-
382
- await cleanupFiles();
383
356
  });
384
357
 
358
+ consoleLogger.info('Content appended successfully.');
359
+ await cleanupFiles();
360
+
385
361
  outputStream.on('error', err => {
386
- console.error('Error writing to output file:', err);
362
+ consoleLogger.error('Error writing to output file:', err);
387
363
  });
388
364
  };
389
365
 
390
- const writeSummaryHTML = async (allIssues, storagePath, htmlFilename = 'summary') => {
366
+ const writeSummaryHTML = async (
367
+ allIssues: AllIssues,
368
+ storagePath: string,
369
+ htmlFilename = 'summary',
370
+ ) => {
391
371
  const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/summary.ejs'), 'utf-8');
392
372
  const template = ejs.compile(ejsString, {
393
373
  filename: path.join(dirname, './static/ejs/summary.ejs'),
@@ -396,47 +376,56 @@ const writeSummaryHTML = async (allIssues, storagePath, htmlFilename = 'summary'
396
376
  fs.writeFileSync(`${storagePath}/${htmlFilename}.html`, html);
397
377
  };
398
378
 
399
- function writeFormattedValue(value, writeStream) {
400
- if (typeof value === 'function') {
401
- writeStream.write('null');
402
- } else if (value === undefined) {
403
- writeStream.write('null');
404
- } else if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') {
405
- writeStream.write(JSON.stringify(value));
406
- } else if (value === null) {
407
- writeStream.write('null');
408
- }
409
- }
379
+ const cleanUpJsonFiles = async (filesToDelete: string[]) => {
380
+ consoleLogger.info('Cleaning up JSON files...');
381
+ filesToDelete.forEach(file => {
382
+ fs.unlinkSync(file);
383
+ consoleLogger.info(`Deleted ${file}`);
384
+ });
385
+ };
410
386
 
411
- function serializeObject(obj, writeStream, depth = 0, indent = ' ') {
387
+ function* serializeObject(obj: any, depth = 0, indent = ' ') {
412
388
  const currentIndent = indent.repeat(depth);
413
389
  const nextIndent = indent.repeat(depth + 1);
414
390
 
415
391
  if (obj instanceof Date) {
416
- writeStream.write(JSON.stringify(obj.toISOString()));
417
- } else if (Array.isArray(obj)) {
418
- writeStream.write('[\n');
419
- obj.forEach((item, index) => {
420
- if (index > 0) writeStream.write(',\n');
421
- writeStream.write(nextIndent);
422
- serializeObject(item, writeStream, depth + 1, indent);
423
- });
424
- writeStream.write(`\n${currentIndent}]`);
425
- } else if (typeof obj === 'object' && obj !== null) {
426
- writeStream.write('{\n');
392
+ yield JSON.stringify(obj.toISOString());
393
+ return;
394
+ }
395
+
396
+ if (Array.isArray(obj)) {
397
+ yield '[\n';
398
+ for (let i = 0; i < obj.length; i++) {
399
+ if (i > 0) yield ',\n';
400
+ yield nextIndent;
401
+ yield* serializeObject(obj[i], depth + 1, indent);
402
+ }
403
+ yield `\n${currentIndent}]`;
404
+ return;
405
+ }
406
+
407
+ if (obj !== null && typeof obj === 'object') {
408
+ yield '{\n';
427
409
  const keys = Object.keys(obj);
428
- keys.forEach((key, index) => {
429
- if (index > 0) writeStream.write(',\n');
430
- writeStream.write(`${nextIndent}${JSON.stringify(key)}: `);
431
- serializeObject(obj[key], writeStream, depth + 1, indent);
432
- });
433
- writeStream.write(`\n${currentIndent}}`);
434
- } else {
435
- writeFormattedValue(obj, writeStream);
410
+ for (let i = 0; i < keys.length; i++) {
411
+ const key = keys[i];
412
+ if (i > 0) yield ',\n';
413
+ yield `${nextIndent}${JSON.stringify(key)}: `;
414
+ yield* serializeObject(obj[key], depth + 1, indent);
415
+ }
416
+ yield `\n${currentIndent}}`;
417
+ return;
418
+ }
419
+
420
+ if (obj === null || typeof obj === 'function' || typeof obj === 'undefined') {
421
+ yield 'null';
422
+ return;
436
423
  }
424
+
425
+ yield JSON.stringify(obj);
437
426
  }
438
427
 
439
- function writeLargeJsonToFile(obj, filePath) {
428
+ function writeLargeJsonToFile(obj: object, filePath: string) {
440
429
  return new Promise((resolve, reject) => {
441
430
  const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
442
431
 
@@ -446,74 +435,231 @@ function writeLargeJsonToFile(obj, filePath) {
446
435
  });
447
436
 
448
437
  writeStream.on('finish', () => {
449
- consoleLogger.info('Temporary file written successfully:', filePath);
438
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
450
439
  resolve(true);
451
440
  });
452
441
 
453
- serializeObject(obj, writeStream);
454
- writeStream.end();
442
+ const generator = serializeObject(obj);
443
+
444
+ function write() {
445
+ let next: any;
446
+ while (!(next = generator.next()).done) {
447
+ if (!writeStream.write(next.value)) {
448
+ writeStream.once('drain', write);
449
+ return;
450
+ }
451
+ }
452
+ writeStream.end();
453
+ }
454
+
455
+ write();
455
456
  });
456
457
  }
457
458
 
458
- const base64Encode = async (data, num, storagePath, generateJsonFiles) => {
459
- try {
460
- const tempFilePath =
461
- num === 1
462
- ? path.join(storagePath, 'scanItems.json')
463
- : num === 2
464
- ? path.join(storagePath, 'scanData.json')
465
- : path.join(storagePath, `${uuidv4()}.json`);
459
+ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
460
+ return new Promise((resolve, reject) => {
461
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
462
+ const writeQueue: string[] = [];
463
+ let isWriting = false;
464
+
465
+ const processNextWrite = async () => {
466
+ if (isWriting || writeQueue.length === 0) return;
467
+
468
+ isWriting = true;
469
+ const data = writeQueue.shift()!;
470
+
471
+ try {
472
+ if (!writeStream.write(data)) {
473
+ await new Promise<void>(resolve => {
474
+ writeStream.once('drain', () => {
475
+ resolve();
476
+ });
477
+ });
478
+ }
479
+ } catch (error) {
480
+ writeStream.destroy(error as Error);
481
+ return;
482
+ }
483
+
484
+ isWriting = false;
485
+ processNextWrite();
486
+ };
466
487
 
467
- await writeLargeJsonToFile(data, tempFilePath);
488
+ const queueWrite = (data: string) => {
489
+ writeQueue.push(data);
490
+ processNextWrite();
491
+ };
492
+
493
+ writeStream.on('error', error => {
494
+ consoleLogger.error(`Error writing object to JSON file: ${error}`);
495
+ reject(error);
496
+ });
468
497
 
469
- const outputFilename = `encoded_${uuidv4()}.txt`;
470
- const outputFilePath = path.join(process.cwd(), outputFilename);
498
+ writeStream.on('finish', () => {
499
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
500
+ resolve(true);
501
+ });
471
502
 
472
503
  try {
473
- const readStream = fs.createReadStream(tempFilePath, {
474
- encoding: 'utf8',
475
- highWaterMark: BUFFER_LIMIT,
476
- });
477
- const writeStream = fs.createWriteStream(outputFilePath, { encoding: 'utf8' });
504
+ queueWrite('{\n');
505
+ const keys = Object.keys(obj);
506
+
507
+ keys.forEach((key, i) => {
508
+ const value = obj[key];
509
+ queueWrite(` "${key}": {\n`);
510
+
511
+ const { rules, ...otherProperties } = value;
512
+
513
+ // Write other properties
514
+ Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
515
+ const propValueString =
516
+ propValue === null ||
517
+ typeof propValue === 'function' ||
518
+ typeof propValue === 'undefined'
519
+ ? 'null'
520
+ : JSON.stringify(propValue);
521
+ queueWrite(` "${propKey}": ${propValueString}`);
522
+ if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
523
+ queueWrite(',\n');
524
+ } else {
525
+ queueWrite('\n');
526
+ }
527
+ });
478
528
 
479
- let previousChunk = null;
529
+ if (rules && Array.isArray(rules)) {
530
+ queueWrite(' "rules": [\n');
531
+
532
+ rules.forEach((rule, j) => {
533
+ queueWrite(' {\n');
534
+ const { pagesAffected, ...otherRuleProperties } = rule;
535
+
536
+ Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
537
+ const ruleValueString =
538
+ ruleValue === null ||
539
+ typeof ruleValue === 'function' ||
540
+ typeof ruleValue === 'undefined'
541
+ ? 'null'
542
+ : JSON.stringify(ruleValue);
543
+ queueWrite(` "${ruleKey}": ${ruleValueString}`);
544
+ if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
545
+ queueWrite(',\n');
546
+ } else {
547
+ queueWrite('\n');
548
+ }
549
+ });
550
+
551
+ if (pagesAffected && Array.isArray(pagesAffected)) {
552
+ queueWrite(' "pagesAffected": [\n');
553
+
554
+ pagesAffected.forEach((page, p) => {
555
+ const pageJson = JSON.stringify(page, null, 2)
556
+ .split('\n')
557
+ .map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
558
+ .join('\n');
559
+
560
+ queueWrite(pageJson);
561
+
562
+ if (p < pagesAffected.length - 1) {
563
+ queueWrite(',\n');
564
+ } else {
565
+ queueWrite('\n');
566
+ }
567
+ });
568
+
569
+ queueWrite(' ]');
570
+ }
480
571
 
481
- for await (const chunk of readStream) {
482
- const encodedChunk = Buffer.from(chunk).toString('base64');
572
+ queueWrite('\n }');
573
+ if (j < rules.length - 1) {
574
+ queueWrite(',\n');
575
+ } else {
576
+ queueWrite('\n');
577
+ }
578
+ });
483
579
 
484
- if (previousChunk !== null) {
485
- // Note: Notice the pipe symbol `|`, it is intended to be here as a delimiter
486
- // for the scenario where there are chunking happens
487
- writeStream.write(`${previousChunk}|`);
580
+ queueWrite(' ]');
488
581
  }
489
582
 
490
- previousChunk = encodedChunk;
491
- }
583
+ queueWrite('\n }');
584
+ if (i < keys.length - 1) {
585
+ queueWrite(',\n');
586
+ } else {
587
+ queueWrite('\n');
588
+ }
589
+ });
492
590
 
493
- if (previousChunk !== null) {
494
- writeStream.write(previousChunk);
495
- }
591
+ queueWrite('}\n');
496
592
 
497
- await new Promise((resolve, reject) => {
498
- writeStream.end(resolve);
499
- writeStream.on('error', reject);
500
- });
593
+ // Ensure all queued writes are processed before ending
594
+ const checkQueueAndEnd = () => {
595
+ if (writeQueue.length === 0 && !isWriting) {
596
+ writeStream.end();
597
+ } else {
598
+ setTimeout(checkQueueAndEnd, 100);
599
+ }
600
+ };
501
601
 
502
- return outputFilePath;
503
- } finally {
504
- if (!generateJsonFiles) {
505
- await fs.promises
506
- .unlink(tempFilePath)
507
- .catch(err => console.error('Temp file delete error:', err));
508
- }
602
+ checkQueueAndEnd();
603
+ } catch (err) {
604
+ writeStream.destroy(err as Error);
605
+ reject(err);
606
+ }
607
+ });
608
+ };
609
+
610
+ async function compressJsonFileStreaming(inputPath: string, outputPath: string) {
611
+ // Create the read and write streams
612
+ const readStream = fs.createReadStream(inputPath);
613
+ const writeStream = fs.createWriteStream(outputPath);
614
+
615
+ // Create a gzip transform stream
616
+ const gzip = zlib.createGzip();
617
+
618
+ // Create a Base64 transform stream
619
+ const base64Encode = new Base64Encode();
620
+
621
+ // Pipe the streams:
622
+ // read -> gzip -> base64 -> write
623
+ await pipeline(readStream, gzip, base64Encode, writeStream);
624
+ console.log(`File successfully compressed and saved to ${outputPath}`);
625
+ }
626
+
627
+ const writeJsonFileAndCompressedJsonFile = async (
628
+ data: object,
629
+ storagePath: string,
630
+ filename: string,
631
+ ): Promise<{ jsonFilePath: string; base64FilePath: string }> => {
632
+ try {
633
+ consoleLogger.info(`Writing JSON to ${filename}.json`);
634
+ const jsonFilePath = path.join(storagePath, `${filename}.json`);
635
+ if (filename === 'scanItems') {
636
+ await writeLargeScanItemsJsonToFile(data, jsonFilePath);
637
+ } else {
638
+ await writeLargeJsonToFile(data, jsonFilePath);
509
639
  }
640
+
641
+ consoleLogger.info(
642
+ `Reading ${filename}.json, gzipping and base64 encoding it into ${filename}.json.gz.b64`,
643
+ );
644
+ const base64FilePath = path.join(storagePath, `${filename}.json.gz.b64`);
645
+ await compressJsonFileStreaming(jsonFilePath, base64FilePath);
646
+
647
+ consoleLogger.info(`Finished compression and base64 encoding for ${filename}`);
648
+ return {
649
+ jsonFilePath,
650
+ base64FilePath,
651
+ };
510
652
  } catch (error) {
511
- console.error('Error encoding data to Base64:', error);
653
+ consoleLogger.error(`Error compressing and encoding ${filename}`);
512
654
  throw error;
513
655
  }
514
656
  };
515
657
 
516
- const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma) => {
658
+ const streamEncodedDataToFile = async (
659
+ inputFilePath: string,
660
+ writeStream: fs.WriteStream,
661
+ appendComma: boolean,
662
+ ) => {
517
663
  const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
518
664
  let isFirstChunk = true;
519
665
 
@@ -531,35 +677,120 @@ const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma)
531
677
  }
532
678
  };
533
679
 
534
- const writeBase64 = async (allIssues, storagePath, generateJsonFiles) => {
680
+ const writeJsonAndBase64Files = async (
681
+ allIssues: AllIssues,
682
+ storagePath: string,
683
+ ): Promise<{
684
+ scanDataJsonFilePath: string;
685
+ scanDataBase64FilePath: string;
686
+ scanItemsJsonFilePath: string;
687
+ scanItemsBase64FilePath: string;
688
+ scanItemsSummaryJsonFilePath: string;
689
+ scanItemsSummaryBase64FilePath: string;
690
+ scanDataJsonFileSize: number;
691
+ scanItemsJsonFileSize: number;
692
+ }> => {
535
693
  const { items, ...rest } = allIssues;
536
- const encodedScanItemsPath = await base64Encode(items, 1, storagePath, generateJsonFiles);
537
- const encodedScanDataPath = await base64Encode(rest, 2, storagePath, generateJsonFiles);
694
+ const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
695
+ await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
696
+ const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
697
+ await writeJsonFileAndCompressedJsonFile(items, storagePath, 'scanItems');
698
+
699
+ // scanItemsSummary
700
+ // the below mutates the original items object, since it is expensive to clone
701
+ items.mustFix.rules.forEach(rule => {
702
+ rule.pagesAffected.forEach(page => {
703
+ page.itemsCount = page.items.length;
704
+ page.items = [];
705
+ });
706
+ });
707
+ items.goodToFix.rules.forEach(rule => {
708
+ rule.pagesAffected.forEach(page => {
709
+ page.itemsCount = page.items.length;
710
+ page.items = [];
711
+ });
712
+ });
713
+ items.needsReview.rules.forEach(rule => {
714
+ rule.pagesAffected.forEach(page => {
715
+ page.itemsCount = page.items.length;
716
+ page.items = [];
717
+ });
718
+ });
719
+ items.passed.rules.forEach(rule => {
720
+ rule.pagesAffected.forEach(page => {
721
+ page.itemsCount = page.items.length;
722
+ page.items = [];
723
+ });
724
+ });
725
+
726
+ items.mustFix.totalRuleIssues = items.mustFix.rules.length;
727
+ items.goodToFix.totalRuleIssues = items.goodToFix.rules.length;
728
+ items.needsReview.totalRuleIssues = items.needsReview.rules.length;
729
+ items.passed.totalRuleIssues = items.passed.rules.length;
730
+
731
+ const {
732
+ pagesScanned,
733
+ topTenPagesWithMostIssues,
734
+ pagesNotScanned,
735
+ wcagLinks,
736
+ wcagPassPercentage,
737
+ totalPagesScanned,
738
+ totalPagesNotScanned,
739
+ topTenIssues,
740
+ } = rest;
741
+
742
+ const summaryItems = {
743
+ ...items,
744
+ pagesScanned,
745
+ topTenPagesWithMostIssues,
746
+ pagesNotScanned,
747
+ wcagLinks,
748
+ wcagPassPercentage,
749
+ totalPagesScanned,
750
+ totalPagesNotScanned,
751
+ topTenIssues,
752
+ };
753
+
754
+ const {
755
+ jsonFilePath: scanItemsSummaryJsonFilePath,
756
+ base64FilePath: scanItemsSummaryBase64FilePath,
757
+ } = await writeJsonFileAndCompressedJsonFile(summaryItems, storagePath, 'scanItemsSummary');
758
+
759
+ return {
760
+ scanDataJsonFilePath,
761
+ scanDataBase64FilePath,
762
+ scanItemsJsonFilePath,
763
+ scanItemsBase64FilePath,
764
+ scanItemsSummaryJsonFilePath,
765
+ scanItemsSummaryBase64FilePath,
766
+ scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
767
+ scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
768
+ };
769
+ };
538
770
 
771
+ const writeScanDetailsCsv = async (
772
+ scanDataFilePath: string,
773
+ scanItemsFilePath: string,
774
+ scanItemsSummaryFilePath: string,
775
+ storagePath: string,
776
+ ) => {
539
777
  const filePath = path.join(storagePath, 'scanDetails.csv');
778
+ const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
540
779
  const directoryPath = path.dirname(filePath);
541
780
 
542
781
  if (!fs.existsSync(directoryPath)) {
543
782
  fs.mkdirSync(directoryPath, { recursive: true });
544
783
  }
545
784
 
546
- const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
547
-
548
- csvWriteStream.write('scanData_base64,scanItems_base64\n');
549
- await streamEncodedDataToFile(encodedScanDataPath, csvWriteStream, true);
550
- await streamEncodedDataToFile(encodedScanItemsPath, csvWriteStream, false);
785
+ csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
786
+ await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
787
+ await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
788
+ await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
551
789
 
552
790
  await new Promise((resolve, reject) => {
553
791
  csvWriteStream.end(resolve);
554
792
  csvWriteStream.on('error', reject);
555
793
  });
556
-
557
- await fs.promises
558
- .unlink(encodedScanDataPath)
559
- .catch(err => console.error('Encoded file delete error:', err));
560
- await fs.promises
561
- .unlink(encodedScanItemsPath)
562
- .catch(err => console.error('Encoded file delete error:', err));
563
794
  };
564
795
 
565
796
  let browserChannel = 'chrome';
@@ -572,12 +803,13 @@ if (os.platform() === 'linux') {
572
803
  browserChannel = 'chromium';
573
804
  }
574
805
 
575
- const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary') => {
806
+ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filename = 'summary') => {
576
807
  const htmlFilePath = `${storagePath}/${filename}.html`;
577
808
  const fileDestinationPath = `${storagePath}/${filename}.pdf`;
578
809
  const browser = await chromium.launch({
579
- headless: true,
810
+ headless: false,
580
811
  channel: browserChannel,
812
+ args: ['--headless=new', '--no-sandbox'],
581
813
  });
582
814
 
583
815
  const context = await browser.newContext({
@@ -624,7 +856,12 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
624
856
  Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
625
857
  Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
626
858
 
627
- allIssues.topFiveMostIssues.push({ url, pageTitle, totalIssues: totalIssuesInPage.size });
859
+ allIssues.topFiveMostIssues.push({
860
+ url,
861
+ pageTitle,
862
+ totalIssues: totalIssuesInPage.size,
863
+ totalOccurrences: 0,
864
+ });
628
865
 
629
866
  ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
630
867
  if (!pageResults[category]) return;
@@ -706,9 +943,47 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
706
943
  });
707
944
  };
708
945
 
946
+ const getTopTenIssues = allIssues => {
947
+ const categories = ['mustFix', 'goodToFix'];
948
+ const rulesWithCounts = [];
949
+
950
+ const conformanceLevels = {
951
+ wcag2a: 'A',
952
+ wcag2aa: 'AA',
953
+ wcag21aa: 'AA',
954
+ wcag22aa: 'AA',
955
+ wcag2aaa: 'AAA',
956
+ };
957
+
958
+ categories.forEach(category => {
959
+ const rules = allIssues.items[category]?.rules || [];
960
+
961
+ rules.forEach(rule => {
962
+ const wcagLevel = rule.conformance[0];
963
+ const aLevel = conformanceLevels[wcagLevel] || wcagLevel;
964
+
965
+ rulesWithCounts.push({
966
+ category,
967
+ ruleId: rule.rule,
968
+ description: rule.description,
969
+ axeImpact: rule.axeImpact,
970
+ conformance: aLevel,
971
+ totalItems: rule.totalItems,
972
+ });
973
+ });
974
+ });
975
+
976
+ rulesWithCounts.sort((a, b) => b.totalItems - a.totalItems);
977
+
978
+ return rulesWithCounts.slice(0, 10);
979
+ };
980
+
709
981
  const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
982
+ const urlOccurrencesMap = new Map<string, number>();
983
+
710
984
  ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
711
985
  allIssues.totalItems += allIssues.items[category].totalItems;
986
+
712
987
  allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
713
988
  .map(ruleEntry => {
714
989
  const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
@@ -716,9 +991,14 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
716
991
  .map(pageEntry => {
717
992
  if (isCustomFlow) {
718
993
  const [pageIndex, pageInfo] = pageEntry as unknown as [number, PageInfo];
994
+ urlOccurrencesMap.set(
995
+ pageInfo.url!,
996
+ (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length,
997
+ );
719
998
  return { pageIndex, ...pageInfo };
720
999
  }
721
1000
  const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1001
+ urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length);
722
1002
  return { url, ...pageInfo };
723
1003
  })
724
1004
  .sort((page1, page2) => page2.items.length - page1.items.length);
@@ -726,8 +1006,19 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
726
1006
  })
727
1007
  .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
728
1008
  });
1009
+
1010
+ const updateIssuesWithOccurrences = (issuesList: Array<any>) => {
1011
+ issuesList.forEach(issue => {
1012
+ issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1013
+ });
1014
+ };
1015
+
729
1016
  allIssues.topFiveMostIssues.sort((page1, page2) => page2.totalIssues - page1.totalIssues);
730
1017
  allIssues.topFiveMostIssues = allIssues.topFiveMostIssues.slice(0, 5);
1018
+ allIssues.topTenPagesWithMostIssues = allIssues.topFiveMostIssues.slice(0, 10);
1019
+ updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues);
1020
+ const topTenIssues = getTopTenIssues(allIssues);
1021
+ allIssues.topTenIssues = topTenIssues;
731
1022
  };
732
1023
 
733
1024
  const createRuleIdJson = allIssues => {
@@ -827,6 +1118,7 @@ const generateArtifacts = async (
827
1118
  endTime: scanDetails.endTime ? scanDetails.endTime : new Date(),
828
1119
  urlScanned,
829
1120
  scanType,
1121
+ deviceChosen: scanDetails.deviceChosen || 'Desktop',
830
1122
  formatAboutStartTime,
831
1123
  isCustomFlow,
832
1124
  viewport,
@@ -836,21 +1128,43 @@ const generateArtifacts = async (
836
1128
  totalPagesNotScanned: pagesNotScanned.length,
837
1129
  totalItems: 0,
838
1130
  topFiveMostIssues: [],
1131
+ topTenPagesWithMostIssues: [],
1132
+ topTenIssues: [],
839
1133
  wcagViolations: [],
840
1134
  customFlowLabel,
841
1135
  phAppVersion,
842
1136
  items: {
843
- mustFix: { description: itemTypeDescription.mustFix, totalItems: 0, rules: [] },
844
- goodToFix: { description: itemTypeDescription.goodToFix, totalItems: 0, rules: [] },
845
- needsReview: { description: itemTypeDescription.needsReview, totalItems: 0, rules: [] },
846
- passed: { description: itemTypeDescription.passed, totalItems: 0, rules: [] },
1137
+ mustFix: {
1138
+ description: itemTypeDescription.mustFix,
1139
+ totalItems: 0,
1140
+ totalRuleIssues: 0,
1141
+ rules: [],
1142
+ },
1143
+ goodToFix: {
1144
+ description: itemTypeDescription.goodToFix,
1145
+ totalItems: 0,
1146
+ totalRuleIssues: 0,
1147
+ rules: [],
1148
+ },
1149
+ needsReview: {
1150
+ description: itemTypeDescription.needsReview,
1151
+ totalItems: 0,
1152
+ totalRuleIssues: 0,
1153
+ rules: [],
1154
+ },
1155
+ passed: {
1156
+ description: itemTypeDescription.passed,
1157
+ totalItems: 0,
1158
+ totalRuleIssues: 0,
1159
+ rules: [],
1160
+ },
847
1161
  },
848
1162
  cypressScanAboutMetadata,
849
1163
  wcagLinks: constants.wcagLinks,
850
1164
  // Populate boolean values for id="advancedScanOptionsSummary"
851
1165
  advancedScanOptionsSummaryItems: {
852
1166
  showIncludeScreenshots: [true].includes(scanDetails.isIncludeScreenshots),
853
- showAllowSubdomains: [true].includes(scanDetails.isAllowSubdomains),
1167
+ showAllowSubdomains: ['same-domain'].includes(scanDetails.isAllowSubdomains),
854
1168
  showEnableCustomChecks: ['default', 'enable-wcag-aaa'].includes(
855
1169
  scanDetails.isEnableCustomChecks?.[0],
856
1170
  ),
@@ -934,9 +1248,45 @@ const generateArtifacts = async (
934
1248
  }
935
1249
 
936
1250
  await writeCsv(allIssues, storagePath);
937
- await writeBase64(allIssues, storagePath, generateJsonFiles);
1251
+ const {
1252
+ scanDataJsonFilePath,
1253
+ scanDataBase64FilePath,
1254
+ scanItemsJsonFilePath,
1255
+ scanItemsBase64FilePath,
1256
+ scanItemsSummaryJsonFilePath,
1257
+ scanItemsSummaryBase64FilePath,
1258
+ scanDataJsonFileSize,
1259
+ scanItemsJsonFileSize,
1260
+ } = await writeJsonAndBase64Files(allIssues, storagePath);
1261
+ const BIG_RESULTS_THRESHOLD = 500 * 1024 * 1024; // 500 MB
1262
+ const resultsTooBig = scanDataJsonFileSize + scanItemsJsonFileSize > BIG_RESULTS_THRESHOLD;
1263
+
1264
+ await writeScanDetailsCsv(
1265
+ scanDataBase64FilePath,
1266
+ scanItemsBase64FilePath,
1267
+ scanItemsSummaryBase64FilePath,
1268
+ storagePath,
1269
+ );
938
1270
  await writeSummaryHTML(allIssues, storagePath);
939
- await writeHTML(allIssues, storagePath);
1271
+ await writeHTML(
1272
+ allIssues,
1273
+ storagePath,
1274
+ 'report',
1275
+ scanDataBase64FilePath,
1276
+ resultsTooBig ? scanItemsSummaryBase64FilePath : scanItemsBase64FilePath,
1277
+ );
1278
+
1279
+ if (!generateJsonFiles) {
1280
+ await cleanUpJsonFiles([
1281
+ scanDataJsonFilePath,
1282
+ scanDataBase64FilePath,
1283
+ scanItemsJsonFilePath,
1284
+ scanItemsBase64FilePath,
1285
+ scanItemsSummaryJsonFilePath,
1286
+ scanItemsSummaryBase64FilePath,
1287
+ ]);
1288
+ }
1289
+
940
1290
  await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length), 1);
941
1291
 
942
1292
  // Take option if set