@govtechsg/oobee 0.10.76 → 0.10.78-alpha1
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/.github/workflows/publish.yml +8 -1
- package/INTEGRATION.md +50 -3
- package/dist/cli.js +252 -0
- package/dist/combine.js +221 -0
- package/dist/constants/cliFunctions.js +306 -0
- package/dist/constants/common.js +1669 -0
- package/dist/constants/constants.js +913 -0
- package/dist/constants/errorMeta.json +319 -0
- package/dist/constants/itemTypeDescription.js +7 -0
- package/dist/constants/oobeeAi.js +121 -0
- package/dist/constants/questions.js +151 -0
- package/dist/constants/sampleData.js +176 -0
- package/dist/crawlers/commonCrawlerFunc.js +428 -0
- package/dist/crawlers/crawlDomain.js +613 -0
- package/dist/crawlers/crawlIntelligentSitemap.js +135 -0
- package/dist/crawlers/crawlLocalFile.js +151 -0
- package/dist/crawlers/crawlSitemap.js +303 -0
- package/dist/crawlers/custom/escapeCssSelector.js +10 -0
- package/dist/crawlers/custom/evaluateAltText.js +11 -0
- package/dist/crawlers/custom/extractAndGradeText.js +44 -0
- package/dist/crawlers/custom/extractText.js +27 -0
- package/dist/crawlers/custom/findElementByCssSelector.js +36 -0
- package/dist/crawlers/custom/flagUnlabelledClickableElements.js +963 -0
- package/dist/crawlers/custom/framesCheck.js +37 -0
- package/dist/crawlers/custom/getAxeConfiguration.js +111 -0
- package/dist/crawlers/custom/gradeReadability.js +23 -0
- package/dist/crawlers/custom/utils.js +1024 -0
- package/dist/crawlers/custom/xPathToCss.js +147 -0
- package/dist/crawlers/guards/urlGuard.js +71 -0
- package/dist/crawlers/pdfScanFunc.js +276 -0
- package/dist/crawlers/runCustom.js +89 -0
- package/dist/exclusions.txt +7 -0
- package/dist/generateHtmlReport.js +144 -0
- package/dist/index.js +62 -0
- package/dist/logs.js +84 -0
- package/dist/mergeAxeResults.js +1588 -0
- package/dist/npmIndex.js +640 -0
- package/dist/proxyService.js +360 -0
- package/dist/runGenerateJustHtmlReport.js +16 -0
- package/dist/screenshotFunc/htmlScreenshotFunc.js +355 -0
- package/dist/screenshotFunc/pdfScreenshotFunc.js +645 -0
- package/dist/services/s3Uploader.js +127 -0
- package/dist/static/ejs/partials/components/allIssues/AllIssues.ejs +9 -0
- package/dist/static/ejs/partials/components/allIssues/CategoryBadges.ejs +82 -0
- package/dist/static/ejs/partials/components/allIssues/FilterBar.ejs +33 -0
- package/dist/static/ejs/partials/components/allIssues/IssuesTable.ejs +41 -0
- package/dist/static/ejs/partials/components/header/SiteInfo.ejs +119 -0
- package/dist/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +15 -0
- package/dist/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +44 -0
- package/dist/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +142 -0
- package/dist/static/ejs/partials/components/prioritiseIssues/IssueDetailCard.ejs +36 -0
- package/dist/static/ejs/partials/components/prioritiseIssues/PrioritiseIssues.ejs +47 -0
- package/dist/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +196 -0
- package/dist/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +48 -0
- package/dist/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
- package/dist/static/ejs/partials/components/shared/InfoAlert.ejs +3 -0
- package/dist/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
- package/dist/static/ejs/partials/components/summaryScanResults.ejs +16 -0
- package/dist/static/ejs/partials/components/summaryTable.ejs +20 -0
- package/dist/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
- package/dist/static/ejs/partials/components/topTen.ejs +6 -0
- package/dist/static/ejs/partials/components/wcagCompliance/FailedCriteria.ejs +47 -0
- package/dist/static/ejs/partials/components/wcagCompliance/WcagCompliance.ejs +16 -0
- package/dist/static/ejs/partials/components/wcagCompliance/WcagGaugeBar.ejs +16 -0
- package/dist/static/ejs/partials/components/wcagCoverageDetails.ejs +18 -0
- package/dist/static/ejs/partials/footer.ejs +24 -0
- package/dist/static/ejs/partials/header.ejs +14 -0
- package/dist/static/ejs/partials/main.ejs +29 -0
- package/dist/static/ejs/partials/scripts/allIssues/AllIssues.ejs +376 -0
- package/dist/static/ejs/partials/scripts/bootstrap.ejs +8 -0
- package/dist/static/ejs/partials/scripts/categorySummary.ejs +141 -0
- package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
- package/dist/static/ejs/partials/scripts/header/SiteInfo.ejs +44 -0
- package/dist/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +51 -0
- package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +127 -0
- package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanDetails.ejs +60 -0
- package/dist/static/ejs/partials/scripts/highlightjs.ejs +335 -0
- package/dist/static/ejs/partials/scripts/popper.ejs +7 -0
- package/dist/static/ejs/partials/scripts/prioritiseIssues/IssueDetailCard.ejs +137 -0
- package/dist/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +214 -0
- package/dist/static/ejs/partials/scripts/prioritiseIssues/wcagSvgMap.ejs +861 -0
- package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +957 -0
- package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +353 -0
- package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +468 -0
- package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +306 -0
- package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +483 -0
- package/dist/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +35 -0
- package/dist/static/ejs/partials/scripts/screenshotLightbox.ejs +75 -0
- package/dist/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
- package/dist/static/ejs/partials/scripts/summaryTable.ejs +78 -0
- package/dist/static/ejs/partials/scripts/topTen.ejs +61 -0
- package/dist/static/ejs/partials/scripts/utils.ejs +453 -0
- package/dist/static/ejs/partials/scripts/wcagCompliance/FailedCriteria.ejs +103 -0
- package/dist/static/ejs/partials/scripts/wcagCompliance/WcagGaugeBar.ejs +47 -0
- package/dist/static/ejs/partials/scripts/wcagCompliance.ejs +15 -0
- package/dist/static/ejs/partials/scripts/wcagCoverageDetails.ejs +75 -0
- package/dist/static/ejs/partials/styles/allIssues/AllIssues.ejs +384 -0
- package/dist/static/ejs/partials/styles/bootstrap.ejs +12391 -0
- package/dist/static/ejs/partials/styles/header/SiteInfo.ejs +121 -0
- package/dist/static/ejs/partials/styles/header/aboutScanModal/AboutScanModal.ejs +82 -0
- package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanConfiguration.ejs +50 -0
- package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +149 -0
- package/dist/static/ejs/partials/styles/header.ejs +7 -0
- package/dist/static/ejs/partials/styles/highlightjs.ejs +54 -0
- package/dist/static/ejs/partials/styles/prioritiseIssues/IssueDetailCard.ejs +141 -0
- package/dist/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +204 -0
- package/dist/static/ejs/partials/styles/ruleModal/ruleOffcanvas.ejs +456 -0
- package/dist/static/ejs/partials/styles/scannedPagesSegmentedTabs.ejs +46 -0
- package/dist/static/ejs/partials/styles/shared/InfoAlert.ejs +12 -0
- package/dist/static/ejs/partials/styles/styles.ejs +1607 -0
- package/dist/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
- package/dist/static/ejs/partials/styles/topTenCard.ejs +44 -0
- package/dist/static/ejs/partials/styles/wcagCompliance/FailedCriteria.ejs +59 -0
- package/dist/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +62 -0
- package/dist/static/ejs/partials/styles/wcagCompliance.ejs +36 -0
- package/dist/static/ejs/partials/styles/wcagCoverageDetails.ejs +33 -0
- package/dist/static/ejs/partials/summaryHeader.ejs +70 -0
- package/dist/static/ejs/partials/summaryMain.ejs +49 -0
- package/dist/static/ejs/report.ejs +226 -0
- package/dist/static/ejs/summary.ejs +47 -0
- package/dist/types/types.js +1 -0
- package/dist/utils.js +1070 -0
- package/examples/oobee-cypress-integration-js/cypress/support/e2e.js +36 -6
- package/examples/oobee-cypress-integration-js/cypress.config.js +45 -1
- package/examples/oobee-cypress-integration-ts/cypress.config.ts +47 -1
- package/examples/oobee-cypress-integration-ts/src/cypress/support/e2e.ts +36 -6
- package/examples/oobee-playwright-integration-js/oobee-playwright-demo.js +2 -1
- package/examples/oobee-playwright-integration-ts/src/oobee-playwright-demo.ts +2 -1
- package/examples/oobee-scan-html-demo.js +51 -0
- package/examples/oobee-scan-page-demo.js +40 -0
- package/package.json +9 -3
- package/src/constants/common.ts +2 -2
- package/src/constants/constants.ts +3 -1
- package/src/crawlers/crawlDomain.ts +1 -0
- package/src/crawlers/runCustom.ts +0 -1
- package/src/mergeAxeResults.ts +43 -22
- package/src/npmIndex.ts +500 -131
package/dist/utils.js
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import axe from 'axe-core';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import constants, { BrowserTypes, destinationPath, getIntermediateScreenshotsPath, } from './constants/constants.js';
|
|
7
|
+
import { consoleLogger, errorsTxtPath } from './logs.js';
|
|
8
|
+
import { getAxeConfiguration } from './crawlers/custom/getAxeConfiguration.js';
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
import { createReadStream, createWriteStream } from 'fs';
|
|
11
|
+
export const getVersion = () => {
|
|
12
|
+
const loadJSON = (filePath) => JSON.parse(fs.readFileSync(new URL(filePath, import.meta.url)).toString());
|
|
13
|
+
const versionNum = loadJSON('../package.json').version;
|
|
14
|
+
return versionNum;
|
|
15
|
+
};
|
|
16
|
+
export const getHost = (url) => new URL(url).host;
|
|
17
|
+
export const getCurrentDate = () => {
|
|
18
|
+
const date = new Date();
|
|
19
|
+
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
|
20
|
+
};
|
|
21
|
+
export const isWhitelistedContentType = (contentType) => {
|
|
22
|
+
const whitelist = ['text/html'];
|
|
23
|
+
return whitelist.filter(type => contentType.trim().startsWith(type)).length === 1;
|
|
24
|
+
};
|
|
25
|
+
export const getPdfStoragePath = (randomToken) => {
|
|
26
|
+
const storagePath = getStoragePath(randomToken);
|
|
27
|
+
const pdfStoragePath = path.join(storagePath, 'pdfs');
|
|
28
|
+
if (!fs.existsSync(pdfStoragePath)) {
|
|
29
|
+
fs.mkdirSync(pdfStoragePath, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
return pdfStoragePath;
|
|
32
|
+
};
|
|
33
|
+
export const getStoragePath = (randomToken) => {
|
|
34
|
+
// If exportDirectory is set, use it
|
|
35
|
+
if (constants.exportDirectory) {
|
|
36
|
+
return constants.exportDirectory;
|
|
37
|
+
}
|
|
38
|
+
// Otherwise, use the current working directory
|
|
39
|
+
let storagePath = path.join(process.cwd(), 'results', randomToken);
|
|
40
|
+
// Ensure storagePath is writable; if directory doesn't exist, try to create it in Documents or home directory
|
|
41
|
+
const isWritable = (() => {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(storagePath)) {
|
|
44
|
+
fs.mkdirSync(storagePath, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
fs.accessSync(storagePath, fs.constants.W_OK);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
if (!isWritable) {
|
|
54
|
+
if (os.platform() === 'win32') {
|
|
55
|
+
// Use Documents folder on Windows
|
|
56
|
+
const documentsPath = path.join(process.env.USERPROFILE || process.env.HOMEPATH || '', 'Documents');
|
|
57
|
+
storagePath = path.join(documentsPath, 'Oobee', randomToken);
|
|
58
|
+
}
|
|
59
|
+
else if (os.platform() === 'darwin') {
|
|
60
|
+
// Use Documents folder on Mac
|
|
61
|
+
const documentsPath = path.join(process.env.HOME || '', 'Documents');
|
|
62
|
+
storagePath = path.join(documentsPath, 'Oobee', randomToken);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Use home directory for Linux/other
|
|
66
|
+
const homePath = process.env.HOME || '';
|
|
67
|
+
storagePath = path.join(homePath, 'Oobee', randomToken);
|
|
68
|
+
}
|
|
69
|
+
consoleLogger.warn(`Warning: Cannot write to cwd, writing to ${storagePath}`);
|
|
70
|
+
}
|
|
71
|
+
if (!fs.existsSync(storagePath)) {
|
|
72
|
+
fs.mkdirSync(storagePath, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
constants.exportDirectory = storagePath;
|
|
75
|
+
return storagePath;
|
|
76
|
+
};
|
|
77
|
+
export const getUserDataFilePath = () => {
|
|
78
|
+
const platform = os.platform();
|
|
79
|
+
if (platform === 'win32') {
|
|
80
|
+
return path.join(process.env.APPDATA, 'Oobee', 'userData.txt');
|
|
81
|
+
}
|
|
82
|
+
if (platform === 'darwin') {
|
|
83
|
+
return path.join(process.env.HOME, 'Library', 'Application Support', 'Oobee', 'userData.txt');
|
|
84
|
+
}
|
|
85
|
+
// linux and other OS
|
|
86
|
+
return path.join(process.env.HOME, '.config', 'oobee', 'userData.txt');
|
|
87
|
+
};
|
|
88
|
+
export const getUserDataTxt = () => {
|
|
89
|
+
const textFilePath = getUserDataFilePath();
|
|
90
|
+
// check if textFilePath exists
|
|
91
|
+
if (fs.existsSync(textFilePath)) {
|
|
92
|
+
const userData = JSON.parse(fs.readFileSync(textFilePath, 'utf8'));
|
|
93
|
+
// If userId doesn't exist, generate one and save it
|
|
94
|
+
if (!userData.userId) {
|
|
95
|
+
userData.userId = uuidv4();
|
|
96
|
+
fs.writeFileSync(textFilePath, JSON.stringify(userData, null, 2));
|
|
97
|
+
}
|
|
98
|
+
return userData;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
export const writeToUserDataTxt = async (key, value) => {
|
|
103
|
+
const textFilePath = getUserDataFilePath();
|
|
104
|
+
// Create file if it doesn't exist
|
|
105
|
+
if (fs.existsSync(textFilePath)) {
|
|
106
|
+
const userData = JSON.parse(fs.readFileSync(textFilePath, 'utf8'));
|
|
107
|
+
userData[key] = value;
|
|
108
|
+
// Ensure userId exists
|
|
109
|
+
if (!userData.userId) {
|
|
110
|
+
userData.userId = uuidv4();
|
|
111
|
+
}
|
|
112
|
+
fs.writeFileSync(textFilePath, JSON.stringify(userData, null, 2));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const textFilePathDir = path.dirname(textFilePath);
|
|
116
|
+
if (!fs.existsSync(textFilePathDir)) {
|
|
117
|
+
fs.mkdirSync(textFilePathDir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
// Initialize with userId
|
|
120
|
+
fs.appendFileSync(textFilePath, JSON.stringify({ [key]: value, userId: uuidv4() }, null, 2));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
export const createAndUpdateResultsFolders = async (randomToken) => {
|
|
124
|
+
const storagePath = getStoragePath(randomToken);
|
|
125
|
+
await fs.ensureDir(`${storagePath}`);
|
|
126
|
+
const intermediatePdfResultsPath = `${randomToken}/${constants.pdfScanResultFileName}`;
|
|
127
|
+
const transferResults = async (intermPath, resultFile) => {
|
|
128
|
+
try {
|
|
129
|
+
if (fs.existsSync(intermPath)) {
|
|
130
|
+
await fs.copy(intermPath, `${storagePath}/${resultFile}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === 'EBUSY') {
|
|
135
|
+
consoleLogger.error(`Unable to copy the file from ${intermPath} to ${storagePath}/${resultFile} because it is currently in use.`);
|
|
136
|
+
consoleLogger.error('Please close any applications that might be using this file and try again.');
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
consoleLogger.error(`An unexpected error occurred while copying the file from ${intermPath} to ${storagePath}/${resultFile}: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
await Promise.all([transferResults(intermediatePdfResultsPath, constants.pdfScanResultFileName)]);
|
|
144
|
+
};
|
|
145
|
+
export const createScreenshotsFolder = (randomToken) => {
|
|
146
|
+
const storagePath = getStoragePath(randomToken);
|
|
147
|
+
const intermediateScreenshotsPath = getIntermediateScreenshotsPath(randomToken);
|
|
148
|
+
if (fs.existsSync(intermediateScreenshotsPath)) {
|
|
149
|
+
fs.readdir(intermediateScreenshotsPath, (err, files) => {
|
|
150
|
+
if (err) {
|
|
151
|
+
consoleLogger.error(`Screenshots were not moved successfully: ${err.message}`);
|
|
152
|
+
}
|
|
153
|
+
if (!fs.existsSync(destinationPath(storagePath))) {
|
|
154
|
+
try {
|
|
155
|
+
fs.mkdirSync(destinationPath(storagePath), { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
consoleLogger.error('Screenshots folder was not created successfully:', error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
files.forEach(file => {
|
|
162
|
+
fs.renameSync(`${intermediateScreenshotsPath}/${file}`, `${destinationPath(storagePath)}/${file}`);
|
|
163
|
+
});
|
|
164
|
+
fs.rmdir(intermediateScreenshotsPath, rmdirErr => {
|
|
165
|
+
if (rmdirErr) {
|
|
166
|
+
consoleLogger.error(rmdirErr);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
let __shuttingDown = false;
|
|
173
|
+
let __stopAllLock = null;
|
|
174
|
+
let __softCloseHandler = null;
|
|
175
|
+
export function registerSoftClose(handler) {
|
|
176
|
+
__softCloseHandler = handler;
|
|
177
|
+
}
|
|
178
|
+
export async function softCloseBrowserAndContext() {
|
|
179
|
+
if (!__softCloseHandler) {
|
|
180
|
+
consoleLogger.info('softCloseBrowserAndContext: no handler registered (probably not a custom-flow scan)');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
consoleLogger.info('softCloseBrowserAndContext: calling registered handler...');
|
|
185
|
+
await __softCloseHandler();
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
consoleLogger.warn(`softCloseBrowserAndContext error: ${e?.message || e}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Register a resource so it can be stopped later.
|
|
193
|
+
* Supports Crawlee crawlers, Playwright BrowserContexts, and Browsers.
|
|
194
|
+
*/
|
|
195
|
+
export function register(resource) {
|
|
196
|
+
const name = resource?.constructor?.name;
|
|
197
|
+
if (name?.endsWith('Crawler')) {
|
|
198
|
+
constants.resources.crawlers.add(resource);
|
|
199
|
+
}
|
|
200
|
+
else if (name === 'BrowserContext') {
|
|
201
|
+
constants.resources.browserContexts.add(resource);
|
|
202
|
+
}
|
|
203
|
+
else if (name === 'Browser') {
|
|
204
|
+
constants.resources.browsers.add(resource);
|
|
205
|
+
}
|
|
206
|
+
return resource;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Stops or tears down all tracked resources.
|
|
210
|
+
* @param mode "graceful" (finish in-flight), "abort" (drop in-flight), or "teardown" (close immediately)
|
|
211
|
+
* @param timeoutMs Max time to wait before forcing shutdown
|
|
212
|
+
*/
|
|
213
|
+
export async function stopAll({ mode = 'graceful', timeoutMs = 10_000 } = {}) {
|
|
214
|
+
if (__stopAllLock)
|
|
215
|
+
return __stopAllLock; // prevent overlap
|
|
216
|
+
__stopAllLock = (async () => {
|
|
217
|
+
const timeout = (ms) => new Promise(res => setTimeout(res, ms));
|
|
218
|
+
consoleLogger.info(`Stop browsers starting, mode=${mode}, timeoutMs=${timeoutMs}`);
|
|
219
|
+
// --- Crawlers ---
|
|
220
|
+
for (const c of [...constants.resources.crawlers]) {
|
|
221
|
+
try {
|
|
222
|
+
const pool = c.autoscaledPool;
|
|
223
|
+
if (pool && typeof pool.isRunning !== 'undefined' && !pool.isRunning) {
|
|
224
|
+
consoleLogger.info('Skipping crawler (already stopped)');
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
consoleLogger.info(`Closing crawler (${mode})...`);
|
|
228
|
+
if (mode === 'graceful') {
|
|
229
|
+
if (typeof c.stop === 'function') {
|
|
230
|
+
await Promise.race([c.stop(), timeout(timeoutMs)]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else if (mode === 'abort') {
|
|
234
|
+
pool?.abort?.();
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
if (typeof c.teardown === 'function') {
|
|
238
|
+
await Promise.race([c.teardown(), timeout(timeoutMs)]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
consoleLogger.info(`Crawler closed (${mode})`);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
consoleLogger.warn(`Error stopping crawler: ${err.message}`);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
constants.resources.crawlers.delete(c);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// --- BrowserContexts ---
|
|
251
|
+
for (const ctx of [...constants.resources.browserContexts]) {
|
|
252
|
+
// compute once so we can also use in finally
|
|
253
|
+
const pagesArr = typeof ctx.pages === 'function' ? ctx.pages() : [];
|
|
254
|
+
const hasOpenPages = Array.isArray(pagesArr) && pagesArr.length > 0;
|
|
255
|
+
try {
|
|
256
|
+
const browser = typeof ctx.browser === 'function' ? ctx.browser() : null;
|
|
257
|
+
if (browser && browser.isClosed?.()) {
|
|
258
|
+
consoleLogger.info('Skipping BrowserContext (browser already closed)');
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// ➜ Graceful: don't kill contexts that are still doing work
|
|
262
|
+
if (mode === 'graceful' && hasOpenPages) {
|
|
263
|
+
consoleLogger.info(`Skipping BrowserContext in graceful (has ${pagesArr.length} open page(s))`);
|
|
264
|
+
continue; // leave it for the teardown pass
|
|
265
|
+
}
|
|
266
|
+
// (Optional speed-up) close pages first if any
|
|
267
|
+
if (hasOpenPages) {
|
|
268
|
+
consoleLogger.info(`Closing ${pagesArr.length} page(s) before context close...`);
|
|
269
|
+
for (const p of pagesArr) {
|
|
270
|
+
try {
|
|
271
|
+
await Promise.race([p.close(), timeout(1500)]);
|
|
272
|
+
}
|
|
273
|
+
catch { }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
consoleLogger.info('Closing BrowserContext...');
|
|
277
|
+
if (typeof ctx.close === 'function') {
|
|
278
|
+
await Promise.race([ctx.close(), timeout(timeoutMs)]);
|
|
279
|
+
}
|
|
280
|
+
consoleLogger.info('BrowserContext closed');
|
|
281
|
+
// also close its browser (persistent contexts)
|
|
282
|
+
const b = browser;
|
|
283
|
+
if (b && !b.isClosed?.()) {
|
|
284
|
+
consoleLogger.info('Closing Browser (from context.browser())...');
|
|
285
|
+
if (typeof b.close === 'function') {
|
|
286
|
+
await Promise.race([b.close(), timeout(timeoutMs)]);
|
|
287
|
+
}
|
|
288
|
+
consoleLogger.info('Browser closed (from context.browser())');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
consoleLogger.warn(`Error closing BrowserContext: ${err.message}`);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
// only delete from the set if we actually closed it (or tried to)
|
|
296
|
+
if (!(mode === 'graceful' && hasOpenPages)) {
|
|
297
|
+
constants.resources.browserContexts.delete(ctx);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// --- Browsers ---
|
|
302
|
+
for (const b of [...constants.resources.browsers]) {
|
|
303
|
+
try {
|
|
304
|
+
if (b.isClosed?.()) {
|
|
305
|
+
consoleLogger.info('Skipping Browser (already closed)');
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
consoleLogger.info('Closing Browser...');
|
|
309
|
+
if (typeof b.close === 'function') {
|
|
310
|
+
await Promise.race([b.close(), timeout(timeoutMs)]);
|
|
311
|
+
}
|
|
312
|
+
consoleLogger.info('Browser closed');
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
consoleLogger.warn(`Error closing Browser: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
constants.resources.browsers.delete(b);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
consoleLogger.info(`Stop browsers finished for mode=${mode}`);
|
|
322
|
+
})();
|
|
323
|
+
try {
|
|
324
|
+
await __stopAllLock;
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
__stopAllLock = null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export const cleanUp = async (randomToken, isError = false) => {
|
|
331
|
+
if (isError) {
|
|
332
|
+
await stopAll({ mode: 'graceful', timeoutMs: 8000 });
|
|
333
|
+
await stopAll({ mode: 'teardown', timeoutMs: 4000 });
|
|
334
|
+
}
|
|
335
|
+
if (randomToken === undefined && constants.randomToken) {
|
|
336
|
+
randomToken = constants.randomToken;
|
|
337
|
+
}
|
|
338
|
+
if (constants.userDataDirectory)
|
|
339
|
+
try {
|
|
340
|
+
fs.rmSync(constants.userDataDirectory, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
consoleLogger.warn(`Unable to force remove userDataDirectory: ${error.message}`);
|
|
344
|
+
}
|
|
345
|
+
if (randomToken !== undefined) {
|
|
346
|
+
const storagePath = getStoragePath(randomToken);
|
|
347
|
+
try {
|
|
348
|
+
fs.rmSync(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
consoleLogger.warn(`Unable to force remove pdfs folder: ${error.message}`);
|
|
358
|
+
}
|
|
359
|
+
let deleteErrorLogFile = true;
|
|
360
|
+
if (isError) {
|
|
361
|
+
let logsPath = storagePath;
|
|
362
|
+
if (process.env.OOBEE_LOGS_PATH) {
|
|
363
|
+
logsPath = process.env.OOBEE_LOGS_PATH;
|
|
364
|
+
}
|
|
365
|
+
if (fs.existsSync(errorsTxtPath)) {
|
|
366
|
+
try {
|
|
367
|
+
const logFilePath = path.join(logsPath, `logs-${randomToken}.txt`);
|
|
368
|
+
fs.copyFileSync(errorsTxtPath, logFilePath);
|
|
369
|
+
console.log(`An error occured. Log file is located at: ${logFilePath}`);
|
|
370
|
+
}
|
|
371
|
+
catch (copyError) {
|
|
372
|
+
consoleLogger.error(`Error copying errors file during cleanup: ${copyError.message}`);
|
|
373
|
+
console.log(`An error occured. Log file is located at: ${errorsTxtPath}`);
|
|
374
|
+
deleteErrorLogFile = false; // Do not delete the log file if copy failed
|
|
375
|
+
}
|
|
376
|
+
if (deleteErrorLogFile && fs.existsSync(errorsTxtPath)) {
|
|
377
|
+
try {
|
|
378
|
+
fs.unlinkSync(errorsTxtPath);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
consoleLogger.warn(`Unable to delete log file ${errorsTxtPath}: ${error.message}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (fs.existsSync(storagePath) && fs.readdirSync(storagePath).length === 0) {
|
|
387
|
+
try {
|
|
388
|
+
fs.rmdirSync(storagePath);
|
|
389
|
+
consoleLogger.info(`Deleted empty storage path: ${storagePath}`);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
consoleLogger.warn(`Error deleting empty storage path ${storagePath}: ${error.message}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
consoleLogger.info(`Clean up completed for: ${randomToken}`);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
export const cleanUpAndExit = async (exitCode, randomToken, isError = false) => {
|
|
399
|
+
if (__shuttingDown) {
|
|
400
|
+
consoleLogger.info('Cleanup already in progress; ignoring duplicate exit request.');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
__shuttingDown = true;
|
|
404
|
+
try {
|
|
405
|
+
await cleanUp(randomToken, isError); // runs stopAll inside cleanUp
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
consoleLogger.warn(`Cleanup error: ${e?.message || e}`);
|
|
409
|
+
}
|
|
410
|
+
consoleLogger.info(`Exiting with code: ${exitCode}`);
|
|
411
|
+
process.exit(exitCode); // explicit exit after cleanup completes
|
|
412
|
+
};
|
|
413
|
+
// Clean up listeners for process signals (e.g. parent process wants to stop Oobee scan mid-point)
|
|
414
|
+
// Necessary to remove residual userDataDirectory and crawlee files generated by Chrome/Edge browser on each run, so that storage does not baloon up on the server
|
|
415
|
+
export const listenForCleanUp = (randomToken) => {
|
|
416
|
+
consoleLogger.info(`PID: ${process.pid}`);
|
|
417
|
+
// SIGINT signal happens when the user presses Ctrl+C in the terminal
|
|
418
|
+
process.on('SIGINT', async () => {
|
|
419
|
+
consoleLogger.info('SIGINT received. Cleaning up and exiting.');
|
|
420
|
+
await cleanUpAndExit(130, randomToken, true);
|
|
421
|
+
});
|
|
422
|
+
// SIGTERM signal happens when the process is terminated (by another process or system shutdown)
|
|
423
|
+
process.on('SIGTERM', async () => {
|
|
424
|
+
consoleLogger.info('SIGTERM received. Cleaning up and exiting.');
|
|
425
|
+
await cleanUpAndExit(143, randomToken, true);
|
|
426
|
+
});
|
|
427
|
+
// Note: user-defined signal reserved for application-specific use.
|
|
428
|
+
// SIGUSR1 for handling closing playwright browser and continue generate artifacts etc
|
|
429
|
+
process.on('SIGUSR1', async () => {
|
|
430
|
+
consoleLogger.info('SIGUSR1 received. Soft-closing browser/context only.');
|
|
431
|
+
await softCloseBrowserAndContext();
|
|
432
|
+
});
|
|
433
|
+
};
|
|
434
|
+
export const getWcagPassPercentage = (wcagViolations, showEnableWcagAaa) => {
|
|
435
|
+
// These AAA rules should not be counted as WCAG Pass Percentage only contains A and AA
|
|
436
|
+
const wcagAAALinks = ['WCAG 1.4.6', 'WCAG 2.2.4', 'WCAG 2.4.9', 'WCAG 3.1.5', 'WCAG 3.2.5', 'WCAG 2.1.3'];
|
|
437
|
+
const wcagAAA = ['wcag146', 'wcag224', 'wcag249', 'wcag315', 'wcag325', 'wcag213'];
|
|
438
|
+
const wcagLinksAAandAAA = constants.wcagLinks;
|
|
439
|
+
const wcagViolationsAAandAAA = showEnableWcagAaa ? wcagViolations.length : null;
|
|
440
|
+
const totalChecksAAandAAA = showEnableWcagAaa ? Object.keys(wcagLinksAAandAAA).length : null;
|
|
441
|
+
const passedChecksAAandAAA = showEnableWcagAaa
|
|
442
|
+
? totalChecksAAandAAA - wcagViolationsAAandAAA
|
|
443
|
+
: null;
|
|
444
|
+
// eslint-disable-next-line no-nested-ternary
|
|
445
|
+
const passPercentageAAandAAA = showEnableWcagAaa
|
|
446
|
+
? totalChecksAAandAAA === 0
|
|
447
|
+
? 0
|
|
448
|
+
: (passedChecksAAandAAA / totalChecksAAandAAA) * 100
|
|
449
|
+
: null;
|
|
450
|
+
const wcagViolationsAA = wcagViolations.filter(violation => !wcagAAA.includes(violation)).length;
|
|
451
|
+
const totalChecksAA = Object.keys(wcagLinksAAandAAA).filter(key => !wcagAAALinks.includes(key)).length;
|
|
452
|
+
const passedChecksAA = totalChecksAA - wcagViolationsAA;
|
|
453
|
+
const passPercentageAA = totalChecksAA === 0 ? 0 : (passedChecksAA / totalChecksAA) * 100;
|
|
454
|
+
return {
|
|
455
|
+
passPercentageAA: passPercentageAA.toFixed(2), // toFixed returns a string, which is correct here
|
|
456
|
+
totalWcagChecksAA: totalChecksAA,
|
|
457
|
+
totalWcagViolationsAA: wcagViolationsAA,
|
|
458
|
+
passPercentageAAandAAA: passPercentageAAandAAA ? passPercentageAAandAAA.toFixed(2) : null, // toFixed returns a string, which is correct here
|
|
459
|
+
totalWcagChecksAAandAAA: totalChecksAAandAAA,
|
|
460
|
+
totalWcagViolationsAAandAAA: wcagViolationsAAandAAA,
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
export const getProgressPercentage = (scanPagesDetail, showEnableWcagAaa) => {
|
|
464
|
+
const pages = scanPagesDetail.pagesAffected || [];
|
|
465
|
+
const progressPercentagesAA = pages.map((page) => {
|
|
466
|
+
const violations = page.conformance;
|
|
467
|
+
return getWcagPassPercentage(violations, showEnableWcagAaa).passPercentageAA;
|
|
468
|
+
});
|
|
469
|
+
const progressPercentagesAAandAAA = pages.map((page) => {
|
|
470
|
+
const violations = page.conformance;
|
|
471
|
+
return getWcagPassPercentage(violations, showEnableWcagAaa).passPercentageAAandAAA;
|
|
472
|
+
});
|
|
473
|
+
const totalAA = progressPercentagesAA.reduce((sum, p) => sum + parseFloat(p), 0);
|
|
474
|
+
const avgAA = progressPercentagesAA.length ? totalAA / progressPercentagesAA.length : 0;
|
|
475
|
+
const totalAAandAAA = progressPercentagesAAandAAA.reduce((sum, p) => sum + parseFloat(p), 0);
|
|
476
|
+
const avgAAandAAA = progressPercentagesAAandAAA.length
|
|
477
|
+
? totalAAandAAA / progressPercentagesAAandAAA.length
|
|
478
|
+
: 0;
|
|
479
|
+
return {
|
|
480
|
+
averageProgressPercentageAA: avgAA.toFixed(2),
|
|
481
|
+
averageProgressPercentageAAandAAA: avgAAandAAA.toFixed(2),
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
export const getTotalRulesCount = async (enableWcagAaa, disableOobee) => {
|
|
485
|
+
const axeConfig = getAxeConfiguration({
|
|
486
|
+
enableWcagAaa,
|
|
487
|
+
gradingReadabilityFlag: '',
|
|
488
|
+
disableOobee,
|
|
489
|
+
});
|
|
490
|
+
// Get default rules from axe-core
|
|
491
|
+
const defaultRules = axe.getRules();
|
|
492
|
+
// Merge custom rules with default rules, converting RuleMetadata to Rule
|
|
493
|
+
const mergedRules = defaultRules.map(defaultRule => {
|
|
494
|
+
const customRule = axeConfig.rules.find(r => r.id === defaultRule.ruleId);
|
|
495
|
+
if (customRule) {
|
|
496
|
+
// Merge properties from customRule into defaultRule (RuleMetadata) to create a Rule
|
|
497
|
+
return {
|
|
498
|
+
id: defaultRule.ruleId,
|
|
499
|
+
enabled: customRule.enabled,
|
|
500
|
+
selector: customRule.selector,
|
|
501
|
+
any: customRule.any,
|
|
502
|
+
tags: defaultRule.tags,
|
|
503
|
+
metadata: customRule.metadata, // Use custom metadata if it exists
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
// Convert defaultRule (RuleMetadata) to Rule
|
|
507
|
+
return {
|
|
508
|
+
id: defaultRule.ruleId,
|
|
509
|
+
enabled: true, // Default to true if not overridden
|
|
510
|
+
tags: defaultRule.tags,
|
|
511
|
+
// No metadata here, since defaultRule.metadata might not exist
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
// Add any custom rules that don't override the default rules
|
|
515
|
+
axeConfig.rules.forEach(customRule => {
|
|
516
|
+
if (!mergedRules.some(mergedRule => mergedRule.id === customRule.id)) {
|
|
517
|
+
// Ensure customRule is of type Rule
|
|
518
|
+
const rule = {
|
|
519
|
+
id: customRule.id,
|
|
520
|
+
enabled: customRule.enabled,
|
|
521
|
+
selector: customRule.selector,
|
|
522
|
+
any: customRule.any,
|
|
523
|
+
tags: customRule.tags,
|
|
524
|
+
metadata: customRule.metadata,
|
|
525
|
+
// Add other properties if needed
|
|
526
|
+
};
|
|
527
|
+
mergedRules.push(rule);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
// Apply the merged configuration to axe-core
|
|
531
|
+
axe.configure({ ...axeConfig, rules: mergedRules });
|
|
532
|
+
// ... (rest of your logic)
|
|
533
|
+
let totalRulesMustFix = 0;
|
|
534
|
+
let totalRulesGoodToFix = 0;
|
|
535
|
+
const wcagRegex = /^wcag\d+a+$/;
|
|
536
|
+
// Use mergedRules instead of rules to check enabled property
|
|
537
|
+
mergedRules.forEach(rule => {
|
|
538
|
+
if (!rule.enabled) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (rule.id === 'frame-tested')
|
|
542
|
+
return; // Ignore 'frame-tested' rule
|
|
543
|
+
const tags = rule.tags || [];
|
|
544
|
+
// Skip experimental and deprecated rules
|
|
545
|
+
if (tags.includes('experimental') || tags.includes('deprecated')) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');
|
|
549
|
+
// Ensure conformance level is sorted correctly
|
|
550
|
+
if (conformance.length > 0 &&
|
|
551
|
+
conformance[0] !== 'best-practice' &&
|
|
552
|
+
!wcagRegex.test(conformance[0])) {
|
|
553
|
+
conformance.sort((a, b) => {
|
|
554
|
+
if (wcagRegex.test(a) && !wcagRegex.test(b)) {
|
|
555
|
+
return -1;
|
|
556
|
+
}
|
|
557
|
+
if (!wcagRegex.test(a) && wcagRegex.test(b)) {
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
return 0;
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (conformance.includes('best-practice')) {
|
|
564
|
+
// console.log(`${totalRulesMustFix} Good To Fix: ${rule.id}`);
|
|
565
|
+
totalRulesGoodToFix += 1; // Categorized as "Good to Fix"
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// console.log(`${totalRulesMustFix} Must Fix: ${rule.id}`);
|
|
569
|
+
totalRulesMustFix += 1; // Otherwise, it's "Must Fix"
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return {
|
|
573
|
+
totalRulesMustFix,
|
|
574
|
+
totalRulesGoodToFix,
|
|
575
|
+
totalRulesMustFixAndGoodToFix: totalRulesMustFix + totalRulesGoodToFix,
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
/**
|
|
579
|
+
* Dynamically generates a map of WCAG criteria IDs to their details (name and level)
|
|
580
|
+
* Reuses the rule processing logic from getTotalRulesCount
|
|
581
|
+
*/
|
|
582
|
+
export const getWcagCriteriaMap = async (enableWcagAaa = true, disableOobee = false) => {
|
|
583
|
+
// Reuse the configuration setup from getTotalRulesCount
|
|
584
|
+
const axeConfig = getAxeConfiguration({
|
|
585
|
+
enableWcagAaa,
|
|
586
|
+
gradingReadabilityFlag: '',
|
|
587
|
+
disableOobee,
|
|
588
|
+
});
|
|
589
|
+
// Get default rules from axe-core
|
|
590
|
+
const defaultRules = axe.getRules();
|
|
591
|
+
// Merge custom rules with default rules
|
|
592
|
+
const mergedRules = defaultRules.map(defaultRule => {
|
|
593
|
+
const customRule = axeConfig.rules.find(r => r.id === defaultRule.ruleId);
|
|
594
|
+
if (customRule) {
|
|
595
|
+
return {
|
|
596
|
+
id: defaultRule.ruleId,
|
|
597
|
+
enabled: customRule.enabled,
|
|
598
|
+
selector: customRule.selector,
|
|
599
|
+
any: customRule.any,
|
|
600
|
+
tags: defaultRule.tags,
|
|
601
|
+
metadata: customRule.metadata,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
id: defaultRule.ruleId,
|
|
606
|
+
enabled: true,
|
|
607
|
+
tags: defaultRule.tags,
|
|
608
|
+
};
|
|
609
|
+
});
|
|
610
|
+
// Add custom rules that don't override default rules
|
|
611
|
+
axeConfig.rules.forEach(customRule => {
|
|
612
|
+
if (!mergedRules.some(rule => rule.id === customRule.id)) {
|
|
613
|
+
mergedRules.push({
|
|
614
|
+
id: customRule.id,
|
|
615
|
+
enabled: customRule.enabled,
|
|
616
|
+
selector: customRule.selector,
|
|
617
|
+
any: customRule.any,
|
|
618
|
+
tags: customRule.tags,
|
|
619
|
+
metadata: customRule.metadata,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
// Apply configuration
|
|
624
|
+
axe.configure({ ...axeConfig, rules: mergedRules });
|
|
625
|
+
// Build WCAG criteria map
|
|
626
|
+
const wcagCriteriaMap = {};
|
|
627
|
+
// Process rules to extract WCAG information
|
|
628
|
+
mergedRules.forEach(rule => {
|
|
629
|
+
if (!rule.enabled)
|
|
630
|
+
return;
|
|
631
|
+
if (rule.id === 'frame-tested')
|
|
632
|
+
return;
|
|
633
|
+
const tags = rule.tags || [];
|
|
634
|
+
if (tags.includes('experimental') || tags.includes('deprecated'))
|
|
635
|
+
return;
|
|
636
|
+
// Look for WCAG criteria tags (format: wcag111, wcag143, etc.)
|
|
637
|
+
tags.forEach(tag => {
|
|
638
|
+
const wcagMatch = tag.match(/^wcag(\d+)$/);
|
|
639
|
+
if (wcagMatch) {
|
|
640
|
+
const wcagId = tag;
|
|
641
|
+
// Default values
|
|
642
|
+
let level = 'a';
|
|
643
|
+
let name = '';
|
|
644
|
+
// Try to extract better info from metadata if available
|
|
645
|
+
const metadata = rule.metadata;
|
|
646
|
+
if (metadata && metadata.wcag) {
|
|
647
|
+
const wcagInfo = metadata.wcag;
|
|
648
|
+
// Find matching criterion in metadata
|
|
649
|
+
for (const key in wcagInfo) {
|
|
650
|
+
const criterion = wcagInfo[key];
|
|
651
|
+
if (criterion &&
|
|
652
|
+
criterion.num &&
|
|
653
|
+
`wcag${criterion.num.replace(/\./g, '')}` === wcagId) {
|
|
654
|
+
// Extract level
|
|
655
|
+
if (criterion.level) {
|
|
656
|
+
level = String(criterion.level).toLowerCase();
|
|
657
|
+
}
|
|
658
|
+
// Extract name
|
|
659
|
+
if (criterion.handle) {
|
|
660
|
+
name = String(criterion.handle);
|
|
661
|
+
}
|
|
662
|
+
else if (criterion.id) {
|
|
663
|
+
name = String(criterion.id);
|
|
664
|
+
}
|
|
665
|
+
else if (criterion.num) {
|
|
666
|
+
name = `wcag-${String(criterion.num).replace(/\./g, '-')}`;
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Generate fallback name if none found
|
|
673
|
+
if (!name) {
|
|
674
|
+
const numStr = wcagMatch[1];
|
|
675
|
+
const formattedNum = numStr.replace(/(\d)(\d)(\d+)?/, '$1.$2.$3');
|
|
676
|
+
name = `wcag-${formattedNum.replace(/\./g, '-')}`;
|
|
677
|
+
}
|
|
678
|
+
// Store in map
|
|
679
|
+
wcagCriteriaMap[wcagId] = {
|
|
680
|
+
name: name.toLowerCase().replace(/_/g, '-'),
|
|
681
|
+
level
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
return wcagCriteriaMap;
|
|
687
|
+
};
|
|
688
|
+
export const getIssuesPercentage = async (scanPagesDetail, enableWcagAaa, disableOobee) => {
|
|
689
|
+
const pages = scanPagesDetail.pagesAffected || [];
|
|
690
|
+
const totalPages = pages.length;
|
|
691
|
+
const pagesAffectedPerRule = {};
|
|
692
|
+
pages.forEach(page => {
|
|
693
|
+
page.typesOfIssues.forEach(issue => {
|
|
694
|
+
if ((issue.occurrencesMustFix || issue.occurrencesGoodToFix) > 0) {
|
|
695
|
+
pagesAffectedPerRule[issue.ruleId] = (pagesAffectedPerRule[issue.ruleId] || 0) + 1;
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
const pagesPercentageAffectedPerRule = {};
|
|
700
|
+
Object.entries(pagesAffectedPerRule).forEach(([ruleId, count]) => {
|
|
701
|
+
pagesPercentageAffectedPerRule[ruleId] =
|
|
702
|
+
totalPages > 0 ? ((count / totalPages) * 100).toFixed(2) : '0.00';
|
|
703
|
+
});
|
|
704
|
+
const typesOfIssuesCountAtMustFix = pages.map(page => page.typesOfIssues.filter(issue => (issue.occurrencesMustFix || 0) > 0).length);
|
|
705
|
+
const typesOfIssuesCountAtGoodToFix = pages.map(page => page.typesOfIssues.filter(issue => (issue.occurrencesGoodToFix || 0) > 0).length);
|
|
706
|
+
const typesOfIssuesCountSumMustFixAndGoodToFix = pages.map((_, index) => (typesOfIssuesCountAtMustFix[index] || 0) + (typesOfIssuesCountAtGoodToFix[index] || 0));
|
|
707
|
+
const { totalRulesMustFix, totalRulesGoodToFix, totalRulesMustFixAndGoodToFix } = await getTotalRulesCount(enableWcagAaa, disableOobee);
|
|
708
|
+
const avgMustFixPerPage = totalPages > 0
|
|
709
|
+
? typesOfIssuesCountAtMustFix.reduce((sum, count) => sum + count, 0) / totalPages
|
|
710
|
+
: 0;
|
|
711
|
+
const avgGoodToFixPerPage = totalPages > 0
|
|
712
|
+
? typesOfIssuesCountAtGoodToFix.reduce((sum, count) => sum + count, 0) / totalPages
|
|
713
|
+
: 0;
|
|
714
|
+
const avgMustFixAndGoodToFixPerPage = totalPages > 0
|
|
715
|
+
? typesOfIssuesCountSumMustFixAndGoodToFix.reduce((sum, count) => sum + count, 0) / totalPages
|
|
716
|
+
: 0;
|
|
717
|
+
const avgTypesOfIssuesPercentageOfTotalRulesAtMustFix = totalRulesMustFix > 0 ? ((avgMustFixPerPage / totalRulesMustFix) * 100).toFixed(2) : '0.00';
|
|
718
|
+
const avgTypesOfIssuesPercentageOfTotalRulesAtGoodToFix = totalRulesGoodToFix > 0
|
|
719
|
+
? ((avgGoodToFixPerPage / totalRulesGoodToFix) * 100).toFixed(2)
|
|
720
|
+
: '0.00';
|
|
721
|
+
const avgTypesOfIssuesPercentageOfTotalRulesAtMustFixAndGoodToFix = totalRulesMustFixAndGoodToFix > 0
|
|
722
|
+
? ((avgMustFixAndGoodToFixPerPage / totalRulesMustFixAndGoodToFix) * 100).toFixed(2)
|
|
723
|
+
: '0.00';
|
|
724
|
+
const avgTypesOfIssuesCountAtMustFix = avgMustFixPerPage.toFixed(2);
|
|
725
|
+
const avgTypesOfIssuesCountAtGoodToFix = avgGoodToFixPerPage.toFixed(2);
|
|
726
|
+
const avgTypesOfIssuesCountAtMustFixAndGoodToFix = avgMustFixAndGoodToFixPerPage.toFixed(2);
|
|
727
|
+
return {
|
|
728
|
+
avgTypesOfIssuesCountAtMustFix,
|
|
729
|
+
avgTypesOfIssuesCountAtGoodToFix,
|
|
730
|
+
avgTypesOfIssuesCountAtMustFixAndGoodToFix,
|
|
731
|
+
avgTypesOfIssuesPercentageOfTotalRulesAtMustFix,
|
|
732
|
+
avgTypesOfIssuesPercentageOfTotalRulesAtGoodToFix,
|
|
733
|
+
avgTypesOfIssuesPercentageOfTotalRulesAtMustFixAndGoodToFix,
|
|
734
|
+
totalRulesMustFix,
|
|
735
|
+
totalRulesGoodToFix,
|
|
736
|
+
totalRulesMustFixAndGoodToFix,
|
|
737
|
+
pagesAffectedPerRule,
|
|
738
|
+
pagesPercentageAffectedPerRule,
|
|
739
|
+
};
|
|
740
|
+
};
|
|
741
|
+
export const getFormattedTime = (inputDate) => {
|
|
742
|
+
if (inputDate) {
|
|
743
|
+
return inputDate.toLocaleTimeString('en-GB', {
|
|
744
|
+
year: 'numeric',
|
|
745
|
+
month: 'short',
|
|
746
|
+
day: 'numeric',
|
|
747
|
+
hour12: false,
|
|
748
|
+
hour: 'numeric',
|
|
749
|
+
minute: '2-digit',
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return new Date().toLocaleTimeString('en-GB', {
|
|
753
|
+
year: 'numeric',
|
|
754
|
+
month: 'short',
|
|
755
|
+
day: 'numeric',
|
|
756
|
+
hour12: false,
|
|
757
|
+
hour: 'numeric',
|
|
758
|
+
minute: '2-digit',
|
|
759
|
+
timeZoneName: 'longGeneric',
|
|
760
|
+
});
|
|
761
|
+
};
|
|
762
|
+
export const formatDateTimeForMassScanner = (date) => {
|
|
763
|
+
// Format date and time parts separately
|
|
764
|
+
const year = date.getFullYear().toString().slice(-2); // Get the last two digits of the year
|
|
765
|
+
const month = `0${date.getMonth() + 1}`.slice(-2); // Month is zero-indexed
|
|
766
|
+
const day = `0${date.getDate()}`.slice(-2);
|
|
767
|
+
const hour = `0${date.getHours()}`.slice(-2);
|
|
768
|
+
const minute = `0${date.getMinutes()}`.slice(-2);
|
|
769
|
+
// Combine formatted date and time with a slash
|
|
770
|
+
const formattedDateTime = `${day}/${month}/${year} ${hour}:${minute}`;
|
|
771
|
+
return formattedDateTime;
|
|
772
|
+
};
|
|
773
|
+
export const setHeadlessMode = (browser, isHeadless) => {
|
|
774
|
+
const isWindowsOSAndEdgeBrowser = browser === BrowserTypes.EDGE && os.platform() === 'win32';
|
|
775
|
+
if (isHeadless || isWindowsOSAndEdgeBrowser) {
|
|
776
|
+
process.env.CRAWLEE_HEADLESS = '1';
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
process.env.CRAWLEE_HEADLESS = '0';
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
export const setThresholdLimits = (setWarnLevel) => {
|
|
783
|
+
process.env.WARN_LEVEL = setWarnLevel;
|
|
784
|
+
};
|
|
785
|
+
export const zipResults = async (zipName, resultsPath) => {
|
|
786
|
+
// Resolve and validate the output path
|
|
787
|
+
const zipFilePath = path.isAbsolute(zipName) ? zipName : path.join(resultsPath, zipName);
|
|
788
|
+
// Ensure parent dir exists
|
|
789
|
+
fs.mkdirSync(path.dirname(zipFilePath), { recursive: true });
|
|
790
|
+
// Remove any prior file atomically
|
|
791
|
+
try {
|
|
792
|
+
fs.unlinkSync(zipFilePath);
|
|
793
|
+
}
|
|
794
|
+
catch { /* ignore if not exists */ }
|
|
795
|
+
// CWD must exist and be a directory
|
|
796
|
+
const stats = fs.statSync(resultsPath);
|
|
797
|
+
if (!stats.isDirectory()) {
|
|
798
|
+
throw new Error(`resultsPath is not a directory: ${resultsPath}`);
|
|
799
|
+
}
|
|
800
|
+
async function addFolderToZip(folderPath, zipFolder) {
|
|
801
|
+
const items = await fs.readdir(folderPath);
|
|
802
|
+
for (const item of items) {
|
|
803
|
+
const fullPath = path.join(folderPath, item);
|
|
804
|
+
const stats = await fs.stat(fullPath);
|
|
805
|
+
if (stats.isDirectory()) {
|
|
806
|
+
const folder = zipFolder.folder(item);
|
|
807
|
+
await addFolderToZip(fullPath, folder);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
// Add file as a stream so that it doesn't load the entire file into memory
|
|
811
|
+
zipFolder.file(item, createReadStream(fullPath));
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const zip = new JSZip();
|
|
816
|
+
await addFolderToZip(resultsPath, zip);
|
|
817
|
+
const zipStream = zip.generateNodeStream({
|
|
818
|
+
type: 'nodebuffer',
|
|
819
|
+
streamFiles: true,
|
|
820
|
+
compression: 'DEFLATE',
|
|
821
|
+
});
|
|
822
|
+
await new Promise((resolve, reject) => {
|
|
823
|
+
const outStream = createWriteStream(zipFilePath);
|
|
824
|
+
zipStream.pipe(outStream)
|
|
825
|
+
.on('finish', () => resolve(undefined))
|
|
826
|
+
.on('error', reject);
|
|
827
|
+
});
|
|
828
|
+
};
|
|
829
|
+
// areLinksEqual compares 2 string URLs and ignores comparison of 'www.' and url protocol
|
|
830
|
+
// i.e. 'http://google.com' and 'https://www.google.com' returns true
|
|
831
|
+
export const areLinksEqual = (link1, link2) => {
|
|
832
|
+
try {
|
|
833
|
+
const format = (link) => {
|
|
834
|
+
return new URL(link.replace(/www\./, ''));
|
|
835
|
+
};
|
|
836
|
+
const l1 = format(link1);
|
|
837
|
+
const l2 = format(link2);
|
|
838
|
+
const areHostEqual = l1.host === l2.host;
|
|
839
|
+
const arePathEqual = l1.pathname === l2.pathname;
|
|
840
|
+
return areHostEqual && arePathEqual;
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return link1 === link2;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
export const randomThreeDigitNumberString = () => {
|
|
847
|
+
// Generate a random decimal between 0 (inclusive) and 1 (exclusive)
|
|
848
|
+
const randomDecimal = Math.random();
|
|
849
|
+
// Multiply by 900 to get a decimal between 0 (inclusive) and 900 (exclusive)
|
|
850
|
+
const scaledDecimal = randomDecimal * 900;
|
|
851
|
+
// Add 100 to ensure the result is between 100 (inclusive) and 1000 (exclusive)
|
|
852
|
+
const threeDigitNumber = Math.floor(scaledDecimal) + 100;
|
|
853
|
+
return String(threeDigitNumber);
|
|
854
|
+
};
|
|
855
|
+
export const isFollowStrategy = (link1, link2, rule) => {
|
|
856
|
+
const parsedLink1 = new URL(link1);
|
|
857
|
+
const parsedLink2 = new URL(link2);
|
|
858
|
+
if (rule === 'same-domain') {
|
|
859
|
+
const link1Domain = parsedLink1.hostname.split('.').slice(-2).join('.');
|
|
860
|
+
const link2Domain = parsedLink2.hostname.split('.').slice(-2).join('.');
|
|
861
|
+
return link1Domain === link2Domain;
|
|
862
|
+
}
|
|
863
|
+
return parsedLink1.hostname === parsedLink2.hostname;
|
|
864
|
+
};
|
|
865
|
+
export const retryFunction = async (func, maxAttempt) => {
|
|
866
|
+
let attemptCount = 0;
|
|
867
|
+
while (attemptCount < maxAttempt) {
|
|
868
|
+
attemptCount += 1;
|
|
869
|
+
try {
|
|
870
|
+
// eslint-disable-next-line no-await-in-loop
|
|
871
|
+
const result = await func();
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
catch (error) {
|
|
875
|
+
// do nothing, just retry
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
throw new Error('Maximum number of attempts reached');
|
|
879
|
+
};
|
|
880
|
+
/**
|
|
881
|
+
* Determines which WCAG criteria might appear in the "needsReview" category
|
|
882
|
+
* based on axe-core's rule configuration.
|
|
883
|
+
*
|
|
884
|
+
* This dynamically analyzes the rules that might produce "incomplete" results which
|
|
885
|
+
* get categorized as "needsReview" during scans.
|
|
886
|
+
*
|
|
887
|
+
* @param enableWcagAaa Whether to include WCAG AAA criteria
|
|
888
|
+
* @param disableOobee Whether to disable custom Oobee rules
|
|
889
|
+
* @returns A map of WCAG criteria IDs to whether they may produce needsReview results
|
|
890
|
+
*/
|
|
891
|
+
export const getPotentialNeedsReviewWcagCriteria = async (enableWcagAaa = true, disableOobee = false) => {
|
|
892
|
+
// Reuse configuration setup from other functions
|
|
893
|
+
const axeConfig = getAxeConfiguration({
|
|
894
|
+
enableWcagAaa,
|
|
895
|
+
gradingReadabilityFlag: '',
|
|
896
|
+
disableOobee,
|
|
897
|
+
});
|
|
898
|
+
// Configure axe-core with our settings
|
|
899
|
+
axe.configure(axeConfig);
|
|
900
|
+
// Get all rules from axe-core
|
|
901
|
+
const allRules = axe.getRules();
|
|
902
|
+
// Set to store rule IDs that might produce incomplete results
|
|
903
|
+
const rulesLikelyToProduceIncomplete = new Set();
|
|
904
|
+
// Dynamically analyze each rule and its checks to determine if it might produce incomplete results
|
|
905
|
+
for (const rule of allRules) {
|
|
906
|
+
try {
|
|
907
|
+
// Skip disabled rules
|
|
908
|
+
const customRule = axeConfig.rules.find(r => r.id === rule.ruleId);
|
|
909
|
+
if (customRule && customRule.enabled === false)
|
|
910
|
+
continue;
|
|
911
|
+
// Skip frame-tested rule as it's handled specially
|
|
912
|
+
if (rule.ruleId === 'frame-tested')
|
|
913
|
+
continue;
|
|
914
|
+
// Get the rule object from axe-core's internal data
|
|
915
|
+
const ruleObj = axe._audit?.rules?.find(r => r.id === rule.ruleId);
|
|
916
|
+
if (!ruleObj)
|
|
917
|
+
continue;
|
|
918
|
+
// For each check in the rule, determine if it might produce an "incomplete" result
|
|
919
|
+
const checks = [
|
|
920
|
+
...(ruleObj.any || []),
|
|
921
|
+
...(ruleObj.all || []),
|
|
922
|
+
...(ruleObj.none || [])
|
|
923
|
+
];
|
|
924
|
+
// Get check details from axe-core's internal data
|
|
925
|
+
for (const checkId of checks) {
|
|
926
|
+
const check = axe._audit?.checks?.[checkId];
|
|
927
|
+
if (!check)
|
|
928
|
+
continue;
|
|
929
|
+
// A check can produce incomplete results if:
|
|
930
|
+
// 1. It has an "incomplete" message
|
|
931
|
+
// 2. Its evaluate function explicitly returns undefined
|
|
932
|
+
// 3. It is known to need human verification (accessibility issues that are context-dependent)
|
|
933
|
+
const hasIncompleteMessage = check.messages && 'incomplete' in check.messages;
|
|
934
|
+
// Many checks are implemented as strings that are later evaluated to functions
|
|
935
|
+
const evaluateCode = check.evaluate ? check.evaluate.toString() : '';
|
|
936
|
+
const explicitlyReturnsUndefined = evaluateCode.includes('return undefined') ||
|
|
937
|
+
evaluateCode.includes('return;');
|
|
938
|
+
// Some checks use specific patterns that indicate potential for incomplete results
|
|
939
|
+
const indicatesManualVerification = evaluateCode.includes('return undefined') ||
|
|
940
|
+
evaluateCode.includes('this.data(') ||
|
|
941
|
+
evaluateCode.includes('options.reviewOnFail') ||
|
|
942
|
+
evaluateCode.includes('incomplete') ||
|
|
943
|
+
(check.metadata && check.metadata.incomplete === true);
|
|
944
|
+
if (hasIncompleteMessage || explicitlyReturnsUndefined || indicatesManualVerification) {
|
|
945
|
+
rulesLikelyToProduceIncomplete.add(rule.ruleId);
|
|
946
|
+
break; // One check is enough to mark the rule
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// Also check rule-level metadata for indicators of potential incomplete results
|
|
950
|
+
if (ruleObj.metadata) {
|
|
951
|
+
if (ruleObj.metadata.incomplete === true ||
|
|
952
|
+
(ruleObj.metadata.messages && 'incomplete' in ruleObj.metadata.messages)) {
|
|
953
|
+
rulesLikelyToProduceIncomplete.add(rule.ruleId);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (e) {
|
|
958
|
+
// Silently continue if we encounter errors analyzing a rule
|
|
959
|
+
// This is a safeguard against unexpected changes in axe-core's internal structure
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Also check custom Oobee rules if they're enabled
|
|
963
|
+
if (!disableOobee) {
|
|
964
|
+
for (const rule of axeConfig.rules || []) {
|
|
965
|
+
if (!rule.enabled)
|
|
966
|
+
continue;
|
|
967
|
+
// Check if the rule's metadata indicates it might produce incomplete results
|
|
968
|
+
try {
|
|
969
|
+
const hasIncompleteMessage = (rule?.metadata?.messages?.incomplete !== undefined) ||
|
|
970
|
+
(axeConfig.checks || []).some(check => check.id === rule.id &&
|
|
971
|
+
(check.metadata?.messages?.incomplete !== undefined));
|
|
972
|
+
if (hasIncompleteMessage) {
|
|
973
|
+
rulesLikelyToProduceIncomplete.add(rule.id);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
catch (e) {
|
|
977
|
+
// Continue if we encounter errors
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// Map from WCAG criteria IDs to whether they might produce needsReview results
|
|
982
|
+
const potentialNeedsReviewCriteria = {};
|
|
983
|
+
// Process each rule to map to WCAG criteria
|
|
984
|
+
for (const rule of allRules) {
|
|
985
|
+
if (rule.ruleId === 'frame-tested')
|
|
986
|
+
continue;
|
|
987
|
+
const tags = rule.tags || [];
|
|
988
|
+
if (tags.includes('experimental') || tags.includes('deprecated'))
|
|
989
|
+
continue;
|
|
990
|
+
// Map rule to WCAG criteria
|
|
991
|
+
for (const tag of tags) {
|
|
992
|
+
if (/^wcag\d+$/.test(tag)) {
|
|
993
|
+
const mightNeedReview = rulesLikelyToProduceIncomplete.has(rule.ruleId);
|
|
994
|
+
// If we haven't seen this criterion before or we're updating it to true
|
|
995
|
+
if (mightNeedReview || !potentialNeedsReviewCriteria[tag]) {
|
|
996
|
+
potentialNeedsReviewCriteria[tag] = mightNeedReview;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return potentialNeedsReviewCriteria;
|
|
1002
|
+
};
|
|
1003
|
+
/**
|
|
1004
|
+
* Categorizes a WCAG criterion into one of: "mustFix", "goodToFix", or "needsReview"
|
|
1005
|
+
* for use in Sentry reporting
|
|
1006
|
+
*
|
|
1007
|
+
* @param wcagId The WCAG criterion ID (e.g., "wcag144")
|
|
1008
|
+
* @param enableWcagAaa Whether WCAG AAA criteria are enabled
|
|
1009
|
+
* @param disableOobee Whether Oobee custom rules are disabled
|
|
1010
|
+
* @returns The category: "mustFix", "goodToFix", or "needsReview"
|
|
1011
|
+
*/
|
|
1012
|
+
export const categorizeWcagCriterion = async (wcagId, enableWcagAaa = true, disableOobee = false) => {
|
|
1013
|
+
// First check if this criterion might produce "needsReview" results
|
|
1014
|
+
const needsReviewMap = await getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee);
|
|
1015
|
+
if (needsReviewMap[wcagId]) {
|
|
1016
|
+
return 'needsReview';
|
|
1017
|
+
}
|
|
1018
|
+
// Get the WCAG criteria map to check the level
|
|
1019
|
+
const wcagCriteriaMap = await getWcagCriteriaMap(enableWcagAaa, disableOobee);
|
|
1020
|
+
const criterionInfo = wcagCriteriaMap[wcagId];
|
|
1021
|
+
if (!criterionInfo) {
|
|
1022
|
+
// If we can't find info, default to mustFix for safety
|
|
1023
|
+
return 'mustFix';
|
|
1024
|
+
}
|
|
1025
|
+
// Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix)
|
|
1026
|
+
if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') {
|
|
1027
|
+
return 'mustFix';
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
return 'goodToFix';
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
/**
|
|
1034
|
+
* Batch categorizes multiple WCAG criteria for Sentry reporting
|
|
1035
|
+
*
|
|
1036
|
+
* @param wcagIds Array of WCAG criterion IDs (e.g., ["wcag144", "wcag143"])
|
|
1037
|
+
* @param enableWcagAaa Whether WCAG AAA criteria are enabled
|
|
1038
|
+
* @param disableOobee Whether Oobee custom rules are disabled
|
|
1039
|
+
* @returns Object mapping each criterion to its category
|
|
1040
|
+
*/
|
|
1041
|
+
export const categorizeWcagCriteria = async (wcagIds, enableWcagAaa = true, disableOobee = false) => {
|
|
1042
|
+
// Get both maps once to avoid repeated expensive calls
|
|
1043
|
+
const [needsReviewMap, wcagCriteriaMap] = await Promise.all([
|
|
1044
|
+
getPotentialNeedsReviewWcagCriteria(enableWcagAaa, disableOobee),
|
|
1045
|
+
getWcagCriteriaMap(enableWcagAaa, disableOobee)
|
|
1046
|
+
]);
|
|
1047
|
+
const result = {};
|
|
1048
|
+
wcagIds.forEach(wcagId => {
|
|
1049
|
+
// First check if this criterion might produce "needsReview" results
|
|
1050
|
+
if (needsReviewMap[wcagId]) {
|
|
1051
|
+
result[wcagId] = 'needsReview';
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
// Get criterion info
|
|
1055
|
+
const criterionInfo = wcagCriteriaMap[wcagId];
|
|
1056
|
+
if (!criterionInfo) {
|
|
1057
|
+
// If we can't find info, default to mustFix for safety
|
|
1058
|
+
result[wcagId] = 'mustFix';
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
// Check if it's a level A or AA criterion (mustFix) or AAA (goodToFix)
|
|
1062
|
+
if (criterionInfo.level === 'a' || criterionInfo.level === 'aa') {
|
|
1063
|
+
result[wcagId] = 'mustFix';
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
result[wcagId] = 'goodToFix';
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
return result;
|
|
1070
|
+
};
|