@govtechsg/oobee 0.10.43 → 0.10.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.43",
4
+ "version": "0.10.47",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "dependencies": {
8
8
  "@json2csv/node": "^7.0.3",
9
9
  "@napi-rs/canvas": "^0.1.53",
10
+ "@sentry/node": "^9.13.0",
10
11
  "axe-core": "^4.10.2",
11
12
  "axios": "^1.8.2",
12
13
  "base64-stream": "^1.0.0",
package/src/combine.ts CHANGED
@@ -98,8 +98,15 @@ const combineRun = async (details: Data, deviceToScan: string) => {
98
98
  isSlowScanMode: envDetails.specifiedMaxConcurrency,
99
99
  isAdhereRobots: envDetails.followRobots,
100
100
  deviceChosen: deviceToScan,
101
+ nameEmail: undefined as { name: string; email: string } | undefined,
101
102
  };
102
103
 
104
+ // Parse nameEmail and add it to scanDetails for use in generateArtifacts
105
+ if (nameEmail) {
106
+ const [name, email] = nameEmail.split(':');
107
+ scanDetails.nameEmail = { name, email };
108
+ }
109
+
103
110
  const viewportSettings: ViewportSettingsClass = new ViewportSettingsClass(
104
111
  deviceChosen,
105
112
  customDevice,
@@ -23,7 +23,9 @@ import constants, {
23
23
  getDefaultEdgeDataDir,
24
24
  getDefaultChromiumDataDir,
25
25
  proxy,
26
+ // Legacy code start - Google Sheets submission
26
27
  formDataFields,
28
+ // Legacy code end - Google Sheets submission
27
29
  ScannerTypes,
28
30
  BrowserTypes,
29
31
  } from './constants.js';
@@ -1753,6 +1755,7 @@ export const submitForm = async (
1753
1755
  numberOfPagesNotScanned: number,
1754
1756
  metadata: string,
1755
1757
  ) => {
1758
+ // Legacy code start - Google Sheets submission
1756
1759
  const additionalPageDataJson = JSON.stringify({
1757
1760
  redirectsScanned: numberOfRedirectsScanned,
1758
1761
  pagesNotScanned: numberOfPagesNotScanned,
@@ -1787,6 +1790,7 @@ export const submitForm = async (
1787
1790
  }
1788
1791
  }
1789
1792
  };
1793
+ // Legacy code end - Google Sheets submission
1790
1794
 
1791
1795
  export async function initModifiedUserAgent(
1792
1796
  browser?: string,
@@ -1877,21 +1881,27 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
1877
1881
  page.waitForLoadState('networkidle'), // Wait for network requests to settle
1878
1882
  new Promise(resolve => setTimeout(resolve, timeout)), // Hard timeout as a fallback
1879
1883
  page.evaluate(OBSERVER_TIMEOUT => {
1880
- return new Promise(resolve => {
1884
+ return new Promise<string>(resolve => {
1881
1885
  // Skip mutation check for PDFs
1882
1886
  if (document.contentType === 'application/pdf') {
1883
1887
  resolve('Skipping DOM mutation check for PDF.');
1884
1888
  return;
1885
1889
  }
1886
1890
 
1891
+ const root = document.documentElement || document.body;
1892
+ if (!(root instanceof Node)) {
1893
+ // Not a valid DOM root—treat as loaded
1894
+ resolve('No valid root to observe; treating as loaded.');
1895
+ return;
1896
+ }
1897
+
1887
1898
  let timeout: NodeJS.Timeout;
1888
1899
  let mutationCount = 0;
1889
- const MAX_MUTATIONS = 250; // Limit max mutations
1900
+ const MAX_MUTATIONS = 250;
1890
1901
  const mutationHash: Record<string, number> = {};
1891
1902
 
1892
1903
  const observer = new MutationObserver(mutationsList => {
1893
1904
  clearTimeout(timeout);
1894
-
1895
1905
  mutationCount++;
1896
1906
  if (mutationCount > MAX_MUTATIONS) {
1897
1907
  observer.disconnect();
@@ -1899,24 +1909,20 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
1899
1909
  return;
1900
1910
  }
1901
1911
 
1902
- mutationsList.forEach(mutation => {
1912
+ for (const mutation of mutationsList) {
1903
1913
  if (mutation.target instanceof Element) {
1904
- Array.from(mutation.target.attributes).forEach(attr => {
1905
- const mutationKey = `${mutation.target.nodeName}-${attr.name}`;
1906
-
1907
- if (mutationKey) {
1908
- mutationHash[mutationKey] = (mutationHash[mutationKey] || 0) + 1;
1909
-
1910
- if (mutationHash[mutationKey] >= 10) {
1911
- observer.disconnect();
1912
- resolve(`Repeated mutation detected for ${mutationKey}, exiting.`);
1913
- }
1914
+ for (const attr of Array.from(mutation.target.attributes)) {
1915
+ const key = `${mutation.target.nodeName}-${attr.name}`;
1916
+ mutationHash[key] = (mutationHash[key] || 0) + 1;
1917
+ if (mutationHash[key] >= 10) {
1918
+ observer.disconnect();
1919
+ resolve(`Repeated mutation detected for ${key}, exiting.`);
1920
+ return;
1914
1921
  }
1915
- });
1922
+ }
1916
1923
  }
1917
- });
1924
+ }
1918
1925
 
1919
- // If no mutations occur for 1 second, resolve
1920
1926
  timeout = setTimeout(() => {
1921
1927
  observer.disconnect();
1922
1928
  resolve('DOM stabilized after mutations.');
@@ -1929,9 +1935,10 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
1929
1935
  resolve('Observer timeout reached, exiting.');
1930
1936
  }, OBSERVER_TIMEOUT);
1931
1937
 
1932
- observer.observe(document.documentElement, {
1938
+ // Only observe if root is a Node
1939
+ observer.observe(root, {
1933
1940
  childList: true,
1934
- subtree: true,
1941
+ subtree: true,
1935
1942
  attributes: true,
1936
1943
  });
1937
1944
  });
@@ -6,6 +6,7 @@ import which from 'which';
6
6
  import os from 'os';
7
7
  import { spawnSync, execSync } from 'child_process';
8
8
  import { chromium } from 'playwright';
9
+ import * as Sentry from '@sentry/node';
9
10
  import { silentLogger } from '../logs.js';
10
11
  import { PageInfo } from '../mergeAxeResults.js';
11
12
 
@@ -274,6 +275,44 @@ export const impactOrder = {
274
275
  critical: 3,
275
276
  };
276
277
 
278
+ /**
279
+ * Suppresses the "Setting the NODE_TLS_REJECT_UNAUTHORIZED
280
+ * environment variable to '0' is insecure" warning,
281
+ * then disables TLS validation globally.
282
+ */
283
+ export function suppressTlsRejectWarning(): void {
284
+ // Monkey-patch process.emitWarning
285
+ const originalEmitWarning = process.emitWarning;
286
+ process.emitWarning = (warning: string | Error, ...args: any[]) => {
287
+ const msg = typeof warning === 'string' ? warning : warning.message;
288
+ if (msg.includes('NODE_TLS_REJECT_UNAUTHORIZED')) {
289
+ // swallow only that one warning
290
+ return;
291
+ }
292
+ // forward everything else
293
+ originalEmitWarning.call(process, warning, ...args);
294
+ };
295
+
296
+ // Now turn off cert validation
297
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
298
+ }
299
+
300
+ suppressTlsRejectWarning();
301
+
302
+ export const sentryConfig = {
303
+ dsn: process.env.OOBEE_SENTRY_DSN || "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168",
304
+ tracesSampleRate: 1.0, // Capture 100% of transactions for performance monitoring
305
+ profilesSampleRate: 1.0, // Capture 100% of profiles
306
+ };
307
+
308
+ // Function to set Sentry user ID from userData.txt
309
+ export const setSentryUser = (userId: string) => {
310
+ if (userId) {
311
+ Sentry.setUser({ id: userId });
312
+ }
313
+ };
314
+
315
+ // Legacy code start - Google Sheets submission
277
316
  export const formDataFields = {
278
317
  formUrl: `https://docs.google.com/forms/d/e/1FAIpQLSem5C8fyNs5TiU5Vv2Y63-SH7CHN86f-LEPxeN_1u_ldUbgUA/formResponse`, // prod
279
318
  entryUrlField: 'entry.1562345227',
@@ -286,6 +325,7 @@ export const formDataFields = {
286
325
  additionalPageDataField: 'entry.2090887881',
287
326
  metadataField: 'entry.1027769131',
288
327
  };
328
+ // Legacy code end - Google Sheets submission
289
329
 
290
330
  export const sitemapPaths = [
291
331
  '/sitemap.xml',
@@ -482,8 +482,6 @@ const crawlDomain = async ({
482
482
  async crawlingContext => {
483
483
  const { page, request } = crawlingContext;
484
484
 
485
- request.skipNavigation = true;
486
-
487
485
  await page.evaluate(() => {
488
486
  return new Promise(resolve => {
489
487
  let timeout;
@@ -514,9 +512,11 @@ const crawlDomain = async ({
514
512
  resolve('Observer timeout reached.');
515
513
  }, OBSERVER_TIMEOUT);
516
514
 
517
- // **HERE**: select the real DOM node inside evaluate
518
- const root = document.documentElement;
519
- observer.observe(root, { childList: true, subtree: true });
515
+ const root = document.documentElement || document.body || document;
516
+ if (!root || typeof observer.observe !== 'function') {
517
+ resolve('No root node to observe.');
518
+ return;
519
+ }
520
520
  });
521
521
  });
522
522
 
@@ -199,9 +199,11 @@ const crawlSitemap = async (
199
199
  resolve('Observer timeout reached.');
200
200
  }, OBSERVER_TIMEOUT);
201
201
 
202
- // **HERE**: select the real DOM node inside evaluate
203
- const root = document.documentElement;
204
- observer.observe(root, { childList: true, subtree: true });
202
+ const root = document.documentElement || document.body || document;
203
+ if (!root || typeof observer.observe !== 'function') {
204
+ resolve('No root node to observe.');
205
+ return;
206
+ }
205
207
  });
206
208
  });
207
209
  } catch (err) {
@@ -12,8 +12,11 @@ import { AsyncParser, ParserOptions } from '@json2csv/node';
12
12
  import zlib from 'zlib';
13
13
  import { Base64Encode } from 'base64-stream';
14
14
  import { pipeline } from 'stream/promises';
15
- import constants, { ScannerTypes } from './constants/constants.js';
15
+ // @ts-ignore
16
+ import * as Sentry from '@sentry/node';
17
+ import constants, { ScannerTypes, sentryConfig, setSentryUser } from './constants/constants.js';
16
18
  import { urlWithoutAuth } from './constants/common.js';
19
+
17
20
  import {
18
21
  createScreenshotsFolder,
19
22
  getStoragePath,
@@ -23,6 +26,9 @@ import {
23
26
  retryFunction,
24
27
  zipResults,
25
28
  getIssuesPercentage,
29
+ getWcagCriteriaMap,
30
+ categorizeWcagCriteria,
31
+ getUserDataTxt,
26
32
  } from './utils.js';
27
33
  import { consoleLogger, silentLogger } from './logs.js';
28
34
  import itemTypeDescription from './constants/itemTypeDescription.js';
@@ -231,7 +237,7 @@ const writeCsv = async (allIssues, storagePath) => {
231
237
  includeEmptyRows: true,
232
238
  };
233
239
 
234
- // Create the parse stream (its asynchronous)
240
+ // Create the parse stream (it's asynchronous)
235
241
  const parser = new AsyncParser(opts);
236
242
  const parseStream = parser.parse(allIssues);
237
243
 
@@ -249,7 +255,9 @@ const writeCsv = async (allIssues, storagePath) => {
249
255
  scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
250
256
  severity: 'error',
251
257
  issueId: 'error-pages-skipped',
252
- issueDescription: page.metadata ? page.metadata : 'An unknown error caused the page to be skipped',
258
+ issueDescription: page.metadata
259
+ ? page.metadata
260
+ : 'An unknown error caused the page to be skipped',
253
261
  wcagConformance: '',
254
262
  url: page.url || page || '',
255
263
  pageTitle: 'Error',
@@ -561,15 +569,15 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
561
569
  queueWrite(` "${key}": ${JSON.stringify(value)}`);
562
570
  } else {
563
571
  queueWrite(` "${key}": {\n`);
564
-
572
+
565
573
  const { rules, ...otherProperties } = value;
566
-
574
+
567
575
  // Write other properties
568
576
  Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
569
577
  const propValueString =
570
578
  propValue === null ||
571
- typeof propValue === 'function' ||
572
- typeof propValue === 'undefined'
579
+ typeof propValue === 'function' ||
580
+ typeof propValue === 'undefined'
573
581
  ? 'null'
574
582
  : JSON.stringify(propValue);
575
583
  queueWrite(` "${propKey}": ${propValueString}`);
@@ -590,8 +598,8 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
590
598
  Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
591
599
  const ruleValueString =
592
600
  ruleValue === null ||
593
- typeof ruleValue === 'function' ||
594
- typeof ruleValue === 'undefined'
601
+ typeof ruleValue === 'function' ||
602
+ typeof ruleValue === 'undefined'
595
603
  ? 'null'
596
604
  : JSON.stringify(ruleValue);
597
605
  queueWrite(` "${ruleKey}": ${ruleValueString}`);
@@ -634,15 +642,13 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
634
642
  queueWrite(' ]');
635
643
  }
636
644
  queueWrite('\n }');
637
- }
638
-
639
- if (i < keys.length - 1) {
640
- queueWrite(',\n');
641
- } else {
642
- queueWrite('\n');
643
- }
644
-
645
+ }
645
646
 
647
+ if (i < keys.length - 1) {
648
+ queueWrite(',\n');
649
+ } else {
650
+ queueWrite('\n');
651
+ }
646
652
  });
647
653
 
648
654
  queueWrite('}\n');
@@ -759,33 +765,43 @@ const writeJsonAndBase64Files = async (
759
765
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
760
766
  await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
761
767
  const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
762
- await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
763
-
764
- // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
765
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
766
- if (items[category].rules && Array.isArray(items[category].rules)) {
767
- items[category].rules.forEach((rule) => {
768
- rule.pagesAffectedCount = Array.isArray(rule.pagesAffected)
769
- ? rule.pagesAffected.length
770
- : 0;
771
- });
768
+ await writeJsonFileAndCompressedJsonFile(
769
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
770
+ storagePath,
771
+ 'scanItems',
772
+ );
772
773
 
773
- // Sort in descending order of pagesAffectedCount
774
- items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
775
- }
776
- });
774
+ // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
775
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
776
+ if (items[category].rules && Array.isArray(items[category].rules)) {
777
+ items[category].rules.forEach(rule => {
778
+ rule.pagesAffectedCount = Array.isArray(rule.pagesAffected) ? rule.pagesAffected.length : 0;
779
+ });
777
780
 
778
- // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
779
- const scanIssuesSummary = {
780
- mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
781
- goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
782
- needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
783
- passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
784
- };
781
+ // Sort in descending order of pagesAffectedCount
782
+ items[category].rules.sort(
783
+ (a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0),
784
+ );
785
+ }
786
+ });
785
787
 
786
- // Write out the scanIssuesSummary JSON using the new structure
787
- const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath } =
788
- await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
788
+ // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
789
+ const scanIssuesSummary = {
790
+ mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
791
+ goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
792
+ needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
793
+ passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
794
+ };
795
+
796
+ // Write out the scanIssuesSummary JSON using the new structure
797
+ const {
798
+ jsonFilePath: scanIssuesSummaryJsonFilePath,
799
+ base64FilePath: scanIssuesSummaryBase64FilePath,
800
+ } = await writeJsonFileAndCompressedJsonFile(
801
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary },
802
+ storagePath,
803
+ 'scanIssuesSummary',
804
+ );
789
805
 
790
806
  // scanItemsSummary
791
807
  // the below mutates the original items object, since it is expensive to clone
@@ -845,8 +861,12 @@ const writeJsonAndBase64Files = async (
845
861
  const {
846
862
  jsonFilePath: scanItemsMiniReportJsonFilePath,
847
863
  base64FilePath: scanItemsMiniReportBase64FilePath,
848
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini }, storagePath, 'scanItemsSummaryMiniReport');
849
-
864
+ } = await writeJsonFileAndCompressedJsonFile(
865
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini },
866
+ storagePath,
867
+ 'scanItemsSummaryMiniReport',
868
+ );
869
+
850
870
  const summaryItems = {
851
871
  mustFix: {
852
872
  totalItems: items.mustFix?.totalItems || 0,
@@ -873,17 +893,29 @@ const writeJsonAndBase64Files = async (
873
893
  const {
874
894
  jsonFilePath: scanItemsSummaryJsonFilePath,
875
895
  base64FilePath: scanItemsSummaryBase64FilePath,
876
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
896
+ } = await writeJsonFileAndCompressedJsonFile(
897
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems },
898
+ storagePath,
899
+ 'scanItemsSummary',
900
+ );
877
901
 
878
902
  const {
879
903
  jsonFilePath: scanPagesDetailJsonFilePath,
880
- base64FilePath: scanPagesDetailBase64FilePath
881
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail }, storagePath, 'scanPagesDetail');
904
+ base64FilePath: scanPagesDetailBase64FilePath,
905
+ } = await writeJsonFileAndCompressedJsonFile(
906
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail },
907
+ storagePath,
908
+ 'scanPagesDetail',
909
+ );
882
910
 
883
911
  const {
884
912
  jsonFilePath: scanPagesSummaryJsonFilePath,
885
- base64FilePath: scanPagesSummaryBase64FilePath
886
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary }, storagePath, 'scanPagesSummary');
913
+ base64FilePath: scanPagesSummaryBase64FilePath,
914
+ } = await writeJsonFileAndCompressedJsonFile(
915
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary },
916
+ storagePath,
917
+ 'scanPagesSummary',
918
+ );
887
919
 
