@govtechsg/oobee 0.10.83 → 0.10.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, silentLogger } from './logs.js';
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
- export type ItemsInfo = {
47
- html: string;
48
- message: string;
49
- screenshotPath: string;
50
- xpath: string;
51
- displayNeedsReview?: boolean;
52
- };
53
-
54
- export type PageInfo = {
55
- items?: ItemsInfo[];
56
- itemsCount?: number;
57
- pageTitle: string;
58
- url: string;
59
- actualUrl: string;
60
- pageImagePath?: string;
61
- pageIndex?: number;
62
- metadata?: string;
63
- httpStatusCode?: number;
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(`[parseContentToJson] ${parseError?.name || 'Error'}: ${parseError?.message || parseError}`);
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 { jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath, base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath } =
420
- await writeJsonFileAndCompressedJsonFile(
421
- scanItemsWithHtmlGroupRefs.items,
422
- storagePath,
423
- 'scanItems-light',
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 || ""}"\n`);
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("</script>\n<script>\n");
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("</script>\n");
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(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
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("<script>\n");
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,
@@ -1133,18 +393,6 @@ const writeSummaryPdf = async (
1133
393
  // Tracking WCAG occurrences
1134
394
  const wcagOccurrencesMap = new Map<string, number>();
1135
395
 
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
396
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1149
397
  const { url, pageTitle, filePath } = pageResults;
1150
398
 
@@ -1206,10 +454,10 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1206
454
  const currRuleFromAllIssues = currCategoryFromAllIssues.rules[rule];
1207
455
 
1208
456
  currRuleFromAllIssues.totalItems += count;
1209
-
457
+
1210
458
  // Build htmlGroups for pre-computed Group by HTML Element
1211
459
  buildHtmlGroups(currRuleFromAllIssues, items, url);
1212
-
460
+
1213
461
  if (isCustomFlow) {
1214
462
  const { pageIndex, pageImagePath, metadata } = pageResults;
1215
463
  currRuleFromAllIssues.pagesAffected[pageIndex] = {
@@ -1219,85 +467,17 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1219
467
  metadata,
1220
468
  items: [...items],
1221
469
  };
1222
- } else {
1223
- if (!(url in currRuleFromAllIssues.pagesAffected)) {
1224
- currRuleFromAllIssues.pagesAffected[url] = {
1225
- pageTitle,
1226
- items: [...items],
1227
- ...(filePath && { filePath }),
1228
- };
1229
- }
1230
-
470
+ } else if (!(url in currRuleFromAllIssues.pagesAffected)) {
471
+ currRuleFromAllIssues.pagesAffected[url] = {
472
+ pageTitle,
473
+ items: [...items],
474
+ ...(filePath && { filePath }),
475
+ };
1231
476
  }
1232
477
  });
1233
478
  });
1234
479
  };
1235
480
 
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
481
  const getTopTenIssues = allIssues => {
1302
482
  const categories = ['mustFix', 'goodToFix'];
1303
483
  const rulesWithCounts = [];
@@ -1401,7 +581,12 @@ function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<s
1401
581
  });
1402
582
  }
1403
583
 
