@govtechsg/oobee 0.10.76 → 0.10.77

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 (134) hide show
  1. package/.github/workflows/publish.yml +8 -1
  2. package/INTEGRATION.md +7 -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 +1571 -0
  37. package/dist/npmIndex.js +429 -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/package.json +9 -3
  130. package/src/constants/common.ts +2 -2
  131. package/src/constants/constants.ts +3 -1
  132. package/src/crawlers/crawlDomain.ts +1 -0
  133. package/src/crawlers/runCustom.ts +0 -1
  134. package/src/npmIndex.ts +42 -24
@@ -0,0 +1,1571 @@
1
+ import fs, { ensureDirSync } from 'fs-extra';
2
+ import printMessage from 'print-message';
3
+ import path from 'path';
4
+ import ejs from 'ejs';
5
+ import { fileURLToPath } from 'url';
6
+ import { createWriteStream } from 'fs';
7
+ import { AsyncParser } from '@json2csv/node';
8
+ import zlib from 'zlib';
9
+ import { Base64Encode } from 'base64-stream';
10
+ import { pipeline } from 'stream/promises';
11
+ // @ts-ignore
12
+ import * as Sentry from '@sentry/node';
13
+ import constants, { BrowserTypes, ScannerTypes, sentryConfig, setSentryUser, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
14
+ import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
15
+ import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage, getWcagCriteriaMap, categorizeWcagCriteria, getUserDataTxt, register, } from './utils.js';
16
+ import { consoleLogger } from './logs.js';
17
+ import itemTypeDescription from './constants/itemTypeDescription.js';
18
+ import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js';
19
+ const filename = fileURLToPath(import.meta.url);
20
+ const dirname = path.dirname(filename);
21
+ const BUFFER_LIMIT = 100 * 1024 * 1024; // 100MB size
22
+ const extractFileNames = async (directory) => {
23
+ ensureDirSync(directory);
24
+ return fs
25
+ .readdir(directory)
26
+ .then(allFiles => allFiles.filter(file => path.extname(file).toLowerCase() === '.json'))
27
+ .catch(readdirError => {
28
+ consoleLogger.info('An error has occurred when retrieving files, please try again.');
29
+ throw readdirError;
30
+ });
31
+ };
32
+ const parseContentToJson = async (rPath) => fs
33
+ .readFile(rPath, 'utf8')
34
+ .then(content => JSON.parse(content))
35
+ .catch(parseError => {
36
+ consoleLogger.info('An error has occurred when parsing the content, please try again.');
37
+ });
38
+ const writeCsv = async (allIssues, storagePath) => {
39
+ const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
40
+ const formatPageViolation = pageNum => {
41
+ if (pageNum < 0)
42
+ return 'Document';
43
+ return `Page ${pageNum}`;
44
+ };
45
+ // transform allIssues into the form:
46
+ // [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
47
+ const getRulesByCategory = (allIssues) => {
48
+ return Object.entries(allIssues.items)
49
+ .filter(([category]) => category !== 'passed')
50
+ .reduce((prev, [category, value]) => {
51
+ const rulesEntries = Object.entries(value.rules);
52
+ rulesEntries.forEach(([, ruleInfo]) => {
53
+ prev.push([category, ruleInfo]);
54
+ });
55
+ return prev;
56
+ }, [])
57
+ .sort((a, b) => {
58
+ // sort rules according to severity, then ruleId
59
+ const compareCategory = -a[0].localeCompare(b[0]);
60
+ return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
61
+ });
62
+ };
63
+ const flattenRule = catAndRule => {
64
+ const [severity, rule] = catAndRule;
65
+ const results = [];
66
+ const { rule: issueId, description: issueDescription, axeImpact, conformance, pagesAffected, helpUrl: learnMore, } = rule;
67
+ // format clauses as a string
68
+ const wcagConformance = conformance.join(',');
69
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
70
+ pagesAffected.forEach(affectedPage => {
71
+ const { url, items } = affectedPage;
72
+ items.forEach(item => {
73
+ const { html, page, message, xpath } = item;
74
+ const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
75
+ const violation = html || formatPageViolation(page); // page is a number, not a string
76
+ const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
77
+ results.push({
78
+ customFlowLabel: allIssues.customFlowLabel || '',
79
+ deviceChosen: allIssues.deviceChosen || '',
80
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
81
+ severity: severity || '',
82
+ issueId: issueId || '',
83
+ issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
84
+ wcagConformance: wcagConformance || '',
85
+ url: url || '',
86
+ pageTitle: affectedPage.pageTitle || 'No page title',
87
+ context: context || '',
88
+ howToFix: howToFix || '',
89
+ axeImpact: axeImpact || '',
90
+ xpath: xpath || '',
91
+ learnMore: learnMore || '',
92
+ });
93
+ });
94
+ });
95
+ if (results.length === 0)
96
+ return {};
97
+ return results;
98
+ };
99
+ const opts = {
100
+ transforms: [getRulesByCategory, flattenRule],
101
+ fields: [
102
+ 'customFlowLabel',
103
+ 'deviceChosen',
104
+ 'scanCompletedAt',
105
+ 'severity',
106
+ 'issueId',
107
+ 'issueDescription',
108
+ 'wcagConformance',
109
+ 'url',
110
+ 'pageTitle',
111
+ 'context',
112
+ 'howToFix',
113
+ 'axeImpact',
114
+ 'xpath',
115
+ 'learnMore',
116
+ ],
117
+ includeEmptyRows: true,
118
+ };
119
+ // Create the parse stream (it's asynchronous)
120
+ const parser = new AsyncParser(opts);
121
+ const parseStream = parser.parse(allIssues);
122
+ // Pipe JSON2CSV output into the file, but don't end automatically
123
+ parseStream.pipe(csvOutput, { end: false });
124
+ // Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
125
+ parseStream.on('end', () => {
126
+ if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
127
+ csvOutput.write('\n');
128
+ allIssues.pagesNotScanned.forEach(page => {
129
+ const skippedPage = {
130
+ customFlowLabel: allIssues.customFlowLabel || '',
131
+ deviceChosen: allIssues.deviceChosen || '',
132
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
133
+ severity: 'error',
134
+ issueId: 'error-pages-skipped',
135
+ issueDescription: page.metadata
136
+ ? page.metadata
137
+ : 'An unknown error caused the page to be skipped',
138
+ wcagConformance: '',
139
+ url: page.url || page || '',
140
+ pageTitle: 'Error',
141
+ context: '',
142
+ howToFix: '',
143
+ axeImpact: '',
144
+ xpath: '',
145
+ learnMore: '',
146
+ };
147
+ csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
148
+ });
149
+ }
150
+ // Now close the CSV file
151
+ csvOutput.end();
152
+ });
153
+ parseStream.on('error', err => {
154
+ console.error('Error parsing CSV:', err);
155
+ csvOutput.end();
156
+ });
157
+ };
158
+ const compileHtmlWithEJS = async (allIssues, storagePath, htmlFilename = 'report') => {
159
+ const htmlFilePath = `${path.join(storagePath, htmlFilename)}.html`;
160
+ const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/report.ejs'), 'utf-8');
161
+ const template = ejs.compile(ejsString, {
162
+ filename: path.join(dirname, './static/ejs/report.ejs'),
163
+ });
164
+ const html = template({ ...allIssues, storagePath: JSON.stringify(storagePath) });
165
+ await fs.writeFile(htmlFilePath, html);
166
+ let htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });
167
+ const headIndex = htmlContent.indexOf('</head>');
168
+ const injectScript = `
169
+ <script>
170
+ // IMPORTANT! DO NOT REMOVE ME: Decode the encoded data
171
+
172
+ </script>
173
+ `;
174
+ if (headIndex !== -1) {
175
+ htmlContent = htmlContent.slice(0, headIndex) + injectScript + htmlContent.slice(headIndex);
176
+ }
177
+ else {
178
+ htmlContent += injectScript;
179
+ }
180
+ await fs.writeFile(htmlFilePath, htmlContent);
181
+ return htmlFilePath;
182
+ };
183
+ const splitHtmlAndCreateFiles = async (htmlFilePath, storagePath) => {
184
+ try {
185
+ const htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });
186
+ const splitMarker = '// IMPORTANT! DO NOT REMOVE ME: Decode the encoded data';
187
+ const splitIndex = htmlContent.indexOf(splitMarker);
188
+ if (splitIndex === -1) {
189
+ throw new Error('Marker comment not found in the HTML file.');
190
+ }
191
+ const topContent = `${htmlContent.slice(0, splitIndex + splitMarker.length)}\n\n`;
192
+ const bottomContent = htmlContent.slice(splitIndex + splitMarker.length);
193
+ const topFilePath = path.join(storagePath, 'report-partial-top.htm.txt');
194
+ const bottomFilePath = path.join(storagePath, 'report-partial-bottom.htm.txt');
195
+ await fs.writeFile(topFilePath, topContent, { encoding: 'utf8' });
196
+ await fs.writeFile(bottomFilePath, bottomContent, { encoding: 'utf8' });
197
+ await fs.unlink(htmlFilePath);
198
+ return { topFilePath, bottomFilePath };
199
+ }
200
+ catch (error) {
201
+ console.error('Error splitting HTML and creating files:', error);
202
+ }
203
+ };
204
+ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDetailsFilePath, scanItemsFilePath) => {
205
+ const htmlFilePath = await compileHtmlWithEJS(allIssues, storagePath, htmlFilename);
206
+ const { topFilePath, bottomFilePath } = await splitHtmlAndCreateFiles(htmlFilePath, storagePath);
207
+ const prefixData = fs.readFileSync(path.join(storagePath, 'report-partial-top.htm.txt'), 'utf-8');
208
+ const suffixData = fs.readFileSync(path.join(storagePath, 'report-partial-bottom.htm.txt'), 'utf-8');
209
+ const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
210
+ encoding: 'utf8',
211
+ highWaterMark: BUFFER_LIMIT,
212
+ });
213
+ const scanItemsReadStream = fs.createReadStream(scanItemsFilePath, {
214
+ encoding: 'utf8',
215
+ highWaterMark: BUFFER_LIMIT,
216
+ });
217
+ const outputFilePath = `${storagePath}/${htmlFilename}.html`;
218
+ const outputStream = fs.createWriteStream(outputFilePath, { flags: 'a' });
219
+ const cleanupFiles = async () => {
220
+ try {
221
+ await Promise.all([fs.promises.unlink(topFilePath), fs.promises.unlink(bottomFilePath)]);
222
+ }
223
+ catch (err) {
224
+ console.error('Error cleaning up temporary files:', err);
225
+ }
226
+ };
227
+ outputStream.write(prefixData);
228
+ // For Proxied AI environments only
229
+ outputStream.write(`let proxyUrl = "${process.env.PROXY_API_BASE_URL || ""}"\n`);
230
+ // Initialize GenAI feature flag
231
+ outputStream.write(`
232
+ // Fetch GenAI feature flag from backend
233
+ window.oobeeGenAiFeatureEnabled = false;
234
+ if (proxyUrl !== "" && proxyUrl !== undefined && proxyUrl !== null) {
235
+ (async () => {
236
+ try {
237
+ const featuresUrl = proxyUrl + '/api/ai/features';
238
+ const response = await fetch(featuresUrl, {
239
+ method: 'GET',
240
+ headers: { 'Accept': 'application/json' }
241
+ });
242
+ if (response.ok) {
243
+ const features = await response.json();
244
+ window.oobeeGenAiFeatureEnabled = features.genai_ui_enabled || false;
245
+ console.log('GenAI UI feature flag:', window.oobeeGenAiFeatureEnabled);
246
+ } else {
247
+ console.warn('Failed to fetch GenAI feature flag:', response.status);
248
+ }
249
+ } catch (error) {
250
+ console.warn('Error fetching GenAI feature flag:', error);
251
+ }
252
+ })();
253
+ } else {
254
+ console.warn('Skipping fetch GenAI feature as it is local report');
255
+ }
256
+ \n`);
257
+ // outputStream.write("scanData = decompressJsonObject('");
258
+ outputStream.write("let scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse('");
259
+ scanDetailsReadStream.pipe(outputStream, { end: false });
260
+ scanDetailsReadStream.on('end', () => {
261
+ // outputStream.write("')\n\n");
262
+ outputStream.write("'); })();\n\n");
263
+ // outputStream.write("(scanItems = decompressJsonObject('");
264
+ outputStream.write("let scanItemsPromise = (async () => { console.log('Loading scanItems...'); scanItems = await decodeUnzipParse('");
265
+ scanItemsReadStream.pipe(outputStream, { end: false });
266
+ });
267
+ scanDetailsReadStream.on('error', err => {
268
+ console.error('Read stream error:', err);
269
+ outputStream.end();
270
+ });
271
+ scanItemsReadStream.on('end', () => {
272
+ // outputStream.write("')\n\n");
273
+ outputStream.write("'); })();\n\n");
274
+ outputStream.write(suffixData);
275
+ outputStream.end();
276
+ });
277
+ scanItemsReadStream.on('error', err => {
278
+ console.error('Read stream error:', err);
279
+ outputStream.end();
280
+ });
281
+ consoleLogger.info('Content appended successfully.');
282
+ await cleanupFiles();
283
+ outputStream.on('error', err => {
284
+ consoleLogger.error('Error writing to output file:', err);
285
+ });
286
+ };
287
+ const writeSummaryHTML = async (allIssues, storagePath, htmlFilename = 'summary') => {
288
+ const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/summary.ejs'), 'utf-8');
289
+ const template = ejs.compile(ejsString, {
290
+ filename: path.join(dirname, './static/ejs/summary.ejs'),
291
+ });
292
+ const html = template(allIssues);
293
+ fs.writeFileSync(`${storagePath}/${htmlFilename}.html`, html);
294
+ };
295
+ const writeSitemap = async (pagesScanned, storagePath) => {
296
+ const sitemapPath = path.join(storagePath, 'sitemap.txt');
297
+ const content = pagesScanned.map(p => p.url).join('\n');
298
+ await fs.writeFile(sitemapPath, content, { encoding: 'utf-8' });
299
+ consoleLogger.info(`Sitemap written to ${sitemapPath}`);
300
+ };
301
+ const cleanUpJsonFiles = async (filesToDelete) => {
302
+ consoleLogger.info('Cleaning up JSON files...');
303
+ filesToDelete.forEach(file => {
304
+ fs.unlinkSync(file);
305
+ consoleLogger.info(`Deleted ${file}`);
306
+ });
307
+ };
308
+ function* serializeObject(obj, depth = 0, indent = ' ') {
309
+ const currentIndent = indent.repeat(depth);
310
+ const nextIndent = indent.repeat(depth + 1);
311
+ if (obj instanceof Date) {
312
+ yield JSON.stringify(obj.toISOString());
313
+ return;
314
+ }
315
+ if (Array.isArray(obj)) {
316
+ yield '[\n';
317
+ for (let i = 0; i < obj.length; i++) {
318
+ if (i > 0)
319
+ yield ',\n';
320
+ yield nextIndent;
321
+ yield* serializeObject(obj[i], depth + 1, indent);
322
+ }
323
+ yield `\n${currentIndent}]`;
324
+ return;
325
+ }
326
+ if (obj !== null && typeof obj === 'object') {
327
+ yield '{\n';
328
+ const keys = Object.keys(obj);
329
+ for (let i = 0; i < keys.length; i++) {
330
+ const key = keys[i];
331
+ if (i > 0)
332
+ yield ',\n';
333
+ yield `${nextIndent}${JSON.stringify(key)}: `;
334
+ yield* serializeObject(obj[key], depth + 1, indent);
335
+ }
336
+ yield `\n${currentIndent}}`;
337
+ return;
338
+ }
339
+ if (obj === null || typeof obj === 'function' || typeof obj === 'undefined') {
340
+ yield 'null';
341
+ return;
342
+ }
343
+ yield JSON.stringify(obj);
344
+ }
345
+ function writeLargeJsonToFile(obj, filePath) {
346
+ return new Promise((resolve, reject) => {
347
+ const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
348
+ writeStream.on('error', error => {
349
+ consoleLogger.error('Stream error:', error);
350
+ reject(error);
351
+ });
352
+ writeStream.on('finish', () => {
353
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
354
+ resolve(true);
355
+ });
356
+ const generator = serializeObject(obj);
357
+ function write() {
358
+ let next;
359
+ while (!(next = generator.next()).done) {
360
+ if (!writeStream.write(next.value)) {
361
+ writeStream.once('drain', write);
362
+ return;
363
+ }
364
+ }
365
+ writeStream.end();
366
+ }
367
+ write();
368
+ });
369
+ }
370
+ const writeLargeScanItemsJsonToFile = async (obj, filePath) => {
371
+ return new Promise((resolve, reject) => {
372
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
373
+ const writeQueue = [];
374
+ let isWriting = false;
375
+ const processNextWrite = async () => {
376
+ if (isWriting || writeQueue.length === 0)
377
+ return;
378
+ isWriting = true;
379
+ const data = writeQueue.shift();
380
+ try {
381
+ if (!writeStream.write(data)) {
382
+ await new Promise(resolve => {
383
+ writeStream.once('drain', () => {
384
+ resolve();
385
+ });
386
+ });
387
+ }
388
+ }
389
+ catch (error) {
390
+ writeStream.destroy(error);
391
+ return;
392
+ }
393
+ isWriting = false;
394
+ processNextWrite();
395
+ };
396
+ const queueWrite = (data) => {
397
+ writeQueue.push(data);
398
+ processNextWrite();
399
+ };
400
+ writeStream.on('error', error => {
401
+ consoleLogger.error(`Error writing object to JSON file: ${error}`);
402
+ reject(error);
403
+ });
404
+ writeStream.on('finish', () => {
405
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
406
+ resolve(true);
407
+ });
408
+ try {
409
+ queueWrite('{\n');
410
+ const keys = Object.keys(obj);
411
+ keys.forEach((key, i) => {
412
+ const value = obj[key];
413
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
414
+ queueWrite(` "${key}": ${JSON.stringify(value)}`);
415
+ }
416
+ else {
417
+ queueWrite(` "${key}": {\n`);
418
+ const { rules, ...otherProperties } = value;
419
+ // Write other properties
420
+ Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
421
+ const propValueString = propValue === null ||
422
+ typeof propValue === 'function' ||
423
+ typeof propValue === 'undefined'
424
+ ? 'null'
425
+ : JSON.stringify(propValue);
426
+ queueWrite(` "${propKey}": ${propValueString}`);
427
+ if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
428
+ queueWrite(',\n');
429
+ }
430
+ else {
431
+ queueWrite('\n');
432
+ }
433
+ });
434
+ if (rules && Array.isArray(rules)) {
435
+ queueWrite(' "rules": [\n');
436
+ rules.forEach((rule, j) => {
437
+ queueWrite(' {\n');
438
+ const { pagesAffected, ...otherRuleProperties } = rule;
439
+ Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
440
+ const ruleValueString = ruleValue === null ||
441
+ typeof ruleValue === 'function' ||
442
+ typeof ruleValue === 'undefined'
443
+ ? 'null'
444
+ : JSON.stringify(ruleValue);
445
+ queueWrite(` "${ruleKey}": ${ruleValueString}`);
446
+ if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
447
+ queueWrite(',\n');
448
+ }
449
+ else {
450
+ queueWrite('\n');
451
+ }
452
+ });
453
+ if (pagesAffected && Array.isArray(pagesAffected)) {
454
+ queueWrite(' "pagesAffected": [\n');
455
+ pagesAffected.forEach((page, p) => {
456
+ const pageJson = JSON.stringify(page, null, 2)
457
+ .split('\n')
458
+ .map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
459
+ .join('\n');
460
+ queueWrite(pageJson);
461
+ if (p < pagesAffected.length - 1) {
462
+ queueWrite(',\n');
463
+ }
464
+ else {
465
+ queueWrite('\n');
466
+ }
467
+ });
468
+ queueWrite(' ]');
469
+ }
470
+ queueWrite('\n }');
471
+ if (j < rules.length - 1) {
472
+ queueWrite(',\n');
473
+ }
474
+ else {
475
+ queueWrite('\n');
476
+ }
477
+ });
478
+ queueWrite(' ]');
479
+ }
480
+ queueWrite('\n }');
481
+ }
482
+ if (i < keys.length - 1) {
483
+ queueWrite(',\n');
484
+ }
485
+ else {
486
+ queueWrite('\n');
487
+ }
488
+ });
489
+ queueWrite('}\n');
490
+ // Ensure all queued writes are processed before ending
491
+ const checkQueueAndEnd = () => {
492
+ if (writeQueue.length === 0 && !isWriting) {
493
+ writeStream.end();
494
+ }
495
+ else {
496
+ setTimeout(checkQueueAndEnd, 100);
497
+ }
498
+ };
499
+ checkQueueAndEnd();
500
+ }
501
+ catch (err) {
502
+ writeStream.destroy(err);
503
+ reject(err);
504
+ }
505
+ });
506
+ };
507
+ async function compressJsonFileStreaming(inputPath, outputPath) {
508
+ // Create the read and write streams
509
+ const readStream = fs.createReadStream(inputPath);
510
+ const writeStream = fs.createWriteStream(outputPath);
511
+ // Create a gzip transform stream
512
+ const gzip = zlib.createGzip();
513
+ // Create a Base64 transform stream
514
+ const base64Encode = new Base64Encode();
515
+ // Pipe the streams:
516
+ // read -> gzip -> base64 -> write
517
+ await pipeline(readStream, gzip, base64Encode, writeStream);
518
+ consoleLogger.info(`File successfully compressed and saved to ${outputPath}`);
519
+ }
520
+ const writeJsonFileAndCompressedJsonFile = async (data, storagePath, filename) => {
521
+ try {
522
+ consoleLogger.info(`Writing JSON to ${filename}.json`);
523
+ const jsonFilePath = path.join(storagePath, `${filename}.json`);
524
+ if (filename === 'scanItems') {
525
+ await writeLargeScanItemsJsonToFile(data, jsonFilePath);
526
+ }
527
+ else {
528
+ await writeLargeJsonToFile(data, jsonFilePath);
529
+ }
530
+ consoleLogger.info(`Reading ${filename}.json, gzipping and base64 encoding it into ${filename}.json.gz.b64`);
531
+ const base64FilePath = path.join(storagePath, `${filename}.json.gz.b64`);
532
+ await compressJsonFileStreaming(jsonFilePath, base64FilePath);
533
+ consoleLogger.info(`Finished compression and base64 encoding for ${filename}`);
534
+ return {
535
+ jsonFilePath,
536
+ base64FilePath,
537
+ };
538
+ }
539
+ catch (error) {
540
+ consoleLogger.error(`Error compressing and encoding ${filename}`);
541
+ throw error;
542
+ }
543
+ };
544
+ const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma) => {
545
+ const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
546
+ let isFirstChunk = true;
547
+ for await (const chunk of readStream) {
548
+ if (isFirstChunk) {
549
+ isFirstChunk = false;
550
+ writeStream.write(chunk);
551
+ }
552
+ else {
553
+ writeStream.write(chunk);
554
+ }
555
+ }
556
+ if (appendComma) {
557
+ writeStream.write(',');
558
+ }
559
+ };
560
+ const writeJsonAndBase64Files = async (allIssues, storagePath) => {
561
+ const { items, ...rest } = allIssues;
562
+ const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } = await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
563
+ const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
564
+ // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
565
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
566
+ if (items[category].rules && Array.isArray(items[category].rules)) {
567
+ items[category].rules.forEach(rule => {
568
+ rule.pagesAffectedCount = Array.isArray(rule.pagesAffected) ? rule.pagesAffected.length : 0;
569
+ });
570
+ // Sort in descending order of pagesAffectedCount
571
+ items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
572
+ }
573
+ });
574
+ // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
575
+ const scanIssuesSummary = {
576
+ // Replace rule descriptions with short descriptions from the map
577
+ mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
578
+ ...ruleInfo,
579
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
580
+ })),
581
+ goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
582
+ ...ruleInfo,
583
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
584
+ })),
585
+ needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ({
586
+ ...ruleInfo,
587
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
588
+ })),
589
+ passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ({
590
+ ...ruleInfo,
591
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
592
+ })),
593
+ };
594
+ // Write out the scanIssuesSummary JSON using the new structure
595
+ const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
596
+ // scanItemsSummary
597
+ // the below mutates the original items object, since it is expensive to clone
598
+ items.mustFix.rules.forEach(rule => {
599
+ rule.pagesAffected.forEach(page => {
600
+ page.itemsCount = page.items.length;
601
+ });
602
+ });
603
+ items.goodToFix.rules.forEach(rule => {
604
+ rule.pagesAffected.forEach(page => {
605
+ page.itemsCount = page.items.length;
606
+ });
607
+ });
608
+ items.needsReview.rules.forEach(rule => {
609
+ rule.pagesAffected.forEach(page => {
610
+ page.itemsCount = page.items.length;
611
+ });
612
+ });
613
+ items.passed.rules.forEach(rule => {
614
+ rule.pagesAffected.forEach(page => {
615
+ page.itemsCount = page.items.length;
616
+ });
617
+ });
618
+ items.mustFix.totalRuleIssues = items.mustFix.rules.length;
619
+ items.goodToFix.totalRuleIssues = items.goodToFix.rules.length;
620
+ items.needsReview.totalRuleIssues = items.needsReview.rules.length;
621
+ items.passed.totalRuleIssues = items.passed.rules.length;
622
+ const { pagesScanned, topTenPagesWithMostIssues, pagesNotScanned, wcagLinks, wcagPassPercentage, progressPercentage, issuesPercentage, totalPagesScanned, totalPagesNotScanned, topTenIssues, } = rest;
623
+ const summaryItemsMini = {
624
+ ...items,
625
+ pagesScanned,
626
+ topTenPagesWithMostIssues,
627
+ pagesNotScanned,
628
+ wcagLinks,
629
+ wcagPassPercentage,
630
+ progressPercentage,
631
+ issuesPercentage,
632
+ totalPagesScanned,
633
+ totalPagesNotScanned,
634
+ topTenIssues,
635
+ };
636
+ const { jsonFilePath: scanItemsMiniReportJsonFilePath, base64FilePath: scanItemsMiniReportBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini }, storagePath, 'scanItemsSummaryMiniReport');
637
+ const summaryItems = {
638
+ mustFix: {
639
+ totalItems: items.mustFix?.totalItems || 0,
640
+ totalRuleIssues: items.mustFix?.totalRuleIssues || 0,
641
+ },
642
+ goodToFix: {
643
+ totalItems: items.goodToFix?.totalItems || 0,
644
+ totalRuleIssues: items.goodToFix?.totalRuleIssues || 0,
645
+ },
646
+ needsReview: {
647
+ totalItems: items.needsReview?.totalItems || 0,
648
+ totalRuleIssues: items.needsReview?.totalRuleIssues || 0,
649
+ },
650
+ topTenPagesWithMostIssues,
651
+ wcagLinks,
652
+ wcagPassPercentage,
653
+ progressPercentage,
654
+ issuesPercentage,
655
+ totalPagesScanned,
656
+ totalPagesNotScanned,
657
+ topTenIssues,
658
+ };
659
+ const { jsonFilePath: scanItemsSummaryJsonFilePath, base64FilePath: scanItemsSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
660
+ const { jsonFilePath: scanPagesDetailJsonFilePath, base64FilePath: scanPagesDetailBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail }, storagePath, 'scanPagesDetail');
661
+ const { jsonFilePath: scanPagesSummaryJsonFilePath, base64FilePath: scanPagesSummaryBase64FilePath, } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary }, storagePath, 'scanPagesSummary');
662
+ return {
663
+ scanDataJsonFilePath,
664
+ scanDataBase64FilePath,
665
+ scanItemsJsonFilePath,
666
+ scanItemsBase64FilePath,
667
+ scanItemsSummaryJsonFilePath,
668
+ scanItemsSummaryBase64FilePath,
669
+ scanItemsMiniReportJsonFilePath,
670
+ scanItemsMiniReportBase64FilePath,
671
+ scanIssuesSummaryJsonFilePath,
672
+ scanIssuesSummaryBase64FilePath,
673
+ scanPagesDetailJsonFilePath,
674
+ scanPagesDetailBase64FilePath,
675
+ scanPagesSummaryJsonFilePath,
676
+ scanPagesSummaryBase64FilePath,
677
+ scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
678
+ scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
679
+ };
680
+ };
681
+ const writeScanDetailsCsv = async (scanDataFilePath, scanItemsFilePath, scanItemsSummaryFilePath, storagePath) => {
682
+ const filePath = path.join(storagePath, 'scanDetails.csv');
683
+ const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
684
+ const directoryPath = path.dirname(filePath);
685
+ if (!fs.existsSync(directoryPath)) {
686
+ fs.mkdirSync(directoryPath, { recursive: true });
687
+ }
688
+ csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
689
+ await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
690
+ await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
691
+ await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
692
+ await new Promise((resolve, reject) => {
693
+ csvWriteStream.end(resolve);
694
+ csvWriteStream.on('error', reject);
695
+ });
696
+ };
697
+ const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary', browser, userDataDirectory) => {
698
+ const htmlFilePath = `${storagePath}/${filename}.html`;
699
+ const fileDestinationPath = `${storagePath}/${filename}.pdf`;
700
+ const effectiveUserDataDirectory = process.env.CRAWLEE_HEADLESS === '1' ? userDataDirectory : '';
701
+ const context = await constants.launcher.launchPersistentContext(effectiveUserDataDirectory, {
702
+ headless: true,
703
+ ...getPlaywrightLaunchOptions(browser),
704
+ });
705
+ register(context);
706
+ const page = await context.newPage();
707
+ const data = fs.readFileSync(htmlFilePath, { encoding: 'utf-8' });
708
+ await page.setContent(data);
709
+ await page.waitForLoadState('networkidle', { timeout: 30000 });
710
+ await page.emulateMedia({ media: 'print' });
711
+ await page.pdf({
712
+ margin: { bottom: '32px' },
713
+ path: fileDestinationPath,
714
+ format: 'A4',
715
+ displayHeaderFooter: true,
716
+ footerTemplate: `
717
+ <div style="margin-top:50px;color:#26241b;font-family:Open Sans;text-align: center;width: 100%;font-weight:400">
718
+ <span style="color:#26241b;font-size: 14px;font-weight:400">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
719
+ </div>
720
+ `,
721
+ });
722
+ await page.close();
723
+ await context.close().catch(() => { });
724
+ if (pagesScanned < 2000) {
725
+ fs.unlinkSync(htmlFilePath);
726
+ }
727
+ };
728
+ // Tracking WCAG occurrences
729
+ const wcagOccurrencesMap = new Map();
730
+ // Format WCAG tag in requested format: wcag111a_Occurrences
731
+ const formatWcagTag = async (wcagId) => {
732
+ // Get dynamic WCAG criteria map
733
+ const wcagCriteriaMap = await getWcagCriteriaMap();
734
+ if (wcagCriteriaMap[wcagId]) {
735
+ const { level } = wcagCriteriaMap[wcagId];
736
+ return `${wcagId}${level}_Occurrences`;
737
+ }
738
+ return null;
739
+ };
740
+ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
741
+ const { url, pageTitle, filePath } = pageResults;
742
+ const totalIssuesInPage = new Set();
743
+ Object.keys(pageResults.mustFix.rules).forEach(k => totalIssuesInPage.add(k));
744
+ Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
745
+ Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
746
+ allIssues.topFiveMostIssues.push({
747
+ url,
748
+ pageTitle,
749
+ totalIssues: totalIssuesInPage.size,
750
+ totalOccurrences: 0,
751
+ });
752
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
753
+ if (!pageResults[category])
754
+ return;
755
+ const { totalItems, rules } = pageResults[category];
756
+ const currCategoryFromAllIssues = allIssues.items[category];
757
+ currCategoryFromAllIssues.totalItems += totalItems;
758
+ Object.keys(rules).forEach(rule => {
759
+ const { description, axeImpact, helpUrl, conformance, totalItems: count, items, } = rules[rule];
760
+ if (!(rule in currCategoryFromAllIssues.rules)) {
761
+ currCategoryFromAllIssues.rules[rule] = {
762
+ description,
763
+ axeImpact,
764
+ helpUrl,
765
+ conformance,
766
+ totalItems: 0,
767
+ // numberOfPagesAffectedAfterRedirects: 0,
768
+ pagesAffected: {},
769
+ };
770
+ }
771
+ if (category !== 'passed' && category !== 'needsReview') {
772
+ conformance
773
+ .filter(c => /wcag[0-9]{3,4}/.test(c))
774
+ .forEach(c => {
775
+ if (!allIssues.wcagViolations.includes(c)) {
776
+ allIssues.wcagViolations.push(c);
777
+ }
778
+ // Track WCAG criteria occurrences for Sentry
779
+ const currentCount = wcagOccurrencesMap.get(c) || 0;
780
+ wcagOccurrencesMap.set(c, currentCount + count);
781
+ });
782
+ }
783
+ const currRuleFromAllIssues = currCategoryFromAllIssues.rules[rule];
784
+ currRuleFromAllIssues.totalItems += count;
785
+ if (isCustomFlow) {
786
+ const { pageIndex, pageImagePath, metadata } = pageResults;
787
+ currRuleFromAllIssues.pagesAffected[pageIndex] = {
788
+ url,
789
+ pageTitle,
790
+ pageImagePath,
791
+ metadata,
792
+ items: [],
793
+ };
794
+ currRuleFromAllIssues.pagesAffected[pageIndex].items.push(...items);
795
+ }
796
+ else {
797
+ if (!(url in currRuleFromAllIssues.pagesAffected)) {
798
+ currRuleFromAllIssues.pagesAffected[url] = {
799
+ pageTitle,
800
+ items: [],
801
+ ...(filePath && { filePath }),
802
+ };
803
+ /* if (actualUrl) {
804
+ currRuleFromAllIssues.pagesAffected[url].actualUrl = actualUrl;
805
+ // Deduct duplication count from totalItems
806
+ currRuleFromAllIssues.totalItems -= 1;
807
+ // Previously using pagesAffected.length to display no. of pages affected
808
+ // However, since pagesAffected array contains duplicates, we need to deduct the duplicates
809
+ // Hence, start with negative offset, will add pagesAffected.length later
810
+ currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects -= 1;
811
+ currCategoryFromAllIssues.totalItems -= 1;
812
+ } */
813
+ }
814
+ currRuleFromAllIssues.pagesAffected[url].items.push(...items);
815
+ // currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects +=
816
+ // currRuleFromAllIssues.pagesAffected.length;
817
+ }
818
+ });
819
+ });
820
+ };
821
+ const getTopTenIssues = allIssues => {
822
+ const categories = ['mustFix', 'goodToFix'];
823
+ const rulesWithCounts = [];
824
+ // This is no longer required and shall not be maintained in future
825
+ /*
826
+ const conformanceLevels = {
827
+ wcag2a: 'A',
828
+ wcag2aa: 'AA',
829
+ wcag21aa: 'AA',
830
+ wcag22aa: 'AA',
831
+ wcag2aaa: 'AAA',
832
+ };
833
+ */
834
+ categories.forEach(category => {
835
+ const rules = allIssues.items[category]?.rules || [];
836
+ rules.forEach(rule => {
837
+ // This is not needed anymore since we want to have the clause number too
838
+ /*
839
+ const wcagLevel = rule.conformance[0];
840
+ const aLevel = conformanceLevels[wcagLevel] || wcagLevel;
841
+ */
842
+ rulesWithCounts.push({
843
+ category,
844
+ ruleId: rule.rule,
845
+ // Replace description with new Oobee short description if available
846
+ description: a11yRuleShortDescriptionMap[rule.rule] || rule.description,
847
+ axeImpact: rule.axeImpact,
848
+ conformance: rule.conformance,
849
+ totalItems: rule.totalItems,
850
+ });
851
+ });
852
+ });
853
+ rulesWithCounts.sort((a, b) => b.totalItems - a.totalItems);
854
+ return rulesWithCounts.slice(0, 10);
855
+ };
856
+ const flattenAndSortResults = (allIssues, isCustomFlow) => {
857
+ // Create a map that will sum items only from mustFix, goodToFix, and needsReview.
858
+ const urlOccurrencesMap = new Map();
859
+ // Iterate over all categories; update the map only if the category is not "passed"
860
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
861
+ // Accumulate totalItems regardless of category.
862
+ allIssues.totalItems += allIssues.items[category].totalItems;
863
+ allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
864
+ .map(ruleEntry => {
865
+ const [rule, ruleInfo] = ruleEntry;
866
+ ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
867
+ .map(pageEntry => {
868
+ if (isCustomFlow) {
869
+ const [pageIndex, pageInfo] = pageEntry;
870
+ // Only update the occurrences map if not passed.
871
+ if (category !== 'passed') {
872
+ urlOccurrencesMap.set(pageInfo.url, (urlOccurrencesMap.get(pageInfo.url) || 0) + pageInfo.items.length);
873
+ }
874
+ return { pageIndex, ...pageInfo };
875
+ }
876
+ const [url, pageInfo] = pageEntry;
877
+ if (category !== 'passed') {
878
+ urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length);
879
+ }
880
+ return { url, ...pageInfo };
881
+ })
882
+ // Sort pages so that those with the most items come first
883
+ .sort((page1, page2) => page2.items.length - page1.items.length);
884
+ return { rule, ...ruleInfo };
885
+ })
886
+ // Sort the rules by totalItems (descending)
887
+ .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
888
+ });
889
+ // Sort top pages (assumes topFiveMostIssues is already populated)
890
+ allIssues.topFiveMostIssues.sort((p1, p2) => p2.totalIssues - p1.totalIssues);
891
+ allIssues.topTenPagesWithMostIssues = allIssues.topFiveMostIssues.slice(0, 10);
892
+ allIssues.topFiveMostIssues = allIssues.topFiveMostIssues.slice(0, 5);
893
+ // Update each issue in topTenPagesWithMostIssues with the computed occurrences,
894
+ // excluding passed items.
895
+ updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues, urlOccurrencesMap);
896
+ // Get and assign the topTenIssues (using your existing helper)
897
+ const topTenIssues = getTopTenIssues(allIssues);
898
+ allIssues.topTenIssues = topTenIssues;
899
+ };
900
+ // Helper: Update totalOccurrences for each issue using our urlOccurrencesMap.
901
+ // For pages that have only passed items, the map will return undefined, so default to 0.
902
+ function updateIssuesWithOccurrences(issuesList, urlOccurrencesMap) {
903
+ issuesList.forEach(issue => {
904
+ issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
905
+ });
906
+ }
907
+ const createRuleIdJson = allIssues => {
908
+ const compiledRuleJson = {};
909
+ const ruleIterator = rule => {
910
+ const ruleId = rule.rule;
911
+ let snippets = [];
912
+ if (oobeeAiRules.includes(ruleId)) {
913
+ const snippetsSet = new Set();
914
+ rule.pagesAffected.forEach(page => {
915
+ page.items.forEach(htmlItem => {
916
+ snippetsSet.add(oobeeAiHtmlETL(htmlItem.html));
917
+ });
918
+ });
919
+ snippets = [...snippetsSet];
920
+ rule.pagesAffected.forEach(p => {
921
+ delete p.items;
922
+ });
923
+ }
924
+ compiledRuleJson[ruleId] = {
925
+ snippets,
926
+ occurrences: rule.totalItems,
927
+ };
928
+ };
929
+ allIssues.items.mustFix.rules.forEach(ruleIterator);
930
+ allIssues.items.goodToFix.rules.forEach(ruleIterator);
931
+ allIssues.items.needsReview.rules.forEach(ruleIterator);
932
+ return compiledRuleJson;
933
+ };
934
+ const moveElemScreenshots = (randomToken, storagePath) => {
935
+ const currentScreenshotsPath = `${randomToken}/elemScreenshots`;
936
+ const resultsScreenshotsPath = `${storagePath}/elemScreenshots`;
937
+ if (fs.existsSync(currentScreenshotsPath)) {
938
+ fs.moveSync(currentScreenshotsPath, resultsScreenshotsPath);
939
+ }
940
+ };
941
+ /**
942
+ * Build allIssues.scanPagesDetail and allIssues.scanPagesSummary
943
+ * by analyzing pagesScanned (including mustFix/goodToFix/etc.).
944
+ */
945
+ function populateScanPagesDetail(allIssues) {
946
+ // --------------------------------------------
947
+ // 1) Gather your "scanned" pages from allIssues
948
+ // --------------------------------------------
949
+ const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
950
+ // --------------------------------------------
951
+ // 2) Define category constants (optional, just for clarity)
952
+ // --------------------------------------------
953
+ const mustFixCategory = 'mustFix';
954
+ const goodToFixCategory = 'goodToFix';
955
+ const needsReviewCategory = 'needsReview';
956
+ const passedCategory = 'passed';
957
+ // --------------------------------------------
958
+ // 4) We'll accumulate pages in a map keyed by URL
959
+ // --------------------------------------------
960
+ const pagesMap = {};
961
+ // --------------------------------------------
962
+ // 5) Build pagesMap by iterating over each category in allIssues.items
963
+ // --------------------------------------------
964
+ Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
965
+ if (!categoryData?.rules)
966
+ return; // no rules in this category? skip
967
+ categoryData.rules.forEach(rule => {
968
+ const { rule: ruleId, conformance = [] } = rule;
969
+ rule.pagesAffected.forEach(p => {
970
+ const { url, pageTitle, items = [] } = p;
971
+ const itemsCount = items.length;
972
+ // Ensure the page is in pagesMap
973
+ if (!pagesMap[url]) {
974
+ pagesMap[url] = {
975
+ pageTitle,
976
+ url,
977
+ totalOccurrencesFailedIncludingNeedsReview: 0,
978
+ totalOccurrencesFailedExcludingNeedsReview: 0,
979
+ totalOccurrencesNeedsReview: 0,
980
+ totalOccurrencesPassed: 0,
981
+ typesOfIssues: {},
982
+ };
983
+ }
984
+ // Ensure the rule is present for this page
985
+ if (!pagesMap[url].typesOfIssues[ruleId]) {
986
+ pagesMap[url].typesOfIssues[ruleId] = {
987
+ ruleId,
988
+ wcagConformance: conformance,
989
+ occurrencesMustFix: 0,
990
+ occurrencesGoodToFix: 0,
991
+ occurrencesNeedsReview: 0,
992
+ occurrencesPassed: 0,
993
+ };
994
+ }
995
+ // Depending on the category, increment the relevant occurrence counts
996
+ if (categoryName === mustFixCategory) {
997
+ pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
998
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
999
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
1000
+ }
1001
+ else if (categoryName === goodToFixCategory) {
1002
+ pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
1003
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
1004
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
1005
+ }
1006
+ else if (categoryName === needsReviewCategory) {
1007
+ pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
1008
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
1009
+ pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
1010
+ }
1011
+ else if (categoryName === passedCategory) {
1012
+ pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
1013
+ pagesMap[url].totalOccurrencesPassed += itemsCount;
1014
+ }
1015
+ });
1016
+ });
1017
+ });
1018
+ // --------------------------------------------
1019
+ // 6) Separate scanned pages into “affected” vs. “notAffected”
1020
+ // --------------------------------------------
1021
+ const pagesInMap = Object.values(pagesMap); // All pages that have some record in pagesMap
1022
+ const pagesInMapUrls = new Set(Object.keys(pagesMap));
1023
+ // (a) Pages with only passed (no mustFix/goodToFix/needsReview)
1024
+ const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
1025
+ // (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
1026
+ const pagesNoEntries = allScannedPages
1027
+ .filter(sp => !pagesInMapUrls.has(sp.url))
1028
+ .map(sp => ({
1029
+ pageTitle: sp.pageTitle,
1030
+ url: sp.url,
1031
+ totalOccurrencesFailedIncludingNeedsReview: 0,
1032
+ totalOccurrencesFailedExcludingNeedsReview: 0,
1033
+ totalOccurrencesNeedsReview: 0,
1034
+ totalOccurrencesPassed: 0,
1035
+ typesOfIssues: {},
1036
+ }));
1037
+ // Combine these into "notAffected"
1038
+ const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
1039
+ // "affected" pages => have at least 1 mustFix/goodToFix/needsReview
1040
+ const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
1041
+ // --------------------------------------------
1042
+ // 7) Transform both arrays to the final shape
1043
+ // --------------------------------------------
1044
+ function transformPageData(page) {
1045
+ const typesOfIssuesArray = Object.values(page.typesOfIssues);
1046
+ // Compute sums for each failing category
1047
+ const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
1048
+ const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
1049
+ const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
1050
+ // Build categoriesPresent based on nonzero failing counts
1051
+ const categoriesPresent = [];
1052
+ if (mustFixSum > 0)
1053
+ categoriesPresent.push('mustFix');
1054
+ if (goodToFixSum > 0)
1055
+ categoriesPresent.push('goodToFix');
1056
+ if (needsReviewSum > 0)
1057
+ categoriesPresent.push('needsReview');
1058
+ // Count how many rules have failing issues
1059
+ const failedRuleIds = new Set();
1060
+ typesOfIssuesArray.forEach(r => {
1061
+ if ((r.occurrencesMustFix || 0) > 0 ||
1062
+ (r.occurrencesGoodToFix || 0) > 0 ||
1063
+ (r.occurrencesNeedsReview || 0) > 0) {
1064
+ failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
1065
+ }
1066
+ });
1067
+ const failedRuleCount = failedRuleIds.size;
1068
+ // Possibly these two for future convenience
1069
+ const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0).length;
1070
+ const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(r => (r.occurrencesNeedsReview || 0) > 0 &&
1071
+ (r.occurrencesMustFix || 0) === 0 &&
1072
+ (r.occurrencesGoodToFix || 0) === 0).length;
1073
+ // Aggregate wcagConformance for rules that actually fail
1074
+ const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
1075
+ const nonPassedCount = (curr.occurrencesMustFix || 0) +
1076
+ (curr.occurrencesGoodToFix || 0) +
1077
+ (curr.occurrencesNeedsReview || 0);
1078
+ if (nonPassedCount > 0) {
1079
+ return acc.concat(curr.wcagConformance || []);
1080
+ }
1081
+ return acc;
1082
+ }, []);
1083
+ // Remove duplicates
1084
+ const conformance = Array.from(new Set(allConformance));
1085
+ return {
1086
+ pageTitle: page.pageTitle,
1087
+ url: page.url,
1088
+ totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
1089
+ totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
1090
+ totalOccurrencesMustFix: mustFixSum,
1091
+ totalOccurrencesGoodToFix: goodToFixSum,
1092
+ totalOccurrencesNeedsReview: needsReviewSum,
1093
+ totalOccurrencesPassed: page.totalOccurrencesPassed,
1094
+ typesOfIssuesExclusiveToNeedsReviewCount,
1095
+ typesOfIssuesCount: failedRuleCount,
1096
+ typesOfIssuesExcludingNeedsReviewCount,
1097
+ categoriesPresent,
1098
+ conformance,
1099
+ // Keep full detail for "scanPagesDetail"
1100
+ typesOfIssues: typesOfIssuesArray,
1101
+ };
1102
+ }
1103
+ // Transform raw pages
1104
+ const pagesAffected = pagesAffectedRaw.map(transformPageData);
1105
+ const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
1106
+ // --------------------------------------------
1107
+ // 8) Sort pages by typesOfIssuesCount (descending) for both arrays
1108
+ // --------------------------------------------
1109
+ pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1110
+ pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1111
+ // --------------------------------------------
1112
+ // 9) Compute scanned/ skipped counts
1113
+ // --------------------------------------------
1114
+ const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
1115
+ const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
1116
+ ? allIssues.pagesNotScanned.length
1117
+ : 0;
1118
+ // --------------------------------------------
1119
+ // 10) Build scanPagesDetail (with full "typesOfIssues")
1120
+ // --------------------------------------------
1121
+ allIssues.scanPagesDetail = {
1122
+ pagesAffected,
1123
+ pagesNotAffected,
1124
+ scannedPagesCount,
1125
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1126
+ pagesNotScannedCount,
1127
+ };
1128
+ // --------------------------------------------
1129
+ // 11) Build scanPagesSummary (strip out "typesOfIssues")
1130
+ // --------------------------------------------
1131
+ function stripTypesOfIssues(page) {
1132
+ const { typesOfIssues, ...rest } = page;
1133
+ return rest;
1134
+ }
1135
+ const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
1136
+ const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
1137
+ allIssues.scanPagesSummary = {
1138
+ pagesAffected: summaryPagesAffected,
1139
+ pagesNotAffected: summaryPagesNotAffected,
1140
+ scannedPagesCount,
1141
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
1142
+ pagesNotScannedCount,
1143
+ };
1144
+ }
1145
+ // Send WCAG criteria breakdown to Sentry
1146
+ const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson, scanInfo, allIssues, pagesScannedCount = 0) => {
1147
+ try {
1148
+ // Initialize Sentry
1149
+ Sentry.init(sentryConfig);
1150
+ // Set user ID for Sentry tracking
1151
+ const userData = getUserDataTxt();
1152
+ if (userData && userData.userId) {
1153
+ setSentryUser(userData.userId);
1154
+ }
1155
+ // Prepare tags for the event
1156
+ const tags = {};
1157
+ const wcagCriteriaBreakdown = {};
1158
+ // Tag app version
1159
+ tags.version = appVersion;
1160
+ // Get dynamic WCAG criteria map once
1161
+ const wcagCriteriaMap = await getWcagCriteriaMap();
1162
+ // Categorize all WCAG criteria for reporting
1163
+ const wcagIds = Array.from(new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]));
1164
+ const categorizedWcag = await categorizeWcagCriteria(wcagIds);
1165
+ // First ensure all WCAG criteria are included in the tags with a value of 0
1166
+ // This ensures criteria with no violations are still reported
1167
+ for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
1168
+ const formattedTag = await formatWcagTag(wcagId);
1169
+ if (formattedTag) {
1170
+ // Initialize with zero
1171
+ tags[formattedTag] = '0';
1172
+ // Store in breakdown object with category information
1173
+ wcagCriteriaBreakdown[formattedTag] = {
1174
+ count: 0,
1175
+ category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
1176
+ };
1177
+ }
1178
+ }
1179
+ // Now override with actual counts from the scan
1180
+ for (const [wcagId, count] of wcagBreakdown.entries()) {
1181
+ const formattedTag = await formatWcagTag(wcagId);
1182
+ if (formattedTag) {
1183
+ // Add as a tag with the count as value
1184
+ tags[formattedTag] = String(count);
1185
+ // Update count in breakdown object
1186
+ if (wcagCriteriaBreakdown[formattedTag]) {
1187
+ wcagCriteriaBreakdown[formattedTag].count = count;
1188
+ }
1189
+ else {
1190
+ // If somehow this wasn't in our initial map
1191
+ wcagCriteriaBreakdown[formattedTag] = {
1192
+ count,
1193
+ category: categorizedWcag[wcagId] || 'mustFix',
1194
+ };
1195
+ }
1196
+ }
1197
+ }
1198
+ // Calculate category counts based on actual issue counts from the report
1199
+ // rather than occurrence counts from wcagBreakdown
1200
+ const categoryCounts = {
1201
+ mustFix: 0,
1202
+ goodToFix: 0,
1203
+ needsReview: 0,
1204
+ };
1205
+ if (allIssues) {
1206
+ // Use the actual report data for the counts
1207
+ categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
1208
+ categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
1209
+ categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
1210
+ }
1211
+ else {
1212
+ // Fallback to the old way if allIssues not provided
1213
+ Object.values(wcagCriteriaBreakdown).forEach(item => {
1214
+ if (item.count > 0 && categoryCounts[item.category] !== undefined) {
1215
+ categoryCounts[item.category] += 1; // Count rules, not occurrences
1216
+ }
1217
+ });
1218
+ }
1219
+ // Add category counts as tags
1220
+ tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
1221
+ tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
1222
+ tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
1223
+ // Also add occurrence counts for reference
1224
+ if (allIssues) {
1225
+ tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
1226
+ tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
1227
+ tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
1228
+ // Add number of pages scanned tag
1229
+ tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
1230
+ }
1231
+ else if (pagesScannedCount > 0) {
1232
+ // Still add the pages scanned count even if we don't have allIssues
1233
+ tags['Pages-Scanned-Count'] = String(pagesScannedCount);
1234
+ }
1235
+ // Send the event to Sentry
1236
+ await Sentry.captureEvent({
1237
+ message: 'Accessibility Scan Completed',
1238
+ level: 'info',
1239
+ tags: {
1240
+ ...tags,
1241
+ event_type: 'accessibility_scan',
1242
+ scanType: scanInfo.scanType,
1243
+ browser: scanInfo.browser,
1244
+ entryUrl: scanInfo.entryUrl,
1245
+ },
1246
+ user: {
1247
+ ...(scanInfo.email && scanInfo.name
1248
+ ? {
1249
+ email: scanInfo.email,
1250
+ username: scanInfo.name,
1251
+ }
1252
+ : {}),
1253
+ ...(userData && userData.userId ? { id: userData.userId } : {}),
1254
+ },
1255
+ extra: {
1256
+ additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : '{}',
1257
+ wcagBreakdown: wcagCriteriaBreakdown,
1258
+ reportCounts: allIssues
1259
+ ? {
1260
+ mustFix: {
1261
+ issues: allIssues.items.mustFix.rules?.length ?? 0,
1262
+ occurrences: allIssues.items.mustFix.totalItems ?? 0,
1263
+ },
1264
+ goodToFix: {
1265
+ issues: allIssues.items.goodToFix.rules?.length ?? 0,
1266
+ occurrences: allIssues.items.goodToFix.totalItems ?? 0,
1267
+ },
1268
+ needsReview: {
1269
+ issues: allIssues.items.needsReview.rules?.length ?? 0,
1270
+ occurrences: allIssues.items.needsReview.totalItems ?? 0,
1271
+ },
1272
+ }
1273
+ : undefined,
1274
+ },
1275
+ });
1276
+ // Wait for events to be sent
1277
+ await Sentry.flush(2000);
1278
+ }
1279
+ catch (error) {
1280
+ console.error('Error sending WCAG breakdown to Sentry:', error);
1281
+ }
1282
+ };
1283
+ const formatAboutStartTime = (dateString) => {
1284
+ const utcStartTimeDate = new Date(dateString);
1285
+ const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
1286
+ year: 'numeric',
1287
+ month: 'short',
1288
+ day: 'numeric',
1289
+ hour12: false,
1290
+ hour: 'numeric',
1291
+ minute: '2-digit',
1292
+ timeZoneName: 'shortGeneric',
1293
+ });
1294
+ const timezoneAbbreviation = new Intl.DateTimeFormat('en', {
1295
+ timeZoneName: 'shortOffset',
1296
+ })
1297
+ .formatToParts(utcStartTimeDate)
1298
+ .find(part => part.type === 'timeZoneName').value;
1299
+ // adding a breakline between the time and timezone so it looks neater on report
1300
+ const timeColonIndex = formattedStartTime.lastIndexOf(':');
1301
+ const timePart = formattedStartTime.slice(0, timeColonIndex + 3);
1302
+ const timeZonePart = formattedStartTime.slice(timeColonIndex + 4);
1303
+ const htmlFormattedStartTime = `${timePart}<br>${timeZonePart} ${timezoneAbbreviation}`;
1304
+ return htmlFormattedStartTime;
1305
+ };
1306
+ const generateArtifacts = async (randomToken, urlScanned, scanType, viewport, pagesScanned, pagesNotScanned, customFlowLabel, cypressScanAboutMetadata, scanDetails, zip = undefined, // optional
1307
+ generateJsonFiles = false) => {
1308
+ const storagePath = getStoragePath(randomToken);
1309
+ const intermediateDatasetsPath = `${storagePath}/crawlee`;
1310
+ const oobeeAppVersion = getVersion();
1311
+ const isCustomFlow = scanType === ScannerTypes.CUSTOM;
1312
+ const allIssues = {
1313
+ storagePath,
1314
+ oobeeAi: {
1315
+ htmlETL: oobeeAiHtmlETL,
1316
+ rules: oobeeAiRules,
1317
+ },
1318
+ siteName: (pagesScanned[0]?.pageTitle ?? '').replace(/^\d+\s*:\s*/, '').trim(),
1319
+ startTime: scanDetails.startTime ? scanDetails.startTime : new Date(),
1320
+ endTime: scanDetails.endTime ? scanDetails.endTime : new Date(),
1321
+ urlScanned,
1322
+ scanType,
1323
+ deviceChosen: scanDetails.deviceChosen || 'Desktop',
1324
+ formatAboutStartTime,
1325
+ isCustomFlow,
1326
+ viewport,
1327
+ pagesScanned,
1328
+ pagesNotScanned,
1329
+ totalPagesScanned: pagesScanned.length,
1330
+ totalPagesNotScanned: pagesNotScanned.length,
1331
+ totalItems: 0,
1332
+ topFiveMostIssues: [],
1333
+ topTenPagesWithMostIssues: [],
1334
+ topTenIssues: [],
1335
+ wcagViolations: [],
1336
+ customFlowLabel,
1337
+ oobeeAppVersion,
1338
+ items: {
1339
+ mustFix: {
1340
+ description: itemTypeDescription.mustFix,
1341
+ totalItems: 0,
1342
+ totalRuleIssues: 0,
1343
+ rules: [],
1344
+ },
1345
+ goodToFix: {
1346
+ description: itemTypeDescription.goodToFix,
1347
+ totalItems: 0,
1348
+ totalRuleIssues: 0,
1349
+ rules: [],
1350
+ },
1351
+ needsReview: {
1352
+ description: itemTypeDescription.needsReview,
1353
+ totalItems: 0,
1354
+ totalRuleIssues: 0,
1355
+ rules: [],
1356
+ },
1357
+ passed: {
1358
+ description: itemTypeDescription.passed,
1359
+ totalItems: 0,
1360
+ totalRuleIssues: 0,
1361
+ rules: [],
1362
+ },
1363
+ },
1364
+ cypressScanAboutMetadata,
1365
+ wcagLinks: constants.wcagLinks,
1366
+ wcagClauses: WCAGclauses,
1367
+ a11yRuleShortDescriptionMap,
1368
+ disabilityBadgesMap,
1369
+ a11yRuleLongDescriptionMap,
1370
+ wcagCriteriaLabels: constants.wcagCriteriaLabels,
1371
+ scanPagesDetail: {
1372
+ pagesAffected: [],
1373
+ pagesNotAffected: [],
1374
+ scannedPagesCount: 0,
1375
+ pagesNotScanned: [],
1376
+ pagesNotScannedCount: 0,
1377
+ },
1378
+ // Populate boolean values for id="advancedScanOptionsSummary"
1379
+ advancedScanOptionsSummaryItems: {
1380
+ showIncludeScreenshots: [true].includes(scanDetails.isIncludeScreenshots),
1381
+ showAllowSubdomains: ['same-domain'].includes(scanDetails.isAllowSubdomains),
1382
+ showEnableCustomChecks: ['default', 'enable-wcag-aaa'].includes(scanDetails.isEnableCustomChecks?.[0]),
1383
+ showEnableWcagAaa: (scanDetails.isEnableWcagAaa || []).includes('enable-wcag-aaa'),
1384
+ showSlowScanMode: [1].includes(scanDetails.isSlowScanMode),
1385
+ showAdhereRobots: [true].includes(scanDetails.isAdhereRobots),
1386
+ },
1387
+ };
1388
+ const allFiles = await extractFileNames(intermediateDatasetsPath);
1389
+ const jsonArray = await Promise.all(allFiles.map(async (file) => parseContentToJson(`${intermediateDatasetsPath}/${file}`)));
1390
+ await Promise.all(jsonArray.map(async (pageResults) => {
1391
+ await pushResults(pageResults, allIssues, isCustomFlow);
1392
+ })).catch(flattenIssuesError => {
1393
+ consoleLogger.info('An error has occurred when flattening the issues, please try again.');
1394
+ });
1395
+ flattenAndSortResults(allIssues, isCustomFlow);
1396
+ const labelKey = scanType.toLowerCase() === 'custom' ? 'CustomFlowLabel' : 'Label';
1397
+ const labelValue = allIssues.customFlowLabel || 'N/A';
1398
+ printMessage([
1399
+ 'Scan Summary',
1400
+ `Oobee App Version: ${allIssues.oobeeAppVersion}`,
1401
+ '',
1402
+ `Site Name: ${allIssues.siteName}`,
1403
+ `URL: ${allIssues.urlScanned}`,
1404
+ `Pages Scanned: ${allIssues.totalPagesScanned}`,
1405
+ `Start Time: ${allIssues.startTime}`,
1406
+ `End Time: ${allIssues.endTime}`,
1407
+ `Elapsed Time: ${(new Date(allIssues.endTime).getTime() - new Date(allIssues.startTime).getTime()) / 1000}s`,
1408
+ `Device: ${allIssues.deviceChosen}`,
1409
+ `Viewport: ${allIssues.viewport}`,
1410
+ `Scan Type: ${allIssues.scanType}`,
1411
+ `${labelKey}: ${labelValue}`,
1412
+ '',
1413
+ `Must Fix: ${allIssues.items.mustFix.rules.length} ${Object.keys(allIssues.items.mustFix.rules).length === 1 ? 'issue' : 'issues'} / ${allIssues.items.mustFix.totalItems} ${allIssues.items.mustFix.totalItems === 1 ? 'occurrence' : 'occurrences'}`,
1414
+ `Good to Fix: ${allIssues.items.goodToFix.rules.length} ${Object.keys(allIssues.items.goodToFix.rules).length === 1 ? 'issue' : 'issues'} / ${allIssues.items.goodToFix.totalItems} ${allIssues.items.goodToFix.totalItems === 1 ? 'occurrence' : 'occurrences'}`,
1415
+ `Manual Review Required: ${allIssues.items.needsReview.rules.length} ${Object.keys(allIssues.items.needsReview.rules).length === 1 ? 'issue' : 'issues'} / ${allIssues.items.needsReview.totalItems} ${allIssues.items.needsReview.totalItems === 1 ? 'occurrence' : 'occurrences'}`,
1416
+ `Passed: ${allIssues.items.passed.totalItems} ${allIssues.items.passed.totalItems === 1 ? 'occurrence' : 'occurrences'}`,
1417
+ ]);
1418
+ // move screenshots folder to report folders
1419
+ moveElemScreenshots(randomToken, storagePath);
1420
+ if (isCustomFlow) {
1421
+ createScreenshotsFolder(randomToken);
1422
+ }
1423
+ populateScanPagesDetail(allIssues);
1424
+ allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1425
+ allIssues.progressPercentage = getProgressPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1426
+ allIssues.issuesPercentage = await getIssuesPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa, allIssues.advancedScanOptionsSummaryItems.disableOobee);
1427
+ consoleLogger.info(`Site Name: ${allIssues.siteName}`);
1428
+ consoleLogger.info(`URL: ${allIssues.urlScanned}`);
1429
+ consoleLogger.info(`Pages Scanned: ${allIssues.totalPagesScanned}`);
1430
+ consoleLogger.info(`Start Time: ${allIssues.startTime}`);
1431
+ consoleLogger.info(`End Time: ${allIssues.endTime}`);
1432
+ const elapsedSeconds = (new Date(allIssues.endTime).getTime() - new Date(allIssues.startTime).getTime()) / 1000;
1433
+ consoleLogger.info(`Elapsed Time: ${elapsedSeconds}s`);
1434
+ consoleLogger.info(`Device: ${allIssues.deviceChosen}`);
1435
+ consoleLogger.info(`Viewport: ${allIssues.viewport}`);
1436
+ consoleLogger.info(`Scan Type: ${allIssues.scanType}`);
1437
+ consoleLogger.info(`Label: ${allIssues.customFlowLabel || 'N/A'}`);
1438
+ const getAxeImpactCount = (allIssues) => {
1439
+ const impactCount = {
1440
+ critical: 0,
1441
+ serious: 0,
1442
+ moderate: 0,
1443
+ minor: 0,
1444
+ };
1445
+ Object.values(allIssues.items).forEach(category => {
1446
+ if (category.totalItems > 0) {
1447
+ Object.values(category.rules).forEach(rule => {
1448
+ if (rule.axeImpact === 'critical') {
1449
+ impactCount.critical += rule.totalItems;
1450
+ }
1451
+ else if (rule.axeImpact === 'serious') {
1452
+ impactCount.serious += rule.totalItems;
1453
+ }
1454
+ else if (rule.axeImpact === 'moderate') {
1455
+ impactCount.moderate += rule.totalItems;
1456
+ }
1457
+ else if (rule.axeImpact === 'minor') {
1458
+ impactCount.minor += rule.totalItems;
1459
+ }
1460
+ });
1461
+ }
1462
+ });
1463
+ return impactCount;
1464
+ };
1465
+ if (process.env.OOBEE_VERBOSE) {
1466
+ const axeImpactCount = getAxeImpactCount(allIssues);
1467
+ const { items, startTime, endTime, ...rest } = allIssues;
1468
+ rest.critical = axeImpactCount.critical;
1469
+ rest.serious = axeImpactCount.serious;
1470
+ rest.moderate = axeImpactCount.moderate;
1471
+ rest.minor = axeImpactCount.minor;
1472
+ }
1473
+ await writeCsv(allIssues, storagePath);
1474
+ await writeSitemap(pagesScanned, storagePath);
1475
+ const { scanDataJsonFilePath, scanDataBase64FilePath, scanItemsJsonFilePath, scanItemsBase64FilePath, scanItemsSummaryJsonFilePath, scanItemsSummaryBase64FilePath, scanItemsMiniReportJsonFilePath, scanItemsMiniReportBase64FilePath, scanIssuesSummaryJsonFilePath, scanIssuesSummaryBase64FilePath, scanPagesDetailJsonFilePath, scanPagesDetailBase64FilePath, scanPagesSummaryJsonFilePath, scanPagesSummaryBase64FilePath, scanDataJsonFileSize, scanItemsJsonFileSize, } = await writeJsonAndBase64Files(allIssues, storagePath);
1476
+ const BIG_RESULTS_THRESHOLD = 500 * 1024 * 1024; // 500 MB
1477
+ const resultsTooBig = scanDataJsonFileSize + scanItemsJsonFileSize > BIG_RESULTS_THRESHOLD;
1478
+ await writeScanDetailsCsv(scanDataBase64FilePath, scanItemsBase64FilePath, scanItemsSummaryBase64FilePath, storagePath);
1479
+ await writeSummaryHTML(allIssues, storagePath);
1480
+ await writeHTML(allIssues, storagePath, 'report', scanDataBase64FilePath, resultsTooBig ? scanItemsMiniReportBase64FilePath : scanItemsBase64FilePath);
1481
+ if (!generateJsonFiles) {
1482
+ await cleanUpJsonFiles([
1483
+ scanDataJsonFilePath,
1484
+ scanDataBase64FilePath,
1485
+ scanItemsJsonFilePath,
1486
+ scanItemsBase64FilePath,
1487
+ scanItemsSummaryJsonFilePath,
1488
+ scanItemsSummaryBase64FilePath,
1489
+ scanItemsMiniReportJsonFilePath,
1490
+ scanItemsMiniReportBase64FilePath,
1491
+ scanIssuesSummaryJsonFilePath,
1492
+ scanIssuesSummaryBase64FilePath,
1493
+ scanPagesDetailJsonFilePath,
1494
+ scanPagesDetailBase64FilePath,
1495
+ scanPagesSummaryJsonFilePath,
1496
+ scanPagesSummaryBase64FilePath,
1497
+ ]);
1498
+ }
1499
+ const browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
1500
+ // Should consider refactor constants.userDataDirectory to be a parameter in future
1501
+ await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length, 'summary', browserChannel, constants.userDataDirectory), 1);
1502
+ try {
1503
+ fs.rmSync(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
1504
+ }
1505
+ catch (error) {
1506
+ consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
1507
+ }
1508
+ try {
1509
+ fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
1510
+ }
1511
+ catch (error) {
1512
+ consoleLogger.warn(`Unable to force remove pdfs folder: ${error.message}`);
1513
+ }
1514
+ // Take option if set
1515
+ if (typeof zip === 'string') {
1516
+ constants.cliZipFileName = zip;
1517
+ if (!zip.endsWith('.zip')) {
1518
+ constants.cliZipFileName += '.zip';
1519
+ }
1520
+ }
1521
+ if (!path.isAbsolute(constants.cliZipFileName) ||
1522
+ path.dirname(constants.cliZipFileName) === '.') {
1523
+ constants.cliZipFileName = path.join(storagePath, constants.cliZipFileName);
1524
+ }
1525
+ try {
1526
+ await fs.ensureDir(storagePath);
1527
+ await zipResults(constants.cliZipFileName, storagePath);
1528
+ const messageToDisplay = [
1529
+ `Report of this run is at ${constants.cliZipFileName}`,
1530
+ `Results directory is at ${storagePath}`,
1531
+ ];
1532
+ if (process.send && process.env.OOBEE_VERBOSE) {
1533
+ const zipFileNameMessage = {
1534
+ type: 'zipFileName',
1535
+ payload: `${constants.cliZipFileName}`,
1536
+ };
1537
+ const storagePathMessage = {
1538
+ type: 'storagePath',
1539
+ payload: `${storagePath}`,
1540
+ };
1541
+ process.send(JSON.stringify(storagePathMessage));
1542
+ process.send(JSON.stringify(zipFileNameMessage));
1543
+ }
1544
+ printMessage(messageToDisplay);
1545
+ }
1546
+ catch (error) {
1547
+ printMessage([`Error in zipping results: ${error}`]);
1548
+ }
1549
+ // Generate scrubbed HTML Code Snippets
1550
+ const ruleIdJson = createRuleIdJson(allIssues);
1551
+ // At the end of the function where results are generated, add:
1552
+ try {
1553
+ // Always send WCAG breakdown to Sentry, even if no violations were found
1554
+ // This ensures that all criteria are reported, including those with 0 occurrences
1555
+ await sendWcagBreakdownToSentry(oobeeAppVersion, wcagOccurrencesMap, ruleIdJson, {
1556
+ entryUrl: urlScanned,
1557
+ scanType,
1558
+ browser: scanDetails.deviceChosen,
1559
+ email: scanDetails.nameEmail?.email,
1560
+ name: scanDetails.nameEmail?.name,
1561
+ }, allIssues, pagesScanned.length);
1562
+ }
1563
+ catch (error) {
1564
+ console.error('Error sending WCAG data to Sentry:', error);
1565
+ }
1566
+ if (process.env.RUNNING_FROM_PH_GUI || process.env.OOBEE_VERBOSE)
1567
+ console.log('Report generated successfully');
1568
+ return ruleIdJson;
1569
+ };
1570
+ export { writeHTML, compressJsonFileStreaming, flattenAndSortResults, populateScanPagesDetail, getWcagPassPercentage, getProgressPercentage, getIssuesPercentage, itemTypeDescription, oobeeAiHtmlETL, oobeeAiRules, formatAboutStartTime, };
1571
+ export default generateArtifacts;