888
920
  return {
889
921
  scanDataJsonFilePath,
@@ -985,6 +1017,21 @@ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filena
985
1017
  }
986
1018
  };
987
1019
 
1020
+ // Tracking WCAG occurrences
1021
+ const wcagOccurrencesMap = new Map<string, number>();
1022
+
1023
+ // Format WCAG tag in requested format: wcag111a_Occurrences
1024
+ const formatWcagTag = async (wcagId: string): Promise<string | null> => {
1025
+ // Get dynamic WCAG criteria map
1026
+ const wcagCriteriaMap = await getWcagCriteriaMap();
1027
+
1028
+ if (wcagCriteriaMap[wcagId]) {
1029
+ const { level } = wcagCriteriaMap[wcagId];
1030
+ return `${wcagId}${level}_Occurrences`;
1031
+ }
1032
+ return null;
1033
+ };
1034
+
988
1035
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
989
1036
  const { url, pageTitle, filePath } = pageResults;
990
1037
 
@@ -1036,6 +1083,10 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1036
1083
  if (!allIssues.wcagViolations.includes(c)) {
1037
1084
  allIssues.wcagViolations.push(c);
1038
1085
  }
1086
+
1087
+ // Track WCAG criteria occurrences for Sentry
1088
+ const currentCount = wcagOccurrencesMap.get(c) || 0;
1089
+ wcagOccurrencesMap.set(c, currentCount + count);
1039
1090
  });
