@govtechsg/oobee 0.10.45 → 0.10.49

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.45",
4
+ "version": "0.10.49",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "dependencies": {
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,
@@ -18,17 +18,14 @@ import { minimatch } from 'minimatch';
18
18
  import { globSync, GlobOptionsWithFileTypesFalse } from 'glob';
19
19
  import { LaunchOptions, Locator, Page, devices, webkit } from 'playwright';
20
20
  import printMessage from 'print-message';
21
- // @ts-ignore
22
- import * as Sentry from '@sentry/node';
23
21
  import constants, {
24
22
  getDefaultChromeDataDir,
25
23
  getDefaultEdgeDataDir,
26
24
  getDefaultChromiumDataDir,
27
25
  proxy,
28
- sentryConfig,
29
- // Legacy code start - Google Sheets submission
26
+ // Legacy code start - Google Sheets submission
30
27
  formDataFields,
31
- // Legacy code end - Google Sheets submission
28
+ // Legacy code end - Google Sheets submission
32
29
  ScannerTypes,
33
30
  BrowserTypes,
34
31
  } from './constants.js';
@@ -1758,159 +1755,42 @@ export const submitForm = async (
1758
1755
  numberOfPagesNotScanned: number,
1759
1756
  metadata: string,
1760
1757
  ) => {
1761
- // Initialize Sentry
1762
- Sentry.init(sentryConfig);
1763
-
1764
- // Format the data as you want it to appear in Sentry
1765
- const additionalPageData = {
1758
+ // Legacy code start - Google Sheets submission
1759
+ const additionalPageDataJson = JSON.stringify({
1760
+ redirectsScanned: numberOfRedirectsScanned,
1766
1761
  pagesNotScanned: numberOfPagesNotScanned,
1767
- redirectsScanned: numberOfRedirectsScanned
1768
- };
1762
+ });
1769
1763
 
1770
- // Extract issue occurrences from scan results if possible
1771
- const issueOccurrences = extractIssueOccurrences(scanResultsJson);
1772
-
1773
- // Determine if it's a government website
1774
- const isGov = entryUrl.includes('.gov');
1775
-
1776
- // Get email domain/tag
1777
- const emailTag = email.split('@')[1] || '';
1778
-
1779
- // Format timestamp
1780
- const timestamp = new Date().toISOString();
1781
-
1782
- // Prepare redirect URL if different from entry URL
1783
- const redirectUrl = scannedUrl !== entryUrl ? scannedUrl : null;
1784
-
1785
- try {
1786
- // Capture the scan data as a Sentry event with each field as a separate entry
1787
- Sentry.captureEvent({
1788
- message: `Accessibility scan completed for ${entryUrl}`,
1789
- level: 'info',
1790
- tags: {
1791
- scanType: scanType,
1792
- browser: browserToRun,
1793
- isGov: isGov,
1794
- emailDomain: emailTag,
1795
- },
1796
- user: {
1797
- email: email,
1798
- username: name,
1799
- },
1800
- extra: {
1801
- // Top-level fields as shown in your screenshot
1802
- entryUrl: entryUrl,
1803
- websiteUrl: scannedUrl,
1804
- scanType: scanType,
1805
- numberOfPagesScanned: numberOfPagesScanned,
1806
- metadata: metadata ? JSON.parse(metadata) : {},
1807
- scanResults: scanResultsJson.length > 8000 ?
1808
- scanResultsJson.substring(0, 8000) + '...[truncated]' :
1809
- scanResultsJson,
1810
-
1811
- // Additional fields you requested
1812
- additionalPageData: additionalPageData,
1813
- additionalScan: additionalPageData,
1814
- additionalPagesData: additionalPageData,
1815
-
1816
- // Individual fields as requested
1817
- timestamp: timestamp,
1818
- redirectUrl: redirectUrl,
1819
- isGov: isGov,
1820
- emailTag: emailTag,
1821
- consolidatedScanType: scanType.toLowerCase(),
1822
- email: email,
1823
- name: name,
1824
- filledNoPagesScanned: numberOfPagesScanned > 0,
1825
- redirectsScanned: numberOfRedirectsScanned,
1826
- pagesNotScanned: numberOfPagesNotScanned,
1827
- issueOccurrences: issueOccurrences
1828
- }
1829
- });
1764
+ let finalUrl =
1765
+ `${formDataFields.formUrl}?` +
1766
+ `${formDataFields.entryUrlField}=${entryUrl}&` +
1767
+ `${formDataFields.scanTypeField}=${scanType}&` +
1768
+ `${formDataFields.emailField}=${email}&` +
1769
+ `${formDataFields.nameField}=${name}&` +
1770
+ `${formDataFields.resultsField}=${encodeURIComponent(scanResultsJson)}&` +
1771
+ `${formDataFields.numberOfPagesScannedField}=${numberOfPagesScanned}&` +
1772
+ `${formDataFields.additionalPageDataField}=${encodeURIComponent(additionalPageDataJson)}&` +
1773
+ `${formDataFields.metadataField}=${encodeURIComponent(metadata)}`;
1830
1774
 
1831
- // IMPORTANT: Wait for the event to be sent
1832
- await Sentry.flush(2000); // Wait up to 2 seconds for the event to be sent
1833
-
1834
- } catch (error) {
1835
- console.error('Error sending data to Sentry:', error);
1775
+ if (scannedUrl !== entryUrl) {
1776
+ finalUrl += `&${formDataFields.redirectUrlField}=${scannedUrl}`;
1836
1777
  }
1837
1778
 
1838
- // Legacy code start - Google Sheets submission
1839
- try {
1840
- const additionalPageDataJson = JSON.stringify({
1841
- redirectsScanned: numberOfRedirectsScanned,
1842
- pagesNotScanned: numberOfPagesNotScanned,
1843
- });
1844
-
1845
- let finalUrl =
1846
- `${formDataFields.formUrl}?` +
1847
- `${formDataFields.entryUrlField}=${entryUrl}&` +
1848
- `${formDataFields.scanTypeField}=${scanType}&` +
1849
- `${formDataFields.emailField}=${email}&` +
1850
- `${formDataFields.nameField}=${name}&` +
1851
- `${formDataFields.resultsField}=${encodeURIComponent(scanResultsJson)}&` +
1852
- `${formDataFields.numberOfPagesScannedField}=${numberOfPagesScanned}&` +
1853
- `${formDataFields.additionalPageDataField}=${encodeURIComponent(additionalPageDataJson)}&` +
1854
- `${formDataFields.metadataField}=${encodeURIComponent(metadata)}`;
1855
-
1856
- if (scannedUrl !== entryUrl) {
1857
- finalUrl += `&${formDataFields.redirectUrlField}=${scannedUrl}`;
1858
- }
1859
-
1860
- if (proxy) {
1861
- await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
1862
- } else {
1863
- try {
1864
- await axios.get(finalUrl, { timeout: 2000 });
1865
- } catch (error) {
1866
- if (error.code === 'ECONNABORTED') {
1867
- if (browserToRun || constants.launcher === webkit) {
1868
- await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
1869
- }
1779
+ if (proxy) {
1780
+ await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
1781
+ } else {
1782
+ try {
1783
+ await axios.get(finalUrl, { timeout: 2000 });
1784
+ } catch (error) {
1785
+ if (error.code === 'ECONNABORTED') {
1786
+ if (browserToRun || constants.launcher === webkit) {
1787
+ await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
1870
1788
  }
1871
1789
  }
1872
1790
  }
1873
- console.log('Legacy Google Sheets form submitted successfully');
1874
- } catch (legacyError) {
1875
- console.error('Error submitting legacy Google Sheets form:', legacyError);
1876
1791
  }
1877
- // Legacy code end - Google Sheets submission
1878
1792
  };
1879
-
1880
- // Helper function to extract issue occurrences from scan results
1881
- function extractIssueOccurrences(scanResultsJson: string): number {
1882
- try {
1883
- const results = JSON.parse(scanResultsJson);
1884
- // Count total occurrences from all issues in the scan results
1885
- // This may need adjustment based on your specific JSON structure
1886
- let totalOccurrences = 0;
1887
-
1888
- // Try to parse the format shown in your screenshot
1889
- if (typeof results === 'object') {
1890
- // Loop through all keys that have "occurrences" properties
1891
- Object.keys(results).forEach(key => {
1892
- if (results[key] && typeof results[key] === 'object' && 'occurrences' in results[key]) {
1893
- totalOccurrences += parseInt(results[key].occurrences, 10) || 0;
1894
- }
1895
- });
1896
-
1897
- // If we found any occurrences, return the total
1898
- if (totalOccurrences > 0) {
1899
- return totalOccurrences;
1900
- }
1901
- }
1902
-
1903
- // Fallback to direct occurrences property if available
1904
- if (results && results.occurrences) {
1905
- return parseInt(results.occurrences, 10) || 0;
1906
- }
1907
-
1908
- return 0;
1909
- } catch (e) {
1910
- console.error('Error extracting issue occurrences:', e);
1911
- return 0;
1912
- }
1913
- }
1793
+ // Legacy code end - Google Sheets submission
1914
1794
 
1915
1795
  export async function initModifiedUserAgent(
1916
1796
  browser?: string,
@@ -2001,21 +1881,27 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
2001
1881
  page.waitForLoadState('networkidle'), // Wait for network requests to settle
2002
1882
  new Promise(resolve => setTimeout(resolve, timeout)), // Hard timeout as a fallback
2003
1883
  page.evaluate(OBSERVER_TIMEOUT => {
2004
- return new Promise(resolve => {
1884
+ return new Promise<string>(resolve => {
2005
1885
  // Skip mutation check for PDFs
2006
1886
  if (document.contentType === 'application/pdf') {
2007
1887
  resolve('Skipping DOM mutation check for PDF.');
2008
1888
  return;
2009
1889
  }
2010
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
+
2011
1898
  let timeout: NodeJS.Timeout;
2012
1899
  let mutationCount = 0;
2013
- const MAX_MUTATIONS = 250; // Limit max mutations
1900
+ const MAX_MUTATIONS = 250;
2014
1901
  const mutationHash: Record<string, number> = {};
2015
1902
 
2016
1903
  const observer = new MutationObserver(mutationsList => {
2017
1904
  clearTimeout(timeout);
2018
-
2019
1905
  mutationCount++;
2020
1906
  if (mutationCount > MAX_MUTATIONS) {
2021
1907
  observer.disconnect();
@@ -2023,24 +1909,20 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
2023
1909
  return;
2024
1910
  }
2025
1911
 
2026
- mutationsList.forEach(mutation => {
1912
+ for (const mutation of mutationsList) {
2027
1913
  if (mutation.target instanceof Element) {
2028
- Array.from(mutation.target.attributes).forEach(attr => {
2029
- const mutationKey = `${mutation.target.nodeName}-${attr.name}`;
2030
-
2031
- if (mutationKey) {
2032
- mutationHash[mutationKey] = (mutationHash[mutationKey] || 0) + 1;
2033
-
2034
- if (mutationHash[mutationKey] >= 10) {
2035
- observer.disconnect();
2036
- resolve(`Repeated mutation detected for ${mutationKey}, exiting.`);
2037
- }
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;
2038
1921
  }
2039
- });
1922
+ }
2040
1923
  }
2041
- });
1924
+ }
2042
1925
 
2043
- // If no mutations occur for 1 second, resolve
2044
1926
  timeout = setTimeout(() => {
2045
1927
  observer.disconnect();
2046
1928
  resolve('DOM stabilized after mutations.');
@@ -2053,9 +1935,10 @@ export const waitForPageLoaded = async (page: Page, timeout = 10000) => {
2053
1935
  resolve('Observer timeout reached, exiting.');
2054
1936
  }, OBSERVER_TIMEOUT);
2055
1937
 
2056
- observer.observe(document.documentElement, {
1938
+ // Only observe if root is a Node
1939
+ observer.observe(root, {
2057
1940
  childList: true,
2058
- subtree: true,
1941
+ subtree: true,
2059
1942
  attributes: true,
2060
1943
  });
2061
1944
  });
@@ -275,11 +275,43 @@ export const impactOrder = {
275
275
  critical: 3,
276
276
  };
277
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
+
278
302
  export const sentryConfig = {
279
- dsn: "https://e4ab99e457c531e7bde4a8dc3dd2b1ab@o4509047624761344.ingest.us.sentry.io/4509192349548544",
303
+ dsn: process.env.OOBEE_SENTRY_DSN || "https://3b8c7ee46b06f33815a1301b6713ebc3@o4509047624761344.ingest.us.sentry.io/4509327783559168",
280
304
  tracesSampleRate: 1.0, // Capture 100% of transactions for performance monitoring
281
305
  profilesSampleRate: 1.0, // Capture 100% of profiles
282
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
+
283
315
  // Legacy code start - Google Sheets submission
284
316
  export const formDataFields = {
285
317
  formUrl: `https://docs.google.com/forms/d/e/1FAIpQLSem5C8fyNs5TiU5Vv2Y63-SH7CHN86f-LEPxeN_1u_ldUbgUA/formResponse`, // prod
@@ -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,10 +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, sentryConfig } from './constants/constants.js';
16
- import { urlWithoutAuth } from './constants/common.js';
17
15
  // @ts-ignore
18
16
  import * as Sentry from '@sentry/node';
17
+ import constants, { ScannerTypes, sentryConfig, setSentryUser } from './constants/constants.js';
18
+ import { urlWithoutAuth } from './constants/common.js';
19
+
19
20
  import {
20
21
  createScreenshotsFolder,
21
22
  getStoragePath,
@@ -26,6 +27,8 @@ import {
26
27
  zipResults,
27
28
  getIssuesPercentage,
28
29
  getWcagCriteriaMap,
30
+ categorizeWcagCriteria,
31
+ getUserDataTxt,
29
32
  } from './utils.js';
30
33
  import { consoleLogger, silentLogger } from './logs.js';
31
34
  import itemTypeDescription from './constants/itemTypeDescription.js';
@@ -252,7 +255,9 @@ const writeCsv = async (allIssues, storagePath) => {
252
255
  scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
253
256
  severity: 'error',
254
257
  issueId: 'error-pages-skipped',
255
- 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',
256
261
  wcagConformance: '',
257
262
  url: page.url || page || '',
258
263
  pageTitle: 'Error',
@@ -564,15 +569,15 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
564
569
  queueWrite(` "${key}": ${JSON.stringify(value)}`);
565
570
  } else {
566
571
  queueWrite(` "${key}": {\n`);
567
-
572
+
568
573
  const { rules, ...otherProperties } = value;
569
-
574
+
570
575
  // Write other properties
571
576
  Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
572
577
  const propValueString =
573
578
  propValue === null ||
574
- typeof propValue === 'function' ||
575
- typeof propValue === 'undefined'
579
+ typeof propValue === 'function' ||
580
+ typeof propValue === 'undefined'
576
581
  ? 'null'
577
582
  : JSON.stringify(propValue);
578
583
  queueWrite(` "${propKey}": ${propValueString}`);
@@ -593,8 +598,8 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
593
598
  Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
594
599
  const ruleValueString =
595
600
  ruleValue === null ||
596
- typeof ruleValue === 'function' ||
597
- typeof ruleValue === 'undefined'
601
+ typeof ruleValue === 'function' ||
602
+ typeof ruleValue === 'undefined'
598
603
  ? 'null'
599
604
  : JSON.stringify(ruleValue);
600
605
  queueWrite(` "${ruleKey}": ${ruleValueString}`);
@@ -637,15 +642,13 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
637
642
  queueWrite(' ]');
638
643
  }
639
644
  queueWrite('\n }');
640
- }
641
-
642
- if (i < keys.length - 1) {
643
- queueWrite(',\n');
644
- } else {
645
- queueWrite('\n');
646
- }
647
-
645
+ }
648
646
 
647
+ if (i < keys.length - 1) {
648
+ queueWrite(',\n');
649
+ } else {
650
+ queueWrite('\n');
651
+ }
649
652
  });
650
653
 
651
654
  queueWrite('}\n');
@@ -762,33 +765,43 @@ const writeJsonAndBase64Files = async (
762
765
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
763
766
  await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
764
767
  const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
765
- await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
766
-
767
- // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
768
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
769
- if (items[category].rules && Array.isArray(items[category].rules)) {
770
- items[category].rules.forEach((rule) => {
771
- rule.pagesAffectedCount = Array.isArray(rule.pagesAffected)
772
- ? rule.pagesAffected.length
773
- : 0;
774
- });
768
+ await writeJsonFileAndCompressedJsonFile(
769
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
770
+ storagePath,
771
+ 'scanItems',
772
+ );
775
773
 
776
- // Sort in descending order of pagesAffectedCount
777
- items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
778
- }
779
- });
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
+ });
780
780
 
781
- // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
782
- const scanIssuesSummary = {
783
- mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
784
- goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
785
- needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
786
- passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
787
- };
781
+ // Sort in descending order of pagesAffectedCount
782
+ items[category].rules.sort(
783
+ (a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0),
784
+ );
785
+ }
786
+ });
787
+
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
+ };
788
795
 
789
- // Write out the scanIssuesSummary JSON using the new structure
790
- const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath } =
791
- await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
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
+ );
792
805
 
793
806
  // scanItemsSummary
794
807
  // the below mutates the original items object, since it is expensive to clone
@@ -848,8 +861,12 @@ const writeJsonAndBase64Files = async (
848
861
  const {
849
862
  jsonFilePath: scanItemsMiniReportJsonFilePath,
850
863
  base64FilePath: scanItemsMiniReportBase64FilePath,
851
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini }, storagePath, 'scanItemsSummaryMiniReport');
852
-
864
+ } = await writeJsonFileAndCompressedJsonFile(
865
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini },
866
+ storagePath,
867
+ 'scanItemsSummaryMiniReport',
868
+ );
869
+
853
870
  const summaryItems = {
854
871
  mustFix: {
855
872
  totalItems: items.mustFix?.totalItems || 0,
@@ -876,17 +893,29 @@ const writeJsonAndBase64Files = async (
876
893
  const {
877
894
  jsonFilePath: scanItemsSummaryJsonFilePath,
878
895
  base64FilePath: scanItemsSummaryBase64FilePath,
879
- } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
896
+ } = await writeJsonFileAndCompressedJsonFile(
897
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems },
898
+ storagePath,
899
+ 'scanItemsSummary',
900
+ );
880
901
 
881
902
  const {
882
903
  jsonFilePath: scanPagesDetailJsonFilePath,
883
- base64FilePath: scanPagesDetailBase64FilePath
884
- } = 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
+ );
885
910
 
886
911
  const {
887
912
  jsonFilePath: scanPagesSummaryJsonFilePath,
888
- base64FilePath: scanPagesSummaryBase64FilePath
889
- } = 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
+ );
890
919
 
891
920
  return {
892
921
  scanDataJsonFilePath,
@@ -988,14 +1017,14 @@ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filena
988
1017
  }
989
1018
  };
