@govtechsg/oobee 0.10.84 → 0.10.86

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 (40) hide show
  1. package/.github/workflows/image.yml +3 -2
  2. package/.github/workflows/publish.yml +10 -0
  3. package/DETAILS.md +29 -0
  4. package/dist/cli.js +7 -6
  5. package/dist/combine.js +1 -1
  6. package/dist/constants/common.js +15 -4
  7. package/dist/constants/constants.js +604 -1
  8. package/dist/crawlers/commonCrawlerFunc.js +3 -2
  9. package/dist/crawlers/crawlSitemap.js +98 -80
  10. package/dist/crawlers/custom/utils.js +218 -71
  11. package/dist/crawlers/guards/urlGuard.js +8 -15
  12. package/dist/crawlers/runCustom.js +24 -15
  13. package/dist/generateOobeeClientScanner.js +570 -0
  14. package/dist/mergeAxeResults.js +49 -29
  15. package/dist/npmIndex.js +10 -2
  16. package/dist/proxyService.js +18 -3
  17. package/dist/services/s3Uploader.js +21 -10
  18. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  19. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  20. package/dist/static/ejs/summary.ejs +10 -5
  21. package/oobee-client-scanner.js +34992 -0
  22. package/package.json +3 -3
  23. package/src/cli.ts +20 -15
  24. package/src/combine.ts +3 -1
  25. package/src/constants/common.ts +22 -10
  26. package/src/constants/constants.ts +602 -1
  27. package/src/crawlers/commonCrawlerFunc.ts +4 -3
  28. package/src/crawlers/crawlSitemap.ts +116 -98
  29. package/src/crawlers/custom/utils.ts +244 -84
  30. package/src/crawlers/guards/urlGuard.ts +24 -31
  31. package/src/crawlers/runCustom.ts +38 -15
  32. package/src/generateOobeeClientScanner.ts +591 -0
  33. package/src/mergeAxeResults.ts +48 -29
  34. package/src/npmIndex.ts +12 -2
  35. package/src/proxyService.ts +25 -4
  36. package/src/services/s3Uploader.ts +23 -11
  37. package/src/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +2 -2
  38. package/src/static/ejs/partials/scripts/ruleModal/constants.ejs +1 -761
  39. package/src/static/ejs/summary.ejs +10 -5
  40. package/testStaticJSScanner.html +534 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * generateOobeeClientScanner.ts