1040
1091
  }
1041
1092
 
@@ -1126,35 +1177,31 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1126
1177
  const urlOccurrencesMap = new Map<string, number>();
1127
1178
 
1128
1179
  // Iterate over all categories; update the map only if the category is not "passed"
1129
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
1180
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
1130
1181
  // Accumulate totalItems regardless of category.
1131
1182
  allIssues.totalItems += allIssues.items[category].totalItems;
1132
1183
 
1133
1184
  allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
1134
- .map((ruleEntry) => {
1185
+ .map(ruleEntry => {
1135
1186
  const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
1136
1187
  ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
1137
- .map((pageEntry) => {
1188
+ .map(pageEntry => {
1138
1189
  if (isCustomFlow) {
1139
1190
  const [pageIndex, pageInfo] = pageEntry as unknown as [number, PageInfo];
1140
1191
  // Only update the occurrences map if not passed.
1141
1192
  if (category !== 'passed') {
1142
1193
  urlOccurrencesMap.set(
1143
1194
  pageInfo.url!,
1144
- (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length
1195
+ (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length,
1145
1196
  );
1146
1197
  }
1147
1198
  return { pageIndex, ...pageInfo };
1148
- } else {
1149
- const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1150
- if (category !== 'passed') {
1151
- urlOccurrencesMap.set(
1152
- url,
1153
- (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length
1154
- );
1155
- }
1156
- return { url, ...pageInfo };
1157
1199
  }
1200
+ const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1201
+ if (category !== 'passed') {
1202
+ urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length);
1203
+ }
1204
+ return { url, ...pageInfo };
1158
1205
  })
1159
1206
  // Sort pages so that those with the most items come first
1160
1207
  .sort((page1, page2) => page2.items.length - page1.items.length);
@@ -1181,12 +1228,11 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1181
1228
  // Helper: Update totalOccurrences for each issue using our urlOccurrencesMap.
1182
1229
  // For pages that have only passed items, the map will return undefined, so default to 0.
1183
1230
  function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<string, number>) {
1184
- issuesList.forEach((issue) => {
1231
+ issuesList.forEach(issue => {
1185
1232
  issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1186
1233
  });
1187
1234
  }
1188
1235
 
1189
-
1190
1236
  const createRuleIdJson = allIssues => {
1191
1237
  const compiledRuleJson = {};
1192
1238
 
@@ -1202,7 +1248,9 @@ const createRuleIdJson = allIssues => {
1202
1248
  });
1203
1249
  });
1204
1250
  snippets = [...snippetsSet];
1205
- rule.pagesAffected.forEach(p => { delete p.items; });
1251
+ rule.pagesAffected.forEach(p => {
1252
+ delete p.items;
1253
+ });
1206
1254
  }
1207
1255
  compiledRuleJson[ruleId] = {
1208
1256
  snippets,
@@ -1232,17 +1280,15 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1232
1280
  // --------------------------------------------
1233
1281
  // 1) Gather your "scanned" pages from allIssues
1234
1282
  // --------------------------------------------
1235
- const allScannedPages = Array.isArray(allIssues.pagesScanned)
1236
- ? allIssues.pagesScanned
1237
- : [];
1283
+ const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
1238
1284
 
1239
1285
  // --------------------------------------------
1240
1286
  // 2) Define category constants (optional, just for clarity)
1241
1287
  // --------------------------------------------
1242
- const mustFixCategory = "mustFix";
1243
- const goodToFixCategory = "goodToFix";
1244
- const needsReviewCategory = "needsReview";
1245
- const passedCategory = "passed";
1288
+ const mustFixCategory = 'mustFix';
1289
+ const goodToFixCategory = 'goodToFix';
1290
+ const needsReviewCategory = 'needsReview';
1291
+ const passedCategory = 'passed';
1246
1292
 
1247
1293
  // --------------------------------------------
1248
1294
  // 3) Set up type declarations (if you want them local to this function)
@@ -1262,8 +1308,8 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1262
1308
  // Summaries
1263
1309
  totalOccurrencesFailedIncludingNeedsReview: number; // mustFix + goodToFix + needsReview
1264
1310
  totalOccurrencesFailedExcludingNeedsReview: number; // mustFix + goodToFix
1265
- totalOccurrencesNeedsReview: number; // needsReview
1266
- totalOccurrencesPassed: number; // passed only
1311
+ totalOccurrencesNeedsReview: number; // needsReview
1312
+ totalOccurrencesPassed: number; // passed only
1267
1313
  typesOfIssues: Record<string, RuleData>;
1268
1314
  };
1269
1315
 
@@ -1338,9 +1384,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1338
1384
  const pagesInMapUrls = new Set(Object.keys(pagesMap));
1339
1385
 
1340
1386
  // (a) Pages with only passed (no mustFix/goodToFix/needsReview)
1341
- const pagesAllPassed = pagesInMap.filter(
1342
- p => p.totalOccurrencesFailedIncludingNeedsReview === 0
1343
- );
1387
+ const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
1344
1388
 
1345
1389
  // (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
1346
1390
  const pagesNoEntries = allScannedPages
@@ -1359,9 +1403,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1359
1403
  const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
1360
1404
 
1361
1405
  // "affected" pages => have at least 1 mustFix/goodToFix/needsReview
1362
- const pagesAffectedRaw = pagesInMap.filter(
1363
- p => p.totalOccurrencesFailedIncludingNeedsReview > 0
1364
- );
1406
+ const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
1365
1407
 
1366
1408
  // --------------------------------------------
1367
1409
  // 7) Transform both arrays to the final shape
@@ -1376,16 +1418,18 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1376
1418
 
1377
1419
  // Build categoriesPresent based on nonzero failing counts
1378
1420
  const categoriesPresent: string[] = [];
1379
- if (mustFixSum > 0) categoriesPresent.push("mustFix");
1380
- if (goodToFixSum > 0) categoriesPresent.push("goodToFix");
1381
- if (needsReviewSum > 0) categoriesPresent.push("needsReview");
1421
+ if (mustFixSum > 0) categoriesPresent.push('mustFix');
1422
+ if (goodToFixSum > 0) categoriesPresent.push('goodToFix');
1423
+ if (needsReviewSum > 0) categoriesPresent.push('needsReview');
1382
1424
 
1383
1425
  // Count how many rules have failing issues
1384
1426
  const failedRuleIds = new Set<string>();
1385
1427
  typesOfIssuesArray.forEach(r => {
1386
- if ((r.occurrencesMustFix || 0) > 0 ||
1387
- (r.occurrencesGoodToFix || 0) > 0 ||
1388
- (r.occurrencesNeedsReview || 0) > 0) {
1428
+ if (
1429
+ (r.occurrencesMustFix || 0) > 0 ||
1430
+ (r.occurrencesGoodToFix || 0) > 0 ||
1431
+ (r.occurrencesNeedsReview || 0) > 0
1432
+ ) {
1389
1433
  failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
1390
1434
  }
1391
1435
  });
@@ -1393,16 +1437,16 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1393
1437
 
1394
1438
  // Possibly these two for future convenience
1395
1439
  const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(
1396
- r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0
1440
+ r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0,
1397
1441
  ).length;
1398
1442
 
1399
1443
  const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(
1400
1444
  r =>
1401
1445
  (r.occurrencesNeedsReview || 0) > 0 &&
1402
1446
  (r.occurrencesMustFix || 0) === 0 &&
1403
- (r.occurrencesGoodToFix || 0) === 0
1447
+ (r.occurrencesGoodToFix || 0) === 0,
1404
1448
  ).length;
1405
-
1449
+
1406
1450
  // Aggregate wcagConformance for rules that actually fail
1407
1451
  const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
1408
1452
  const nonPassedCount =
@@ -1462,9 +1506,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1462
1506
  pagesAffected,
1463
1507
  pagesNotAffected,
1464
1508
  scannedPagesCount,
1465
- pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1466
- ? allIssues.pagesNotScanned
1467
- : [],
1509
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1468
1510
  pagesNotScannedCount,
1469
1511
  };
1470
1512
 
@@ -1483,13 +1525,170 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1483
1525
  pagesAffected: summaryPagesAffected,
1484
1526
  pagesNotAffected: summaryPagesNotAffected,
1485
1527
  scannedPagesCount,
1486
- pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1487
- ? allIssues.pagesNotScanned
1488
- : [],
1528
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1489
1529
  pagesNotScannedCount,
1490
1530
  };
1491
1531
  }
1492
1532
 
1533
+ // Send WCAG criteria breakdown to Sentry
1534
+ const sendWcagBreakdownToSentry = async (
1535
+ wcagBreakdown: Map<string, number>,
1536
+ scanInfo: {
1537
+ entryUrl: string;
1538
+ scanType: string;
1539
+ browser: string;
1540
+ email?: string;
1541
+ name?: string;
1542
+ },
1543
+ allIssues?: AllIssues,
1544
+ pagesScannedCount: number = 0,
1545
+ ) => {
1546
+ try {
1547
+ // Initialize Sentry
1548
+ Sentry.init(sentryConfig);
1549
+ // Set user ID for Sentry tracking
1550
+ const userData = getUserDataTxt();
1551
+ if (userData && userData.userId) {
1552
+ setSentryUser(userData.userId);
1553
+ }
1554
+
1555
+ // Prepare tags for the event
1556
+ const tags: Record<string, string> = {};
1557
+ const wcagCriteriaBreakdown: Record<string, any> = {};
1558
+
1559
+ // Get dynamic WCAG criteria map once
1560
+ const wcagCriteriaMap = await getWcagCriteriaMap();
1561
+
1562
+ // Categorize all WCAG criteria for reporting
1563
+ const wcagIds = Array.from(
1564
+ new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]),
1565
+ );
1566
+ const categorizedWcag = await categorizeWcagCriteria(wcagIds);
1567
+
1568
+ // First ensure all WCAG criteria are included in the tags with a value of 0
1569
+ // This ensures criteria with no violations are still reported
1570
+ for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
1571
+ const formattedTag = await formatWcagTag(wcagId);
1572
+ if (formattedTag) {
1573
+ // Initialize with zero
1574
+ tags[formattedTag] = '0';
1575
+
1576
+ // Store in breakdown object with category information
1577
+ wcagCriteriaBreakdown[formattedTag] = {
1578
+ count: 0,
1579
+ category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
1580
+ };
1581
+ }
1582
+ }
1583
+
1584
+ // Now override with actual counts from the scan
1585
+ for (const [wcagId, count] of wcagBreakdown.entries()) {
1586
+ const formattedTag = await formatWcagTag(wcagId);
1587
+ if (formattedTag) {
1588
+ // Add as a tag with the count as value
1589
+ tags[formattedTag] = String(count);
1590
+
1591
+ // Update count in breakdown object
1592
+ if (wcagCriteriaBreakdown[formattedTag]) {
1593
+ wcagCriteriaBreakdown[formattedTag].count = count;
1594
+ } else {
1595
+ // If somehow this wasn't in our initial map
1596
+ wcagCriteriaBreakdown[formattedTag] = {
1597
+ count,
1598
+ category: categorizedWcag[wcagId] || 'mustFix',
1599
+ };
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ // Calculate category counts based on actual issue counts from the report
1605
+ // rather than occurrence counts from wcagBreakdown
1606
+ const categoryCounts = {
1607
+ mustFix: 0,
1608
+ goodToFix: 0,
1609
+ needsReview: 0,
1610
+ };
1611
+
1612
+ if (allIssues) {
1613
+ // Use the actual report data for the counts
1614
+ categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
1615
+ categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
1616
+ categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
1617
+ } else {
1618
+ // Fallback to the old way if allIssues not provided
1619
+ Object.values(wcagCriteriaBreakdown).forEach(item => {
1620
+ if (item.count > 0 && categoryCounts[item.category] !== undefined) {
1621
+ categoryCounts[item.category] += 1; // Count rules, not occurrences
1622
+ }
1623
+ });
1624
+ }
1625
+
1626
+ // Add category counts as tags
1627
+ tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
1628
+ tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
1629
+ tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
1630
+
1631
+ // Also add occurrence counts for reference
1632
+ if (allIssues) {
1633
+ tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
1634
+ tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
1635
+ tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
1636
+
1637
+ // Add number of pages scanned tag
1638
+ tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
1639
+ } else if (pagesScannedCount > 0) {
1640
+ // Still add the pages scanned count even if we don't have allIssues
1641
+ tags['Pages-Scanned-Count'] = String(pagesScannedCount);
1642
+ }
1643
+
1644
+ // Send the event to Sentry
1645
+ await Sentry.captureEvent({
1646
+ message: 'Accessibility Scan Completed',
1647
+ level: 'info',
1648
+ tags: {
1649
+ ...tags,
1650
+ event_type: 'accessibility_scan',
1651
+ scanType: scanInfo.scanType,
1652
+ browser: scanInfo.browser,
1653
+ entryUrl: scanInfo.entryUrl,
1654
+ },
1655
+ user: {
1656
+ ...(scanInfo.email && scanInfo.name
1657
+ ? {
1658
+ email: scanInfo.email,
1659
+ username: scanInfo.name,
1660
+ }
1661
+ : {}),
1662
+ ...(userData && userData.userId ? { id: userData.userId } : {}),
1663
+ },
1664
+ extra: {
1665
+ wcagBreakdown: wcagCriteriaBreakdown,
1666
+ reportCounts: allIssues
1667
+ ? {
1668
+ mustFix: {
1669
+ issues: allIssues.items.mustFix.rules?.length ?? 0,
1670
+ occurrences: allIssues.items.mustFix.totalItems ?? 0,
1671
+ },
1672
+ goodToFix: {
1673
+ issues: allIssues.items.goodToFix.rules?.length ?? 0,
1674
+ occurrences: allIssues.items.goodToFix.totalItems ?? 0,
1675
+ },
1676
+ needsReview: {
1677
+ issues: allIssues.items.needsReview.rules?.length ?? 0,
1678
+ occurrences: allIssues.items.needsReview.totalItems ?? 0,
1679
+ },
1680
+ }
1681
+ : undefined,
1682
+ },
1683
+ });
1684
+
1685
+ // Wait for events to be sent
1686
+ await Sentry.flush(2000);
1687
+ } catch (error) {
1688
+ console.error('Error sending WCAG breakdown to Sentry:', error);
1689
+ }
1690
+ };
1691
+
1493
1692
  const generateArtifacts = async (
1494
1693
  randomToken: string,
1495
1694
  urlScanned: string,
@@ -1512,6 +1711,7 @@ const generateArtifacts = async (
1512
1711
  isEnableWcagAaa: string[];
1513
1712
  isSlowScanMode: number;
1514
1713
  isAdhereRobots: boolean;
1714
+ nameEmail?: { name: string; email: string };
1515
1715
  },
1516
1716
  zip: string = undefined, // optional
1517
1717
  generateJsonFiles = false,
@@ -1661,13 +1861,20 @@ const generateArtifacts = async (
1661
1861
 
1662
1862
  populateScanPagesDetail(allIssues);
1663
1863
 
1664
- allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1665
- allIssues.progressPercentage = getProgressPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1666
-
1864
+ allIssues.wcagPassPercentage = getWcagPassPercentage(
1865
+ allIssues.wcagViolations,
1866
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1867
+ );
1868
+ allIssues.progressPercentage = getProgressPercentage(
1869
+ allIssues.scanPagesDetail,
1870
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1871
+ );
1872
+
1667
1873
  allIssues.issuesPercentage = await getIssuesPercentage(
1668
- allIssues.scanPagesDetail,
1669
- allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1670
- allIssues.advancedScanOptionsSummaryItems.disableOobee);
1874
+ allIssues.scanPagesDetail,
1875
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1876
+ allIssues.advancedScanOptionsSummaryItems.disableOobee,
1877
+ );
1671
1878
 
1672
1879
  // console.log(allIssues.progressPercentage);
1673
1880
  // console.log(allIssues.issuesPercentage);
@@ -1806,6 +2013,26 @@ const generateArtifacts = async (
1806
2013
  printMessage([`Error in zipping results: ${error}`]);
1807
2014
  });
1808
2015
 
2016
+ // At the end of the function where results are generated, add:
2017
+ try {
2018
+ // Always send WCAG breakdown to Sentry, even if no violations were found
2019
+ // This ensures that all criteria are reported, including those with 0 occurrences
2020
+ await sendWcagBreakdownToSentry(
2021
+ wcagOccurrencesMap,
2022
+ {
2023
+ entryUrl: urlScanned,
2024
+ scanType,
2025
+ browser: scanDetails.deviceChosen,
2026
+ email: scanDetails.nameEmail?.email,
2027
+ name: scanDetails.nameEmail?.name,
2028
+ },
2029
+ allIssues,
2030
+ pagesScanned.length,
2031
+ );
2032
+ } catch (error) {
2033
+ console.error('Error sending WCAG data to Sentry:', error);
2034
+ }
2035
+
1809
2036
  return createRuleIdJson(allIssues);
1810
2037
  };
1811
2038
 
@@ -32,7 +32,7 @@ export const takeScreenshotForHTMLElements = async (
32
32
 
33
33
  // Check if rule ID is 'oobee-grading-text-contents' and skip screenshot logic
34
34
  if (rule === 'oobee-grading-text-contents') {
35
- consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
35
+ // consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
36
36
  newViolations.push(violation); // Make sure it gets added
37
37
  continue;
38
38
  }
package/src/utils.ts CHANGED
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import fs from 'fs-extra';
5
5
  import axe, { Rule } from 'axe-core';
6
+ import { v4 as uuidv4 } from 'uuid';
6
7
  import constants, {
7
8
  BrowserTypes,
8
9
  destinationPath,
@@ -97,6 +98,11 @@ export const getUserDataTxt = () => {
97
98
  // check if textFilePath exists
98
99
  if (fs.existsSync(textFilePath)) {
99
100
  const userData = JSON.parse(fs.readFileSync(textFilePath, 'utf8'));
101
+ // If userId doesn't exist, generate one and save it
102
+ if (!userData.userId) {
103
+ userData.userId = uuidv4();
104
+ fs.writeFileSync(textFilePath, JSON.stringify(userData, null, 2));
105
+ }
100
106
  return userData;
101
107
  }
102
108
  return null;
@@ -109,13 +115,18 @@ export const writeToUserDataTxt = async (key: string, value: string): Promise<vo
109
115
  if (fs.existsSync(textFilePath)) {
110
116
  const userData = JSON.parse(fs.readFileSync(textFilePath, 'utf8'));
111
117
  userData[key] = value;
118
+ // Ensure userId exists
119
+ if (!userData.userId) {
120
+ userData.userId = uuidv4();
121
+ }
112
122
  fs.writeFileSync(textFilePath, JSON.stringify(userData, null, 2));
113
123
  } else {
114
124
  const textFilePathDir = path.dirname(textFilePath);
115
125
  if (!fs.existsSync(textFilePathDir)) {
116
126
  fs.mkdirSync(textFilePathDir, { recursive: true });
117
127
  }
118
- fs.appendFileSync(textFilePath, JSON.stringify({ [key]: value }, null, 2));
128
+ // Initialize with userId
129
+ fs.appendFileSync(textFilePath, JSON.stringify({ [key]: value, userId: uuidv4() }, null, 2));
119
130
  }
120
131
  };
121
132
 
@@ -420,6 +431,132 @@ export const getTotalRulesCount = async (
420
431
  };
421
432
  };
422
433
 
434
+ /**
435
+ * Dynamically generates a map of WCAG criteria IDs to their details (name and level)
436
+ * Reuses the rule processing logic from getTotalRulesCount
437
+ */
438
+ export const getWcagCriteriaMap = async (
439
+ enableWcagAaa: boolean = true,
440
+ disableOobee: boolean = false
441
+ ): Promise<Record<string, { name: string; level: string }>> => {
442
+ // Reuse the configuration setup from getTotalRulesCount
443
+ const axeConfig = getAxeConfiguration({
444
+ enableWcagAaa,
445
+ gradingReadabilityFlag: '',
446
+ disableOobee,
447
+ });
448
+
449
+ // Get default rules from axe-core
450
+ const defaultRules = axe.getRules();
451
+
452
+ // Merge custom rules with default rules
453
+ const mergedRules: Rule[] = defaultRules.map(defaultRule => {
454
+ const customRule = axeConfig.rules.find(r => r.id === defaultRule.ruleId);
455
+ if (customRule) {
456
+ return {
457
+ id: defaultRule.ruleId,
458
+ enabled: customRule.enabled,
459
+ selector: customRule.selector,
460
+ any: customRule.any,
461
+ tags: defaultRule.tags,
462
+ metadata: customRule.metadata,
463
+ };
464
+ }
465
+ return {
466
+ id: defaultRule.ruleId,
467
+ enabled: true,
468
+ tags: defaultRule.tags,
469
+ };
470
+ });
471
+
472
+ // Add custom rules that don't override default rules
473
+ axeConfig.rules.forEach(customRule => {
474
+ if (!mergedRules.some(rule => rule.id === customRule.id)) {
475
+ mergedRules.push({
476
+ id: customRule.id,
477
+ enabled: customRule.enabled,
478
+ selector: customRule.selector,
479
+ any: customRule.any,
480
+ tags: customRule.tags,
481
+ metadata: customRule.metadata,
482
+ });
483
+ }
484
+ });
485
+
486
+ // Apply configuration
487
+ axe.configure({ ...axeConfig, rules: mergedRules });
488
+
489
+ // Build WCAG criteria map
490
+ const wcagCriteriaMap: Record<string, { name: string; level: string }> = {};
491
+
492
+ // Process rules to extract WCAG information
493
+ mergedRules.forEach(rule => {
494
+ if (!rule.enabled) return;
495
+ if (rule.id === 'frame-tested') return;
496
+
497
+ const tags = rule.tags || [];
498
+ if (tags.includes('experimental') || tags.includes('deprecated')) return;
499
+
500
+ // Look for WCAG criteria tags (format: wcag111, wcag143, etc.)
501
+ tags.forEach(tag => {
502
+ const wcagMatch = tag.match(/^wcag(\d+)$/);
503
+ if (wcagMatch) {
504
+ const wcagId = tag;
505
+
506
+ // Default values
507
+ let level = 'a';
508
+ let name = '';
509
+
510
+ // Try to extract better info from metadata if available
511
+ const metadata = rule.metadata as any;
512
+ if (metadata && metadata.wcag) {
513
+ const wcagInfo = metadata.wcag as any;
514
+
515
+ // Find matching criterion in metadata
516
+ for (const key in wcagInfo) {
517
+ const criterion = wcagInfo[key];
518
+ if (criterion &&
519
+ criterion.num &&
520
+ `wcag${criterion.num.replace(/\./g, '')}` === wcagId) {
521
+
522
+ // Extract level
523
+ if (criterion.level) {
524
+ level = String(criterion.level).toLowerCase();
525
+ }
526
+
527
+ // Extract name
528
+ if (criterion.handle) {
529
+ name = String(criterion.handle);
530
+ } else if (criterion.id) {
531
+ name = String(criterion.id);
532
+ } else if (criterion.num) {
533
+ name = `wcag-${String(criterion.num).replace(/\./g, '-')}`;
534
+ }
535
+
536
+ break;
537
+ }
538
+ }
539
+ }
540
+
541
+ // Generate fallback name if none found
542
+ if (!name) {
543
+ const numStr = wcagMatch[1];
544
+ const formattedNum = numStr.replace(/(\d)(\d)(\d+)?/, '$1.$2.$3');
545
+ name = `wcag-${formattedNum.replace(/\./g, '-')}`;
546
+ }
547
+
548
+ // Store in map
549
+ wcagCriteriaMap[wcagId] = {
550
+ name: name.toLowerCase().replace(/_/g, '-'),
551
+ level
552
+ };
553
+ }
554
+ });
555
+ });
556
+
557
+ return wcagCriteriaMap;
558
+ };
559
+
423
560
  export const getIssuesPercentage = async (
424
561
  scanPagesDetail: ScanPagesDetail,
425
562
  enableWcagAaa: boolean,
@@ -651,3 +788,231 @@ export const retryFunction = async <T>(func: () => Promise<T>, maxAttempt: numbe
651
788
  }
652
789
  throw new Error('Maximum number of attempts reached');
653
790
  };
791
+
792
+ /**
793
+ * Determines which WCAG criteria might appear in the "needsReview" category
794
+ * based on axe-core's rule configuration.
795
+ *
796
+ * This dynamically analyzes the rules that might produce "incomplete" results which
797
+ * get categorized as "needsReview" during scans.
798
+ *
799
+ * @param enableWcagAaa Whether to include WCAG AAA criteria
800
+ * @param disableOobee Whether to disable custom Oobee rules
801
+ * @returns A map of WCAG criteria IDs to whether they may produce needsReview results
802
+ */
803
+ export const getPotentialNeedsReviewWcagCriteria = async (
804
+ enableWcagAaa: boolean = true,
805
+ disableOobee: boolean = false
806
+ ): Promise<Record<string, boolean>> => {
807
+ // Reuse configuration setup from other functions
808
+ const axeConfig = getAxeConfiguration({
809
+ enableWcagAaa,
810
+ gradingReadabilityFlag: '',
811
+ disableOobee,
812
+ });
813
+
814
+ // Configure axe-core with our settings
815
+ axe.configure(axeConfig);
816
+
817
+ // Get all rules from axe-core
818
+ const allRules = axe.getRules();
819
+
820
+ // Set to store rule IDs that might produce incomplete results
821
+ const rulesLikelyToProduceIncomplete = new Set<string>();
822
+
823
+ // Dynamically analyze each rule and its checks to determine if it might produce incomplete results
824
+ for (const rule of allRules) {
825
+ try {
826
+ // Skip disabled rules
827
+ const customRule = axeConfig.rules.find(r => r.id === rule.ruleId);
828
+ if (customRule && customRule.enabled === false) continue;
829
+
830
+ // Skip frame-tested rule as it's handled specially
831
+ if (rule.ruleId === 'frame-tested') continue;
832
+
833
+ // Get the rule object from axe-core's internal data
834
+ const ruleObj = (axe as any)._audit?.rules?.find(r => r.id === rule.ruleId);
835
+ if (!ruleObj) continue;
836
+
837
+ // For each check in the rule, determine if it might produce an "incomplete" result
838
+ const checks = [
839
+ ...(ruleObj.any || []),
840
+ ...(ruleObj.all || []),
841
+ ...(ruleObj.none || [])
842
+ ];
843
+
844
+ // Get check details from axe-core's internal data
845
+ for (const checkId of checks) {
846
+ const check = (axe as any)._audit?.checks?.[checkId];
847
+ if (!check) continue;
848
+
849
+ // A check can produce incomplete results if:
850
+ // 1. It has an "incomplete" message
851
+ // 2. Its evaluate function explicitly returns undefined
852
+ // 3. It is known to need human verification (accessibility issues that are context-dependent)
853
+ const hasIncompleteMessage = check.messages && 'incomplete' in check.messages;
854
+
855
+ // Many checks are implemented as strings that are later evaluated to functions
856
+ const evaluateCode = check.evaluate ? check.evaluate.toString() : '';
857
+ const explicitlyReturnsUndefined = evaluateCode.includes('return undefined') ||
858
+ evaluateCode.includes('return;');
859
+
860
+ // Some checks use specific patterns that indicate potential for incomplete results
861
+ const indicatesManualVerification =
862
+ evaluateCode.includes('return undefined') ||
863
+ evaluateCode.includes('this.data(') ||
864
+ evaluateCode.includes('options.reviewOnFail') ||
865
+ evaluateCode.includes('incomplete') ||
866
+ (check.metadata && check.metadata.incomplete === true);
867
+
868
+ if (hasIncompleteMessage || explicitlyReturnsUndefined || indicatesManualVerification) {
869
+ rulesLikelyToProduceIncomplete.add(rule.ruleId);
870
+ break; // One check is enough to mark the rule
871
+ }
872
+ }
873
+
874
+ // Also check rule-level metadata for indicators of potential incomplete results
875
+ if (ruleObj.metadata) {
876
+ if (ruleObj.metadata.incomplete === true ||
877
+ (ruleObj.metadata.messages && 'incomplete' in ruleObj.metadata.messages)) {
878
+ rulesLikelyToProduceIncomplete.add(rule.ruleId);
879
+ }
880
+ }
881
+ } catch (e) {
882
+ // Silently continue if we encounter errors analyzing a rule
883
+ // This is a safeguard against unexpected changes in axe-core's internal structure
884
+ }
885
+ }
886
+
887
+ // Also check custom Oobee rules if they're enabled
888
+ if (!disableOobee) {
889
+ for (const rule of axeConfig.rules || []) {
890
+ if (!rule.enabled) continue;
891
+
892
+ // Check if the rule's metadata indicates it might produce incomplete results
893
+ try {
894
+ const hasIncompleteMessage =
895
+ ((rule as any)?.metadata?.messages?.incomplete !== undefined) ||
896
+ (axeConfig.checks || []).some(check =>
897
+ check.id === rule.id &&
898
+ (check.metadata?.messages?.incomplete !== undefined));
899
+
900
+ if (hasIncompleteMessage) {
901
+ rulesLikelyToProduceIncomplete.add(rule.id);
902
+ }
903
+ } catch (e) {
904
+ // Continue if we encounter errors
905
+ }
906
+ }
907
+ }
908
+
909
+ // Map from WCAG criteria IDs to whether they might produce needsReview results
910
+ const potentialNeedsReviewCriteria: Record<string, boolean> = {};
911
+
912
+ // Process each rule to map to WCAG criteria
913
+ for (const rule of allRules) {
914
+ if (rule.ruleId === 'frame-tested') continue;
915
+
916
+ const tags = rule.tags || [];
917
+ if (tags.includes('experimental') || tags.includes('deprecated')) continue;
918
+
919
+ // Map rule to WCAG criteria
920
+ for (const tag of tags) {
921
+ if (/^wcag\d+$/.test(tag)) {
922
+ const mightNeedReview = rulesLikelyToProduceIncomplete.has(rule.ruleId);
923
+
924
+ // If we haven't seen this criterion before or we're updating it to true
925
+ if (mightNeedReview || !potentialNeedsReviewCriteria[tag]) {
926
+ potentialNeedsReviewCriteria[tag] = mightNeedReview;
927
+ }
928
+ }
929
+ }
930
+ }
931
+
932
+ return potentialNeedsReviewCriteria;
933
+ };
934
+
935
+ /**
936
+ * Categorizes a WCAG criterion into one of: "mustFix", "goodToFix", or "needsReview"
937
+ * for use in Sentry reporting
938
+ *
939
+ * @param wcagId The WCAG criterion ID (e.g., "wcag144")
940
+ * @param enableWcagAaa Whether WCAG AAA criteria are enabled
941
+ * @param disableOobee Whether Oobee custom rules are disabled
942
+ * @returns The category: "mustFix", "goodToFix", or "needsReview"
943
+ */
944
+ export const categorizeWcagCriterion = async (
945
+ wcagId: string,
946
+ enableWcagAaa: boolean = true,
947
+ disableOobee: boolean = false
948
+ ): Promise<'mustFix' | 'goodToFix' | 'needsReview'> => {
949
+ // First check if this criterion might produce "needsReview" results
950
+ const needsReviewMap = await getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee);
951
+ if (needsReviewMap[wcagId]) {
952
+ return 'needsReview';
953
+ }
954
+
955
+ // Get the WCAG criteria map to check the level
956
+ const wcagCriteriaMap = await getWcagCriteriaMap(enableWcagAaa, disableOobee);
957
+ const criterionInfo = wcagCriteriaMap[wcagId];
958
+
959
+ if (!criterionInfo) {
960
+ // If we can't find info, default to mustFix for safety
961
+ return 'mustFix';
962
+ }
963
+
964
+ // Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix)
965
+ if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') {
966
+ return 'mustFix';
967
+ } else {
968
+ return 'goodToFix';
969
+ }
970
+ };
971
+
972
+ /**
973
+ * Batch categorizes multiple WCAG criteria for Sentry reporting
974
+ *
975
+ * @param wcagIds Array of WCAG criterion IDs (e.g., ["wcag144", "wcag143"])
976
+ * @param enableWcagAaa Whether WCAG AAA criteria are enabled
977
+ * @param disableOobee Whether Oobee custom rules are disabled
978
+ * @returns Object mapping each criterion to its category
979
+ */
980
+ export const categorizeWcagCriteria = async (
981
+ wcagIds: string[],
982
+ enableWcagAaa: boolean = true,
983
+ disableOobee: boolean = false
984
+ ): Promise<Record<string, 'mustFix' | 'goodToFix' | 'needsReview'>> => {
985
+ // Get both maps once to avoid repeated expensive calls
986
+ const [needsReviewMap, wcagCriteriaMap] = await Promise.all([
987
+ getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee),
988
+ getWcagCriteriaMap(enableWcagAaa, disableOobee)
989
+ ]);
990
+
991
+ const result: Record<string, 'mustFix' | 'goodToFix' | 'needsReview'> = {};
992
+
993
+ wcagIds.forEach(wcagId => {
994
+ // First check if this criterion might produce "needsReview" results
995
+ if (needsReviewMap[wcagId]) {
996
+ result[wcagId] = 'needsReview';
997
+ return;
998
+ }
999
+
1000
+ // Get criterion info
1001
+ const criterionInfo = wcagCriteriaMap[wcagId];
1002
+
1003
+ if (!criterionInfo) {
1004
+ // If we can't find info, default to mustFix for safety
1005
+ result[wcagId] = 'mustFix';
1006
+ return;
1007
+ }
1008
+
1009
+ // Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix)
1010
+ if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') {
1011
+ result[wcagId] = 'mustFix';
1012
+ } else {
1013
+ result[wcagId] = 'goodToFix';
1014
+ }
1015
+ });
1016
+
1017
+ return result;
1018
+ };