@govtechsg/oobee 0.10.89 → 0.10.90

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,15 +1,35 @@
1
1
  import { createWriteStream } from 'fs';
2
- import { AsyncParser } from '@json2csv/node';
3
2
  import { a11yRuleShortDescriptionMap } from '../constants/constants.js';
4
- const writeCsv = async (allIssues, storagePath) => {
3
+ function escapeCsvField(value) {
4
+ if (value == null)
5
+ return '';
6
+ const str = String(value);
7
+ return `"${str.replace(/"/g, '""')}"`;
8
+ }
9
+ const writeCsv = async (allIssues, storagePath, itemsStore) => {
5
10
  const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
6
11
  const formatPageViolation = (pageNum) => {
7
12
  if (pageNum < 0)
8
13
  return 'Document';
9
14
  return `Page ${pageNum}`;
10
15
  };
11
- // transform allIssues into the form:
12
- // [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
16
+ const fields = [
17
+ 'customFlowLabel',
18
+ 'deviceChosen',
19
+ 'scanCompletedAt',
20
+ 'severity',
21
+ 'issueId',
22
+ 'issueDescription',
23
+ 'wcagConformance',
24
+ 'url',
25
+ 'pageTitle',
26
+ 'context',
27
+ 'howToFix',
28
+ 'axeImpact',
29
+ 'xpath',
30
+ 'learnMore',
31
+ ];
32
+ csvOutput.write(fields.map(escapeCsvField).join(',') + '\n');
13
33
  const getRulesByCategory = (issues) => {
14
34
  return Object.entries(issues.items)
15
35
  .filter(([category]) => category !== 'passed')
@@ -21,105 +41,104 @@ const writeCsv = async (allIssues, storagePath) => {
21
41
  return prev;
22
42
  }, [])
23
43
  .sort((a, b) => {
24
- // sort rules according to severity, then ruleId
25
44
  const compareCategory = -a[0].localeCompare(b[0]);
26
45
  return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
27
46
  });
28
47
  };
29
- const flattenRule = (catAndRule) => {
30
- const [severity, rule] = catAndRule;
31
- const results = [];
48
+ const rulesByCategory = getRulesByCategory(allIssues);
49
+ for (const [severity, rule] of rulesByCategory) {
32
50
  const { rule: issueId, description: issueDescription, axeImpact, conformance, pagesAffected, helpUrl: learnMore, } = rule;
33
- // format clauses as a string
34
51
  const wcagConformance = conformance.join(',');
35
- pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
36
- pagesAffected.forEach(affectedPage => {
37
- const { url, items } = affectedPage;
38
- items.forEach(item => {
39
- const { html, message, xpath } = item;
40
- const page = item.page;
41
- const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
42
- const violation = html || formatPageViolation(page); // page is a number, not a string
43
- const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
44
- results.push({
45
- customFlowLabel: allIssues.customFlowLabel || '',
46
- deviceChosen: allIssues.deviceChosen || '',
47
- scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
48
- severity: severity || '',
49
- issueId: issueId || '',
50
- issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
51
- wcagConformance: wcagConformance || '',
52
- url: url || '',
53
- pageTitle: affectedPage.pageTitle || 'No page title',
54
- context: context || '',
55
- howToFix: howToFix || '',
56
- axeImpact: axeImpact || '',
57
- xpath: xpath || '',
58
- learnMore: learnMore || '',
59
- });
60
- });
61
- });
62
- if (results.length === 0)
63
- return {};
64
- return results;
65
- };
66
- const opts = {
67
- transforms: [getRulesByCategory, flattenRule],
68
- fields: [
69
- 'customFlowLabel',
70
- 'deviceChosen',
71
- 'scanCompletedAt',
72
- 'severity',
73
- 'issueId',
74
- 'issueDescription',
75
- 'wcagConformance',
76
- 'url',
77
- 'pageTitle',
78
- 'context',
79
- 'howToFix',
80
- 'axeImpact',
81
- 'xpath',
82
- 'learnMore',
83
- ],
84
- includeEmptyRows: true,
85
- };
86
- // Create the parse stream (it's asynchronous)
87
- const parser = new AsyncParser(opts);
88
- const parseStream = parser.parse(allIssues);
89
- // Pipe JSON2CSV output into the file, but don't end automatically
90
- parseStream.pipe(csvOutput, { end: false });
91
- // Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
92
- parseStream.on('end', () => {
93
- if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
94
- csvOutput.write('\n');
95
- allIssues.pagesNotScanned.forEach(page => {
96
- const skippedPage = {
97
- customFlowLabel: allIssues.customFlowLabel || '',
98
- deviceChosen: allIssues.deviceChosen || '',
99
- scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
100
- severity: 'error',
101
- issueId: 'error-pages-skipped',
102
- issueDescription: page.metadata
103
- ? page.metadata
104
- : 'An unknown error caused the page to be skipped',
105
- wcagConformance: '',
106
- url: page.url || page || '',
107
- pageTitle: 'Error',
108
- context: '',
109
- howToFix: '',
110
- axeImpact: '',
111
- xpath: '',
112
- learnMore: '',
113
- };
114
- csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
115
- });
52
+ if (itemsStore) {
53
+ const itemsMap = await itemsStore.readRuleItemsMap(severity, issueId);
54
+ const sortedPages = [...pagesAffected].sort((a, b) => (a.url || '').localeCompare(b.url || ''));
55
+ for (const affectedPage of sortedPages) {
56
+ const key = affectedPage.pageIndex != null ? String(affectedPage.pageIndex) : affectedPage.url;
57
+ const entry = itemsMap.get(key);
58
+ if (!entry)
59
+ continue;
60
+ for (const item of entry.items) {
61
+ const { html, message, xpath } = item;
62
+ const page = item.page;
63
+ const howToFix = (message || '').replace(/(\r\n|\n|\r)/g, '\\n');
64
+ const violation = html || formatPageViolation(page);
65
+ const context = violation.replace(/(\r\n|\n|\r)/g, '');
66
+ const row = [
67
+ allIssues.customFlowLabel || '',
68
+ allIssues.deviceChosen || '',
69
+ allIssues.endTime ? allIssues.endTime.toISOString() : '',
70
+ severity || '',
71
+ issueId || '',
72
+ a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
73
+ wcagConformance || '',
74
+ affectedPage.url || '',
75
+ affectedPage.pageTitle || 'No page title',
76
+ context || '',
77
+ howToFix || '',
78
+ axeImpact || '',
79
+ xpath || '',
80
+ learnMore || '',
81
+ ].map(escapeCsvField);
82
+ csvOutput.write(row.join(',') + '\n');
83
+ }
84
+ }
116
85
  }
117
- // Now close the CSV file
118
- csvOutput.end();
119
- });
120
- parseStream.on('error', (err) => {
121
- console.error('Error parsing CSV:', err);
122
- csvOutput.end();
86
+ else {
87
+ const sortedPages = [...pagesAffected].sort((a, b) => (a.url || '').localeCompare(b.url || ''));
88
+ for (const affectedPage of sortedPages) {
89
+ const items = affectedPage.items || [];
90
+ for (const item of items) {
91
+ const { html, message, xpath } = item;
92
+ const page = item.page;
93
+ const howToFix = (message || '').replace(/(\r\n|\n|\r)/g, '\\n');
94
+ const violation = html || formatPageViolation(page);
95
+ const context = violation.replace(/(\r\n|\n|\r)/g, '');
96
+ const row = [
97
+ allIssues.customFlowLabel || '',
98
+ allIssues.deviceChosen || '',
99
+ allIssues.endTime ? allIssues.endTime.toISOString() : '',
100
+ severity || '',
101
+ issueId || '',
102
+ a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
103
+ wcagConformance || '',
104
+ affectedPage.url || '',
105
+ affectedPage.pageTitle || 'No page title',
106
+ context || '',
107
+ howToFix || '',
108
+ axeImpact || '',
109
+ xpath || '',
110
+ learnMore || '',
111
+ ].map(escapeCsvField);
112
+ csvOutput.write(row.join(',') + '\n');
113
+ }
114
+ }
115
+ }
116
+ }
117
+ if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
118
+ allIssues.pagesNotScanned.forEach(page => {
119
+ const row = [
120
+ allIssues.customFlowLabel || '',
121
+ allIssues.deviceChosen || '',
122
+ allIssues.endTime ? allIssues.endTime.toISOString() : '',
123
+ 'error',
124
+ 'error-pages-skipped',
125
+ page.metadata ? page.metadata : 'An unknown error caused the page to be skipped',
126
+ '',
127
+ page.url || page || '',
128
+ 'Error',
129
+ '',
130
+ '',
131
+ '',
132
+ '',
133
+ '',
134
+ ].map(escapeCsvField);
135
+ csvOutput.write(row.join(',') + '\n');
136
+ });
137
+ }
138
+ csvOutput.end();
139
+ await new Promise((resolve, reject) => {
140
+ csvOutput.on('finish', resolve);
141
+ csvOutput.on('error', reject);
123
142
  });
124
143
  };
125
144
  export default writeCsv;
@@ -12,6 +12,7 @@ import { consoleLogger } from './logs.js';
12
12
  import itemTypeDescription from './constants/itemTypeDescription.js';
13
13
  import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js';
14
14
  import { buildHtmlGroups, convertItemsToReferences } from './mergeAxeResults/itemReferences.js';
15
+ import { ItemsStore } from './mergeAxeResults/itemsStore.js';
15
16
  import { compressJsonFileStreaming, writeJsonAndBase64Files, writeJsonFileAndCompressedJsonFile, } from './mergeAxeResults/jsonArtifacts.js';
16
17
  import writeCsv from './mergeAxeResults/writeCsv.js';
17
18
  import writeScanDetailsCsv from './mergeAxeResults/writeScanDetailsCsv.js';
@@ -306,7 +307,7 @@ const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary',
306
307
  };
