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