@govtechsg/oobee 0.10.20

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 (123) hide show
  1. package/.dockerignore +22 -0
  2. package/.github/pull_request_template.md +11 -0
  3. package/.github/workflows/docker-test.yml +54 -0
  4. package/.github/workflows/image.yml +107 -0
  5. package/.github/workflows/publish.yml +18 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/purple-a11y.iml +9 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/.prettierrc.json +12 -0
  10. package/.vscode/extensions.json +5 -0
  11. package/.vscode/settings.json +10 -0
  12. package/CODE_OF_CONDUCT.md +128 -0
  13. package/DETAILS.md +163 -0
  14. package/Dockerfile +60 -0
  15. package/INSTALLATION.md +146 -0
  16. package/INTEGRATION.md +785 -0
  17. package/LICENSE +22 -0
  18. package/README.md +587 -0
  19. package/SECURITY.md +5 -0
  20. package/__mocks__/mock-report.html +1431 -0
  21. package/__mocks__/mockFunctions.ts +32 -0
  22. package/__mocks__/mockIssues.ts +64 -0
  23. package/__mocks__/mock_all_issues/000000001.json +64 -0
  24. package/__mocks__/mock_all_issues/000000002.json +53 -0
  25. package/__mocks__/mock_all_issues/fake-file.txt +0 -0
  26. package/__tests__/logs.test.ts +25 -0
  27. package/__tests__/mergeAxeResults.test.ts +278 -0
  28. package/__tests__/utils.test.ts +118 -0
  29. package/a11y-scan-results.zip +0 -0
  30. package/eslint.config.js +53 -0
  31. package/exclusions.txt +2 -0
  32. package/gitlab-pipeline-template.yml +54 -0
  33. package/jest.config.js +1 -0
  34. package/package.json +96 -0
  35. package/scripts/copyFiles.js +44 -0
  36. package/scripts/install_oobee_dependencies.cmd +13 -0
  37. package/scripts/install_oobee_dependencies.command +101 -0
  38. package/scripts/install_oobee_dependencies.ps1 +110 -0
  39. package/scripts/oobee_shell.cmd +13 -0
  40. package/scripts/oobee_shell.command +11 -0
  41. package/scripts/oobee_shell.sh +55 -0
  42. package/scripts/oobee_shell_ps.ps1 +54 -0
  43. package/src/cli.ts +401 -0
  44. package/src/combine.ts +240 -0
  45. package/src/constants/__tests__/common.test.ts +44 -0
  46. package/src/constants/cliFunctions.ts +305 -0
  47. package/src/constants/common.ts +1840 -0
  48. package/src/constants/constants.ts +443 -0
  49. package/src/constants/errorMeta.json +319 -0
  50. package/src/constants/itemTypeDescription.ts +11 -0
  51. package/src/constants/oobeeAi.ts +141 -0
  52. package/src/constants/questions.ts +181 -0
  53. package/src/constants/sampleData.ts +187 -0
  54. package/src/crawlers/__tests__/commonCrawlerFunc.test.ts +51 -0
  55. package/src/crawlers/commonCrawlerFunc.ts +656 -0
  56. package/src/crawlers/crawlDomain.ts +877 -0
  57. package/src/crawlers/crawlIntelligentSitemap.ts +156 -0
  58. package/src/crawlers/crawlLocalFile.ts +193 -0
  59. package/src/crawlers/crawlSitemap.ts +356 -0
  60. package/src/crawlers/custom/extractAndGradeText.ts +57 -0
  61. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +964 -0
  62. package/src/crawlers/custom/utils.ts +486 -0
  63. package/src/crawlers/customAxeFunctions.ts +82 -0
  64. package/src/crawlers/pdfScanFunc.ts +468 -0
  65. package/src/crawlers/runCustom.ts +117 -0
  66. package/src/index.ts +173 -0
  67. package/src/logs.ts +66 -0
  68. package/src/mergeAxeResults.ts +964 -0
  69. package/src/npmIndex.ts +284 -0
  70. package/src/screenshotFunc/htmlScreenshotFunc.ts +411 -0
  71. package/src/screenshotFunc/pdfScreenshotFunc.ts +762 -0
  72. package/src/static/ejs/partials/components/categorySelector.ejs +4 -0
  73. package/src/static/ejs/partials/components/categorySelectorDropdown.ejs +57 -0
  74. package/src/static/ejs/partials/components/pagesScannedModal.ejs +70 -0
  75. package/src/static/ejs/partials/components/reportSearch.ejs +47 -0
  76. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +105 -0
  77. package/src/static/ejs/partials/components/scanAbout.ejs +263 -0
  78. package/src/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
  79. package/src/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
  80. package/src/static/ejs/partials/components/summaryScanResults.ejs +16 -0
  81. package/src/static/ejs/partials/components/summaryTable.ejs +20 -0
  82. package/src/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
  83. package/src/static/ejs/partials/components/topFive.ejs +6 -0
  84. package/src/static/ejs/partials/components/wcagCompliance.ejs +70 -0
  85. package/src/static/ejs/partials/footer.ejs +21 -0
  86. package/src/static/ejs/partials/header.ejs +230 -0
  87. package/src/static/ejs/partials/main.ejs +40 -0
  88. package/src/static/ejs/partials/scripts/bootstrap.ejs +8 -0
  89. package/src/static/ejs/partials/scripts/categorySelectorDropdownScript.ejs +190 -0
  90. package/src/static/ejs/partials/scripts/categorySummary.ejs +141 -0
  91. package/src/static/ejs/partials/scripts/highlightjs.ejs +335 -0
  92. package/src/static/ejs/partials/scripts/popper.ejs +7 -0
  93. package/src/static/ejs/partials/scripts/reportSearch.ejs +248 -0
  94. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +801 -0
  95. package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +71 -0
  96. package/src/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
  97. package/src/static/ejs/partials/scripts/summaryTable.ejs +78 -0
  98. package/src/static/ejs/partials/scripts/utils.ejs +441 -0
  99. package/src/static/ejs/partials/styles/bootstrap.ejs +12375 -0
  100. package/src/static/ejs/partials/styles/highlightjs.ejs +54 -0
  101. package/src/static/ejs/partials/styles/styles.ejs +1843 -0
  102. package/src/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
  103. package/src/static/ejs/partials/summaryHeader.ejs +70 -0
  104. package/src/static/ejs/partials/summaryMain.ejs +75 -0
  105. package/src/static/ejs/report.ejs +420 -0
  106. package/src/static/ejs/summary.ejs +47 -0
  107. package/src/static/mustache/.prettierrc +4 -0
  108. package/src/static/mustache/Attention Deficit.mustache +11 -0
  109. package/src/static/mustache/Blind.mustache +11 -0
  110. package/src/static/mustache/Cognitive.mustache +7 -0
  111. package/src/static/mustache/Colorblindness.mustache +20 -0
  112. package/src/static/mustache/Deaf.mustache +12 -0
  113. package/src/static/mustache/Deafblind.mustache +7 -0
  114. package/src/static/mustache/Dyslexia.mustache +14 -0
  115. package/src/static/mustache/Low Vision.mustache +7 -0
  116. package/src/static/mustache/Mobility.mustache +15 -0
  117. package/src/static/mustache/Sighted Keyboard Users.mustache +42 -0
  118. package/src/static/mustache/report.mustache +1709 -0
  119. package/src/types/print-message.d.ts +28 -0
  120. package/src/types/types.ts +46 -0
  121. package/src/types/xpath-to-css.d.ts +3 -0
  122. package/src/utils.ts +332 -0
  123. package/tsconfig.json +15 -0