307
308
  // Tracking WCAG occurrences
308
309
  const wcagOccurrencesMap = new Map();
309
- const pushResults = async (pageResults, allIssues, isCustomFlow) => {
310
+ const pushResults = async (pageResults, allIssues, isCustomFlow, itemsStore) => {
310
311
  const { url, pageTitle, filePath } = pageResults;
311
312
  const totalIssuesInPage = new Set();
312
313
  Object.keys(pageResults.mustFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
@@ -318,13 +319,13 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
318
319
  totalIssues: totalIssuesInPage.size,
319
320
  totalOccurrences: 0,
320
321
  });
321
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
322
+ for (const category of ['mustFix', 'goodToFix', 'needsReview', 'passed']) {
322
323
  if (!pageResults[category])
323
- return;
324
+ continue;
324
325
  const { totalItems, rules } = pageResults[category];
325
326
  const currCategoryFromAllIssues = allIssues.items[category];
326
327
  currCategoryFromAllIssues.totalItems += totalItems;
327
- Object.keys(rules).forEach(rule => {
328
+ for (const rule of Object.keys(rules)) {
328
329
  const { description, axeImpact, helpUrl, conformance, totalItems: count, items, } = rules[rule];
329
330
  if (!(rule in currCategoryFromAllIssues.rules)) {
330
331
  currCategoryFromAllIssues.rules[rule] = {
@@ -333,7 +334,6 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
333
334
  helpUrl,
334
335
  conformance,
335
336
  totalItems: 0,
336
- // numberOfPagesAffectedAfterRedirects: 0,
337
337
  pagesAffected: {},
338
338
  };
339
339
  }
@@ -344,15 +344,12 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
344
344
  if (!allIssues.wcagViolations.includes(c)) {
345
345
  allIssues.wcagViolations.push(c);
346
346
  }
347
- // Track WCAG criteria occurrences for Sentry
348
347
  const currentCount = wcagOccurrencesMap.get(c) || 0;
349
348
  wcagOccurrencesMap.set(c, currentCount + count);
350
349
  });
351
350
  }
352
351
  const currRuleFromAllIssues = currCategoryFromAllIssues.rules[rule];
353
352
  currRuleFromAllIssues.totalItems += count;
354
- // Build htmlGroups for pre-computed Group by HTML Element
355
- buildHtmlGroups(currRuleFromAllIssues, items, url);
356
353
  if (isCustomFlow) {
357
354
  const { pageIndex, pageImagePath, metadata } = pageResults;
358
355
  currRuleFromAllIssues.pagesAffected[pageIndex] = {
@@ -360,18 +357,32 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
360
357
  pageTitle,
361
358
  pageImagePath,
362
359
  metadata,
363
- items: [...items],
360
+ itemsCount: items.length,
364
361
  };
362
+ await itemsStore.appendPageItems(category, rule, {
363
+ url,
364
+ pageTitle,
365
+ items,
366
+ pageIndex,
367
+ pageImagePath,
368
+ metadata,
369
+ });
365
370
  }
366
371
  else if (!(url in currRuleFromAllIssues.pagesAffected)) {
367
372
  currRuleFromAllIssues.pagesAffected[url] = {
368
373
  pageTitle,
369
- items: [...items],
374
+ itemsCount: items.length,
370
375
  ...(filePath && { filePath }),
371
376
  };
377
+ await itemsStore.appendPageItems(category, rule, {
378
+ url,
379
+ pageTitle,
380
+ items,
381
+ ...(filePath && { filePath }),
382
+ });
372
383
  }
373
- });
374
- });
384
+ }
385
+ }
375
386
  };