1404
- const extractRuleAiData = (ruleId: string, totalItems: number, items: any[], callback?: () => void) => {
584
+ const extractRuleAiData = (
585
+ ruleId: string,
586
+ totalItems: number,
587
+ items: any[],
588
+ callback?: () => void,
589
+ ) => {
1405
590
  let snippets = [];
1406
591
 
1407
592
  if (oobeeAiRules.includes(ruleId)) {
@@ -1421,8 +606,7 @@ const extractRuleAiData = (ruleId: string, totalItems: number, items: any[], cal
1421
606
  };
1422
607
 
1423
608
  // This is for telemetry purposes called within mergeAxeResults.ts
1424
- export
1425
- const createRuleIdJson = allIssues => {
609
+ export const createRuleIdJson = allIssues => {
1426
610
  const compiledRuleJson = {};
1427
611
 
1428
612
  ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
@@ -1445,9 +629,11 @@ export const createBasicFormHTMLSnippet = filteredResults => {
1445
629
 
1446
630
  ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
1447
631
  if (filteredResults[category] && filteredResults[category].rules) {
1448
- Object.entries(filteredResults[category].rules).forEach(([ruleId, ruleVal]: [string, any]) => {
1449
- compiledRuleJson[ruleId] = extractRuleAiData(ruleId, ruleVal.totalItems, ruleVal.items);
1450
- });
632
+ Object.entries(filteredResults[category].rules).forEach(
633
+ ([ruleId, ruleVal]: [string, any]) => {
634
+ compiledRuleJson[ruleId] = extractRuleAiData(ruleId, ruleVal.totalItems, ruleVal.items);
635
+ },
636
+ );
1451
637
  }
1452
638
  });
1453
639
 
@@ -1462,429 +648,6 @@ const moveElemScreenshots = (randomToken: string, storagePath: string) => {
1462
648
  }
1463
649
  };
1464
650
 
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
651
  const formatAboutStartTime = (dateString: string) => {
1889
652
  const utcStartTimeDate = new Date(dateString);
1890
653
  const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
@@ -1939,7 +702,6 @@ const generateArtifacts = async (
1939
702
  zip: string = undefined, // optional
1940
703
  generateJsonFiles = false,
1941
704
  ) => {
1942
-
1943
705
  consoleLogger.info('Generating report artifacts');
1944
706
 
1945
707
  const storagePath = getStoragePath(randomToken);
@@ -2037,7 +799,7 @@ const generateArtifacts = async (
2037
799
  await pushResults(pageResults, allIssues, isCustomFlow);
2038
800
  }),
2039
801
  ).catch(flattenIssuesError => {
2040
- consoleLogger.error(
802
+ consoleLogger.error(
2041
803
  `[generateArtifacts] Error flattening issues: ${flattenIssuesError?.stack || flattenIssuesError}`,
2042
804
  );
2043
805
  });
@@ -2176,22 +938,22 @@ const generateArtifacts = async (
2176
938
  scanItemsBase64FilePath,
2177
939
  );
2178
940
 
2179
- if (!generateJsonFiles) {
2180
- await cleanUpJsonFiles([
2181
- scanDataJsonFilePath,
2182
- scanDataBase64FilePath,
2183
- scanItemsJsonFilePath,
2184
- scanItemsBase64FilePath,
2185
- scanItemsSummaryJsonFilePath,
2186
- scanItemsSummaryBase64FilePath,
2187
- scanIssuesSummaryJsonFilePath,
2188
- scanIssuesSummaryBase64FilePath,
2189
- scanPagesDetailJsonFilePath,
2190
- scanPagesDetailBase64FilePath,
2191
- scanPagesSummaryJsonFilePath,
2192
- scanPagesSummaryBase64FilePath,
2193
- ]);
2194
- }
941
+ if (!generateJsonFiles) {
942
+ await cleanUpJsonFiles([
943
+ scanDataJsonFilePath,
944
+ scanDataBase64FilePath,
945
+ scanItemsJsonFilePath,
946
+ scanItemsBase64FilePath,
947
+ scanItemsSummaryJsonFilePath,
948
+ scanItemsSummaryBase64FilePath,
949
+ scanIssuesSummaryJsonFilePath,
950
+ scanIssuesSummaryBase64FilePath,
951
+ scanPagesDetailJsonFilePath,
952
+ scanPagesDetailBase64FilePath,
953
+ scanPagesSummaryJsonFilePath,
954
+ scanPagesSummaryBase64FilePath,
955
+ ]);
956
+ }
2195
957
 
2196
958
  const browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
2197
959
 
@@ -2300,8 +1062,10 @@ if (!generateJsonFiles) {
2300
1062
  export {
2301
1063
  writeHTML,
2302
1064
  compressJsonFileStreaming,
1065
+ convertItemsToReferences,
2303
1066
  flattenAndSortResults,
2304
1067
  populateScanPagesDetail,
1068
+ sendWcagBreakdownToSentry,
2305
1069
  getWcagPassPercentage,
2306
1070
  getProgressPercentage,
2307
1071
  getIssuesPercentage,
@@ -2311,4 +1075,4 @@ export {
2311
1075
  formatAboutStartTime,
2312
1076
  };
2313
1077
 
2314
- export default generateArtifacts;
1078
+ export default generateArtifacts;