@@ -0,0 +1,964 @@
1
+ /* eslint-disable consistent-return */
2
+ /* eslint-disable no-console */
3
+ import os from 'os';
4
+ import fs, { ensureDirSync } from 'fs-extra';
5
+ import printMessage from 'print-message';
6
+ import path from 'path';
7
+ import ejs from 'ejs';
8
+ import { fileURLToPath } from 'url';
9
+ import { chromium } from 'playwright';
10
+ import { createWriteStream } from 'fs';
11
+ import { AsyncParser, ParserOptions } from '@json2csv/node';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+ import constants, { ScannerTypes } from './constants/constants.js';
14
+ import { urlWithoutAuth } from './constants/common.js';
15
+ import {
16
+ createScreenshotsFolder,
17
+ getStoragePath,
18
+ getVersion,
19
+ getWcagPassPercentage,
20
+ retryFunction,
21
+ zipResults,
22
+ } from './utils.js';
23
+ import { consoleLogger, silentLogger } from './logs.js';
24
+ import itemTypeDescription from './constants/itemTypeDescription.js';
25
+ import { oobeeAiHtmlETL, oobeeAiRules } from './constants/oobeeAi.js';
26
+
27
+ const cwd = process.cwd();
28
+
29
+ export type ItemsInfo = {
30
+ html: string;
31
+ message: string;
32
+ screenshotPath: string;
33
+ xpath: string;
34
+ displayNeedsReview?: boolean;
35
+ };
36
+
37
+ type PageInfo = {
38
+ items: ItemsInfo[];
39
+ pageTitle: string;
40
+ url?: string;
41
+ pageImagePath?: string;
42
+ pageIndex?: number;
43
+ metadata: string;
44
+ };
45
+
46
+ export type RuleInfo = {
47
+ totalItems: number;
48
+ pagesAffected: PageInfo[];
49
+ rule: string;
50
+ description: string;
51
+ axeImpact: string;
52
+ conformance: string[];
53
+ helpUrl: string;
54
+ };
55
+
56
+ type AllIssues = {
57
+ storagePath: string;
58
+ oobeeAi: {
59
+ htmlETL: any;
60
+ rules: string[];
61
+ };
62
+ startTime: Date;
63
+ endTime: Date;
64
+ urlScanned: string;
65
+ scanType: string;
66
+ formatAboutStartTime: (dateString: any) => string;
67
+ isCustomFlow: boolean;
68
+ viewport: string;
69
+ pagesScanned: PageInfo[];
70
+ pagesNotScanned: PageInfo[];
71
+ totalPagesScanned: number;
72
+ totalPagesNotScanned: number;
73
+ totalItems: number;
74
+ topFiveMostIssues: Array<any>;
75
+ wcagViolations: string[];
76
+ customFlowLabel: string;
77
+ phAppVersion: string;
78
+ items: {
79
+ mustFix: { description: string; totalItems: number; rules: RuleInfo[] };
80
+ goodToFix: { description: string; totalItems: number; rules: RuleInfo[] };
81
+ needsReview: { description: string; totalItems: number; rules: RuleInfo[] };
82
+ passed: { description: string; totalItems: number; rules: RuleInfo[] };
83
+ };
84
+ cypressScanAboutMetadata: string;
85
+ wcagLinks: { [key: string]: string };
86
+ [key: string]: any;
87
+ };
88
+
89
+ const filename = fileURLToPath(import.meta.url);
90
+ const dirname = path.dirname(filename);
91
+
92
+ const extractFileNames = async (directory: string): Promise<string[]> => {
93
+ ensureDirSync(directory);
94
+
95
+ return fs
96
+ .readdir(directory)
97
+ .then(allFiles => allFiles.filter(file => path.extname(file).toLowerCase() === '.json'))
98
+ .catch(readdirError => {
99
+ consoleLogger.info('An error has occurred when retrieving files, please try again.');
100
+ silentLogger.error(`(extractFileNames) - ${readdirError}`);
101
+ throw readdirError;
102
+ });
103
+ };
104
+ const parseContentToJson = async rPath =>
105
+ fs
106
+ .readFile(rPath, 'utf8')
107
+ .then(content => JSON.parse(content))
108
+ .catch(parseError => {
109
+ consoleLogger.info('An error has occurred when parsing the content, please try again.');
110
+ silentLogger.error(`(parseContentToJson) - ${parseError}`);
111
+ });
112
+
113
+ const writeCsv = async (allIssues, storagePath) => {
114
+ const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
115
+ const formatPageViolation = pageNum => {
116
+ if (pageNum < 0) return 'Document';
117
+ return `Page ${pageNum}`;
118
+ };
119
+
120
+ // transform allIssues into the form:
121
+ // [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
122
+ const getRulesByCategory = (allIssues: AllIssues) => {
123
+ return Object.entries(allIssues.items)
124
+ .filter(([category]) => category !== 'passed')
125
+ .reduce((prev: [string, RuleInfo][], [category, value]) => {
126
+ const rulesEntries = Object.entries(value.rules);
127
+ rulesEntries.forEach(([, ruleInfo]) => {
128
+ prev.push([category, ruleInfo]);
129
+ });
130
+ return prev;
131
+ }, [])
132
+ .sort((a, b) => {
133
+ // sort rules according to severity, then ruleId
134
+ const compareCategory = -a[0].localeCompare(b[0]);
135
+ return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
136
+ });
137
+ };
138
+ // seems to go into
139
+ const flattenRule = catAndRule => {
140
+ const [severity, rule] = catAndRule;
141
+ const results = [];
142
+ const {
143
+ rule: issueId,
144
+ description: issueDescription,
145
+ axeImpact,
146
+ conformance,
147
+ pagesAffected,
148
+ helpUrl: learnMore,
149
+ } = rule;
150
+ // we filter out the below as it represents the A/AA/AAA level, not the clause itself
151
+ const clausesArr = conformance.filter(
152
+ clause => !['wcag2a', 'wcag2aa', 'wcag2aaa'].includes(clause),
153
+ );
154
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
155
+ // format clauses as a string
156
+ const wcagConformance = clausesArr.join(',');
157
+ pagesAffected.forEach(affectedPage => {
158
+ const { url, items } = affectedPage;
159
+ items.forEach(item => {
160
+ const { html, page, message, xpath } = item;
161
+ const howToFix = message.replace(/(\r\n|\n|\r)/g, ' '); // remove newlines
162
+ const violation = html || formatPageViolation(page); // page is a number, not a string
163
+ const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
164
+
165
+ results.push({
166
+ severity,
167
+ issueId,
168
+ issueDescription,
169
+ wcagConformance,
170
+ url,
171
+ context,
172
+ howToFix,
173
+ axeImpact,
174
+ xpath,
175
+ learnMore,
176
+ });
177
+ });
178
+ });
179
+ if (results.length === 0) return {};
180
+ return results;
181
+ };
182
+ const opts: ParserOptions<any, any> = {
183
+ transforms: [getRulesByCategory, flattenRule],
184
+ fields: [
185
+ 'severity',
186
+ 'issueId',
187
+ 'issueDescription',
188
+ 'wcagConformance',
189
+ 'url',
190
+ 'context',
191
+ 'howToFix',
192
+ 'axeImpact',
193
+ 'xpath',
194
+ 'learnMore',
195
+ ],
196
+ includeEmptyRows: true,
197
+ };
198
+ const parser = new AsyncParser(opts);
199
+ parser.parse(allIssues).pipe(csvOutput);
200
+ };
201
+
202
+ const compileHtmlWithEJS = async (allIssues, storagePath, htmlFilename = 'report') => {
203
+ const htmlFilePath = `${path.join(storagePath, htmlFilename)}.html`;
204
+ const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/report.ejs'), 'utf-8');
205
+ const template = ejs.compile(ejsString, {
206
+ filename: path.join(dirname, './static/ejs/report.ejs'),
207
+ });
208
+ const html = template(allIssues);
209
+ await fs.writeFile(htmlFilePath, html);
210
+
211
+ let htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });
212
+
213
+ const headIndex = htmlContent.indexOf('</head>');
214
+ const injectScript = `
215
+ <script>
216
+ try {
217
+ const base64DecodeChunkedWithDecoder = (data, chunkSize = 1024 * 1024) => {
218
+ const encodedChunks = data.split('.');
219
+ const decoder = new TextDecoder();
220
+ const jsonParts = [];
221
+
222
+ encodedChunks.forEach(chunk => {
223
+ for (let i = 0; i < chunk.length; i += chunkSize) {
224
+ const chunkPart = chunk.slice(i, i + chunkSize);
225
+ const decodedBytes = Uint8Array.from(atob(chunkPart), c => c.charCodeAt(0));
226
+ jsonParts.push(decoder.decode(decodedBytes, { stream: true }));
227
+ }
228
+ });
229
+
230
+ return JSON.parse(jsonParts.join(''));
231
+
232
+ };
233
+
234
+ // IMPORTANT! DO NOT REMOVE ME: Decode the encoded data
235
+ } catch (error) {
236
+ console.error("Error decoding base64 data:", error);
237
+ }
238
+ </script>
239
+ `;
240
+
241
+ if (headIndex !== -1) {
242
+ htmlContent = htmlContent.slice(0, headIndex) + injectScript + htmlContent.slice(headIndex);
243
+ } else {
244
+ htmlContent += injectScript;
245
+ }
246
+
247
+ await fs.writeFile(htmlFilePath, htmlContent);
248
+
249
+ return htmlFilePath;
250
+ };
251
+
252
+ const splitHtmlAndCreateFiles = async (htmlFilePath, storagePath) => {
253
+ try {
254
+ const htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });
255
+ const splitMarker = '// IMPORTANT! DO NOT REMOVE ME: Decode the encoded data';
256
+ const splitIndex = htmlContent.indexOf(splitMarker);
257
+
258
+ if (splitIndex === -1) {
259
+ throw new Error('Marker comment not found in the HTML file.');
260
+ }
261
+
262
+ const topContent = htmlContent.slice(0, splitIndex + splitMarker.length) + '\n\n';
263
+ const bottomContent = htmlContent.slice(splitIndex + splitMarker.length);
264
+
265
+ const topFilePath = path.join(storagePath, 'report-partial-top.htm.txt');
266
+ const bottomFilePath = path.join(storagePath, 'report-partial-bottom.htm.txt');
267
+
268
+ await fs.writeFile(topFilePath, topContent, { encoding: 'utf8' });
269
+ await fs.writeFile(bottomFilePath, bottomContent, { encoding: 'utf8' });
270
+
271
+ await fs.unlink(htmlFilePath);
272
+
273
+ return { topFilePath, bottomFilePath };
274
+ } catch (error) {
275
+ console.error('Error splitting HTML and creating files:', error);
276
+ }
277
+ };
278
+
279
+ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report') => {
280
+ const htmlFilePath = await compileHtmlWithEJS(allIssues, storagePath, htmlFilename);
281
+ const inputFilePath = path.resolve(storagePath, 'scanDetails.csv');
282
+ const outputFilePath = `${storagePath}/${htmlFilename}.html`;
283
+
284
+ const { topFilePath, bottomFilePath } = await splitHtmlAndCreateFiles(htmlFilePath, storagePath);
285
+
286
+ const prefixData = fs.readFileSync(path.join(storagePath, 'report-partial-top.htm.txt'), 'utf-8');
287
+ const suffixData = fs.readFileSync(
288
+ path.join(storagePath, 'report-partial-bottom.htm.txt'),
289
+ 'utf-8',
290
+ );
291
+
292
+ const outputStream = fs.createWriteStream(outputFilePath, { flags: 'a' });
293
+
294
+ outputStream.write(prefixData);
295
+
296
+ // Create a readable stream for the input file with a highWaterMark set to 10MB
297
+ const BUFFER_LIMIT = 10 * 1024 * 1024; // 10 MB
298
+ const inputStream = fs.createReadStream(inputFilePath, {
299
+ encoding: 'utf-8',
300
+ highWaterMark: BUFFER_LIMIT,
301
+ });
302
+
303
+ let isFirstLine = true;
304
+ let lineEndingDetected = false;
305
+ let isFirstField = true;
306
+ let isWritingFirstDataLine = true;
307
+ let buffer = '';
308
+
309
+ function flushBuffer() {
310
+ if (buffer.length > 0) {
311
+ outputStream.write(buffer);
312
+ buffer = '';
313
+ }
314
+ }
315
+
316
+ const cleanupFiles = async () => {
317
+ try {
318
+ await Promise.all([fs.promises.unlink(topFilePath), fs.promises.unlink(bottomFilePath)]);
319
+ } catch (err) {
320
+ console.error('Error cleaning up temporary files:', err);
321
+ }
322
+ };
323
+
324
+ inputStream.on('data', chunk => {
325
+ let chunkIndex = 0;
326
+
327
+ while (chunkIndex < chunk.length) {
328
+ const char = chunk[chunkIndex];
329
+
330
+ if (isFirstLine) {
331
+ if (char === '\n' || char === '\r') {
332
+ lineEndingDetected = true;
333
+ } else if (lineEndingDetected) {
334
+ if (char !== '\n' && char !== '\r') {
335
+ isFirstLine = false;
336
+
337
+ if (isWritingFirstDataLine) {
338
+ buffer += "scanData = base64DecodeChunkedWithDecoder('";
339
+ isWritingFirstDataLine = false;
340
+ }
341
+ buffer += char;
342
+ }
343
+ lineEndingDetected = false;
344
+ }
345
+ } else {
346
+ if (char === ',') {
347
+ buffer += "')\n\n";
348
+ buffer += "scanItems = base64DecodeChunkedWithDecoder('";
349
+ isFirstField = false;
350
+ } else if (char === '\n' || char === '\r') {
351
+ if (!isFirstField) {
352
+ buffer += "')\n";
353
+ }
354
+ } else {
355
+ buffer += char;
356
+ }
357
+
358
+ if (buffer.length >= BUFFER_LIMIT) {
359
+ flushBuffer();
360
+ }
361
+ }
362
+
363
+ chunkIndex++;
364
+ }
365
+ });
366
+
367
+ inputStream.on('end', async () => {
368
+ if (!isFirstField) {
369
+ buffer += "')\n";
370
+ }
371
+ flushBuffer();
372
+
373
+ outputStream.write(suffixData);
374
+ outputStream.end();
375
+ console.log('Content appended successfully.');
376
+
377
+ await cleanupFiles();
378
+ });
379
+
380
+ inputStream.on('error', async err => {
381
+ console.error('Error reading input file:', err);
382
+ outputStream.end();
383
+
384
+ await cleanupFiles();
385
+ });
386
+
387
+ outputStream.on('error', err => {
388
+ console.error('Error writing to output file:', err);
389
+ });
390
+ };
391
+
392
+ const writeSummaryHTML = async (allIssues, storagePath, htmlFilename = 'summary') => {
393
+ const ejsString = fs.readFileSync(path.join(dirname, './static/ejs/summary.ejs'), 'utf-8');
394
+ const template = ejs.compile(ejsString, {
395
+ filename: path.join(dirname, './static/ejs/summary.ejs'),
396
+ });
397
+ const html = template(allIssues);
398
+ fs.writeFileSync(`${storagePath}/${htmlFilename}.html`, html);
399
+ };
400
+
401
+ function writeFormattedValue(value, writeStream) {
402
+ if (typeof value === 'function') {
403
+ writeStream.write('null');
404
+ } else if (value === undefined) {
405
+ writeStream.write('null');
406
+ } else if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') {
407
+ writeStream.write(JSON.stringify(value));
408
+ } else if (value === null) {
409
+ writeStream.write('null');
410
+ }
411
+ }
412
+
413
+ function serializeObject(obj, writeStream, depth = 0, indent = ' ') {
414
+ const currentIndent = indent.repeat(depth);
415
+ const nextIndent = indent.repeat(depth + 1);
416
+
417
+ if (obj instanceof Date) {
418
+ writeStream.write(JSON.stringify(obj.toISOString()));
419
+ } else if (Array.isArray(obj)) {
420
+ writeStream.write('[\n');
421
+ obj.forEach((item, index) => {
422
+ if (index > 0) writeStream.write(',\n');
423
+ writeStream.write(nextIndent);
424
+ serializeObject(item, writeStream, depth + 1, indent);
425
+ });
426
+ writeStream.write(`\n${currentIndent}]`);
427
+ } else if (typeof obj === 'object' && obj !== null) {
428
+ writeStream.write('{\n');
429
+ const keys = Object.keys(obj);
430
+ keys.forEach((key, index) => {
431
+ if (index > 0) writeStream.write(',\n');
432
+ writeStream.write(`${nextIndent}${JSON.stringify(key)}: `);
433
+ serializeObject(obj[key], writeStream, depth + 1, indent);
434
+ });
435
+ writeStream.write(`\n${currentIndent}}`);
436
+ } else {
437
+ writeFormattedValue(obj, writeStream);
438
+ }
439
+ }
440
+
441
+ function writeLargeJsonToFile(obj, filePath) {
442
+ return new Promise((resolve, reject) => {
443
+ const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
444
+
445
+ writeStream.on('error', error => {
446
+ consoleLogger.error('Stream error:', error);
447
+ reject(error);
448
+ });
449
+
450
+ writeStream.on('finish', () => {
451
+ consoleLogger.info('Temporary file written successfully:', filePath);
452
+ resolve(true);
453
+ });
454
+
455
+ serializeObject(obj, writeStream);
456
+ writeStream.end();
457
+ });
458
+ }
459
+
460
+ const base64Encode = async (data, num) => {
461
+ try {
462
+ const tempFilename =
463
+ num === 1
464
+ ? `scanItems_${uuidv4()}.json`
465
+ : num === 2
466
+ ? `scanData_${uuidv4()}.json`
467
+ : `${uuidv4()}.json`;
468
+ const tempFilePath = path.join(process.cwd(), tempFilename);
469
+
470
+ await writeLargeJsonToFile(data, tempFilePath);
471
+
472
+ const outputFilename = `encoded_${uuidv4()}.txt`;
473
+ const outputFilePath = path.join(process.cwd(), outputFilename);
474
+
475
+ try {
476
+ const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
477
+ const readStream = fs.createReadStream(tempFilePath, {
478
+ encoding: 'utf8',
479
+ highWaterMark: CHUNK_SIZE,
480
+ });
481
+ const writeStream = fs.createWriteStream(outputFilePath, { encoding: 'utf8' });
482
+
483
+ for await (const chunk of readStream) {
484
+ const encodedChunk = Buffer.from(chunk).toString('base64');
485
+ writeStream.write(`${encodedChunk}.`);
486
+ }
487
+
488
+ await new Promise((resolve, reject) => {
489
+ writeStream.end(resolve);
490
+ writeStream.on('error', reject);
491
+ });
492
+
493
+ return outputFilePath;
494
+ } finally {
495
+ await fs.promises
496
+ .unlink(tempFilePath)
497
+ .catch(err => console.error('Temp file delete error:', err));
498
+ }
499
+ } catch (error) {
500
+ console.error('Error encoding data to Base64:', error);
501
+ throw error;
502
+ }
503
+ };
504
+
505
+ const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma) => {
506
+ const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
507
+ let isFirstChunk = true;
508
+
509
+ for await (const chunk of readStream) {
510
+ if (isFirstChunk) {
511
+ isFirstChunk = false;
512
+ writeStream.write(chunk);
513
+ } else {
514
+ writeStream.write(chunk);
515
+ }
516
+ }
517
+
518
+ if (appendComma) {
519
+ writeStream.write(',');
520
+ }
521
+ };
522
+
523
+ const writeBase64 = async (allIssues, storagePath) => {
524
+ const { items, ...rest } = allIssues;
525
+ const encodedScanItemsPath = await base64Encode(items, 1);
526
+ const encodedScanDataPath = await base64Encode(rest, 2);
527
+
528
+ const filePath = path.join(storagePath, 'scanDetails.csv');
529
+ const directoryPath = path.dirname(filePath);
530
+
531
+ if (!fs.existsSync(directoryPath)) {
532
+ fs.mkdirSync(directoryPath, { recursive: true });
533
+ }
534
+
535
+ const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
536
+
537
+ csvWriteStream.write('scanData_base64,scanItems_base64\n');
538
+ await streamEncodedDataToFile(encodedScanDataPath, csvWriteStream, true);
539
+ await streamEncodedDataToFile(encodedScanItemsPath, csvWriteStream, false);
540
+
541
+ await new Promise((resolve, reject) => {
542
+ csvWriteStream.end(resolve);
543
+ csvWriteStream.on('error', reject);
544
+ });
545
+
546
+ await fs.promises
547
+ .unlink(encodedScanDataPath)
548
+ .catch(err => console.error('Encoded file delete error:', err));
549
+ await fs.promises
550
+ .unlink(encodedScanItemsPath)
551
+ .catch(err => console.error('Encoded file delete error:', err));
552
+ };
553
+
554
+ let browserChannel = 'chrome';
555
+
556
+ if (os.platform() === 'win32') {
557
+ browserChannel = 'msedge';
558
+ }
559
+
560
+ if (os.platform() === 'linux') {
561
+ browserChannel = 'chromium';
562
+ }
563
+
564
+ const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary') => {
565
+ const htmlFilePath = `${storagePath}/${filename}.html`;
566
+ const fileDestinationPath = `${storagePath}/${filename}.pdf`;
567
+ const browser = await chromium.launch({
568
+ headless: true,
569
+ channel: browserChannel,
570
+ });
571
+
572
+ const context = await browser.newContext({
573
+ ignoreHTTPSErrors: true,
574
+ serviceWorkers: 'block',
575
+ });
576
+
577
+ const page = await context.newPage();
578
+
579
+ const data = fs.readFileSync(htmlFilePath, { encoding: 'utf-8' });
580
+ await page.setContent(data);
581
+
582
+ await page.waitForLoadState('networkidle', { timeout: 30000 });
583
+
584
+ await page.emulateMedia({ media: 'print' });
585
+
586
+ await page.pdf({
587
+ margin: { bottom: '32px' },
588
+ path: fileDestinationPath,
589
+ format: 'A4',
590
+ displayHeaderFooter: true,
591
+ footerTemplate: `
592
+ <div style="margin-top:50px;color:#26241b;font-family:Open Sans;text-align: center;width: 100%;font-weight:400">
593
+ <span style="color:#26241b;font-size: 14px;font-weight:400">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
594
+ </div>
595
+ `,
596
+ });
597
+
598
+ await page.close();
599
+
600
+ await context.close();
601
+ await browser.close();
602
+
603
+ if (pagesScanned < 2000) {
604
+ fs.unlinkSync(htmlFilePath);
605
+ }
606
+ };
607
+
608
+ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
609
+ const { url, pageTitle, filePath } = pageResults;
610
+
611
+ const totalIssuesInPage = new Set();
612
+ Object.keys(pageResults.mustFix.rules).forEach(k => totalIssuesInPage.add(k));
613
+ Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
614
+ Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
615
+
616
+ allIssues.topFiveMostIssues.push({ url, pageTitle, totalIssues: totalIssuesInPage.size });
617
+
618
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
619
+ if (!pageResults[category]) return;
620
+
621
+ const { totalItems, rules } = pageResults[category];
622
+ const currCategoryFromAllIssues = allIssues.items[category];
623
+
624
+ currCategoryFromAllIssues.totalItems += totalItems;
625
+
626
+ Object.keys(rules).forEach(rule => {
627
+ const {
628
+ description,
629
+ axeImpact,
630
+ helpUrl,
631
+ conformance,
632
+ totalItems: count,
633
+ items,
634
+ } = rules[rule];
635
+ if (!(rule in currCategoryFromAllIssues.rules)) {
636
+ currCategoryFromAllIssues.rules[rule] = {
637
+ description,
638
+ axeImpact,
639
+ helpUrl,
640
+ conformance,
641
+ totalItems: 0,
642
+ // numberOfPagesAffectedAfterRedirects: 0,
643
+ pagesAffected: {},
644
+ };
645
+ }
646
+
647
+ if (category !== 'passed' && category !== 'needsReview') {
648
+ conformance
649
+ .filter(c => /wcag[0-9]{3,4}/.test(c))
650
+ .forEach(c => {
651
+ if (!allIssues.wcagViolations.includes(c)) {
652
+ allIssues.wcagViolations.push(c);
653
+ }
654
+ });
655
+ }
656
+
657
+ const currRuleFromAllIssues = currCategoryFromAllIssues.rules[rule];
658
+
659
+ currRuleFromAllIssues.totalItems += count;
660
+
661
+ if (isCustomFlow) {
662
+ const { pageIndex, pageImagePath, metadata } = pageResults;
663
+ currRuleFromAllIssues.pagesAffected[pageIndex] = {
664
+ url,
665
+ pageTitle,
666
+ pageImagePath,
667
+ metadata,
668
+ items: [],
669
+ };
670
+ currRuleFromAllIssues.pagesAffected[pageIndex].items.push(...items);
671
+ } else {
672
+ if (!(url in currRuleFromAllIssues.pagesAffected)) {
673
+ currRuleFromAllIssues.pagesAffected[url] = {
674
+ pageTitle,
675
+ items: [],
676
+ ...(filePath && { filePath }),
677
+ };
678
+ /* if (actualUrl) {
679
+ currRuleFromAllIssues.pagesAffected[url].actualUrl = actualUrl;
680
+ // Deduct duplication count from totalItems
681
+ currRuleFromAllIssues.totalItems -= 1;
682
+ // Previously using pagesAffected.length to display no. of pages affected
683
+ // However, since pagesAffected array contains duplicates, we need to deduct the duplicates
684
+ // Hence, start with negative offset, will add pagesAffected.length later
685
+ currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects -= 1;
686
+ currCategoryFromAllIssues.totalItems -= 1;
687
+ } */
688
+ }
689
+
690
+ currRuleFromAllIssues.pagesAffected[url].items.push(...items);
691
+ // currRuleFromAllIssues.numberOfPagesAffectedAfterRedirects +=
692
+ // currRuleFromAllIssues.pagesAffected.length;
693
+ }
694
+ });
695
+ });
696
+ };
697
+
698
+ const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
699
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
700
+ allIssues.totalItems += allIssues.items[category].totalItems;
701
+ allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
702
+ .map(ruleEntry => {
703
+ const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
704
+ ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
705
+ .map(pageEntry => {
706
+ if (isCustomFlow) {
707
+ const [pageIndex, pageInfo] = pageEntry as unknown as [number, PageInfo];
708
+ return { pageIndex, ...pageInfo };
709
+ }
710
+ const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
711
+ return { url, ...pageInfo };
712
+ })
713
+ .sort((page1, page2) => page2.items.length - page1.items.length);
714
+ return { rule, ...ruleInfo };
715
+ })
716
+ .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
717
+ });
718
+ allIssues.topFiveMostIssues.sort((page1, page2) => page2.totalIssues - page1.totalIssues);
719
+ allIssues.topFiveMostIssues = allIssues.topFiveMostIssues.slice(0, 5);
720
+ };
721
+
722
+ const createRuleIdJson = allIssues => {
723
+ const compiledRuleJson = {};
724
+
725
+ const ruleIterator = rule => {
726
+ const ruleId = rule.rule;
727
+ let snippets = [];
728
+
729
+ if (oobeeAiRules.includes(ruleId)) {
730
+ const snippetsSet = new Set();
731
+ rule.pagesAffected.forEach(page => {
732
+ page.items.forEach(htmlItem => {
733
+ snippetsSet.add(oobeeAiHtmlETL(htmlItem.html));
734
+ });
735
+ });
736
+ snippets = [...snippetsSet];
737
+ }
738
+ compiledRuleJson[ruleId] = {
739
+ snippets,
740
+ occurrences: rule.totalItems,
741
+ };
742
+ };
743
+
744
+ allIssues.items.mustFix.rules.forEach(ruleIterator);
745
+ allIssues.items.goodToFix.rules.forEach(ruleIterator);
746
+ allIssues.items.needsReview.rules.forEach(ruleIterator);
747
+ return compiledRuleJson;
748
+ };
749
+
750
+ const moveElemScreenshots = (randomToken, storagePath) => {
751
+ const currentScreenshotsPath = `${randomToken}/elemScreenshots`;
752
+ const resultsScreenshotsPath = `${storagePath}/elemScreenshots`;
753
+ if (fs.existsSync(currentScreenshotsPath)) {
754
+ fs.moveSync(currentScreenshotsPath, resultsScreenshotsPath);
755
+ }
756
+ };
757
+
758
+ const generateArtifacts = async (
759
+ randomToken,
760
+ urlScanned,
761
+ scanType,
762
+ viewport,
763
+ pagesScanned,
764
+ pagesNotScanned,
765
+ customFlowLabel,
766
+ cypressScanAboutMetadata,
767
+ scanDetails,
768
+ zip = undefined, // optional
769
+ ) => {
770
+ const intermediateDatasetsPath = `${randomToken}/datasets/${randomToken}`;
771
+ const phAppVersion = getVersion();
772
+ const storagePath = getStoragePath(randomToken);
773
+
774
+ urlScanned =
775
+ scanType === ScannerTypes.SITEMAP || scanType === ScannerTypes.LOCALFILE
776
+ ? urlScanned
777
+ : urlWithoutAuth(urlScanned);
778
+
779
+ const formatAboutStartTime = dateString => {
780
+ const utcStartTimeDate = new Date(dateString);
781
+ const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
782
+ year: 'numeric',
783
+ month: 'short',
784
+ day: 'numeric',
785
+ hour12: false,
786
+ hour: 'numeric',
787
+ minute: '2-digit',
788
+ timeZoneName: 'shortGeneric',
789
+ });
790
+
791
+ const timezoneAbbreviation = new Intl.DateTimeFormat('en', {
792
+ timeZoneName: 'shortOffset',
793
+ })
794
+ .formatToParts(utcStartTimeDate)
795
+ .find(part => part.type === 'timeZoneName').value;
796
+
797
+ // adding a breakline between the time and timezone so it looks neater on report
798
+ const timeColonIndex = formattedStartTime.lastIndexOf(':');
799
+ const timePart = formattedStartTime.slice(0, timeColonIndex + 3);
800
+ const timeZonePart = formattedStartTime.slice(timeColonIndex + 4);
801
+ const htmlFormattedStartTime = `${timePart}<br>${timeZonePart} ${timezoneAbbreviation}`;
802
+
803
+ return htmlFormattedStartTime;
804
+ };
805
+
806
+ const isCustomFlow = scanType === ScannerTypes.CUSTOM;
807
+
808
+ const allIssues: AllIssues = {
809
+ storagePath,
810
+ oobeeAi: {
811
+ htmlETL: oobeeAiHtmlETL,
812
+ rules: oobeeAiRules,
813
+ },
814
+ startTime: scanDetails.startTime ? scanDetails.startTime : new Date(),
815
+ endTime: scanDetails.endTime ? scanDetails.endTime : new Date(),
816
+ urlScanned,
817
+ scanType,
818
+ formatAboutStartTime,
819
+ isCustomFlow,
820
+ viewport,
821
+ pagesScanned,
822
+ pagesNotScanned,
823
+ totalPagesScanned: pagesScanned.length,
824
+ totalPagesNotScanned: pagesNotScanned.length,
825
+ totalItems: 0,
826
+ topFiveMostIssues: [],
827
+ wcagViolations: [],
828
+ customFlowLabel,
829
+ phAppVersion,
830
+ items: {
831
+ mustFix: { description: itemTypeDescription.mustFix, totalItems: 0, rules: [] },
832
+ goodToFix: { description: itemTypeDescription.goodToFix, totalItems: 0, rules: [] },
833
+ needsReview: { description: itemTypeDescription.needsReview, totalItems: 0, rules: [] },
834
+ passed: { description: itemTypeDescription.passed, totalItems: 0, rules: [] },
835
+ },
836
+ cypressScanAboutMetadata,
837
+ wcagLinks: constants.wcagLinks,
838
+ };
839
+
840
+ const allFiles = await extractFileNames(intermediateDatasetsPath);
841
+
842
+ const jsonArray = await Promise.all(
843
+ allFiles.map(async file => parseContentToJson(`${intermediateDatasetsPath}/${file}`)),
844
+ );
845
+
846
+ await Promise.all(
847
+ jsonArray.map(async pageResults => {
848
+ await pushResults(pageResults, allIssues, isCustomFlow);
849
+ }),
850
+ ).catch(flattenIssuesError => {
851
+ consoleLogger.info('An error has occurred when flattening the issues, please try again.');
852
+ silentLogger.error(flattenIssuesError.stack);
853
+ });
854
+
855
+ flattenAndSortResults(allIssues, isCustomFlow);
856
+
857
+ printMessage([
858
+ 'Scan Summary',
859
+ '',
860
+ `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'}`,
861
+ `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'}`,
862
+ `Needs Review: ${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'}`,
863
+ `Passed: ${allIssues.items.passed.totalItems} ${allIssues.items.passed.totalItems === 1 ? 'occurrence' : 'occurrences'}`,
864
+ ]);
865
+
866
+ // move screenshots folder to report folders
867
+ moveElemScreenshots(randomToken, storagePath);
868
+ if (isCustomFlow) {
869
+ createScreenshotsFolder(randomToken);
870
+ }
871
+
872
+ allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations);
873
+
874
+ const getAxeImpactCount = (allIssues: AllIssues) => {
875
+ const impactCount = {
876
+ critical: 0,
877
+ serious: 0,
878
+ moderate: 0,
879
+ minor: 0,
880
+ };
881
+ Object.values(allIssues.items).forEach(category => {
882
+ if (category.totalItems > 0) {
883
+ Object.values(category.rules).forEach(rule => {
884
+ if (rule.axeImpact === 'critical') {
885
+ impactCount.critical += rule.totalItems;
886
+ } else if (rule.axeImpact === 'serious') {
887
+ impactCount.serious += rule.totalItems;
888
+ } else if (rule.axeImpact === 'moderate') {
889
+ impactCount.moderate += rule.totalItems;
890
+ } else if (rule.axeImpact === 'minor') {
891
+ impactCount.minor += rule.totalItems;
892
+ }
893
+ });
894
+ }
895
+ });
896
+
897
+ return impactCount;
898
+ };
899
+
900
+ if (process.env.OOBEE_VERBOSE) {
901
+ const axeImpactCount = getAxeImpactCount(allIssues);
902
+ const { items, startTime, endTime, ...rest } = allIssues;
903
+
904
+ rest.critical = axeImpactCount.critical;
905
+ rest.serious = axeImpactCount.serious;
906
+ rest.moderate = axeImpactCount.moderate;
907
+ rest.minor = axeImpactCount.minor;
908
+ }
909
+
910
+ await writeCsv(allIssues, storagePath);
911
+ await writeBase64(allIssues, storagePath);
912
+ await writeSummaryHTML(allIssues, storagePath);
913
+ await writeHTML(allIssues, storagePath);
914
+ await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length), 1);
915
+
916
+ // Take option if set
917
+ if (typeof zip === 'string') {
918
+ constants.cliZipFileName = zip;
919
+
920
+ if (!zip.endsWith('.zip')) {
921
+ constants.cliZipFileName += '.zip';
922
+ }
923
+ }
924
+
925
+ await fs
926
+ .ensureDir(storagePath)
927
+ .then(() => {
928
+ zipResults(constants.cliZipFileName, storagePath);
929
+ const messageToDisplay = [
930
+ `Report of this run is at ${constants.cliZipFileName}`,
931
+ `Results directory is at ${storagePath}`,
932
+ ];
933
+
934
+ if (process.env.REPORT_BREAKDOWN === '1') {
935
+ messageToDisplay.push(
936
+ 'Reports have been further broken down according to their respective impact level.',
937
+ );
938
+ }
939
+
940
+ if (process.send && process.env.OOBEE_VERBOSE && process.env.REPORT_BREAKDOWN != '1') {
941
+ const zipFileNameMessage = {
942
+ type: 'zipFileName',
943
+ payload: `${constants.cliZipFileName}`,
944
+ };
945
+ const storagePathMessage = {
946
+ type: 'storagePath',
947
+ payload: `${storagePath}`,
948
+ };
949
+
950
+ process.send(JSON.stringify(storagePathMessage));
951
+
952
+ process.send(JSON.stringify(zipFileNameMessage));
953
+ }
954
+
955
+ printMessage(messageToDisplay);
956
+ })
957
+ .catch(error => {
958
+ printMessage([`Error in zipping results: ${error}`]);
959
+ });
960
+
961
+ return createRuleIdJson(allIssues);
962
+ };
963
+
964
+ export default generateArtifacts;