@govtechsg/oobee 0.10.43 → 0.10.45

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.45",
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",
@@ -18,12 +18,17 @@ 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';
21
23
  import constants, {
22
24
  getDefaultChromeDataDir,
23
25
  getDefaultEdgeDataDir,
24
26
  getDefaultChromiumDataDir,
25
27
  proxy,
28
+ sentryConfig,
29
+ // Legacy code start - Google Sheets submission
26
30
  formDataFields,
31
+ // Legacy code end - Google Sheets submission
27
32
  ScannerTypes,
28
33
  BrowserTypes,
29
34
  } from './constants.js';
@@ -1753,41 +1758,160 @@ export const submitForm = async (
1753
1758
  numberOfPagesNotScanned: number,
1754
1759
  metadata: string,
1755
1760
  ) => {
1756
- const additionalPageDataJson = JSON.stringify({
1757
- redirectsScanned: numberOfRedirectsScanned,
1761
+ // Initialize Sentry
1762
+ Sentry.init(sentryConfig);
1763
+
1764
+ // Format the data as you want it to appear in Sentry
1765
+ const additionalPageData = {
1758
1766
  pagesNotScanned: numberOfPagesNotScanned,
1759
- });
1767
+ redirectsScanned: numberOfRedirectsScanned
1768
+ };
1760
1769
 
1761
- let finalUrl =
1762
- `${formDataFields.formUrl}?` +
1763
- `${formDataFields.entryUrlField}=${entryUrl}&` +
1764
- `${formDataFields.scanTypeField}=${scanType}&` +
1765
- `${formDataFields.emailField}=${email}&` +
1766
- `${formDataFields.nameField}=${name}&` +
1767
- `${formDataFields.resultsField}=${encodeURIComponent(scanResultsJson)}&` +
1768
- `${formDataFields.numberOfPagesScannedField}=${numberOfPagesScanned}&` +
1769
- `${formDataFields.additionalPageDataField}=${encodeURIComponent(additionalPageDataJson)}&` +
1770
- `${formDataFields.metadataField}=${encodeURIComponent(metadata)}`;
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
+ });
1771
1830
 
1772
- if (scannedUrl !== entryUrl) {
1773
- finalUrl += `&${formDataFields.redirectUrlField}=${scannedUrl}`;
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);
1774
1836
  }
1775
1837
 
1776
- if (proxy) {
1777
- await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
1778
- } else {
1779
- try {
1780
- await axios.get(finalUrl, { timeout: 2000 });
1781
- } catch (error) {
1782
- if (error.code === 'ECONNABORTED') {
1783
- if (browserToRun || constants.launcher === webkit) {
1784
- await submitFormViaPlaywright(browserToRun, userDataDirectory, finalUrl);
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
+ }
1785
1870
  }
1786
1871
  }
1787
1872
  }
1873
+ console.log('Legacy Google Sheets form submitted successfully');
1874
+ } catch (legacyError) {
1875
+ console.error('Error submitting legacy Google Sheets form:', legacyError);
1788
1876
  }
1877
+ // Legacy code end - Google Sheets submission
1789
1878
  };