376
387
  const getTopTenIssues = allIssues => {
377
388
  const categories = ['mustFix', 'goodToFix'];
@@ -422,23 +433,20 @@ const flattenAndSortResults = (allIssues, isCustomFlow) => {
422
433
  .map(pageEntry => {
423
434
  if (isCustomFlow) {
424
435
  const [pageIndex, pageInfo] = pageEntry;
425
- // Only update the occurrences map if not passed.
426
436
  if (category !== 'passed') {
427
- urlOccurrencesMap.set(pageInfo.url, (urlOccurrencesMap.get(pageInfo.url) || 0) + pageInfo.items.length);
437
+ urlOccurrencesMap.set(pageInfo.url, (urlOccurrencesMap.get(pageInfo.url) || 0) + (pageInfo.itemsCount || 0));
428
438
  }
429
439
  return { pageIndex, ...pageInfo };
430
440
  }
431
441
  const [url, pageInfo] = pageEntry;
432
442
  if (category !== 'passed') {
433
- urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length);
443
+ urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + (pageInfo.itemsCount || 0));
434
444
  }
435
445
  return { url, ...pageInfo };
436
446
  })
437
- // Sort pages so that those with the most items come first
438
- .sort((page1, page2) => page2.items.length - page1.items.length);
447
+ .sort((page1, page2) => (page2.itemsCount || 0) - (page1.itemsCount || 0));
439
448
  return { rule, ...ruleInfo };
