@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.
Files changed (137) hide show
  1. package/.github/workflows/publish.yml +8 -1
  2. package/INTEGRATION.md +50 -3
  3. package/dist/cli.js +252 -0
  4. package/dist/combine.js +221 -0
  5. package/dist/constants/cliFunctions.js +306 -0
  6. package/dist/constants/common.js +1669 -0
  7. package/dist/constants/constants.js +913 -0
  8. package/dist/constants/errorMeta.json +319 -0
  9. package/dist/constants/itemTypeDescription.js +7 -0
  10. package/dist/constants/oobeeAi.js +121 -0
  11. package/dist/constants/questions.js +151 -0
  12. package/dist/constants/sampleData.js +176 -0
  13. package/dist/crawlers/commonCrawlerFunc.js +428 -0
  14. package/dist/crawlers/crawlDomain.js +613 -0
  15. package/dist/crawlers/crawlIntelligentSitemap.js +135 -0
  16. package/dist/crawlers/crawlLocalFile.js +151 -0
  17. package/dist/crawlers/crawlSitemap.js +303 -0
  18. package/dist/crawlers/custom/escapeCssSelector.js +10 -0
  19. package/dist/crawlers/custom/evaluateAltText.js +11 -0
  20. package/dist/crawlers/custom/extractAndGradeText.js +44 -0
  21. package/dist/crawlers/custom/extractText.js +27 -0
  22. package/dist/crawlers/custom/findElementByCssSelector.js +36 -0
  23. package/dist/crawlers/custom/flagUnlabelledClickableElements.js +963 -0
  24. package/dist/crawlers/custom/framesCheck.js +37 -0
  25. package/dist/crawlers/custom/getAxeConfiguration.js +111 -0
  26. package/dist/crawlers/custom/gradeReadability.js +23 -0
  27. package/dist/crawlers/custom/utils.js +1024 -0
  28. package/dist/crawlers/custom/xPathToCss.js +147 -0
  29. package/dist/crawlers/guards/urlGuard.js +71 -0
  30. package/dist/crawlers/pdfScanFunc.js +276 -0
  31. package/dist/crawlers/runCustom.js +89 -0
  32. package/dist/exclusions.txt +7 -0
  33. package/dist/generateHtmlReport.js +144 -0
  34. package/dist/index.js +62 -0
  35. package/dist/logs.js +84 -0
  36. package/dist/mergeAxeResults.js +1588 -0
  37. package/dist/npmIndex.js +640 -0
  38. package/dist/proxyService.js +360 -0
  39. package/dist/runGenerateJustHtmlReport.js +16 -0
  40. package/dist/screenshotFunc/htmlScreenshotFunc.js +355 -0
  41. package/dist/screenshotFunc/pdfScreenshotFunc.js +645 -0
  42. package/dist/services/s3Uploader.js +127 -0
  43. package/dist/static/ejs/partials/components/allIssues/AllIssues.ejs +9 -0
  44. package/dist/static/ejs/partials/components/allIssues/CategoryBadges.ejs +82 -0
  45. package/dist/static/ejs/partials/components/allIssues/FilterBar.ejs +33 -0
  46. package/dist/static/ejs/partials/components/allIssues/IssuesTable.ejs +41 -0
  47. package/dist/static/ejs/partials/components/header/SiteInfo.ejs +119 -0
  48. package/dist/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +15 -0
  49. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +44 -0
  50. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +142 -0
  51. package/dist/static/ejs/partials/components/prioritiseIssues/IssueDetailCard.ejs +36 -0
  52. package/dist/static/ejs/partials/components/prioritiseIssues/PrioritiseIssues.ejs +47 -0
  53. package/dist/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +196 -0
  54. package/dist/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +48 -0
  55. package/dist/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
  56. package/dist/static/ejs/partials/components/shared/InfoAlert.ejs +3 -0
  57. package/dist/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
  58. package/dist/static/ejs/partials/components/summaryScanResults.ejs +16 -0
  59. package/dist/static/ejs/partials/components/summaryTable.ejs +20 -0
  60. package/dist/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
  61. package/dist/static/ejs/partials/components/topTen.ejs +6 -0
  62. package/dist/static/ejs/partials/components/wcagCompliance/FailedCriteria.ejs +47 -0
  63. package/dist/static/ejs/partials/components/wcagCompliance/WcagCompliance.ejs +16 -0
  64. package/dist/static/ejs/partials/components/wcagCompliance/WcagGaugeBar.ejs +16 -0
  65. package/dist/static/ejs/partials/components/wcagCoverageDetails.ejs +18 -0
  66. package/dist/static/ejs/partials/footer.ejs +24 -0
  67. package/dist/static/ejs/partials/header.ejs +14 -0
  68. package/dist/static/ejs/partials/main.ejs +29 -0
  69. package/dist/static/ejs/partials/scripts/allIssues/AllIssues.ejs +376 -0
  70. package/dist/static/ejs/partials/scripts/bootstrap.ejs +8 -0
  71. package/dist/static/ejs/partials/scripts/categorySummary.ejs +141 -0
  72. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
  73. package/dist/static/ejs/partials/scripts/header/SiteInfo.ejs +44 -0
  74. package/dist/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +51 -0
  75. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +127 -0
  76. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanDetails.ejs +60 -0
  77. package/dist/static/ejs/partials/scripts/highlightjs.ejs +335 -0
  78. package/dist/static/ejs/partials/scripts/popper.ejs +7 -0
  79. package/dist/static/ejs/partials/scripts/prioritiseIssues/IssueDetailCard.ejs +137 -0
  80. package/dist/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +214 -0
  81. package/dist/static/ejs/partials/scripts/prioritiseIssues/wcagSvgMap.ejs +861 -0
  82. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +957 -0
  83. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +353 -0
  84. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +468 -0
  85. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +306 -0
  86. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +483 -0
  87. package/dist/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +35 -0
  88. package/dist/static/ejs/partials/scripts/screenshotLightbox.ejs +75 -0
  89. package/dist/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
  90. package/dist/static/ejs/partials/scripts/summaryTable.ejs +78 -0
  91. package/dist/static/ejs/partials/scripts/topTen.ejs +61 -0
  92. package/dist/static/ejs/partials/scripts/utils.ejs +453 -0
  93. package/dist/static/ejs/partials/scripts/wcagCompliance/FailedCriteria.ejs +103 -0
  94. package/dist/static/ejs/partials/scripts/wcagCompliance/WcagGaugeBar.ejs +47 -0
  95. package/dist/static/ejs/partials/scripts/wcagCompliance.ejs +15 -0
  96. package/dist/static/ejs/partials/scripts/wcagCoverageDetails.ejs +75 -0
  97. package/dist/static/ejs/partials/styles/allIssues/AllIssues.ejs +384 -0
  98. package/dist/static/ejs/partials/styles/bootstrap.ejs +12391 -0
  99. package/dist/static/ejs/partials/styles/header/SiteInfo.ejs +121 -0
  100. package/dist/static/ejs/partials/styles/header/aboutScanModal/AboutScanModal.ejs +82 -0
  101. package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanConfiguration.ejs +50 -0
  102. package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +149 -0
  103. package/dist/static/ejs/partials/styles/header.ejs +7 -0
  104. package/dist/static/ejs/partials/styles/highlightjs.ejs +54 -0
  105. package/dist/static/ejs/partials/styles/prioritiseIssues/IssueDetailCard.ejs +141 -0
  106. package/dist/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +204 -0
  107. package/dist/static/ejs/partials/styles/ruleModal/ruleOffcanvas.ejs +456 -0
  108. package/dist/static/ejs/partials/styles/scannedPagesSegmentedTabs.ejs +46 -0
  109. package/dist/static/ejs/partials/styles/shared/InfoAlert.ejs +12 -0
  110. package/dist/static/ejs/partials/styles/styles.ejs +1607 -0
  111. package/dist/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
  112. package/dist/static/ejs/partials/styles/topTenCard.ejs +44 -0
  113. package/dist/static/ejs/partials/styles/wcagCompliance/FailedCriteria.ejs +59 -0
  114. package/dist/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +62 -0
  115. package/dist/static/ejs/partials/styles/wcagCompliance.ejs +36 -0
  116. package/dist/static/ejs/partials/styles/wcagCoverageDetails.ejs +33 -0
  117. package/dist/static/ejs/partials/summaryHeader.ejs +70 -0
  118. package/dist/static/ejs/partials/summaryMain.ejs +49 -0
  119. package/dist/static/ejs/report.ejs +226 -0
  120. package/dist/static/ejs/summary.ejs +47 -0
  121. package/dist/types/types.js +1 -0
  122. package/dist/utils.js +1070 -0
  123. package/examples/oobee-cypress-integration-js/cypress/support/e2e.js +36 -6
  124. package/examples/oobee-cypress-integration-js/cypress.config.js +45 -1
  125. package/examples/oobee-cypress-integration-ts/cypress.config.ts +47 -1
  126. package/examples/oobee-cypress-integration-ts/src/cypress/support/e2e.ts +36 -6
  127. package/examples/oobee-playwright-integration-js/oobee-playwright-demo.js +2 -1
  128. package/examples/oobee-playwright-integration-ts/src/oobee-playwright-demo.ts +2 -1
  129. package/examples/oobee-scan-html-demo.js +51 -0
  130. package/examples/oobee-scan-page-demo.js +40 -0
  131. package/package.json +9 -3
  132. package/src/constants/common.ts +2 -2
  133. package/src/constants/constants.ts +3 -1
  134. package/src/crawlers/crawlDomain.ts +1 -0
  135. package/src/crawlers/runCustom.ts +0 -1
  136. package/src/mergeAxeResults.ts +43 -22
  137. 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
+ };