@govtechsg/oobee 0.10.86 → 0.10.88

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 (61) hide show
  1. package/.github/workflows/docker-push-ghcr.yml +49 -0
  2. package/.github/workflows/image.yml +2 -3
  3. package/DETAILS_OUTPUT_EXAMPLES.md +178 -0
  4. package/Dockerfile +6 -7
  5. package/dist/cli.js +18 -5
  6. package/dist/combine.js +3 -0
  7. package/dist/constants/cliFunctions.js +2 -2
  8. package/dist/constants/common.js +55 -13
  9. package/dist/crawlers/commonCrawlerFunc.js +523 -2
  10. package/dist/crawlers/crawlDomain.js +38 -13
  11. package/dist/crawlers/crawlIntelligentSitemap.js +62 -30
  12. package/dist/crawlers/crawlLocalFile.js +2 -2
  13. package/dist/crawlers/crawlSitemap.js +44 -5
  14. package/dist/crawlers/custom/extractAndGradeText.js +1 -1
  15. package/dist/crawlers/custom/getAxeConfiguration.js +26 -21
  16. package/dist/crawlers/custom/gradeReadability.js +1 -1
  17. package/dist/crawlers/custom/utils.js +81 -40
  18. package/dist/generateHtmlReport.js +18 -11
  19. package/dist/mergeAxeResults/itemReferences.js +60 -25
  20. package/dist/mergeAxeResults/sentryTelemetry.js +4 -1
  21. package/dist/mergeAxeResults.js +18 -9
  22. package/dist/npmIndex.js +16 -12
  23. package/dist/screenshotFunc/htmlScreenshotFunc.js +67 -0
  24. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  25. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +45 -6
  26. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +8 -5
  27. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  28. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  29. package/dist/static/ejs/summary.ejs +18 -12
  30. package/dist/utils.js +4 -3
  31. package/examples/oobee-test-details-runner.js +214 -0
  32. package/examples/test-violations.html +42 -0
  33. package/fix-summary-html-oom-pr.md +62 -0
  34. package/package.json +5 -5
  35. package/src/cli.ts +19 -5
  36. package/src/combine.ts +3 -0
  37. package/src/constants/cliFunctions.ts +2 -2
  38. package/src/constants/common.ts +65 -12
  39. package/src/crawlers/commonCrawlerFunc.ts +625 -2
  40. package/src/crawlers/crawlDomain.ts +39 -13
  41. package/src/crawlers/crawlIntelligentSitemap.ts +63 -30
  42. package/src/crawlers/crawlLocalFile.ts +4 -1
  43. package/src/crawlers/crawlSitemap.ts +50 -3
  44. package/src/crawlers/custom/extractAndGradeText.ts +1 -1
  45. package/src/crawlers/custom/getAxeConfiguration.ts +25 -23
  46. package/src/crawlers/custom/gradeReadability.ts +1 -1
  47. package/src/crawlers/custom/utils.ts +99 -43
  48. package/src/generateHtmlReport.ts +21 -11
  49. package/src/mergeAxeResults/itemReferences.ts +70 -26
  50. package/src/mergeAxeResults/sentryTelemetry.ts +4 -1
  51. package/src/mergeAxeResults.ts +21 -11
  52. package/src/npmIndex.ts +17 -12
  53. package/src/screenshotFunc/htmlScreenshotFunc.ts +81 -1
  54. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  55. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +45 -6
  56. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +8 -5
  57. package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  58. package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  59. package/src/static/ejs/summary.ejs +18 -12
  60. package/src/utils.ts +4 -3
  61. package/testStaticJSScanner.html +1 -1
@@ -40,6 +40,7 @@ const RESTRICT_OVERLAY_TO_ENTRY_DOMAIN = parseBoolEnv(
40
40
  process.env.RESTRICT_OVERLAY_TO_ENTRY_DOMAIN,
41
41
  false,
42
42
  );
43
+ const OVERLAY_OPERATION_TIMEOUT_MS = 5000;
43
44
 