440
449
  })
441
- // Sort the rules by totalItems (descending)
442
450
  .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
443
451
  });
444
452
  // Sort top pages (assumes topFiveMostIssues is already populated)
@@ -478,18 +486,22 @@ const extractRuleAiData = (ruleId, totalItems, items, callback) => {
478
486
  };
479
487
  };
480
488
  // This is for telemetry purposes called within mergeAxeResults.ts
481
- export const createRuleIdJson = allIssues => {
489
+ export const createRuleIdJson = async (allIssues, itemsStore) => {
482
490
  const compiledRuleJson = {};
483
- ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
484
- allIssues.items[category].rules.forEach(rule => {
485
- const allItems = rule.pagesAffected.flatMap(page => page.items || []);
486
- compiledRuleJson[rule.rule] = extractRuleAiData(rule.rule, rule.totalItems, allItems, () => {
487
- rule.pagesAffected.forEach(p => {
488
- delete p.items;
489
- });
490
- });
491
- });
492
- });
491
+ for (const category of ['mustFix', 'goodToFix', 'needsReview']) {
492
+ for (const rule of allIssues.items[category].rules) {
493
+ let allItems = [];
494
+ if (itemsStore) {
495
+ for await (const entry of itemsStore.readRuleItems(category, rule.rule)) {
496
+ allItems.push(...entry.items);
497
+ }
498
+ }
499
+ else {
500
+ allItems = rule.pagesAffected.flatMap(page => page.items || []);
501
+ }
502
+ compiledRuleJson[rule.rule] = extractRuleAiData(rule.rule, rule.totalItems, allItems);
503
+ }
504
+ }
493
505
  return compiledRuleJson;
494
506
  };
495
507
  // This is for telemetry purposes called from npmIndex (scanPage and scanHTML) where report is not generated
@@ -619,12 +631,18 @@ generateJsonFiles = false) => {
619
631
  },
620
632
  };
621
633
  const allFiles = await extractFileNames(intermediateDatasetsPath);
