@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 +1 -1
- package/src/combine.ts +7 -0
- package/src/constants/common.ts +50 -167
- package/src/constants/constants.ts +33 -1
- package/src/crawlers/crawlDomain.ts +5 -5
- package/src/crawlers/crawlSitemap.ts +5 -3
- package/src/mergeAxeResults.ts +252 -140
- package/src/utils.ts +240 -1
package/package.json
CHANGED
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,
|
package/src/constants/common.ts
CHANGED
@@ -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
|
-
|
29
|
-
// Legacy code start - Google Sheets submission
|
26
|
+
// Legacy code start - Google Sheets submission
|
30
27
|
formDataFields,
|
31
|
-
|
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
|
-
//
|
1762
|
-
|
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
|
-
|
1768
|
-
};
|
1762
|
+
});
|
1769
1763
|
|
1770
|
-
|
1771
|
-
|
1772
|
-
|
1773
|
-
|
1774
|
-
|
1775
|
-
|
1776
|
-
|
1777
|
-
|
1778
|
-
|
1779
|
-
|
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
|
-
|
1832
|
-
|
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
|
-
|
1839
|
-
|
1840
|
-
|
1841
|
-
|
1842
|
-
|
1843
|
-
})
|
1844
|
-
|
1845
|
-
|
1846
|
-
|
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;
|
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
|
-
|
1912
|
+
for (const mutation of mutationsList) {
|
2027
1913
|
if (mutation.target instanceof Element) {
|
2028
|
-
Array.from(mutation.target.attributes)
|
2029
|
-
const
|
2030
|
-
|
2031
|
-
if (
|
2032
|
-
|
2033
|
-
|
2034
|
-
|
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
|
-
|
1938
|
+
// Only observe if root is a Node
|
1939
|
+
observer.observe(root, {
|
2057
1940
|
childList: true,
|
2058
|
-
subtree:
|
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://
|
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
|
-
|
518
|
-
|
519
|
-
|
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
|
-
|
203
|
-
|
204
|
-
|
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) {
|
package/src/mergeAxeResults.ts
CHANGED
@@ -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
|
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
|
-
|
575
|
-
|
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
|
-
|
597
|
-
|
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(
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
|
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
|
-
|
777
|
-
|
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
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
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
|
-
|
790
|
-
|
791
|
-
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
1185
|
+
.map(ruleEntry => {
|
1157
1186
|
const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
|
1158
1187
|
ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
|
1159
|
-
.map(
|
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(
|
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 => {
|
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 =
|
1265
|
-
const goodToFixCategory =
|
1266
|
-
const needsReviewCategory =
|
1267
|
-
const passedCategory =
|
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;
|
1288
|
-
totalOccurrencesPassed: number;
|
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(
|
1402
|
-
if (goodToFixSum > 0) categoriesPresent.push(
|
1403
|
-
if (needsReviewSum > 0) categoriesPresent.push(
|
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 (
|
1409
|
-
|
1410
|
-
|
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,
|
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
|
-
|
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
|
-
//
|
1556
|
-
wcagCriteriaBreakdown[formattedTag]
|
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
|
1561
|
-
|
1562
|
-
const
|
1563
|
-
|
1564
|
-
|
1565
|
-
|
1566
|
-
|
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:
|
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
|
-
|
1666
|
+
additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : "{}",
|
1584
1667
|
wcagBreakdown: wcagCriteriaBreakdown,
|
1585
|
-
|
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(
|
1769
|
-
|
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(
|
1918
|
-
|
1919
|
-
|
1920
|
-
|
1921
|
-
|
1922
|
-
|
1923
|
-
|
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
|
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
|
-
|
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
|
+
};
|