1790
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
+ }
1914
+
1791
1915
  export async function initModifiedUserAgent(
1792
1916
  browser?: string,
1793
1917
  playwrightDeviceDetailsObject?: object,
@@ -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,12 @@ export const impactOrder = {
274
275
  critical: 3,
275
276
  };
276
277
 
278
+ export const sentryConfig = {
279
+ dsn: "https://e4ab99e457c531e7bde4a8dc3dd2b1ab@o4509047624761344.ingest.us.sentry.io/4509192349548544",
280
+ tracesSampleRate: 1.0, // Capture 100% of transactions for performance monitoring
281
+ profilesSampleRate: 1.0, // Capture 100% of profiles
282
+ };
283
+ // Legacy code start - Google Sheets submission
277
284
  export const formDataFields = {
278
285
  formUrl: `https://docs.google.com/forms/d/e/1FAIpQLSem5C8fyNs5TiU5Vv2Y63-SH7CHN86f-LEPxeN_1u_ldUbgUA/formResponse`, // prod
279
286
  entryUrlField: 'entry.1562345227',
@@ -286,6 +293,7 @@ export const formDataFields = {
286
293
  additionalPageDataField: 'entry.2090887881',
287
294
  metadataField: 'entry.1027769131',
288
295
  };
296
+ // Legacy code end - Google Sheets submission
289
297
 
290
298
  export const sitemapPaths = [
291
299
  '/sitemap.xml',
@@ -12,8 +12,10 @@ 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
+ import constants, { ScannerTypes, sentryConfig } from './constants/constants.js';
16
16
  import { urlWithoutAuth } from './constants/common.js';
17
+ // @ts-ignore
18
+ import * as Sentry from '@sentry/node';
17
19
  import {
18
20
  createScreenshotsFolder,
19
21
  getStoragePath,
@@ -23,6 +25,7 @@ import {
23
25
  retryFunction,
24
26
  zipResults,
25
27
  getIssuesPercentage,
28
+ getWcagCriteriaMap,
26
29
  } from './utils.js';
27
30
  import { consoleLogger, silentLogger } from './logs.js';
28
31
  import itemTypeDescription from './constants/itemTypeDescription.js';
@@ -231,7 +234,7 @@ const writeCsv = async (allIssues, storagePath) => {
231
234
  includeEmptyRows: true,
232
235
  };
233
236
 
234
- // Create the parse stream (its asynchronous)
237
+ // Create the parse stream (it's asynchronous)
235
238
  const parser = new AsyncParser(opts);
236
239
  const parseStream = parser.parse(allIssues);
237
240
 
@@ -985,6 +988,21 @@ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filena
985
988
  }
986
989
  };
987
990
 
991
+ // Tracking WCAG occurrences
992
+ const wcagOccurrencesMap = new Map<string, number>();
993
+
994
+ // Format WCAG tag in requested format: wcag111a_Occurrences
995
+ const formatWcagTag = async (wcagId: string): Promise<string | null> => {
996
+ // Get dynamic WCAG criteria map
997
+ const wcagCriteriaMap = await getWcagCriteriaMap();
998
+
999
+ if (wcagCriteriaMap[wcagId]) {
1000
+ const { level } = wcagCriteriaMap[wcagId];
1001
+ return `${wcagId}${level}_Occurrences`;
1002
+ }
1003
+ return null;
1004
+ };
1005
+
988
1006
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
989
1007
  const { url, pageTitle, filePath } = pageResults;
990
1008
 
@@ -1036,6 +1054,10 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
1036
1054
  if (!allIssues.wcagViolations.includes(c)) {
1037
1055
  allIssues.wcagViolations.push(c);
1038
1056
  }
1057
+
1058
+ // Track WCAG criteria occurrences for Sentry
1059
+ const currentCount = wcagOccurrencesMap.get(c) || 0;
1060
+ wcagOccurrencesMap.set(c, currentCount + count);
1039
1061
  });
1040
1062
  }
1041
1063
 
@@ -1490,6 +1512,87 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1490
1512
  };
1491
1513
  }
1492
1514
 
