@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 +2 -1
- package/src/constants/common.ts +148 -24
- package/src/constants/constants.ts +8 -0
- package/src/mergeAxeResults.ts +123 -2
- package/src/screenshotFunc/htmlScreenshotFunc.ts +1 -1
- package/src/utils.ts +126 -0
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.
|
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",
|
package/src/constants/common.ts
CHANGED
@@ -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
|
-
|
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
1766
|
pagesNotScanned: numberOfPagesNotScanned,
|
1759
|
-
|
1767
|
+
redirectsScanned: numberOfRedirectsScanned
|
1768
|
+
};
|
1760
1769
|
|
1761
|
-
|
1762
|
-
|
1763
|
-
|
1764
|
-
|
1765
|
-
|
1766
|
-
|
1767
|
-
|
1768
|
-
|
1769
|
-
|
1770
|
-
|
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
|
-
|
1773
|
-
|
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
|
-
|
1777
|
-
|
1778
|
-
|
1779
|
-
|
1780
|
-
|
1781
|
-
}
|
1782
|
-
|
1783
|
-
|
1784
|
-
|
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',
|
package/src/mergeAxeResults.ts
CHANGED
@@ -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 (it
|
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,
|