3
+ *
4
+ * Standalone script that generates oobee-client-scanner.js — a self-contained
5
+ * browser bundle that runs axe-core + oobee custom checks, returns results in
6
+ * the same JSON format as npmIndex's processAndSubmitResults, and reports
7
+ * telemetry to Sentry using the official Sentry JavaScript browser SDK.
8
+ *
9
+ * Usage (after `npm run build`):
10
+ * node dist/generateOobeeClientScanner.js [output-path]
11
+ *
12
+ * Default output: ./oobee-client-scanner.js (relative to cwd)
13
+ *
14
+ * Environment variables read at generation time:
15
+ * OOBEE_SENTRY_DSN — Sentry DSN to embed in the bundle (falls back to the
16
+ * default DSN in constants.ts if not set)
17
+ *
18
+ * Then in your HTML:
19
+ * <script src="oobee-client-scanner.js"></script>
20
+ * <script>
21
+ * window.oobee.scan({
22
+ * userInfo: { email: 'you@example.com', name: 'Your Name' },
23
+ * // scanMode: [string] choices: "default" | "disable-oobee" | "enable-wcag-aaa" | "disable-oobee,enable-wcag-aaa"
24
+ * disableOobee: false, // true → skip oobee custom checks
25
+ * enableWcagAaa: true, // true → also run WCAG AAA rules
26
+ * elementsToScan: [], // [] = full page; or CSS selectors / DOM nodes
27
+ * }).then(results => console.log(results));
28
+ *
29
+ * // Scroll to an element by CSS selector (item.xpath from scan results):
30
+ * window.oobee.scrollToElement(item.xpath);
31
+ * </script>
32
+ */
33
+ import { writeFileSync } from 'fs';
34
+ import path from 'path';
35
+ import { createRequire } from 'module';
36
+ import { fileURLToPath } from 'url';
37
+ import axe from 'axe-core';
38
+ import { a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, sentryConfig, wcagCriteriaLabels, formatWcagId, } from './constants/constants.js';
39
+ import { getOobeeFunctionsScript } from './npmIndex.js';
40
+ import { getVersion } from './utils.js';
41
+ const _require = createRequire(import.meta.url);
42
+ const _filename = fileURLToPath(import.meta.url);
43
+ const _dirname = path.dirname(_filename);
44
+ // ---------------------------------------------------------------------------
45
+ // Sentry config — DSN is read from process.env at generation time
46
+ // ---------------------------------------------------------------------------
47
+ const SENTRY_DSN = sentryConfig.dsn; // already resolves OOBEE_SENTRY_DSN || default
48
+ const APP_VERSION = getVersion();
49
+ const SENTRY_NODE_VERSION = (() => {
50
+ try {
51
+ return _require('@sentry/node/package.json').version;
52
+ }
53
+ catch {
54
+ return '9.47.1'; // safe fallback matching currently installed version
55
+ }
56
+ })();
57
+ // ---------------------------------------------------------------------------
58
+ // WCAG conformance helpers — formatWcagId and wcagCriteriaLabels are exported
59
+ // from constants.ts; embedded here so the browser bundle has the same logic.
60
+ // ---------------------------------------------------------------------------
61
+ const wcagConformanceScript = `
62
+ // Format a numeric WCAG criterion tag (mirrors formatWcagId in constants.ts).
63
+ // e.g. wcag143 → "WCAG 1.4.3", wcag1412 → "WCAG 1.4.12"
64
+ var _oobeeFormatWcagId = ${formatWcagId.toString()};
65
+
66
+ // Criteria → level map (mirrors wcagCriteriaLabels in constants.ts).
67
+ var _oobeeWcagCriteriaLabels = ${JSON.stringify(wcagCriteriaLabels, null, 2)};
68
+
69
+ /**
70
+ * Given an axe-core conformance array (e.g. ["wcag2a","wcag111","wcag143"]),
71
+ * returns the formatted criteria labels and the resolved level string —
72
+ * mirrors the logic used in ruleOffcanvas.ejs / AllIssues.ejs.
73
+ *
74
+ * Returns: { criteria: string[], level: string|null }
75
+ * criteria — e.g. ["WCAG 1.1.1", "WCAG 1.4.3"]
76
+ * level — e.g. "A", "AA", "AAA", or null if none found
77
+ */
78
+ function _oobeeFormatConformance(conformance) {
79
+ var wcagTags = (conformance || []).filter(function(c) { return c.startsWith('wcag'); });
80
+ var criteria = [];
81
+ var level = null;
82
+ wcagTags.forEach(function(tag) {
83
+ var formatted = _oobeeFormatWcagId(tag);
84
+ if (_oobeeWcagCriteriaLabels[formatted]) {
85
+ criteria.push(formatted);
86
+ if (!level) level = _oobeeWcagCriteriaLabels[formatted];
87
+ }
88
+ });
89
+ return { criteria: criteria, level: level };
90
+ }
91
+ `;
92
+ // ---------------------------------------------------------------------------
93
+ // filterAxeResults — browser-compatible (mirrors commonCrawlerFunc.ts)
94
+ // ---------------------------------------------------------------------------
95
+ const filterAxeResultsScript = `
96
+ function _oobeeTruncateHtml(html, maxBytes, suffix) {
97
+ maxBytes = maxBytes !== undefined ? maxBytes : 1024;
98
+ suffix = suffix !== undefined ? suffix : '\\u2026'; // '…'
99
+ var encoder = new TextEncoder();
100
+ if (encoder.encode(html).length <= maxBytes) return html;
101
+ var left = 0, right = html.length, result = '';
102
+ while (left <= right) {
103
+ var mid = Math.floor((left + right) / 2);
104
+ var truncated = html.slice(0, mid) + suffix;
105
+ var bytes = encoder.encode(truncated).length;
106
+ if (bytes <= maxBytes) { result = truncated; left = mid + 1; }
107
+ else { right = mid - 1; }
108
+ }
109
+ return result;
110
+ }
111
+
112
+ function _oobeeFilterAxeResults(axeResults, pageTitle) {
113
+ var violations = axeResults.violations || [];
114
+ var passes = axeResults.passes || [];
115
+ var incomplete = axeResults.incomplete || [];
116
+ var url = axeResults.url || (typeof window !== 'undefined' ? window.location.href : '');
117
+
118
+ var totalItems = 0;
119
+ var mustFix = { totalItems: 0, rules: {} };
120
+ var goodToFix = { totalItems: 0, rules: {} };
121
+ var needsReview = { totalItems: 0, rules: {} };
122
+ var passed = { totalItems: 0, rules: {} };
123
+
124
+ var wcagLevelRegex = /^wcag\\d+a+$/;
125
+
126
+ function processItem(item, displayNeedsReview) {
127
+ var rule = item.id;
128
+ var description = item.help;
129
+ var helpUrl = item.helpUrl;
130
+ var tags = item.tags || [];
131
+ var nodes = item.nodes || [];
132
+
133
+ if (rule === 'frame-tested') return;
134
+
135
+ var conformance = tags.filter(function(t) {
136
+ return t.startsWith('wcag') || t === 'best-practice';
137
+ });
138
+
139
+ if (conformance[0] !== 'best-practice' && !wcagLevelRegex.test(conformance[0])) {
140
+ conformance.sort(function(a, b) {
141
+ if (wcagLevelRegex.test(a) && !wcagLevelRegex.test(b)) return -1;
142
+ if (!wcagLevelRegex.test(a) && wcagLevelRegex.test(b)) return 1;
143
+ return 0;
144
+ });
145
+ }
146
+
147
+ var hasWcagA = conformance.some(function(t) { return /^wcag\\d*a$/.test(t); });
148
+ var hasWcagAA = conformance.some(function(t) { return /^wcag\\d*aa$/.test(t); });
149
+
150
+ var category = displayNeedsReview ? needsReview
151
+ : (hasWcagA || hasWcagAA) ? mustFix
152
+ : goodToFix;
153
+
154
+ nodes.forEach(function(node) {
155
+ var html = node.html || '';
156
+ var failureSummary = node.failureSummary || '';
157
+ var target = node.target || [];
158
+ var axeImpact = node.impact;
159
+
160
+ if (!(rule in category.rules)) {
161
+ category.rules[rule] = {
162
+ rule: rule, description: description, axeImpact: axeImpact,
163
+ helpUrl: helpUrl, conformance: conformance, totalItems: 0, items: [],
164
+ };
165
+ }
166
+
167
+ var message = displayNeedsReview
168
+ ? failureSummary.slice(failureSummary.indexOf('\\n') + 1).trim()
169
+ : failureSummary;
170
+
171
+ var finalHtml = html;
172
+ if (html.includes('<\\/script>')) {
173
+ finalHtml = html.replaceAll('<\\/script>', '&lt;/script>');
174
+ }
175
+ finalHtml = _oobeeTruncateHtml(finalHtml);
176
+
177
+ var xpath = (target.length === 1 && typeof target[0] === 'string') ? target[0] : undefined;
178
+
179
+ category.rules[rule].items.push({
180
+ html: finalHtml, message: message, xpath: xpath,
181
+ displayNeedsReview: displayNeedsReview || undefined,
182
+ });
183
+ category.rules[rule].totalItems += 1;
184
+ category.totalItems += 1;
185
+ totalItems += 1;
186
+ });
187
+ }
188
+
189
+ violations.forEach(function(item) { processItem(item, false); });
190
+ incomplete.forEach(function(item) { processItem(item, true); });
191
+
192
+ passes.forEach(function(item) {
193
+ var rule = item.id;
194
+ var description = item.help;
195
+ var axeImpact = item.impact;
196
+ var helpUrl = item.helpUrl;
197
+ var tags = item.tags || [];
198
+ var nodes = item.nodes || [];
199
+
200
+ if (rule === 'frame-tested') return;
201
+
202
+ var conformance = tags.filter(function(t) {
203
+ return t.startsWith('wcag') || t === 'best-practice';
204
+ });
205
+
206
+ nodes.forEach(function(node) {
207
+ if (!(rule in passed.rules)) {
208
+ passed.rules[rule] = {
209
+ rule: rule, description: description, axeImpact: axeImpact,
210
+ helpUrl: helpUrl, conformance: conformance, totalItems: 0, items: [],
211
+ };
212
+ }
213
+ var passedXpath = (node.target && node.target.length === 1 && typeof node.target[0] === 'string')
214
+ ? node.target[0] : undefined;
215
+ passed.rules[rule].items.push({
216
+ html: _oobeeTruncateHtml(node.html || ''), screenshotPath: '',
217
+ message: '', xpath: passedXpath,
218
+ });
219
+ passed.totalItems += 1;
220
+ passed.rules[rule].totalItems += 1;
221
+ totalItems += 1;
222
+ });
223
+ });
224
+
225
+ return {
226
+ url: url, pageTitle: pageTitle, totalItems: totalItems,
227
+ mustFix: mustFix, goodToFix: goodToFix, needsReview: needsReview, passed: passed,
228
+ };
229
+ }
230
+ `;
231
+ // ---------------------------------------------------------------------------
232
+ // Sentry telemetry — uses the official Sentry JavaScript browser SDK loaded
233
+ // from CDN (same major version as the installed @sentry/node build).
234
+ // DSN and app version are baked in at generation time.
235
+ // ---------------------------------------------------------------------------
236
+ const sentryTelemetryScript = (dsn, appVersion, sentryVersion) => `
237
+ var _oobeeSentryDsn = ${JSON.stringify(dsn)};
238
+ var _oobeeAppVersion = ${JSON.stringify(appVersion)};
239
+ var _oobeeSentryVersion = ${JSON.stringify(sentryVersion)};
240
+ var _oobeeSentryInitialized = false;
241
+ var _oobeeSentryLoadPromise = null;
242
+
243
+ /**
244
+ * Lazily load the Sentry JavaScript browser SDK from CDN and return the
245
+ * global Sentry object. Subsequent calls reuse the same promise.
246
+ */
247
+ function _oobeeLoadSentry() {
248
+ if (_oobeeSentryLoadPromise) return _oobeeSentryLoadPromise;
249
+
250
+ _oobeeSentryLoadPromise = new Promise(function(resolve, reject) {
251
+ // Already present (e.g. host page loaded Sentry itself)
252
+ if (window.Sentry && typeof window.Sentry.init === 'function') {
253
+ resolve(window.Sentry);
254
+ return;
255
+ }
256
+ var script = document.createElement('script');
257
+ script.src = 'https://browser.sentry-cdn.com/' + _oobeeSentryVersion + '/bundle.min.js';
258
+ script.crossOrigin = 'anonymous';
259
+ script.onload = function() {
260
+ if (window.Sentry && typeof window.Sentry.init === 'function') {
261
+ resolve(window.Sentry);
262
+ } else {
263
+ reject(new Error('[oobee] Sentry SDK loaded but window.Sentry not found'));
264
+ }
265
+ };
266
+ script.onerror = function() {
267
+ reject(new Error('[oobee] Failed to load Sentry browser SDK from CDN'));
268
+ };
269
+ document.head.appendChild(script);
270
+ });
271
+
272
+ return _oobeeSentryLoadPromise;
273
+ }
274
+
275
+ /**
276
+ * Build WCAG occurrence map and per-criterion level map from scan results.
277
+ * Mirrors the logic in npmIndex.ts processAndSubmitResults.
278
+ */
279
+ function _oobeeBuildWcagData(results) {
280
+ var wcagOccurrencesMap = {}; // { wcag111: 3, wcag412: 1, ... }
281
+ var criterionLevel = {}; // { wcag111: 'a', wcag143: 'aa', ... }
282
+ var criterionRegex = /^wcag[0-9]{3,4}$/;
283
+
284
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(function(cat) {
285
+ var catData = results[cat];
286
+ if (!catData || !catData.rules) return;
287
+
288
+ Object.values(catData.rules).forEach(function(rule) {
289
+ if (!rule.conformance) return;
290
+
291
+ // Derive level from conformance level-tags (wcag2a / wcag2aa / wcag2aaa)
292
+ var level = '';
293
+ var criteria = [];
294
+ rule.conformance.forEach(function(c) {
295
+ if (/^wcag\\d+aaa$/.test(c)) { if (!level) level = 'aaa'; }
296
+ else if (/^wcag\\d+aa$/.test(c)) { if (!level) level = 'aa'; }
297
+ else if (/^wcag\\d+a$/.test(c)) { if (!level) level = 'a'; }
298
+ else if (criterionRegex.test(c)) { criteria.push(c); }
299
+ });
300
+
301
+ criteria.forEach(function(c) {
302
+ // Keep the most severe level seen for a criterion
303
+ var existing = criterionLevel[c];
304
+ if (!existing || level === 'aaa' ||
305
+ (level === 'aa' && existing === 'a') ||
306
+ (level === 'a' && !existing)) {
307
+ criterionLevel[c] = level;
308
+ }
309
+ });
310
+
311
+ // Only count violations for the occurrence map (mustFix + goodToFix)
312
+ if (cat === 'mustFix' || cat === 'goodToFix') {
313
+ criteria.forEach(function(c) {
314
+ wcagOccurrencesMap[c] = (wcagOccurrencesMap[c] || 0) + rule.totalItems;
315
+ });
316
+ }
317
+ });
318
+ });
319
+
320
+ return { wcagOccurrencesMap: wcagOccurrencesMap, criterionLevel: criterionLevel };
321
+ }
322
+
323
+ /**
324
+ * Send an "Accessibility Scan Page" event to Sentry using the official
325
+ * Sentry JavaScript browser SDK API:
326
+ * Sentry.init / Sentry.setUser / Sentry.captureEvent / Sentry.flush
327
+ *
328
+ * @param {object} results - Full oobee scan result from window.oobee.scan()
329
+ * @param {object} userInfo - { email, name } provided by the implementer
330
+ */
331
+ async function _oobeeSendSentryTelemetry(results, userInfo) {
332
+ if (!_oobeeSentryDsn) return;
333
+
334
+ try {
335
+ var Sentry = await _oobeeLoadSentry();
336
+
337
+ // Initialise once per page load
338
+ if (!_oobeeSentryInitialized) {
339
+ Sentry.init({
340
+ dsn: _oobeeSentryDsn,
341
+ tracesSampleRate: 1.0,
342
+ });
343
+ _oobeeSentryInitialized = true;
344
+ }
345
+
346
+ // ── User context ────────────────────────────────────────────────────
347
+ Sentry.setUser({
348
+ email: (userInfo && userInfo.email) || undefined,
349
+ username: (userInfo && userInfo.name) || undefined,
350
+ });
351
+
352
+ // ── WCAG breakdown tags ─────────────────────────────────────────────
353
+ var wcagData = _oobeeBuildWcagData(results);
354
+ var wcagOccurrencesMap = wcagData.wcagOccurrencesMap;
355
+ var criterionLevel = wcagData.criterionLevel;
356
+
357
+ var tags = {};
358
+ var wcagCriteriaBreakdown = {};
359
+
360
+ // Format: wcag111a_Occurrences (mirrors sentryTelemetry.ts formatWcagTag)
361
+ Object.keys(wcagOccurrencesMap).forEach(function(wcagId) {
362
+ var level = criterionLevel[wcagId] || '';
363
+ var formattedTag = wcagId + level + '_Occurrences';
364
+ tags[formattedTag] = String(wcagOccurrencesMap[wcagId]);
365
+ wcagCriteriaBreakdown[formattedTag] = { count: wcagOccurrencesMap[wcagId] };
366
+ });
367
+
368
+ // ── Category counts & occurrences ───────────────────────────────────
369
+ var mustFixRules = results.mustFix ? Object.keys(results.mustFix.rules) : [];
370
+ var goodToFixRules = results.goodToFix ? Object.keys(results.goodToFix.rules) : [];
371
+ var needsReviewRules = results.needsReview ? Object.keys(results.needsReview.rules) : [];
372
+
373
+ tags['version'] = _oobeeAppVersion;
374
+ tags['WCAG-MustFix-Count'] = String(mustFixRules.length);
375
+ tags['WCAG-GoodToFix-Count'] = String(goodToFixRules.length);
376
+ tags['WCAG-NeedsReview-Count'] = String(needsReviewRules.length);
377
+ tags['WCAG-MustFix-Occurrences'] = String(results.mustFix ? results.mustFix.totalItems : 0);
378
+ tags['WCAG-GoodToFix-Occurrences'] = String(results.goodToFix ? results.goodToFix.totalItems : 0);
379
+ tags['WCAG-NeedsReview-Occurrences'] = String(results.needsReview ? results.needsReview.totalItems : 0);
380
+ tags['Pages-Scanned-Count'] = '1';
381
+
382
+ // ── Capture event ───────────────────────────────────────────────────
383
+ Sentry.captureEvent({
384
+ message: 'Accessibility Scan Page',
385
+ level: 'info',
386
+ tags: Object.assign({}, tags, {
387
+ event_type: 'accessibility_scan',
388
+ scanType: 'browser',
389
+ browser: 'browser',
390
+ entryUrl: window.location.href,
391
+ }),
392
+ extra: {
393
+ wcagBreakdown: wcagCriteriaBreakdown,
394
+ reportCounts: {
395
+ mustFix: { issues: mustFixRules.length, occurrences: results.mustFix ? results.mustFix.totalItems : 0 },
396
+ goodToFix: { issues: goodToFixRules.length, occurrences: results.goodToFix ? results.goodToFix.totalItems : 0 },
397
+ needsReview: { issues: needsReviewRules.length, occurrences: results.needsReview ? results.needsReview.totalItems : 0 },
398
+ },
399
+ },
400
+ });
401
+
402
+ await Sentry.flush(2000);
403
+
404
+ } catch (err) {
405
+ // Telemetry failures must never break the caller
406
+ console.error('[oobee-client-scanner] Sentry telemetry error:', err);
407
+ }
408
+ }
409
+ `;
410
+ // ---------------------------------------------------------------------------
411
+ // scan API — enriches results and fires Sentry telemetry
412
+ // ---------------------------------------------------------------------------
413
+ const scanApiScript = (shortDescMap, longDescMap, stepByStepMap) => `
414
+ var _oobeeShortDescMap = ${JSON.stringify(shortDescMap)};
415
+ var _oobeeLongDescMap = ${JSON.stringify(longDescMap)};
416
+ var _oobeeStepByStepGuide = ${JSON.stringify(stepByStepMap)};
417
+
418
+ /**
419
+ * window.oobee.scan(options?) — scan the current page for accessibility issues.
420
+ *
421
+ * @param {object} [options]
422
+ * @param {boolean} [options.disableOobee=false] Disable oobee custom checks.
423
+ * @param {boolean} [options.enableWcagAaa=false] Include WCAG 2 AAA rules.
424
+ * @param {Array} [options.elementsToScan=[]] CSS selectors / DOM nodes to
425
+ * scope the scan; [] = full page.
426
+ * @param {object} [options.userInfo] Implementer-supplied identity.
427
+ * @param {string} [options.userInfo.email] User e-mail for Sentry telemetry.
428
+ * @param {string} [options.userInfo.name] User name for Sentry telemetry.
429
+ *
430
+ * @returns {Promise<object>} Oobee scan result (same shape as npmIndex JSON output).
431
+ */
432
+ window.oobee = {
433
+ scan: async function(options) {
434
+ var opts = options || {};
435
+ var disableOobee = opts.disableOobee !== undefined ? !!opts.disableOobee : false;
436
+ var enableWcagAaa = opts.enableWcagAaa !== undefined ? !!opts.enableWcagAaa : false;
437
+ var elementsToScan = opts.elementsToScan || [];
438
+ var userInfo = opts.userInfo || {};
439
+
440
+ // Update window globals read by runA11yScan
441
+ window.disableOobee = disableOobee;
442
+ window.enableWcagAaa = enableWcagAaa;
443
+
444
+ // Run axe-core + oobee custom checks
445
+ var scanResult = await window.runA11yScan(elementsToScan, '');
446
+
447
+ // Convert raw axe results into oobee category structure
448
+ var filtered = _oobeeFilterAxeResults(scanResult.axeScanResults, scanResult.pageTitle);
449
+
450
+ // Enrich rules with oobee knowledge-base descriptions
451
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(function(category) {
452
+ var cat = filtered[category];
453
+ if (!cat || !cat.rules) return;
454
+ Object.keys(cat.rules).forEach(function(ruleId) {
455
+ var rule = cat.rules[ruleId];
456
+ rule.shortDescription = _oobeeShortDescMap[ruleId];
457
+ rule.longDescription = _oobeeLongDescMap[ruleId];
458
+ rule.stepByStepGuide = _oobeeStepByStepGuide[ruleId];
459
+ });
460
+ });
461
+
462
+ // Fire-and-forget Sentry telemetry (errors are caught internally)
463
+ _oobeeSendSentryTelemetry(filtered, userInfo);
464
+
465
+ return filtered;
466
+ },
467
+
468
+ /**
469
+ * Format a raw conformance tag array into criteria labels + level.
470
+ * Mirrors ruleOffcanvas.ejs / AllIssues.ejs logic (single source of truth
471
+ * via formatWcagId + wcagCriteriaLabels exported from constants.ts).
472
+ *
473
+ * @param {string[]} conformance e.g. ["wcag2a","wcag111","wcag143"]
474
+ * @returns {{ criteria: string[], level: string|null }}
475
+ * e.g. { criteria: ["WCAG 1.1.1","WCAG 1.4.3"], level: "A" }
476
+ */
477
+ formatConformance: _oobeeFormatConformance,
478
+
479
+ /**
480
+ * Scroll the element matching the given CSS selector into view and briefly
481
+ * highlight it with an outline flash. The selector comes from item.xpath
482
+ * in the scan results (axe-core stores CSS selectors there).
483
+ *
484
+ * @param {string} selector CSS selector, e.g. "button:nth-child(2)"
485
+ */
486
+ scrollToElement: function(selector) {
487
+ if (!selector) return;
488
+ var el;
489
+ try { el = document.querySelector(selector); } catch (e) { return; }
490
+ if (!el) return;
491
+
492
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
493
+
494
+ // Brief outline flash so the element is easy to spot
495
+ var prev = el.style.outline;
496
+ el.style.outline = '3px solid #fd7e14';
497
+ setTimeout(function() { el.style.outline = prev; }, 1800);
498
+ },
499
+ };
500
+
501
+ console.log(
502
+ '[oobee-client-scanner] Ready. Call window.oobee.scan() to scan this page.'
503
+ );
504
+ `;
505
+ // ---------------------------------------------------------------------------
506
+ // Assemble the full client bundle
507
+ // ---------------------------------------------------------------------------
508
+ function generateClientBundle() {
509
+ const axeSource = axe.source;
510
+ const oobeeFunctions = getOobeeFunctionsScript(false, false);
511
+ return `/**
512
+ * oobee-client-scanner.js — auto-generated by generateOobeeClientScanner.ts
513
+ * DO NOT EDIT MANUALLY. Re-generate with: node dist/generateOobeeClientScanner.js
514
+ *
515
+ * Embedded at generation time:
516
+ * App version : ${APP_VERSION}
517
+ * Sentry DSN : (from OOBEE_SENTRY_DSN env var or constants.ts default)
518
+ * Sentry SDK : @sentry/browser ${SENTRY_NODE_VERSION} (loaded from CDN at runtime)
519
+ *
520
+ * Usage:
521
+ * <script src="oobee-client-scanner.js"></script>
522
+ * <script>
523
+ * window.oobee.scan({
524
+ * userInfo: { email: 'you@example.com', name: 'Your Name' },
525
+ * // scanMode: [string] choices: "default" | "disable-oobee" | "enable-wcag-aaa" | "disable-oobee,enable-wcag-aaa"
526
+ * // "default" — axe-core + oobee custom checks, WCAG A/AA only
527
+ * // "disable-oobee" — axe-core only, no oobee custom checks
528
+ * // "enable-wcag-aaa" — axe-core + oobee + WCAG AAA rules
529
+ * // "disable-oobee,enable-wcag-aaa" — axe-core + WCAG AAA, no oobee checks
530
+ * disableOobee: false, // true → same as "disable-oobee"
531
+ * enableWcagAaa: true, // true → same as "enable-wcag-aaa"
532
+ * elementsToScan: [], // [] = full page; or pass CSS selectors / DOM nodes
533
+ * }).then(results => console.log(JSON.stringify(results, null, 2)));
534
+ * </script>
535
+ */
536
+ (function () {
537
+ 'use strict';
538
+
539
+ // ── axe-core ──────────────────────────────────────────────────────────────
540
+ ${axeSource}
541
+
542
+ // ── Oobee helper functions + getAxeConfiguration + runA11yScan ───────────
543
+ ${oobeeFunctions}
544
+
545
+ // ── filterAxeResults (browser-compatible) ─────────────────────────────────
546
+ ${filterAxeResultsScript}
547
+
548
+ // ── WCAG conformance helpers (formatWcagId + wcagCriteriaLabels from constants.ts) ──
549
+ ${wcagConformanceScript}
550
+
551
+ // ── Sentry browser telemetry (Sentry JS SDK, loaded from CDN) ────────────
552
+ ${sentryTelemetryScript(SENTRY_DSN, APP_VERSION, SENTRY_NODE_VERSION)}
553
+
554
+ // ── Description maps + window.oobee API ───────────────────────────────────
555
+ ${scanApiScript(a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide)}
556
+ })();
557
+ `;
558
+ }
559
+ // ---------------------------------------------------------------------------
560
+ // Write output file
561
+ // ---------------------------------------------------------------------------
562
+ const outputArg = process.argv[2];
563
+ const outputPath = outputArg
564
+ ? path.resolve(outputArg)
565
+ : path.resolve(process.cwd(), 'oobee-client-scanner.js');
566
+ writeFileSync(outputPath, generateClientBundle(), 'utf-8');
567
+ console.log(`Generated: ${outputPath}`);
568
+ console.log(` App version : ${APP_VERSION}`);
569
+ console.log(` Sentry DSN : ${SENTRY_DSN.slice(0, 40)}…`);
570
+ console.log(` Sentry SDK : @sentry/browser ${SENTRY_NODE_VERSION} (CDN)`);
@@ -5,7 +5,7 @@ import printMessage from 'print-message';
5
5
  import path from 'path';
