@govtechsg/oobee 0.10.83 → 0.10.84

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.
@@ -11,6 +11,7 @@ import { DEBUG, initNewPage, log } from './custom/utils.js';
11
11
  import { guiInfoLog } from '../logs.js';
12
12
  import { ViewportSettingsClass } from '../combine.js';
13
13
  import { addUrlGuardScript } from './guards/urlGuard.js';
14
+ import { getPlaywrightLaunchOptions } from '../constants/common.js';
14
15
 
15
16
  // Export of classes
16
17
 
@@ -79,11 +80,18 @@ const runCustom = async (
79
80
  const deviceConfig = viewportSettings.playwrightDeviceDetailsObject;
80
81
  const hasCustomViewport = !!deviceConfig;
81
82
 
83
+ const baseLaunchOptions = getPlaywrightLaunchOptions('chrome');
84
+
85
+ // Merge base args with custom flow specific args
86
+ const baseArgs = baseLaunchOptions.args || [];
87
+ const customArgs = hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'];
88
+ const mergedArgs = [...baseArgs.filter(a => !a.startsWith('--window-size') && a !== '--start-maximized'), ...customArgs];
89
+
82
90
  const browser = await chromium.launch({
83
- args: hasCustomViewport ? ['--window-size=1920,1040'] : ['--start-maximized'],
91
+ ...baseLaunchOptions,
92
+ args: mergedArgs,
84
93
  headless: false,
85
94
  channel: 'chrome',
86
- // bypassCSP: true,
87
95
  });
88
96
 
89
97
  const context = await browser.newContext({
@@ -0,0 +1,62 @@
1
+ import type { AllIssues, ItemsInfo, RuleInfo } from './types.js';
2
+
3
+ /**
4
+ * Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
5
+ * Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
6
+ */
7
+ export const buildHtmlGroups = (rule: RuleInfo, items: ItemsInfo[], pageUrl: string) => {
8
+ if (!rule.htmlGroups) {
9
+ rule.htmlGroups = {};
10
+ }
11
+
12
+ items.forEach(item => {
13
+ // Use composite key of html + xpath for precise matching
14
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
15
+
16
+ if (!rule.htmlGroups![htmlKey]) {
17
+ // Create new group with the first occurrence
18
+ rule.htmlGroups![htmlKey] = {
19
+ html: item.html || '',
20
+ xpath: item.xpath || '',
21
+ message: item.message || '',
22
+ screenshotPath: item.screenshotPath || '',
23
+ displayNeedsReview: item.displayNeedsReview,
24
+ pageUrls: [],
25
+ };
26
+ }
27
+
28
+ if (!rule.htmlGroups![htmlKey].pageUrls.includes(pageUrl)) {
29
+ rule.htmlGroups![htmlKey].pageUrls.push(pageUrl);
30
+ }
31
+ });
32
+ };
33
+
34
+ /**
35
+ * Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
36
+ * Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
37
+ * Those refs are specifically for htmlGroups lookup (html + xpath).
38
+ */
39
+ export const convertItemsToReferences = (allIssues: AllIssues): AllIssues => {
40
+ const cloned = JSON.parse(JSON.stringify(allIssues));
41
+
42
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
43
+ if (!cloned.items[category]?.rules) return;
44
+
45
+ cloned.items[category].rules.forEach((rule: any) => {
46
+ if (!rule.pagesAffected || !rule.htmlGroups) return;
47
+
48
+ rule.pagesAffected.forEach((page: any) => {
49
+ if (!page.items) return;
50
+
51
+ page.items = page.items.map((item: any) => {
52
+ if (typeof item === 'string') return item; // Already a reference
53
+ // Use composite key matching buildHtmlGroups
54
+ const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
55
+ return htmlKey;
56
+ });
57
+ });
58
+ });
59
+ });
60
+
61
+ return cloned;
62
+ };
@@ -0,0 +1,451 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import zlib from 'zlib';
4
+ import { Base64Encode } from 'base64-stream';
5
+ import { pipeline } from 'stream/promises';
6
+ import { a11yRuleShortDescriptionMap } from '../constants/constants.js';
7
+ import { consoleLogger } from '../logs.js';
8
+ import type { AllIssues } from './types.js';
9
+
10
+ function* serializeObject(obj: any, depth = 0, indent = ' ') {
11
+ const currentIndent = indent.repeat(depth);
12
+ const nextIndent = indent.repeat(depth + 1);
13
+
14
+ if (obj instanceof Date) {
15
+ yield JSON.stringify(obj.toISOString());
16
+ return;
17
+ }
18
+
19
+ if (Array.isArray(obj)) {
20
+ yield '[\n';
21
+ for (let i = 0; i < obj.length; i++) {
22
+ if (i > 0) yield ',\n';
23
+ yield nextIndent;
24
+ yield* serializeObject(obj[i], depth + 1, indent);
25
+ }
26
+ yield `\n${currentIndent}]`;
27
+ return;
28
+ }
29
+
30
+ if (obj !== null && typeof obj === 'object') {
31
+ yield '{\n';
32
+ const keys = Object.keys(obj);
33
+ for (let i = 0; i < keys.length; i++) {
34
+ const key = keys[i];
35
+ if (i > 0) yield ',\n';
36
+ yield `${nextIndent}${JSON.stringify(key)}: `;
37
+ yield* serializeObject(obj[key], depth + 1, indent);
38
+ }
39
+ yield `\n${currentIndent}}`;
40
+ return;
41
+ }
42
+
43
+ if (obj === null || typeof obj === 'function' || typeof obj === 'undefined') {
44
+ yield 'null';
45
+ return;
46
+ }
47
+
48
+ yield JSON.stringify(obj);
49
+ }
50
+
51
+ function writeLargeJsonToFile(obj: object, filePath: string) {
52
+ return new Promise((resolve, reject) => {
53
+ const writeStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
54
+
55
+ writeStream.on('error', error => {
56
+ consoleLogger.error('Stream error:', error);
57
+ reject(error);
58
+ });
59
+
60
+ writeStream.on('finish', () => {
61
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
62
+ resolve(true);
63
+ });
64
+
65
+ const generator = serializeObject(obj);
66
+
67
+ function write() {
68
+ let next: any;
69
+ while (!(next = generator.next()).done) {
70
+ if (!writeStream.write(next.value)) {
71
+ writeStream.once('drain', write);
72
+ return;
73
+ }
74
+ }
75
+ writeStream.end();
76
+ }
77
+
78
+ write();
79
+ });
80
+ }
81
+
82
+ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
83
+ return new Promise((resolve, reject) => {
84
+ const writeStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
85
+ const writeQueue: string[] = [];
86
+ let isWriting = false;
87
+
88
+ const processNextWrite = async () => {
89
+ if (isWriting || writeQueue.length === 0) return;
90
+
91
+ isWriting = true;
92
+ const data = writeQueue.shift()!;
93
+
94
+ try {
95
+ if (!writeStream.write(data)) {
96
+ await new Promise<void>(resolve => {
97
+ writeStream.once('drain', () => {
98
+ resolve();
99
+ });
100
+ });
101
+ }
102
+ } catch (error) {
103
+ writeStream.destroy(error as Error);
104
+ return;
105
+ }
106
+
107
+ isWriting = false;
108
+ processNextWrite();
109
+ };
110
+
111
+ const queueWrite = (data: string) => {
112
+ writeQueue.push(data);
113
+ processNextWrite();
114
+ };
115
+
116
+ writeStream.on('error', error => {
117
+ consoleLogger.error(`Error writing object to JSON file: ${error}`);
118
+ reject(error);
119
+ });
120
+
121
+ writeStream.on('finish', () => {
122
+ consoleLogger.info(`JSON file written successfully: ${filePath}`);
123
+ resolve(true);
124
+ });
125
+
126
+ try {
127
+ queueWrite('{\n');
128
+ const keys = Object.keys(obj);
129
+
130
+ keys.forEach((key, i) => {
131
+ const value = obj[key];
132
+
133
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
134
+ queueWrite(` "${key}": ${JSON.stringify(value)}`);
135
+ } else {
136
+ queueWrite(` "${key}": {\n`);
137
+
138
+ const { rules, ...otherProperties } = value;
139
+
140
+ Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
141
+ const propValueString =
142
+ propValue === null ||
143
+ typeof propValue === 'function' ||
144
+ typeof propValue === 'undefined'
145
+ ? 'null'
146
+ : JSON.stringify(propValue);
147
+ queueWrite(` "${propKey}": ${propValueString}`);
148
+ if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
149
+ queueWrite(',\n');
150
+ } else {
151
+ queueWrite('\n');
152
+ }
153
+ });
154
+
155
+ if (rules && Array.isArray(rules)) {
156
+ queueWrite(' "rules": [\n');
157
+
158
+ rules.forEach((rule, j) => {
159
+ queueWrite(' {\n');
160
+ const { pagesAffected, ...otherRuleProperties } = rule;
161
+
162
+ Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
163
+ const ruleValueString =
164
+ ruleValue === null ||
165
+ typeof ruleValue === 'function' ||
166
+ typeof ruleValue === 'undefined'
167
+ ? 'null'
168
+ : JSON.stringify(ruleValue);
169
+ queueWrite(` "${ruleKey}": ${ruleValueString}`);
170
+ if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
171
+ queueWrite(',\n');
172
+ } else {
173
+ queueWrite('\n');
174
+ }
175
+ });
176
+
177
+ if (pagesAffected && Array.isArray(pagesAffected)) {
178
+ queueWrite(' "pagesAffected": [\n');
179
+
180
+ pagesAffected.forEach((page, p) => {
181
+ const pageJson = JSON.stringify(page, null, 2)
182
+ .split('\n')
183
+ .map(line => ` ${line}`)
184
+ .join('\n');
185
+
186
+ queueWrite(pageJson);
187
+
188
+ if (p < pagesAffected.length - 1) {
189
+ queueWrite(',\n');
190
+ } else {
191
+ queueWrite('\n');
192
+ }
193
+ });
194
+
195
+ queueWrite(' ]');
196
+ }
197
+
198
+ queueWrite('\n }');
199
+ if (j < rules.length - 1) {
200
+ queueWrite(',\n');
201
+ } else {
202
+ queueWrite('\n');
203
+ }
204
+ });
205
+
206
+ queueWrite(' ]');
207
+ }
208
+ queueWrite('\n }');
209
+ }
210
+
211
+ if (i < keys.length - 1) {
212
+ queueWrite(',\n');
213
+ } else {
214
+ queueWrite('\n');
215
+ }
216
+ });
217
+
218
+ queueWrite('}\n');
219
+
220
+ const checkQueueAndEnd = () => {
221
+ if (writeQueue.length === 0 && !isWriting) {
222
+ writeStream.end();
223
+ } else {
224
+ setTimeout(checkQueueAndEnd, 100);
225
+ }
226
+ };
227
+
228
+ checkQueueAndEnd();
229
+ } catch (err) {
230
+ writeStream.destroy(err as Error);
231
+ reject(err);
232
+ }
233
+ });
234
+ };
235
+
236
+ async function compressJsonFileStreaming(inputPath: string, outputPath: string) {
237
+ const readStream = fs.createReadStream(inputPath);
238
+ const writeStream = fs.createWriteStream(outputPath);
239
+ const gzip = zlib.createGzip();
240
+ const base64Encode = new Base64Encode();
241
+
242
+ await pipeline(readStream, gzip, base64Encode, writeStream);
243
+ consoleLogger.info(`File successfully compressed and saved to ${outputPath}`);
244
+ }
245
+
246
+ const writeJsonFileAndCompressedJsonFile = async (
247
+ data: object,
248
+ storagePath: string,
249
+ filename: string,
250
+ ): Promise<{ jsonFilePath: string; base64FilePath: string }> => {
251
+ try {
252
+ consoleLogger.info(`Writing JSON to ${filename}.json`);
253
+ const jsonFilePath = path.join(storagePath, `${filename}.json`);
254
+ if (filename === 'scanItems') {
255
+ await writeLargeScanItemsJsonToFile(data, jsonFilePath);
256
+ } else {
257
+ await writeLargeJsonToFile(data, jsonFilePath);
258
+ }
259
+
260
+ consoleLogger.info(
261
+ `Reading ${filename}.json, gzipping and base64 encoding it into ${filename}.json.gz.b64`,
262
+ );
263
+ const base64FilePath = path.join(storagePath, `${filename}.json.gz.b64`);
264
+ await compressJsonFileStreaming(jsonFilePath, base64FilePath);
265
+
266
+ consoleLogger.info(`Finished compression and base64 encoding for ${filename}`);
267
+ return {
268
+ jsonFilePath,
269
+ base64FilePath,
270
+ };
271
+ } catch (error) {
272
+ consoleLogger.error(`Error compressing and encoding ${filename}`);
273
+ throw error;
274
+ }
275
+ };
276
+
277
+ const writeJsonAndBase64Files = async (
278
+ allIssues: AllIssues,
279
+ storagePath: string,
280
+ ): Promise<{
281
+ scanDataJsonFilePath: string;
282
+ scanDataBase64FilePath: string;
283
+ scanItemsJsonFilePath: string;
284
+ scanItemsBase64FilePath: string;
285
+ scanItemsSummaryJsonFilePath: string;
286
+ scanItemsSummaryBase64FilePath: string;
287
+ scanIssuesSummaryJsonFilePath: string;
288
+ scanIssuesSummaryBase64FilePath: string;
289
+ scanPagesDetailJsonFilePath: string;
290
+ scanPagesDetailBase64FilePath: string;
291
+ scanPagesSummaryJsonFilePath: string;
292
+ scanPagesSummaryBase64FilePath: string;
293
+ scanDataJsonFileSize: number;
294
+ scanItemsJsonFileSize: number;
295
+ }> => {
296
+ const { items, ...rest } = allIssues;
297
+ const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
298
+ await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
299
+ const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
300
+ await writeJsonFileAndCompressedJsonFile(
301
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...items },
302
+ storagePath,
303
+ 'scanItems',
304
+ );
305
+
306
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
307
+ if (items[category].rules && Array.isArray(items[category].rules)) {
308
+ items[category].rules.forEach(rule => {
309
+ rule.pagesAffectedCount = Array.isArray(rule.pagesAffected) ? rule.pagesAffected.length : 0;
310
+ });
311
+
312
+ items[category].rules.sort(
313
+ (a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0),
314
+ );
315
+ }
316
+ });
317
+
318
+ const scanIssuesSummary = {
319
+ mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
320
+ ...ruleInfo,
321
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
322
+ })),
323
+ goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ({
324
+ ...ruleInfo,
325
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
326
+ })),
327
+ needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ({
328
+ ...ruleInfo,
329
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
330
+ })),
331
+ passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ({
332
+ ...ruleInfo,
333
+ description: a11yRuleShortDescriptionMap[ruleInfo.rule] || ruleInfo.description,
334
+ })),
335
+ };
336
+
337
+ const {
338
+ jsonFilePath: scanIssuesSummaryJsonFilePath,
339
+ base64FilePath: scanIssuesSummaryBase64FilePath,
340
+ } = await writeJsonFileAndCompressedJsonFile(
341
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary },
342
+ storagePath,
343
+ 'scanIssuesSummary',
344
+ );
345
+
346
+ items.mustFix.rules.forEach(rule => {
347
+ rule.pagesAffected.forEach(page => {
348
+ page.itemsCount = page.items.length;
349
+ });
350
+ });
351
+ items.goodToFix.rules.forEach(rule => {
352
+ rule.pagesAffected.forEach(page => {
353
+ page.itemsCount = page.items.length;
354
+ });
355
+ });
356
+ items.needsReview.rules.forEach(rule => {
357
+ rule.pagesAffected.forEach(page => {
358
+ page.itemsCount = page.items.length;
359
+ });
360
+ });
361
+ items.passed.rules.forEach(rule => {
362
+ rule.pagesAffected.forEach(page => {
363
+ page.itemsCount = page.items.length;
364
+ });
365
+ });
366
+
367
+ items.mustFix.totalRuleIssues = items.mustFix.rules.length;
368
+ items.goodToFix.totalRuleIssues = items.goodToFix.rules.length;
369
+ items.needsReview.totalRuleIssues = items.needsReview.rules.length;
370
+ items.passed.totalRuleIssues = items.passed.rules.length;
371
+
372
+ const {
373
+ topTenPagesWithMostIssues,
374
+ wcagLinks,
375
+ wcagPassPercentage,
376
+ progressPercentage,
377
+ issuesPercentage,
378
+ totalPagesScanned,
379
+ totalPagesNotScanned,
380
+ topTenIssues,
381
+ } = rest;
382
+
383
+ const summaryItems = {
384
+ mustFix: {
385
+ totalItems: items.mustFix?.totalItems || 0,
386
+ totalRuleIssues: items.mustFix?.totalRuleIssues || 0,
387
+ },
388
+ goodToFix: {
389
+ totalItems: items.goodToFix?.totalItems || 0,
390
+ totalRuleIssues: items.goodToFix?.totalRuleIssues || 0,
391
+ },
392
+ needsReview: {
393
+ totalItems: items.needsReview?.totalItems || 0,
394
+ totalRuleIssues: items.needsReview?.totalRuleIssues || 0,
395
+ },
396
+ topTenPagesWithMostIssues,
397
+ wcagLinks,
398
+ wcagPassPercentage,
399
+ progressPercentage,
400
+ issuesPercentage,
401
+ totalPagesScanned,
402
+ totalPagesNotScanned,
403
+ topTenIssues,
404
+ };
405
+
406
+ const {
407
+ jsonFilePath: scanItemsSummaryJsonFilePath,
408
+ base64FilePath: scanItemsSummaryBase64FilePath,
409
+ } = await writeJsonFileAndCompressedJsonFile(
410
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems },
411
+ storagePath,
412
+ 'scanItemsSummary',
413
+ );
414
+
415
+ const {
416
+ jsonFilePath: scanPagesDetailJsonFilePath,
417
+ base64FilePath: scanPagesDetailBase64FilePath,
418
+ } = await writeJsonFileAndCompressedJsonFile(
419
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail },
420
+ storagePath,
421
+ 'scanPagesDetail',
422
+ );
423
+
424
+ const {
425
+ jsonFilePath: scanPagesSummaryJsonFilePath,
426
+ base64FilePath: scanPagesSummaryBase64FilePath,
427
+ } = await writeJsonFileAndCompressedJsonFile(
428
+ { oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary },
429
+ storagePath,
430
+ 'scanPagesSummary',
431
+ );
432
+
433
+ return {
434
+ scanDataJsonFilePath,
435
+ scanDataBase64FilePath,
436
+ scanItemsJsonFilePath,
437
+ scanItemsBase64FilePath,
438
+ scanItemsSummaryJsonFilePath,
439
+ scanItemsSummaryBase64FilePath,
440
+ scanIssuesSummaryJsonFilePath,
441
+ scanIssuesSummaryBase64FilePath,
442
+ scanPagesDetailJsonFilePath,
443
+ scanPagesDetailBase64FilePath,
444
+ scanPagesSummaryJsonFilePath,
445
+ scanPagesSummaryBase64FilePath,
446
+ scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
447
+ scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
448
+ };
449
+ };
450
+
451
+ export { compressJsonFileStreaming, writeJsonAndBase64Files, writeJsonFileAndCompressedJsonFile };