@govtechsg/oobee 0.10.86 → 0.10.87
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.
- package/.github/workflows/image.yml +2 -3
- package/dist/cli.js +18 -5
- package/dist/combine.js +2 -0
- package/dist/constants/cliFunctions.js +2 -2
- package/dist/constants/common.js +55 -13
- package/dist/crawlers/crawlDomain.js +38 -13
- package/dist/crawlers/crawlIntelligentSitemap.js +62 -30
- package/dist/crawlers/crawlSitemap.js +44 -5
- package/dist/crawlers/custom/utils.js +81 -40
- package/dist/generateHtmlReport.js +18 -11
- package/dist/mergeAxeResults/itemReferences.js +60 -25
- package/dist/mergeAxeResults/sentryTelemetry.js +4 -1
- package/dist/mergeAxeResults.js +18 -9
- package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
- package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
- package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
- package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
- package/dist/static/ejs/summary.ejs +18 -12
- package/dist/utils.js +4 -3
- package/fix-summary-html-oom-pr.md +62 -0
- package/package.json +5 -5
- package/src/cli.ts +19 -5
- package/src/combine.ts +2 -0
- package/src/constants/cliFunctions.ts +2 -2
- package/src/constants/common.ts +65 -12
- package/src/crawlers/crawlDomain.ts +39 -13
- package/src/crawlers/crawlIntelligentSitemap.ts +63 -30
- package/src/crawlers/crawlSitemap.ts +50 -3
- package/src/crawlers/custom/utils.ts +99 -43
- package/src/generateHtmlReport.ts +21 -11
- package/src/mergeAxeResults/itemReferences.ts +70 -26
- package/src/mergeAxeResults/sentryTelemetry.ts +4 -1
- package/src/mergeAxeResults.ts +21 -11
- package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
- package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +38 -2
- package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +1 -1
- package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
- package/src/static/ejs/summary.ejs +18 -12
- package/src/utils.ts +4 -3
- package/testStaticJSScanner.html +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import crawlee, { LaunchContext, Request, RequestList, Dataset } from 'crawlee';
|
|
1
|
+
import crawlee, { EnqueueStrategy, LaunchContext, Request, RequestList, Dataset } from 'crawlee';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import fsp from 'fs/promises';
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
waitForPageLoaded,
|
|
24
24
|
isFilePath,
|
|
25
25
|
} from '../constants/common.js';
|
|
26
|
-
import { areLinksEqual, isWhitelistedContentType, register } from '../utils.js';
|
|
26
|
+
import { areLinksEqual, isFollowStrategy, isWhitelistedContentType, normUrl, register } from '../utils.js';
|
|
27
27
|
import {
|
|
28
28
|
handlePdfDownload,
|
|
29
29
|
runPdfScan,
|
|
@@ -46,6 +46,8 @@ const crawlSitemap = async ({
|
|
|
46
46
|
blacklistedPatterns,
|
|
47
47
|
includeScreenshots,
|
|
48
48
|
extraHTTPHeaders,
|
|
49
|
+
strategy = EnqueueStrategy.All,
|
|
50
|
+
userUrl = '',
|
|
49
51
|
scanDuration = 0,
|
|
50
52
|
fromCrawlIntelligentSitemap = false,
|
|
51
53
|
userUrlInputFromIntelligent = null,
|
|
@@ -65,6 +67,8 @@ const crawlSitemap = async ({
|
|
|
65
67
|
blacklistedPatterns: string[];
|
|
66
68
|
includeScreenshots: boolean;
|
|
67
69
|
extraHTTPHeaders: Record<string, string>;
|
|
70
|
+
strategy?: EnqueueStrategy;
|
|
71
|
+
userUrl?: string;
|
|
68
72
|
scanDuration?: number;
|
|
69
73
|
fromCrawlIntelligentSitemap?: boolean;
|
|
70
74
|
userUrlInputFromIntelligent?: string;
|
|
@@ -99,6 +103,8 @@ const crawlSitemap = async ({
|
|
|
99
103
|
userUrlInputFromIntelligent,
|
|
100
104
|
fromCrawlIntelligentSitemap,
|
|
101
105
|
extraHTTPHeaders,
|
|
106
|
+
strategy,
|
|
107
|
+
userUrl || sitemapUrl,
|
|
102
108
|
);
|
|
103
109
|
|
|
104
110
|
sitemapUrl = encodeURI(sitemapUrl);
|
|
@@ -299,7 +305,7 @@ const crawlSitemap = async ({
|
|
|
299
305
|
if (isScanHtml && status < 300 && isWhitelistedContentType(contentType)) {
|
|
300
306
|
const isRedirected = !areLinksEqual(page.url(), request.url);
|
|
301
307
|
const isLoadedUrlInCrawledUrls = urlsCrawled.scanned.some(
|
|
302
|
-
item => (item.actualUrl || item.url) === page.url(),
|
|
308
|
+
item => normUrl(item.actualUrl || item.url) === normUrl(page.url()),
|
|
303
309
|
);
|
|
304
310
|
|
|
305
311
|
if (isRedirected && isLoadedUrlInCrawledUrls) {
|
|
@@ -327,8 +333,49 @@ const crawlSitemap = async ({
|
|
|
327
333
|
return;
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
if (isRedirected && !isFollowStrategy(actualUrl, request.url, 'same-hostname')) {
|
|
337
|
+
urlsCrawled.notScannedRedirects.push({
|
|
338
|
+
fromUrl: request.url,
|
|
339
|
+
toUrl: actualUrl,
|
|
340
|
+
});
|
|
341
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
342
|
+
numScanned: urlsCrawled.scanned.length,
|
|
343
|
+
urlScanned: request.url,
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
330
348
|
const results = await runAxeScript({ includeScreenshots, page, randomToken });
|
|
331
349
|
|
|
350
|
+
// Detect JS redirects that fire during/after axe scan.
|
|
351
|
+
// Listen for navigation, then give a brief window for pending redirects to complete.
|
|
352
|
+
try {
|
|
353
|
+
let navigatedToUrl: string | null = null;
|
|
354
|
+
const onFrameNavigated = (frame: any) => {
|
|
355
|
+
if (frame === page.mainFrame()) {
|
|
356
|
+
navigatedToUrl = frame.url();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
page.on('framenavigated', onFrameNavigated);
|
|
360
|
+
await page.waitForTimeout(1000);
|
|
361
|
+
page.off('framenavigated', onFrameNavigated);
|
|
362
|
+
|
|
363
|
+
const postScanUrl = navigatedToUrl || page.url();
|
|
364
|
+
if (postScanUrl && postScanUrl !== 'about:blank' && !isFollowStrategy(postScanUrl, request.url, 'same-hostname')) {
|
|
365
|
+
urlsCrawled.notScannedRedirects.push({
|
|
366
|
+
fromUrl: request.url,
|
|
367
|
+
toUrl: postScanUrl,
|
|
368
|
+
});
|
|
369
|
+
guiInfoLog(guiInfoStatusTypes.SKIPPED, {
|
|
370
|
+
numScanned: urlsCrawled.scanned.length,
|
|
371
|
+
urlScanned: request.url,
|
|
372
|
+
});
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
} catch (_) {
|
|
376
|
+
// Page/context was destroyed during navigation — handled by outer catch
|
|
377
|
+
}
|
|
378
|
+
|
|
332
379
|
guiInfoLog(guiInfoStatusTypes.SCANNED, {
|
|
333
380
|
numScanned: urlsCrawled.scanned.length,
|
|
334
381
|
urlScanned: request.url,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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 +
|
|
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 (
|
|
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
|
-
//
|
|
121
|
-
const
|
|
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
|
-
|
|
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,
|
|
229
|
+
await writeHTML(allIssues, storagePath, htmlFilename, scanDataB64Path, scanItemsB64Path);
|
|
221
230
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 = (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
package/src/mergeAxeResults.ts
CHANGED
|
@@ -185,15 +185,15 @@ const writeHTML = async (
|
|
|
185
185
|
'utf-8',
|
|
186
186
|
);
|
|
187
187
|
|
|
188
|
-
// Create lighter
|
|
189
|
-
const
|
|
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:
|
|
194
|
-
base64FilePath:
|
|
193
|
+
jsonFilePath: lightScanItemsPayloadJsonFilePath,
|
|
194
|
+
base64FilePath: lightScanItemsPayloadBase64FilePath,
|
|
195
195
|
} = await writeJsonFileAndCompressedJsonFile(
|
|
196
|
-
|
|
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(
|
|
216
|
-
fs.promises.unlink(
|
|
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(
|
|
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
|
-
|
|
1005
|
+
// Silently ignore — folder may already be gone or still locked
|
|
996
1006
|
}
|
|
997
1007
|
|
|
998
1008
|
try {
|
|
@@ -28,8 +28,11 @@ async function decodeUnzipParse(input) {
|
|
|
28
28
|
offset += arr.length;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
// Step 2: Decompress with pako (GZIP)
|
|
32
|
-
|
|
31
|
+
// Step 2: Decompress with pako (GZIP) to bytes first to avoid large-string
|
|
32
|
+
// construction inside pako for very large payloads.
|
|
33
|
+
const decompressedBytes = pako.ungzip(merged);
|
|
34
|
+
|
|
35
|
+
const decompressed = new TextDecoder().decode(decompressedBytes);
|
|
33
36
|
|
|
34
37
|
// Step 3: Parse JSON
|
|
35
38
|
return JSON.parse(decompressed);
|
|
@@ -37,4 +40,4 @@ async function decodeUnzipParse(input) {
|
|
|
37
40
|
throw new Error(`Failed to decode/unzip/parse: ${err.message}`);
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
|
-
</script>
|
|
43
|
+
</script>
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Rebuilds the item list for a page from pre-computed htmlGroups when the light report omits page.items.
|
|
4
|
+
*/
|
|
5
|
+
function buildItemsFromHtmlGroupsForPage(page, ruleInCategory) {
|
|
6
|
+
const htmlGroups = ruleInCategory.htmlGroups || {};
|
|
7
|
+
const resolvedItems = [];
|
|
8
|
+
|
|
9
|
+
Object.values(htmlGroups).forEach(groupData => {
|
|
10
|
+
if (!Array.isArray(groupData.pageUrls) || !groupData.pageUrls.includes(page.url)) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
resolvedItems.push({
|
|
15
|
+
html: groupData.html,
|
|
16
|
+
xpath: groupData.xpath,
|
|
17
|
+
message: groupData.message,
|
|
18
|
+
screenshotPath: groupData.screenshotPath,
|
|
19
|
+
displayNeedsReview: groupData.displayNeedsReview,
|
|
20
|
+
pageUrl: page.url,
|
|
21
|
+
pageTitle: page.pageTitle || page.metadata
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return resolvedItems;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The embedded report payload now omits page.items and rebuilds occurrences from
|
|
30
|
+
* htmlGroups + page metadata. Keep the older page.items resolution logic below
|
|
31
|
+
* commented for an easy rollback if we need to restore mixed payload support.
|
|
4
32
|
*/
|
|
5
33
|
function resolveItemReferencesForPage(page, ruleInCategory) {
|
|
34
|
+
return buildItemsFromHtmlGroupsForPage(page, ruleInCategory);
|
|
35
|
+
|
|
36
|
+
/*
|
|
6
37
|
const items = page.items || [];
|
|
7
|
-
|
|
38
|
+
|
|
39
|
+
if (items.length === 0) {
|
|
40
|
+
return buildItemsFromHtmlGroupsForPage(page, ruleInCategory);
|
|
41
|
+
}
|
|
8
42
|
|
|
9
43
|
const isReference = typeof items[0] === 'string';
|
|
10
44
|
|
|
@@ -27,6 +61,7 @@
|
|
|
27
61
|
pageTitle: page.pageTitle || page.metadata
|
|
28
62
|
};
|
|
29
63
|
}
|
|
64
|
+
|
|
30
65
|
// Fallback: parse composite key
|
|
31
66
|
const nullByteIndex = compositeKey.indexOf('\x00');
|
|
32
67
|
const html = nullByteIndex !== -1 ? compositeKey.slice(0, nullByteIndex) : compositeKey;
|
|
@@ -40,6 +75,7 @@
|
|
|
40
75
|
pageTitle: page.pageTitle || page.metadata
|
|
41
76
|
};
|
|
42
77
|
});
|
|
78
|
+
*/
|
|
43
79
|
}
|
|
44
80
|
|
|
45
81
|
function buildItemCardsWithPagination(accordionId, category, ruleInCategory, page, index) {
|