1515
+ // Send WCAG criteria breakdown to Sentry
1516
+ const sendWcagBreakdownToSentry = async (
1517
+ wcagBreakdown: Map<string, number>,
1518
+ scanInfo: {
1519
+ entryUrl: string;
1520
+ scanType: string;
1521
+ browser: string;
1522
+ email?: string;
1523
+ name?: string;
1524
+ }
1525
+ ) => {
1526
+ try {
1527
+ // Initialize Sentry
1528
+ Sentry.init(sentryConfig);
1529
+
1530
+ // Prepare tags for the event
1531
+ const tags: Record<string, string> = {};
1532
+ const wcagCriteriaBreakdown: Record<string, number> = {};
1533
+
1534
+ // Get dynamic WCAG criteria map once
1535
+ const wcagCriteriaMap = await getWcagCriteriaMap();
1536
+
1537
+ // First ensure all WCAG criteria are included in the tags with a value of 0
1538
+ // This ensures criteria with no violations are still reported
1539
+ for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
1540
+ const formattedTag = await formatWcagTag(wcagId);
1541
+ if (formattedTag) {
1542
+ // Initialize with zero
1543
+ tags[formattedTag] = '0';
1544
+ wcagCriteriaBreakdown[formattedTag] = 0;
1545
+ }
1546
+ }
1547
+
1548
+ // Now override with actual counts from the scan
1549
+ for (const [wcagId, count] of wcagBreakdown.entries()) {
1550
+ const formattedTag = await formatWcagTag(wcagId);
1551
+ if (formattedTag) {
1552
+ // Add as a tag with the count as value
1553
+ tags[formattedTag] = String(count);
1554
+
1555
+ // Store in breakdown object for the extra data
1556
+ wcagCriteriaBreakdown[formattedTag] = count;
1557
+ }
1558
+ }
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
+
1568
+ // Send the event to Sentry
1569
+ await Sentry.captureEvent({
1570
+ message: `WCAG Accessibility Scan Results for ${scanInfo.entryUrl}`,
1571
+ level: 'info',
1572
+ tags: {
1573
+ ...tags,
1574
+ event_type: 'accessibility_scan',
1575
+ scanType: scanInfo.scanType,
1576
+ browser: scanInfo.browser,
1577
+ },
1578
+ user: scanInfo.email && scanInfo.name ? {
1579
+ email: scanInfo.email,
1580
+ username: scanInfo.name
1581
+ } : undefined,
1582
+ extra: {
1583
+ entryUrl: scanInfo.entryUrl,
1584
+ wcagBreakdown: wcagCriteriaBreakdown,
1585
+ wcagPassPercentage: passingPercentage
1586
+ }
1587
+ });
1588
+
1589
+ // Wait for events to be sent
1590
+ await Sentry.flush(2000);
1591
+ } catch (error) {
1592
+ console.error('Error sending WCAG breakdown to Sentry:', error);
1593
+ }
1594
+ };
1595
+
1493
1596
  const generateArtifacts = async (
1494
1597
  randomToken: string,
1495
1598
  urlScanned: string,
@@ -1512,6 +1615,7 @@ const generateArtifacts = async (
1512
1615
  isEnableWcagAaa: string[];
1513
1616
  isSlowScanMode: number;
1514
1617
  isAdhereRobots: boolean;
1618
+ nameEmail?: { name: string; email: string };
1515
1619
  },
1516
1620
  zip: string = undefined, // optional
1517
1621
  generateJsonFiles = false,
@@ -1806,6 +1910,23 @@ const generateArtifacts = async (
1806
1910
  printMessage([`Error in zipping results: ${error}`]);
1807
1911
  });
1808
1912
 
1913
+ // At the end of the function where results are generated, add:
1914
+ try {
1915
+ // Always send WCAG breakdown to Sentry, even if no violations were found
1916
+ // 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
+ });
1926
+ } catch (error) {
1927
+ console.error('Error sending WCAG data to Sentry:', error);
1928
+ }
1929
+
1809
1930
  return createRuleIdJson(allIssues);
1810
1931
  };
1811
1932
 
@@ -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
@@ -420,6 +420,132 @@ export const getTotalRulesCount = async (
420
420
  };
421
421
  };
422
422
 