6
6
  import ejs from 'ejs';
7
7
  import { fileURLToPath } from 'url';
8
- import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
8
+ import constants, { BrowserTypes, ScannerTypes, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide, } from './constants/constants.js';
9
9
  import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
10
10
  import { createScreenshotsFolder, getStoragePath, getVersion, getWcagPassPercentage, getProgressPercentage, retryFunction, zipResults, getIssuesPercentage, register, } from './utils.js';
11
11
  import { consoleLogger } from './logs.js';
@@ -247,35 +247,54 @@ const cleanUpJsonFiles = async (filesToDelete) => {
247
247
  });
248
248
  };
249
249
  const writeSummaryPdf = async (storagePath, pagesScanned, filename = 'summary', browser, _userDataDirectory) => {
250
- const htmlFilePath = `${storagePath}/${filename}.html`;
251
- const fileDestinationPath = `${storagePath}/${filename}.pdf`;
252
- const launchOptions = getPlaywrightLaunchOptions(browser);
253
- const browserInstance = await constants.launcher.launch({
254
- ...launchOptions,
255
- headless: true, // force headless for PDF
256
- });
257
- register(browserInstance);
258
- const context = await browserInstance.newContext();
259
- const page = await context.newPage();
260
- const data = fs.readFileSync(htmlFilePath, { encoding: 'utf-8' });
261
- await page.setContent(data, { waitUntil: 'domcontentloaded' });
262
- await page.emulateMedia({ media: 'print' });
263
- await page.pdf({
264
- margin: { bottom: '32px' },
265
- path: fileDestinationPath,
266
- format: 'A4',
267
- displayHeaderFooter: true,
268
- footerTemplate: `
250
+ let browserInstance;
251
+ let context;
252
+ let page;
253
+ try {
254
+ const htmlFilePath = path.join(storagePath, `${filename}.html`);
255
+ const fileDestinationPath = path.join(storagePath, `${filename}.pdf`);
256
+ const htmlFileUrl = `file://${htmlFilePath}`;
257
+ const launchOptions = getPlaywrightLaunchOptions(browser);
258
+ browserInstance = await constants.launcher.launch({
259
+ ...launchOptions,
260
+ headless: true,
261
+ });
262
+ register(browserInstance);
263
+ context = await browserInstance.newContext();
264
+ page = await context.newPage();
265
+ await page.goto(htmlFileUrl, {
266
+ waitUntil: 'domcontentloaded',
267
+ timeout: 120000,
268
+ });
269
+ await page.emulateMedia({ media: 'print' });
270
+ await page.pdf({
271
+ margin: { bottom: '32px' },
272
+ path: fileDestinationPath,
273
+ format: 'A4',
274
+ displayHeaderFooter: true,
275
+ footerTemplate: `
269
276
  <div style="margin-top:50px;color:#26241b;font-family:Open Sans;text-align: center;width: 100%;font-weight:400">
270
277
  <span style="color:#26241b;font-size: 14px;font-weight:400">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
271
278
  </div>
272
279
  `,
273
- });
274
- await page.close();
275
- await context.close().catch(() => { });
276
- await browserInstance.close().catch(() => { });
277
- if (pagesScanned < 2000) {
278
- fs.unlinkSync(htmlFilePath);
280
+ });
281
+ if (pagesScanned < 2000) {
282
+ fs.unlinkSync(htmlFilePath);
283
+ }
284
+ }
285
+ catch (err) {
286
+ consoleLogger.info(`Error at writeSummaryPDF ${err instanceof Error ? err.stack : err}`);
287
+ }
288
+ finally {
289
+ await page?.close().catch(err => {
290
+ consoleLogger.info(`Error at page close writeSummaryPDF ${err}`);
291
+ });
292
+ await context?.close().catch(err => {
293
+ consoleLogger.info(`Error at context close writeSummaryPDF ${err}`);
294
+ });
295
+ await browserInstance?.close().catch(err => {
296
+ consoleLogger.info(`Error at browserInstance close writeSummaryPDF ${err}`);
297
+ });
279
298
  }
280
299
  };