44
45
  const isOverlayAllowed = (currentUrl: string, entryUrl: string) => {
45
46
  try {
@@ -306,7 +307,7 @@ export const addOverlayMenu = async (
306
307
  collapsed: false,
307
308
  },
308
309
  ) => {
309
- await page.waitForLoadState('domcontentloaded');
310
+ await page.waitForLoadState('domcontentloaded', { timeout: OVERLAY_OPERATION_TIMEOUT_MS });
310
311
  consoleLogger.info(`Overlay menu: adding to ${menuPos}...`);
311
312
 
312
313
  // Add the overlay menu with initial styling
@@ -1143,6 +1144,7 @@ export const addOverlayMenu = async (
1143
1144
  })
1144
1145
  .catch(error => {
1145
1146
  consoleLogger.error('Overlay menu: failed to add', error);
1147
+ throw error;
1146
1148
  });
1147
1149
  };
1148
1150
 
@@ -1165,6 +1167,8 @@ export const removeOverlayMenu = async page => {
1165
1167
 
1166
1168
  export const initNewPage = async (page, pageClosePromises, processPageParams, pagesDict) => {
1167
1169
  let menuPos = MENU_POSITION.right;
1170
+ let overlayRefreshSeq = 0;
1171
+ let overlayRefreshChain = Promise.resolve();
1168
1172
 
1169
1173
  // eslint-disable-next-line no-underscore-dangle
1170
1174
  const pageId = page._guid;
@@ -1194,6 +1198,83 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1194
1198
  };
1195
1199
  }
1196
1200
 
1201
+ const reconcileOverlayMenu = async (trigger: string) => {
1202
+ // Mark this as the latest refresh so older ones can stop.
1203
+ const refreshSeq = ++overlayRefreshSeq;
1204
+
1205
+ // Serialize overlay updates so multiple navigation events do not add/remove concurrently.
1206
+ overlayRefreshChain = overlayRefreshChain
1207
+ .catch(() => {})
1208
+ .then(async () => {
1209
+ if (refreshSeq !== overlayRefreshSeq || page.isClosed()) return;
1210
+
1211
+ try {
1212
+ // `framenavigated` can fire before the new document is ready for DOM inspection/injection.
1213
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 });
1214
+ } catch {
1215
+ // Best effort only. The page may still be mid-navigation.
1216
+ }
1217
+
1218
+ try {
1219
+ // Give fast redirect chains a brief chance to advance before we inject/remove the overlay.
1220
+ await page.waitForTimeout(300);
1221
+ } catch {
1222
+ // Best effort only. The page may already be closing.
1223
+ }
1224
+
1225
+ // Re-check staleness after waiting because a newer navigation may have happened meanwhile.
1226
+ if (refreshSeq !== overlayRefreshSeq || page.isClosed()) return;
1227
+
1228
+ const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1229
+
1230
+ if (!allowed) {
1231
+ await Promise.race([
1232
+ removeOverlayMenu(page),
1233
+ new Promise((_, reject) => {
1234
+ setTimeout(() => {
1235
+ reject(
1236
+ new Error(
1237
+ `removeOverlayMenu timed out after ${OVERLAY_OPERATION_TIMEOUT_MS}ms`,
1238
+ ),
1239
+ );
1240
+ }, OVERLAY_OPERATION_TIMEOUT_MS);
1241
+ }),
1242
+ ]);
1243
+ return;
1244
+ }
1245
+
1246
+ const hasOverlay = await page.evaluate(() =>
1247
+ Boolean(document.querySelector('#oobeeShadowHost')),
1248
+ );
1249
+
1250
+ consoleLogger.info(`Overlay state (${trigger}): ${hasOverlay}`);
1251
+
1252
+ if (!hasOverlay) {
1253
+ // Recreate the overlay after allowed redirects while preserving current UI state.
1254
+ consoleLogger.info(`Adding overlay menu to page (${trigger}): ${page.url()}`);
1255
+ await Promise.race([
1256
+ addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
1257
+ inProgress: !!pagesDict[pageId]?.isScanning,
1258
+ collapsed: !!pagesDict[pageId]?.collapsed,
1259
+ hideStopInput: !!processPageParams.customFlowLabel,
1260
+ }),
1261
+ new Promise((_, reject) => {
1262
+ setTimeout(() => {
1263
+ reject(
1264
+ new Error(`addOverlayMenu timed out after ${OVERLAY_OPERATION_TIMEOUT_MS}ms`),
1265
+ );
1266
+ }, OVERLAY_OPERATION_TIMEOUT_MS);
1267
+ }),
1268
+ ]);
1269
+ }
1270
+ })
1271
+ .catch(() => {
1272
+ consoleLogger.info('Error in adding overlay menu to page');
1273
+ });
1274
+
1275
+ await overlayRefreshChain;
1276
+ };
1277
+
1197
1278
  type handleOnScanClickFunction = () => void;