423
+ /**
424
+ * Dynamically generates a map of WCAG criteria IDs to their details (name and level)
425
+ * Reuses the rule processing logic from getTotalRulesCount
426
+ */
427
+ export const getWcagCriteriaMap = async (
428
+ enableWcagAaa: boolean = true,
429
+ disableOobee: boolean = false
430
+ ): Promise<Record<string, { name: string; level: string }>> => {
431
+ // Reuse the configuration setup from getTotalRulesCount
432
+ const axeConfig = getAxeConfiguration({
433
+ enableWcagAaa,
434
+ gradingReadabilityFlag: '',
435
+ disableOobee,
436
+ });
437
+
438
+ // Get default rules from axe-core
439
+ const defaultRules = axe.getRules();
440
+
441
+ // Merge custom rules with default rules
442
+ const mergedRules: Rule[] = defaultRules.map(defaultRule => {
443
+ const customRule = axeConfig.rules.find(r => r.id === defaultRule.ruleId);
444
+ if (customRule) {
445
+ return {
446
+ id: defaultRule.ruleId,
447
+ enabled: customRule.enabled,
448
+ selector: customRule.selector,
449
+ any: customRule.any,
450
+ tags: defaultRule.tags,
451
+ metadata: customRule.metadata,
452
+ };
453
+ }
454
+ return {
455
+ id: defaultRule.ruleId,
456
+ enabled: true,
457
+ tags: defaultRule.tags,
458
+ };
459
+ });
460
+
461
+ // Add custom rules that don't override default rules
462
+ axeConfig.rules.forEach(customRule => {
463
+ if (!mergedRules.some(rule => rule.id === customRule.id)) {
464
+ mergedRules.push({
465
+ id: customRule.id,
466
+ enabled: customRule.enabled,
467
+ selector: customRule.selector,
468
+ any: customRule.any,
469
+ tags: customRule.tags,
470
+ metadata: customRule.metadata,
471
+ });
472
+ }
473
+ });
474
+
475
+ // Apply configuration
476
+ axe.configure({ ...axeConfig, rules: mergedRules });
477
+
478
+ // Build WCAG criteria map
479
+ const wcagCriteriaMap: Record<string, { name: string; level: string }> = {};
480
+
481
+ // Process rules to extract WCAG information
482
+ mergedRules.forEach(rule => {
483
+ if (!rule.enabled) return;
484
+ if (rule.id === 'frame-tested') return;
485
+
486
+ const tags = rule.tags || [];
487
+ if (tags.includes('experimental') || tags.includes('deprecated')) return;
488
+
489
+ // Look for WCAG criteria tags (format: wcag111, wcag143, etc.)
490
+ tags.forEach(tag => {
491
+ const wcagMatch = tag.match(/^wcag(\d+)$/);
492
+ if (wcagMatch) {
493
+ const wcagId = tag;
494
+
495
+ // Default values
496
+ let level = 'a';
497
+ let name = '';
498
+
499
+ // Try to extract better info from metadata if available
500
+ const metadata = rule.metadata as any;
501
+ if (metadata && metadata.wcag) {
502
+ const wcagInfo = metadata.wcag as any;
503
+
504
+ // Find matching criterion in metadata
505
+ for (const key in wcagInfo) {
506
+ const criterion = wcagInfo[key];
507
+ if (criterion &&
508
+ criterion.num &&
509
+ `wcag${criterion.num.replace(/\./g, '')}` === wcagId) {
510
+
511
+ // Extract level
512
+ if (criterion.level) {
513
+ level = String(criterion.level).toLowerCase();
514
+ }
515
+
516
+ // Extract name
517
+ if (criterion.handle) {
518
+ name = String(criterion.handle);
519
+ } else if (criterion.id) {
520
+ name = String(criterion.id);
521
+ } else if (criterion.num) {
522
+ name = `wcag-${String(criterion.num).replace(/\./g, '-')}`;
523
+ }
524
+
525
+ break;
526
+ }
527
+ }
528
+ }
529
+
530
+ // Generate fallback name if none found
531
+ if (!name) {
532
+ const numStr = wcagMatch[1];
533
+ const formattedNum = numStr.replace(/(\d)(\d)(\d+)?/, '$1.$2.$3');
534
+ name = `wcag-${formattedNum.replace(/\./g, '-')}`;
535
+ }
536
+
537
+ // Store in map
538
+ wcagCriteriaMap[wcagId] = {
539
+ name: name.toLowerCase().replace(/_/g, '-'),
540
+ level
541
+ };
542
+ }
543
+ });
544
+ });
545
+
546
+ return wcagCriteriaMap;
547
+ };
548
+
423
549
  export const getIssuesPercentage = async (
424
550
  scanPagesDetail: ScanPagesDetail,
425
551
  enableWcagAaa: boolean,