281
300
  // Tracking WCAG occurrences
@@ -283,9 +302,9 @@ const wcagOccurrencesMap = new Map();
283
302
  const pushResults = async (pageResults, allIssues, isCustomFlow) => {
284
303
  const { url, pageTitle, filePath } = pageResults;
285
304
  const totalIssuesInPage = new Set();
286
- Object.keys(pageResults.mustFix.rules).forEach(k => totalIssuesInPage.add(k));
287
- Object.keys(pageResults.goodToFix.rules).forEach(k => totalIssuesInPage.add(k));
288
- Object.keys(pageResults.needsReview.rules).forEach(k => totalIssuesInPage.add(k));
305
+ Object.keys(pageResults.mustFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
306
+ Object.keys(pageResults.goodToFix?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
307
+ Object.keys(pageResults.needsReview?.rules ?? {}).forEach(k => totalIssuesInPage.add(k));
289
308
  allIssues.topFiveMostIssues.push({
290
309
  url,
291
310
  pageTitle,
@@ -573,6 +592,7 @@ generateJsonFiles = false) => {
573
592
  a11yRuleShortDescriptionMap,
574
593
  disabilityBadgesMap,
575
594
  a11yRuleLongDescriptionMap,
595
+ a11yRuleStepByStepGuide,
576
596
  wcagCriteriaLabels: constants.wcagCriteriaLabels,
577
597
  scanPagesDetail: {
578
598
  pagesAffected: [],