990
1019
 
991
- // Tracking WCAG occurrences
1020
+ // Tracking WCAG occurrences
992
1021
  const wcagOccurrencesMap = new Map<string, number>();
993
1022
 
994
1023
  // Format WCAG tag in requested format: wcag111a_Occurrences
995
1024
  const formatWcagTag = async (wcagId: string): Promise<string | null> => {
996
1025
  // Get dynamic WCAG criteria map
997
1026
  const wcagCriteriaMap = await getWcagCriteriaMap();
998
-
1027
+
999
1028
  if (wcagCriteriaMap[wcagId]) {
1000
1029
  const { level } = wcagCriteriaMap[wcagId];
1001
1030
  return `${wcagId}${level}_Occurrences`;
@@ -1054,7 +1083,7 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1054
1083
  if (!allIssues.wcagViolations.includes(c)) {
1055
1084
  allIssues.wcagViolations.push(c);
1056
1085
  }
1057
-
1086
+
1058
1087
  // Track WCAG criteria occurrences for Sentry
1059
1088
  const currentCount = wcagOccurrencesMap.get(c) || 0;
1060
1089
  wcagOccurrencesMap.set(c, currentCount + count);
@@ -1148,35 +1177,31 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1148
1177
  const urlOccurrencesMap = new Map<string, number>();
1149
1178
 
1150
1179
  // Iterate over all categories; update the map only if the category is not "passed"
1151
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
1180
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
1152
1181
  // Accumulate totalItems regardless of category.
1153
1182
  allIssues.totalItems += allIssues.items[category].totalItems;
1154
1183
 
1155
1184
  allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
1156
- .map((ruleEntry) => {
1185
+ .map(ruleEntry => {
1157
1186
  const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
1158
1187
  ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
1159
- .map((pageEntry) => {
1188
+ .map(pageEntry => {
1160
1189
  if (isCustomFlow) {
1161
1190
  const [pageIndex, pageInfo] = pageEntry as unknown as [number, PageInfo];
1162
1191
  // Only update the occurrences map if not passed.
1163
1192
  if (category !== 'passed') {
1164
1193
  urlOccurrencesMap.set(
1165
1194
  pageInfo.url!,
1166
- (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length
1195
+ (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length,
1167
1196
  );
1168
1197
  }
1169
1198
  return { pageIndex, ...pageInfo };
1170
- } else {
1171
- const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1172
- if (category !== 'passed') {
1173
- urlOccurrencesMap.set(
1174
- url,
1175
- (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length
1176
- );
1177
- }
1178
- return { url, ...pageInfo };
1179
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 };
1180
1205
  })
1181
1206
  // Sort pages so that those with the most items come first
1182
1207
  .sort((page1, page2) => page2.items.length - page1.items.length);
@@ -1203,12 +1228,11 @@ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1203
1228
  // Helper: Update totalOccurrences for each issue using our urlOccurrencesMap.
1204
1229
  // For pages that have only passed items, the map will return undefined, so default to 0.
1205
1230
  function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<string, number>) {
1206
- issuesList.forEach((issue) => {
1231
+ issuesList.forEach(issue => {
1207
1232
  issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1208
1233
  });
1209
1234
  }
1210
1235
 
1211
-
1212
1236
  const createRuleIdJson = allIssues => {
1213
1237
  const compiledRuleJson = {};
1214
1238
 
@@ -1224,7 +1248,9 @@ const createRuleIdJson = allIssues => {
1224
1248
  });
1225
1249
  });
1226
1250
  snippets = [...snippetsSet];
1227
- rule.pagesAffected.forEach(p => { delete p.items; });
1251
+ rule.pagesAffected.forEach(p => {
1252
+ delete p.items;
1253
+ });
1228
1254
  }
1229
1255
  compiledRuleJson[ruleId] = {
1230
1256
  snippets,
@@ -1254,17 +1280,15 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1254
1280
  // --------------------------------------------
1255
1281
  // 1) Gather your "scanned" pages from allIssues
1256
1282
  // --------------------------------------------
1257
- const allScannedPages = Array.isArray(allIssues.pagesScanned)
1258
- ? allIssues.pagesScanned
1259
- : [];
1283
+ const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
1260
1284
 
1261
1285
  // --------------------------------------------
1262
1286
  // 2) Define category constants (optional, just for clarity)
1263
1287
  // --------------------------------------------
1264
- const mustFixCategory = "mustFix";
1265
- const goodToFixCategory = "goodToFix";
1266
- const needsReviewCategory = "needsReview";
1267
- const passedCategory = "passed";
1288
+ const mustFixCategory = 'mustFix';
1289
+ const goodToFixCategory = 'goodToFix';
1290
+ const needsReviewCategory = 'needsReview';
1291
+ const passedCategory = 'passed';
1268
1292
 
1269
1293
  // --------------------------------------------
1270
1294
  // 3) Set up type declarations (if you want them local to this function)
@@ -1284,8 +1308,8 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1284
1308
  // Summaries
1285
1309
  totalOccurrencesFailedIncludingNeedsReview: number; // mustFix + goodToFix + needsReview
1286
1310
  totalOccurrencesFailedExcludingNeedsReview: number; // mustFix + goodToFix
1287
- totalOccurrencesNeedsReview: number; // needsReview
1288
- totalOccurrencesPassed: number; // passed only
1311
+ totalOccurrencesNeedsReview: number; // needsReview
1312
+ totalOccurrencesPassed: number; // passed only
1289
1313
  typesOfIssues: Record<string, RuleData>;
1290
1314
  };
1291
1315
 
@@ -1360,9 +1384,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1360
1384
  const pagesInMapUrls = new Set(Object.keys(pagesMap));
1361
1385
 
1362
1386
  // (a) Pages with only passed (no mustFix/goodToFix/needsReview)
1363
- const pagesAllPassed = pagesInMap.filter(
1364
- p => p.totalOccurrencesFailedIncludingNeedsReview === 0
1365
- );
1387
+ const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
1366
1388
 
1367
1389
  // (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
1368
1390
  const pagesNoEntries = allScannedPages
@@ -1381,9 +1403,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1381
1403
  const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
1382
1404
 
1383
1405
  // "affected" pages => have at least 1 mustFix/goodToFix/needsReview
1384
- const pagesAffectedRaw = pagesInMap.filter(
1385
- p => p.totalOccurrencesFailedIncludingNeedsReview > 0
1386
- );
1406
+ const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
1387
1407
 
1388
1408
  // --------------------------------------------
1389
1409
  // 7) Transform both arrays to the final shape
@@ -1398,16 +1418,18 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1398
1418
 
1399
1419
  // Build categoriesPresent based on nonzero failing counts
1400
1420
  const categoriesPresent: string[] = [];
1401
- if (mustFixSum > 0) categoriesPresent.push("mustFix");
1402
- if (goodToFixSum > 0) categoriesPresent.push("goodToFix");
1403
- 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');
1404
1424
 
1405
1425
  // Count how many rules have failing issues
1406
1426
  const failedRuleIds = new Set<string>();
1407
1427
  typesOfIssuesArray.forEach(r => {
1408
- if ((r.occurrencesMustFix || 0) > 0 ||
1409
- (r.occurrencesGoodToFix || 0) > 0 ||
1410
- (r.occurrencesNeedsReview || 0) > 0) {
1428
+ if (
1429
+ (r.occurrencesMustFix || 0) > 0 ||
1430
+ (r.occurrencesGoodToFix || 0) > 0 ||
1431
+ (r.occurrencesNeedsReview || 0) > 0
1432
+ ) {
1411
1433
  failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
1412
1434
  }
1413
1435
  });
@@ -1415,16 +1437,16 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1415
1437
 
1416
1438
  // Possibly these two for future convenience
1417
1439
  const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(
1418
- r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0
1440
+ r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0,
1419
1441
  ).length;
1420
1442
 
1421
1443
  const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(
1422
1444
  r =>
1423
1445
  (r.occurrencesNeedsReview || 0) > 0 &&
1424
1446
  (r.occurrencesMustFix || 0) === 0 &&
1425
- (r.occurrencesGoodToFix || 0) === 0
1447
+ (r.occurrencesGoodToFix || 0) === 0,
1426
1448
  ).length;
1427
-
1449
+
1428
1450
  // Aggregate wcagConformance for rules that actually fail
1429
1451
  const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
1430
1452
  const nonPassedCount =
@@ -1484,9 +1506,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1484
1506
  pagesAffected,
1485
1507
  pagesNotAffected,
1486
1508
  scannedPagesCount,
1487
- pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1488
- ? allIssues.pagesNotScanned
1489
- : [],
1509
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1490
1510
  pagesNotScannedCount,
1491
1511
  };
1492
1512
 
@@ -1505,9 +1525,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1505
1525
  pagesAffected: summaryPagesAffected,
1506
1526
  pagesNotAffected: summaryPagesNotAffected,
1507
1527
  scannedPagesCount,
1508
- pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1509
- ? allIssues.pagesNotScanned
1510
- : [],
1528
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1511
1529
  pagesNotScannedCount,
1512
1530
  };
1513
1531
  }
@@ -1515,25 +1533,39 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1515
1533
  // Send WCAG criteria breakdown to Sentry
1516
1534
  const sendWcagBreakdownToSentry = async (
1517
1535
  wcagBreakdown: Map<string, number>,
1536
+ ruleIdJson: any,
1518
1537
  scanInfo: {
1519
1538
  entryUrl: string;
1520
1539
  scanType: string;
1521
1540
  browser: string;
1522
1541
  email?: string;
1523
1542
  name?: string;
1524
- }
1543
+ },
1544
+ allIssues?: AllIssues,
1545
+ pagesScannedCount: number = 0,
1525
1546
  ) => {
1526
1547
  try {
1527
1548
  // Initialize Sentry
1528
1549
  Sentry.init(sentryConfig);
1550
+ // Set user ID for Sentry tracking
1551
+ const userData = getUserDataTxt();
1552
+ if (userData && userData.userId) {
1553
+ setSentryUser(userData.userId);
1554
+ }
1529
1555
 
1530
1556
  // Prepare tags for the event
1531
1557
  const tags: Record<string, string> = {};
1532
- const wcagCriteriaBreakdown: Record<string, number> = {};
1533
-
1558
+ const wcagCriteriaBreakdown: Record<string, any> = {};
1559
+
1534
1560
  // Get dynamic WCAG criteria map once
1535
1561
  const wcagCriteriaMap = await getWcagCriteriaMap();
1536
-
1562
+
1563
+ // Categorize all WCAG criteria for reporting
1564
+ const wcagIds = Array.from(
1565
+ new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]),
1566
+ );
1567
+ const categorizedWcag = await categorizeWcagCriteria(wcagIds);
1568
+
1537
1569
  // First ensure all WCAG criteria are included in the tags with a value of 0
1538
1570
  // This ensures criteria with no violations are still reported
1539
1571
  for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
@@ -1541,51 +1573,117 @@ const sendWcagBreakdownToSentry = async (
1541
1573
  if (formattedTag) {
1542
1574
  // Initialize with zero
1543
1575
  tags[formattedTag] = '0';
1544
- wcagCriteriaBreakdown[formattedTag] = 0;
1576
+
1577
+ // Store in breakdown object with category information
1578
+ wcagCriteriaBreakdown[formattedTag] = {
1579
+ count: 0,
1580
+ category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
1581
+ };
1545
1582
  }
1546
1583
  }
1547
-
1584
+
1548
1585
  // Now override with actual counts from the scan
1549
1586
  for (const [wcagId, count] of wcagBreakdown.entries()) {
1550
1587
  const formattedTag = await formatWcagTag(wcagId);
1551
1588
  if (formattedTag) {
1552
1589
  // Add as a tag with the count as value
1553
1590
  tags[formattedTag] = String(count);
1554
-
1555
- // Store in breakdown object for the extra data
1556
- wcagCriteriaBreakdown[formattedTag] = count;
1591
+
1592
+ // Update count in breakdown object
1593
+ if (wcagCriteriaBreakdown[formattedTag]) {
1594
+ wcagCriteriaBreakdown[formattedTag].count = count;
1595
+ } else {
1596
+ // If somehow this wasn't in our initial map
1597
+ wcagCriteriaBreakdown[formattedTag] = {
1598
+ count,
1599
+ category: categorizedWcag[wcagId] || 'mustFix',
1600
+ };
1601
+ }
1557
1602
  }
1558
1603
  }
1559
-
1560
- // Calculate the WCAG passing percentage
1561
- const totalCriteria = Object.keys(wcagCriteriaMap).length;
1562
- const violatedCriteria = wcagBreakdown.size;
1563
- const passingPercentage = Math.round(((totalCriteria - violatedCriteria) / totalCriteria) * 100);
1564
-
1565
- // Add the percentage as a tag
1566
- tags['WCAG-Percentage-Passed'] = String(passingPercentage);
1567
-
1604
+
1605
+ // Calculate category counts based on actual issue counts from the report
1606
+ // rather than occurrence counts from wcagBreakdown
1607
+ const categoryCounts = {
1608
+ mustFix: 0,
1609
+ goodToFix: 0,
1610
+ needsReview: 0,
1611
+ };
1612
+
1613
+ if (allIssues) {
1614
+ // Use the actual report data for the counts
1615
+ categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
1616
+ categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
1617
+ categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
1618
+ } else {
1619
+ // Fallback to the old way if allIssues not provided
1620
+ Object.values(wcagCriteriaBreakdown).forEach(item => {
1621
+ if (item.count > 0 && categoryCounts[item.category] !== undefined) {
1622
+ categoryCounts[item.category] += 1; // Count rules, not occurrences
1623
+ }
1624
+ });
1625
+ }
1626
+
1627
+ // Add category counts as tags
1628
+ tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
1629
+ tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
1630
+ tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
1631
+
1632
+ // Also add occurrence counts for reference
1633
+ if (allIssues) {
1634
+ tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
1635
+ tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
1636
+ tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
1637
+
1638
+ // Add number of pages scanned tag
1639
+ tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
1640
+ } else if (pagesScannedCount > 0) {
1641
+ // Still add the pages scanned count even if we don't have allIssues
1642
+ tags['Pages-Scanned-Count'] = String(pagesScannedCount);
1643
+ }
1644
+
1568
1645
  // Send the event to Sentry
1569
1646
  await Sentry.captureEvent({
1570
- message: `WCAG Accessibility Scan Results for ${scanInfo.entryUrl}`,
1647
+ message: 'Accessibility Scan Completed',
1571
1648
  level: 'info',
1572
1649
  tags: {
1573
1650
  ...tags,
1574
1651
  event_type: 'accessibility_scan',
1575
1652
  scanType: scanInfo.scanType,
1576
1653
  browser: scanInfo.browser,
1654
+ entryUrl: scanInfo.entryUrl,
1655
+ },
1656
+ user: {
1657
+ ...(scanInfo.email && scanInfo.name
1658
+ ? {
1659
+ email: scanInfo.email,
1660
+ username: scanInfo.name,
1661
+ }
1662
+ : {}),
1663
+ ...(userData && userData.userId ? { id: userData.userId } : {}),
1577
1664
  },
1578
- user: scanInfo.email && scanInfo.name ? {
1579
- email: scanInfo.email,
1580
- username: scanInfo.name
1581
- } : undefined,
1582
1665
  extra: {
1583
- entryUrl: scanInfo.entryUrl,
1666
+ additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : "{}",
1584
1667
  wcagBreakdown: wcagCriteriaBreakdown,
1585
- wcagPassPercentage: passingPercentage
1586
- }
1668
+ reportCounts: allIssues
1669
+ ? {
1670
+ mustFix: {
1671
+ issues: allIssues.items.mustFix.rules?.length ?? 0,
1672
+ occurrences: allIssues.items.mustFix.totalItems ?? 0,
1673
+ },
1674
+ goodToFix: {
1675
+ issues: allIssues.items.goodToFix.rules?.length ?? 0,
1676
+ occurrences: allIssues.items.goodToFix.totalItems ?? 0,
1677
+ },
1678
+ needsReview: {
1679
+ issues: allIssues.items.needsReview.rules?.length ?? 0,
1680
+ occurrences: allIssues.items.needsReview.totalItems ?? 0,
1681
+ },
1682
+ }
1683
+ : undefined,
1684
+ },
1587
1685
  });
1588
-
1686
+
1589
1687
  // Wait for events to be sent
1590
1688
  await Sentry.flush(2000);
1591
1689
  } catch (error) {
@@ -1765,13 +1863,20 @@ const generateArtifacts = async (
1765
1863
 
1766
1864
  populateScanPagesDetail(allIssues);
1767
1865
 
1768
- allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1769
- allIssues.progressPercentage = getProgressPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1770
-
1866
+ allIssues.wcagPassPercentage = getWcagPassPercentage(
1867
+ allIssues.wcagViolations,
1868
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1869
+ );
1870
+ allIssues.progressPercentage = getProgressPercentage(
1871
+ allIssues.scanPagesDetail,
1872
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1873
+ );
1874
+
1771
1875
  allIssues.issuesPercentage = await getIssuesPercentage(
1772
- allIssues.scanPagesDetail,
1773
- allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1774
- allIssues.advancedScanOptionsSummaryItems.disableOobee);
1876
+ allIssues.scanPagesDetail,
1877
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1878
+ allIssues.advancedScanOptionsSummaryItems.disableOobee,
1879
+ );
1775
1880
 
1776
1881
  // console.log(allIssues.progressPercentage);
1777
1882
  // console.log(allIssues.issuesPercentage);
@@ -1910,24 +2015,31 @@ const generateArtifacts = async (
1910
2015
  printMessage([`Error in zipping results: ${error}`]);
1911
2016
  });
1912
2017
 
2018
+ // Generate scrubbed HTML Code Snippets
2019
+ const ruleIdJson = createRuleIdJson(allIssues);
2020
+
1913
2021
  // At the end of the function where results are generated, add:
1914
2022
  try {
1915
2023
  // Always send WCAG breakdown to Sentry, even if no violations were found
1916
2024
  // This ensures that all criteria are reported, including those with 0 occurrences
1917
- await sendWcagBreakdownToSentry(wcagOccurrencesMap, {
1918
- entryUrl: urlScanned,
1919
- scanType: scanType,
1920
- browser: scanDetails.deviceChosen,
1921
- ...(scanDetails.nameEmail && {
1922
- email: scanDetails.nameEmail.email,
1923
- name: scanDetails.nameEmail.name
1924
- })
1925
- });
2025
+ await sendWcagBreakdownToSentry(
2026
+ wcagOccurrencesMap,
2027
+ ruleIdJson,
2028
+ {
2029
+ entryUrl: urlScanned,
2030
+ scanType,
2031
+ browser: scanDetails.deviceChosen,
2032
+ email: scanDetails.nameEmail?.email,
2033
+ name: scanDetails.nameEmail?.name,
2034
+ },
2035
+ allIssues,
2036
+ pagesScanned.length,
2037
+ );
1926
2038
  } catch (error) {
1927
2039
  console.error('Error sending WCAG data to Sentry:', error);
1928
2040
  }
1929
2041
 
1930
- return createRuleIdJson(allIssues);
2042
+ return ruleIdJson;
1931
2043
  };
1932
2044
 
1933
2045
  export default generateArtifacts;
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
 
@@ -777,3 +788,231 @@ export const retryFunction = async <T>(func: () => Promise<T>, maxAttempt: numbe
777
788
  }
778
789
  throw new Error('Maximum number of attempts reached');
779
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
+ };