1198
1279
 
1199
1280
  // Window functions exposed in browser
@@ -1208,17 +1289,7 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1208
1289
  pagesDict[pageId].isScanning = false;
1209
1290
 
1210
1291
  if (page.isClosed()) return;
1211
- const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1212
-
1213
- if (allowed) {
1214
- await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
1215
- inProgress: false,
1216
- collapsed: !!pagesDict[pageId]?.collapsed,
1217
- hideStopInput: !!processPageParams.customFlowLabel,
1218
- });
1219
- } else {
1220
- await removeOverlayMenu(page);
1221
- }
1292
+ await reconcileOverlayMenu('scan-click');
1222
1293
  } catch (error) {
1223
1294
  log(`Scan failed ${error}`);
1224
1295
  }
@@ -1282,42 +1353,25 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1282
1353
 
1283
1354
  page.on('domcontentloaded', async () => {
1284
1355
  if (page.isClosed()) return;
1285
- try {
1286
- const allowed = isOverlayAllowed(page.url(), processPageParams.entryUrl);
1287
-
1288
- if (!allowed) {
1289
- await removeOverlayMenu(page);
1290
- return;
1291
- }
1292
-
1293
- const existingOverlay = await page.evaluate(() => {
1294
- return document.querySelector('#oobeeShadowHost');
1295
- });
1296
-
1297
- consoleLogger.info(`Overlay state: ${existingOverlay}`);
1298
-
1299
- if (!existingOverlay) {
1300
- consoleLogger.info(`Adding overlay menu to page: ${page.url()}`);
1301
- await addOverlayMenu(page, processPageParams.urlsCrawled, menuPos, {
1302
- inProgress: !!pagesDict[pageId]?.isScanning,
1303
- collapsed: !!pagesDict[pageId]?.collapsed,
1304
- hideStopInput: !!processPageParams.customFlowLabel,
1305
- });
1306
- }
1356
+ await reconcileOverlayMenu('domcontentloaded');
1307
1357
 
1308
- if (isCypressTest) {
1309
- try {
1310
- await handleOnScanClick();
1311
- page.close();
1312
- } catch {
1313
- consoleLogger.info(`Error in calling handleOnScanClick, isCypressTest: ${isCypressTest}`);
1314
- }
1358
+ if (isCypressTest) {
1359
+ try {
1360
+ await handleOnScanClick();
1361
+ page.close();
1362
+ } catch {
1363
+ consoleLogger.info(
1364
+ `Error in calling handleOnScanClick, isCypressTest: ${isCypressTest}`,
1365
+ );
1315
1366
  }
1316
- } catch {
1317
- consoleLogger.info('Error in adding overlay menu to page');
1318
1367
  }
1319
1368
  });
1320
1369
 
1370
+ page.on('framenavigated', async (frame: any) => {
1371
+ if (frame !== page.mainFrame() || page.isClosed()) return;
1372
+ await reconcileOverlayMenu('framenavigated');
1373
+ });
1374
+
1321
1375
  try {
1322
1376
  if (page.isClosed()) return page;
1323
1377
  await page.exposeFunction('handleOnScanClick', handleOnScanClick);
@@ -1337,5 +1391,7 @@ export const initNewPage = async (page, pageClosePromises, processPageParams, pa
1337
1391
  log(`Error exposing functions on page: ${e}`);
1338
1392
  }
1339
1393
 
1394
+ await reconcileOverlayMenu('init');
1395
+
1340
1396
  return page;
1341
1397
  };
@@ -22,6 +22,7 @@ import constants, {
22
22
  a11yRuleShortDescriptionMap,
23
23
  disabilityBadgesMap,
24
24
  a11yRuleLongDescriptionMap,
25
+ a11yRuleStepByStepGuide,
25
26
  } from './constants/constants.js';
26
27
 
27
28
  import { consoleLogger } from './logs.js';
@@ -65,7 +66,12 @@ const ensureCategory = (
65
66
  if (typeof rule.totalItems !== 'number') {
66
67
  rule.totalItems = rule.pagesAffected.reduce(
67
68
  (accumulate: number, page: any) =>
68
- accumulate + (Array.isArray(page.items) ? page.items.length : 0),
69
+ accumulate +
70
+ (Array.isArray(page.items)
71
+ ? page.items.length
72
+ : typeof page.itemsCount === 'number'
73
+ ? page.itemsCount
74
+ : 0),
69
75
  0,
70
76
  );
71
77
  }
@@ -87,7 +93,10 @@ const ensureCategory = (
87
93
  };
88
94
  };
89
95
 
90
- export const generateHtmlReport = async (resultDir: string): Promise<string> => {
96
+ export const generateHtmlReport = async (
97
+ resultDir: string,
98
+ htmlFilename = 'report',
99
+ ): Promise<string> => {
91
100
  try {
92
101
  const storagePath = path.resolve(resultDir);
93
102
  const scanDataJsonPath = path.join(storagePath, 'scanData.json');
@@ -117,24 +126,22 @@ export const generateHtmlReport = async (resultDir: string): Promise<string> =>
117
126
  const scanData = JSON.parse(await fs.readFile(scanDataJsonPath, 'utf8'));
118
127
  const scanItemsAll = JSON.parse(await fs.readFile(scanItemsJsonPath, 'utf8'));
119
128
 
120
- // Use convertItemsToReferences to normalize items structure to match scanItemsWithHtmlGroupRefs format
121
- const scanItemsWithHtmlGroupRefs = convertItemsToReferences({
129
+ // Build the lighter scanItems payload used by the HTML report.
130
+ const lightScanItemsPayload = convertItemsToReferences({
122
131
  items: scanItemsAll,
123
- ...scanData
124
132
  });
125
133
 
126
134
  const {
127
135
  mustFix = {},
128
136
  goodToFix = {},
129
137
  needsReview = {},
130
- passed = {},
131
- } = scanItemsWithHtmlGroupRefs;
138
+ } = lightScanItemsPayload;
132
139
 
133
140
  const items = {
134
141
  mustFix: ensureCategory(mustFix, 'mustFix'),
135
142
  goodToFix: ensureCategory(goodToFix, 'goodToFix'),
136
143
  needsReview: ensureCategory(needsReview, 'needsReview'),
137
- passed: ensureCategory(passed, 'passed'),
144
+ passed: ensureCategory(scanItemsAll.passed || {}, 'passed'),
138
145
  };
139
146
 
140
147
  const pagesScanned = Array.isArray(scanData.pagesScanned) ? scanData.pagesScanned : [];
@@ -183,6 +190,8 @@ export const generateHtmlReport = async (resultDir: string): Promise<string> =>
183
190
  a11yRuleShortDescriptionMap,
184
191
  disabilityBadgesMap,
185
192
  a11yRuleLongDescriptionMap,
193
+ a11yRuleStepByStepGuide,
194
+ wcagCriteriaLabels: constants.wcagCriteriaLabels,
186
195
  advancedScanOptionsSummaryItems: {
187
196
  showIncludeScreenshots: !!scanData.advancedScanOptionsSummaryItems?.showIncludeScreenshots,
188
197
  showAllowSubdomains: !!scanData.advancedScanOptionsSummaryItems?.showAllowSubdomains,
@@ -217,10 +226,11 @@ export const generateHtmlReport = async (resultDir: string): Promise<string> =>
217
226
  (allIssues as any).advancedScanOptionsSummaryItems?.disableOobee,
218
227
  );
219
228
 
220
- await writeHTML(allIssues, storagePath, 'report', scanDataB64Path, scanItemsB64Path);
229
+ await writeHTML(allIssues, storagePath, htmlFilename, scanDataB64Path, scanItemsB64Path);
221
230
 
222
- consoleLogger.info(`Report generated at: ${path.join(storagePath, 'report.html')}`);
223
- return path.join(storagePath, 'report.html');
231
+ const outputPath = path.join(storagePath, `${htmlFilename}.html`);
232
+ consoleLogger.info(`Report generated at: ${outputPath}`);
233
+ return outputPath;
224
234
  } catch (err: any) {
225
235
  consoleLogger.error(`generateHtmlReport failed: ${err?.message || err}`);
226
236
  throw err;
@@ -1,5 +1,9 @@
1
1
  import type { AllIssues, ItemsInfo, RuleInfo } from './types.js';
2
2
 
3
+ type ScanItems = AllIssues['items'];
4
+ type ScanCategory = ScanItems[keyof ScanItems];
5
+ type ScanItemsLight = Pick<ScanItems, 'mustFix' | 'goodToFix' | 'needsReview' | 'passed'>;
6
+
3
7
  /**
4
8
  * Builds pre-computed HTML groups to optimize Group by HTML Element functionality.
5
9
  * Keys are composite "html\x00xpath" strings to ensure unique matching per element instance.
@@ -31,32 +35,72 @@ export const buildHtmlGroups = (rule: RuleInfo, items: ItemsInfo[], pageUrl: str
31
35
  });
32
36
  };
33
37
 
38
+ /*
39
+ // Commenting this out for now as we are not including htmlGroups in the embedded report payload to keep it lean.
40
+ // We can revisit this if we want to include htmlGroups in the future and need a reference builder for it.
41
+ const toHtmlGroupReference = (item: any) => {
42
+ if (typeof item === 'string') {
43
+ return item;
44
+ }
45
+
46
+ return `${item?.html || 'No HTML element'}\x00${item?.xpath || ''}`;
47
+ };
48
+
49
+ const cloneCategoryWithReferenceItems = (category: ScanCategory): ScanCategory =>
50
+ ({
51
+ ...category,
52
+ rules: category.rules.map(
53
+ rule =>
54
+ ({
55
+ ...rule,
56
+ pagesAffected: rule.pagesAffected.map(
57
+ page => {
58
+ const { items, ...pageWithoutItems } = page;
59
+
60
+ return {
61
+ ...pageWithoutItems,
62
+ itemsCount: page.itemsCount ?? (Array.isArray(items) ? items.length : 0),
63
+ items: Array.isArray(items) ? items.map(toHtmlGroupReference) : items,
64
+ } as any;
65
+ },
66
+ ),
67
+ }) as any,
68
+ ),
69
+ }) as ScanCategory;
70
+ */
71
+
72
+ const cloneCategoryLight = (category: ScanCategory, includeHtmlGroups: boolean): ScanCategory =>
73
+ ({
74
+ ...category,
75
+ rules: category.rules.map(
76
+ rule =>
77
+ ({
78
+ rule: rule.rule,
79
+ description: rule.description,
80
+ helpUrl: rule.helpUrl,
81
+ conformance: rule.conformance,
82
+ totalItems: rule.totalItems,
83
+ axeImpact: rule.axeImpact,
84
+ ...(includeHtmlGroups && rule.htmlGroups ? { htmlGroups: rule.htmlGroups } : {}),
85
+ pagesAffected: rule.pagesAffected.map(page => ({
86
+ url: page.url,
87
+ pageTitle: page.pageTitle,
88
+ itemsCount: page.itemsCount ?? (Array.isArray((page as any).items) ? (page as any).items.length : 0),
89
+ })),
90
+ }) as any,
91
+ ),
92
+ }) as ScanCategory;
93
+
34
94
  /**
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).
95
+ * Builds the embedded HTML-report payload from the full scan items.
96
+ * Includes htmlGroups for non-passed categories (Group by HTML Element),
97
+ * excludes them from passed to keep payload within browser memory limits.
38
98
  */
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;
99
+ export const convertItemsToReferences = (source: Pick<AllIssues, 'items'>): ScanItemsLight => {
100
+ return {
101
+ mustFix: cloneCategoryLight(source.items.mustFix, true),
102
+ goodToFix: cloneCategoryLight(source.items.goodToFix, true),
103
+ needsReview: cloneCategoryLight(source.items.needsReview, true),
104
+ passed: cloneCategoryLight(source.items.passed, false),
105
+ };
62
106
  };
@@ -140,7 +140,10 @@ const sendWcagBreakdownToSentry = async (
140
140
  event_type: 'accessibility_scan',
141
141
  scanType: scanInfo.scanType,
142
142
  browser: scanInfo.browser,
143
- entryUrl: scanInfo.entryUrl,
143
+ entryUrl: process.env.OOBEE_SCAN_METADATA ?? scanInfo.entryUrl,
144
+ ...(process.env.OOBEE_SCAN_PRODUCT && {
145
+ scanProduct: process.env.OOBEE_SCAN_PRODUCT,
146
+ }),
144
147
  },
145
148
  user: {
146
149
  ...(scanInfo.email && scanInfo.name
@@ -185,15 +185,15 @@ const writeHTML = async (
185
185
  'utf-8',
186
186
  );
187
187
 
188
- // Create lighter version with item references for embedding in HTML
189
- const scanItemsWithHtmlGroupRefs = convertItemsToReferences(allIssues);
188
+ // Create the lighter scanItems payload for embedding in the HTML report.
189
+ const lightScanItemsPayload = convertItemsToReferences(allIssues);
190
190
 
191
191
  // Write the lighter items to a file and get the base64 path
192
192
  const {
193
- jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath,
194
- base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath,
193
+ jsonFilePath: lightScanItemsPayloadJsonFilePath,
194
+ base64FilePath: lightScanItemsPayloadBase64FilePath,
195
195
  } = await writeJsonFileAndCompressedJsonFile(
196
- scanItemsWithHtmlGroupRefs.items,
196
+ lightScanItemsPayload,
197
197
  storagePath,
198
198
  'scanItems-light',
199
199
  );
@@ -212,8 +212,8 @@ const writeHTML = async (
212
212
  await Promise.all([
213
213
  fs.promises.unlink(topFilePath),
214
214
  fs.promises.unlink(bottomFilePath),
215
- fs.promises.unlink(scanItemsWithHtmlGroupRefsBase64FilePath),
216
- fs.promises.unlink(scanItemsWithHtmlGroupRefsJsonFilePath),
215
+ fs.promises.unlink(lightScanItemsPayloadBase64FilePath),
216
+ fs.promises.unlink(lightScanItemsPayloadJsonFilePath),
217
217
  ]);
218
218
  } catch (err) {
219
219
  console.error('Error cleaning up temporary files:', err);
@@ -251,6 +251,9 @@ const writeHTML = async (
251
251
  } else {
252
252
  console.warn('Skipping fetch GenAI feature as it is local report');
253
253
  }
254
+
255
+ var scanData = null;
256
+ var scanItems = null;
254
257
  \n`);
255
258
 
256
259
  outputStream.write('</script>\n<script type="text/plain" id="scanDataRaw">');
@@ -259,22 +262,25 @@ const writeHTML = async (
259
262
  scanDetailsReadStream.on('end', async () => {
260
263
  outputStream.write('</script>\n<script>\n');
261
264
  outputStream.write(
262
- "var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n",
265
+ "var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); console.log('[report] scanData loaded'); })();\n",
263
266
  );
264
267
  outputStream.write('</script>\n');
265
268
 
266
269
  // Write scanItems in 2MB chunks using a stream to avoid loading entire file into memory
267
270
  try {
268
271
  let chunkIndex = 1;
269
- const scanItemsStream = fs.createReadStream(scanItemsWithHtmlGroupRefsBase64FilePath, {
272
+ const scanItemsStream = fs.createReadStream(lightScanItemsPayloadBase64FilePath, {
270
273
  encoding: 'utf8',
271
274
  highWaterMark: CHUNK_SIZE,
272
275
  });
273
276
 
274
277
  for await (const chunk of scanItemsStream) {
275
- outputStream.write(
278
+ const ok = outputStream.write(
276
279
  `<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`,
277
280
  );
281
+ if (!ok) {
282
+ await new Promise<void>(resolve => outputStream.once('drain', resolve));
283
+ }
278
284
  chunkIndex++;
279
285
  }
280
286
 
@@ -291,6 +297,7 @@ var scanItemsPromise = (async () => {
291
297
  i++;
292
298
  }
293
299
  scanItems = await decodeUnzipParse(chunks);
300
+ console.log('[report] scanItems loaded');
294
301
  })();\n`);
295
302
  outputStream.write(suffixData);
296
303
  outputStream.end();
@@ -989,10 +996,13 @@ const generateArtifacts = async (
989
996
  1,
990
997
  );
991
998
 
999
+ // Brief delay to allow lingering async crawlee storage operations to flush
1000
+ await new Promise(resolve => setTimeout(resolve, 3000));
1001
+
992
1002
  try {
993
1003
  await fs.promises.rm(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
994
1004
  } catch (error) {
995
- consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
1005
+ // Silently ignore folder may already be gone or still locked
996
1006
  }
997
1007
 
998
1008
  try {
package/src/npmIndex.ts CHANGED
@@ -12,10 +12,10 @@ import {
12
12
  getPlaywrightLaunchOptions,
13
13
  submitForm,
14
14
  } from './constants/common.js';
15
- import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
15
+ import { createCrawleeSubFolders, enrichViolationMessages, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
16
16
  import { createAndUpdateResultsFolders, getVersion } from './utils.js';
17
17
  import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
18
- import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
18
+ import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
19
19
  import { consoleLogger, silentLogger } from './logs.js';
20
20
  import { alertMessageOptions } from './constants/cliFunctions.js';
21
21
  import { evaluateAltText } from './crawlers/custom/evaluateAltText.js';
@@ -86,6 +86,13 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
86
86
  window.xPathToCss = ${xPathToCss.toString()};
87
87
  window.extractText = ${extractText.toString()};
88
88
 
89
+ function getReadabilityInterpretation(score) {
90
+ const num = parseFloat(score);
91
+ if (Number.isNaN(num)) return '';
92
+ if (num > 30) return 'It is targeted for junior college (JC) level comprehension and above.';
93
+ return 'It is targeted for university graduate level comprehension and above.';
94
+ }
95
+
89
96
  function getAxeConfiguration({
90
97
  enableWcagAaa = false,
91
98
  gradingReadabilityFlag = '',
@@ -120,7 +127,7 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
120
127
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
121
128
  },
122
129
  },
123
- ...((enableWcagAaa && !disableOobee)
130
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
124
131
  ? [
125
132
  {
126
133
  id: 'oobee-grading-text-contents',
@@ -128,16 +135,11 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
128
135
  impact: 'moderate',
129
136
  messages: {
130
137
  pass: 'The text content is easy to understand.',
131
- fail: 'The text content is potentially difficult to understand.',
132
- incomplete: \`The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of \${gradingReadabilityFlag}.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.\`,
138
+ fail: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
139
+ incomplete: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
133
140
  },
134
141
  },
135
- evaluate: (_node) => {
136
- if (gradingReadabilityFlag === '') {
137
- return true; // Pass if no readability issues
138
- }
139
- // Fail if readability issues are detected
140
- },
142
+ evaluate: (_node) => false,
141
143
  },
142
144
  ]
143
145
  : []),
@@ -168,7 +170,7 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
168
170
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
169
171
  },
170
172
  },
171
- ...((enableWcagAaa && !disableOobee)
173
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
172
174
  ? [
173
175
  {
174
176
  id: 'oobee-grading-text-contents',
@@ -864,6 +866,9 @@ export const scanPage = async (
864
866
  return window.runA11yScan();
865
867
  });
866
868
 
869
+ await enrichViolationMessages(scanResult.axeScanResults, page);
870
+ await enrichColorContrastDOMContext(scanResult.axeScanResults.violations, page);
871
+
867
872
  scanData.push({
868
873
  axeScanResults: scanResult.axeScanResults,
869
874
  pageUrl: page.url(),
@@ -5,10 +5,89 @@ import path from 'path';
5
5
  // import { silentLogger } from '../logs.js';
6
6
  import { Result } from 'axe-core';
7
7
  import { Page } from 'playwright';
8
- import { NodeResultWithScreenshot, ResultWithScreenshot } from '../crawlers/commonCrawlerFunc.js';
8
+ import {
9
+ ContrastDOMContext,
10
+ NodeResultWithScreenshot,
11
+ ResultWithScreenshot,
12
+ } from '../crawlers/commonCrawlerFunc.js';
9
13
 
10
14
  const screenshotMap: Record<string, string> = {}; // Map of screenshot hashkey to its buffer value and screenshot path
11
15
 
16
+ export const enrichColorContrastDOMContext = async (
17
+ violations: Result[],
18
+ page: Page,
19
+ ): Promise<void> => {
20
+ for (const violation of violations) {
21
+ if (violation.id !== 'color-contrast' && violation.id !== 'color-contrast-enhanced') continue;
22
+
23
+ for (const node of violation.nodes) {
24
+ const { target } = node;
25
+ const selector = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;
26
+ if (!selector) continue;
27
+
28
+ try {
29
+ const domContext = await page
30
+ .evaluate((sel: string): ContrastDOMContext | null => {
31
+ const el = document.querySelector(sel);
32
+ if (!el) return null;
33
+
34
+ const style = window.getComputedStyle(el);
35
+ const bgImage = style.backgroundImage;
36
+ const hasGradient = bgImage !== 'none' && bgImage.includes('gradient');
37
+ const hasBackgroundImage = bgImage !== 'none' && !hasGradient;
38
+
39
+ let hasReducedOpacity = parseFloat(style.opacity) < 1;
40
+ let ancestorHasGradient = false;
41
+ let ancestorHasBackgroundImage = false;
42
+
43
+ let ancestor = el.parentElement;
44
+ while (ancestor && ancestor.tagName !== 'HTML') {
45
+ const anStyle = window.getComputedStyle(ancestor);
46
+ if (!hasReducedOpacity && parseFloat(anStyle.opacity) < 1) {
47
+ hasReducedOpacity = true;
48
+ }
49
+ const anBgImg = anStyle.backgroundImage;
50
+ if (anBgImg !== 'none') {
51
+ if (!ancestorHasGradient && anBgImg.includes('gradient')) {
52
+ ancestorHasGradient = true;
53
+ } else if (!ancestorHasBackgroundImage) {
54
+ ancestorHasBackgroundImage = true;
55
+ }
56
+ }
57
+ ancestor = ancestor.parentElement;
58
+ }
59
+
60
+ const mixBlendMode = style.mixBlendMode !== 'normal' ? style.mixBlendMode : null;
61
+ const backdropFilter =
62
+ style.backdropFilter && style.backdropFilter !== 'none'
63
+ ? style.backdropFilter
64
+ : null;
65
+ const filter = style.filter && style.filter !== 'none' ? style.filter : null;
66
+
67
+ return {
68
+ backgroundImage: bgImage !== 'none' ? bgImage : '',
69
+ hasGradient,
70
+ hasBackgroundImage,
71
+ ancestorHasGradient,
72
+ ancestorHasBackgroundImage,
73
+ hasReducedOpacity,
74
+ mixBlendMode,
75
+ backdropFilter,
76
+ filter,
77
+ };
78
+ }, selector)
79
+ .catch(() => null);
80
+
81
+ if (domContext) {
82
+ (node as NodeResultWithScreenshot).contrastDOMContext = domContext;
83
+ }
84
+ } catch {
85
+ // Non-critical; proceed without DOM context
86
+ }
87
+ }
88
+ }
89
+ };
90
+
12
91
  export const takeScreenshotForHTMLElements = async (
13
92
  violations: Result[],
14
93
  page: Page,
@@ -67,6 +146,7 @@ export const takeScreenshotForHTMLElements = async (
67
146
  } catch (e) {
68
147
  // consoleLogger.info(`Unable to take element screenshot at ${selector}`);
69
148
  }
149
+
70
150
  }
71
151
  newViolationNodes.push(nodeWithScreenshotPath);
72
152
  }