622
- const jsonArray = await Promise.all(allFiles.map(async (file) => parseContentToJson(`${intermediateDatasetsPath}/${file}`)));
623
- await Promise.all(jsonArray.map(async (pageResults) => {
624
- await pushResults(pageResults, allIssues, isCustomFlow);
625
- })).catch(flattenIssuesError => {
626
- consoleLogger.error(`[generateArtifacts] Error flattening issues: ${flattenIssuesError?.stack || flattenIssuesError}`);
627
- });
634
+ const itemsStore = new ItemsStore(storagePath);
635
+ for (const file of allFiles) {
636
+ try {
637
+ const pageResults = await parseContentToJson(`${intermediateDatasetsPath}/${file}`);
638
+ if (pageResults) {
639
+ await pushResults(pageResults, allIssues, isCustomFlow, itemsStore);
640
+ }
641
+ }
642
+ catch (flattenIssuesError) {
643
+ consoleLogger.error(`[generateArtifacts] Error processing ${file}: ${flattenIssuesError?.stack || flattenIssuesError}`);
644
+ }
645
+ }
628
646
  flattenAndSortResults(allIssues, isCustomFlow);
629
647
  const labelKey = scanType.toLowerCase() === 'custom' ? 'CustomFlowLabel' : 'Label';
630
648
  const labelValue = allIssues.customFlowLabel || 'N/A';
@@ -654,6 +672,14 @@ generateJsonFiles = false) => {
654
672
  createScreenshotsFolder(randomToken);
655
673
  }
656
674
  populateScanPagesDetail(allIssues);
675
+ // Build htmlGroups in a second pass from disk-backed items
676
+ for (const category of ['mustFix', 'goodToFix', 'needsReview', 'passed']) {
677
+ for (const rule of allIssues.items[category].rules) {
678
+ for await (const entry of itemsStore.readRuleItems(category, rule.rule)) {
679
+ buildHtmlGroups(rule, entry.items, entry.url);
680
+ }
681
+ }
682
+ }
657
683
  allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
658
684
  allIssues.progressPercentage = getProgressPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
659
685
  allIssues.issuesPercentage = await getIssuesPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa, allIssues.advancedScanOptionsSummaryItems.disableOobee);
@@ -703,9 +729,9 @@ generateJsonFiles = false) => {
703
729
  rest.moderate = axeImpactCount.moderate;
704
730
  rest.minor = axeImpactCount.minor;
705
731
  }
706
- await writeCsv(allIssues, storagePath);
732
+ await writeCsv(allIssues, storagePath, itemsStore);
707
733
  await writeSitemap(pagesScanned, storagePath);
708
- const { scanDataJsonFilePath, scanDataBase64FilePath, scanItemsJsonFilePath, scanItemsBase64FilePath, scanItemsSummaryJsonFilePath, scanItemsSummaryBase64FilePath, scanIssuesSummaryJsonFilePath, scanIssuesSummaryBase64FilePath, scanPagesDetailJsonFilePath, scanPagesDetailBase64FilePath, scanPagesSummaryJsonFilePath, scanPagesSummaryBase64FilePath, scanDataJsonFileSize, scanItemsJsonFileSize, } = await writeJsonAndBase64Files(allIssues, storagePath);
734
+ const { scanDataJsonFilePath, scanDataBase64FilePath, scanItemsJsonFilePath, scanItemsBase64FilePath, scanItemsSummaryJsonFilePath, scanItemsSummaryBase64FilePath, scanIssuesSummaryJsonFilePath, scanIssuesSummaryBase64FilePath, scanPagesDetailJsonFilePath, scanPagesDetailBase64FilePath, scanPagesSummaryJsonFilePath, scanPagesSummaryBase64FilePath, scanDataJsonFileSize, scanItemsJsonFileSize, } = await writeJsonAndBase64Files(allIssues, storagePath, itemsStore);
709
735
  // Removed BIG_RESULTS_THRESHOLD check - always use full scanItems
710
736
  await writeScanDetailsCsv(scanDataBase64FilePath, scanItemsBase64FilePath, scanItemsSummaryBase64FilePath, storagePath);
711
737
  await writeSummaryHTML(allIssues, storagePath);
@@ -779,7 +805,9 @@ generateJsonFiles = false) => {
779
805
  printMessage([`Error in zipping results: ${error}`]);
780
806
  }
781
807
  // Generate scrubbed HTML Code Snippets
782
- const ruleIdJson = createRuleIdJson(allIssues);
808
+ const ruleIdJson = await createRuleIdJson(allIssues, itemsStore);
809
+ // Clean up intermediate items files
810
+ await itemsStore.cleanup();
783
811
  // At the end of the function where results are generated, add:
784
812
  try {
785
813
  // Always send WCAG breakdown to Sentry, even if no violations were found
File without changes