@govtechsg/oobee 0.10.77 → 0.10.78-alpha1

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/INTEGRATION.md CHANGED
@@ -426,3 +426,46 @@ You will see Oobee results generated in <code>results</code> folder.
426
426
  runScript();
427
427
 
428
428
  </details>
429
+
430
+ #### Integration with any NodeJS workflow (Beta)
431
+
432
+ You can also use Oobee in any NodeJS script without needing a specific framework integration pattern. This is useful for custom workflows, CI/CD pipelines, or simple scripts where you manage the browser automation yourself or want to scan static HTML.
433
+
434
+ Refer to the examples in `examples/oobee-scan-page-demo.js` and `examples/oobee-scan-html-demo.js`.
435
+
436
+ ##### `async scanPage(pages, config)`
437
+
438
+ Scans one or more Playwright Page objects. This injects the Oobee engine into the page context to perform the audit.
439
+
440
+ **Parameters:**
441
+
442
+ - `pages`: A Playwright `Page` object or an array of `Page` objects.
443
+ - `config`: Configuration object
444
+ - `name`: Name for results (required)
445
+ - `email`: Email for results (required)
446
+ - `pageTitle`: Optional override for page title (only applied if scanning a single page)
447
+ - `metadata`: Optional metadata string
448
+ - `ruleset`: Optional array of `RuleFlags` (e.g. `['enable-wcag-aaa', 'disable-oobee']`)
449
+
450
+ **Returns:**
451
+
452
+ - Scan results object containing categorized violations and pass counts.
453
+
454
+ ##### `async scanHTML(htmlContent, config)`
455
+
456
+ Scans raw HTML string(s). Note that this runs in a JSDOM environment (NodeJS) using axe-core and may not catch accessibility issues that require a full browser layout or JavaScript execution to render.
457
+
458
+ **Parameters:**
459
+
460
+ - `htmlContent`: HTML string or array of HTML strings.
461
+ - `config`: Configuration object
462
+ - `name`: Name for results (required)
463
+ - `email`: Email for results (required)
464
+ - `pageUrl`: Optional URL identifier for the report (defaults to 'raw-html')
465
+ - `pageTitle`: Optional title identifier (defaults to 'HTML Content')
466
+ - `metadata`: Optional metadata string
467
+ - `ruleset`: Optional array of `RuleFlags`
468
+
469
+ **Returns:**
470
+
471
+ - Scan results object containing categorized violations and pass counts.
@@ -8,7 +8,6 @@ import { AsyncParser } from '@json2csv/node';
8
8
  import zlib from 'zlib';
9
9
  import { Base64Encode } from 'base64-stream';
10
10
  import { pipeline } from 'stream/promises';
11
- // @ts-ignore
12
11
  import * as Sentry from '@sentry/node';
13
12
  import constants, { BrowserTypes, ScannerTypes, sentryConfig, setSentryUser, WCAGclauses, a11yRuleShortDescriptionMap, disabilityBadgesMap, a11yRuleLongDescriptionMap, } from './constants/constants.js';
14
13
  import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
@@ -904,31 +903,49 @@ function updateIssuesWithOccurrences(issuesList, urlOccurrencesMap) {
904
903
  issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
905
904
  });
906
905
  }
907
- const createRuleIdJson = allIssues => {
906
+ const extractRuleAiData = (ruleId, totalItems, items, callback) => {
907
+ let snippets = [];
908
+ if (oobeeAiRules.includes(ruleId)) {
909
+ const snippetsSet = new Set();
910
+ if (items) {
911
+ items.forEach(item => {
912
+ snippetsSet.add(oobeeAiHtmlETL(item.html));
913
+ });
914
+ }
915
+ snippets = [...snippetsSet];
916
+ if (callback)
917
+ callback();
918
+ }
919
+ return {
920
+ snippets,
921
+ occurrences: totalItems,
922
+ };
923
+ };
924
+ // This is for telemetry purposes called within mergeAxeResults.ts
925
+ export const createRuleIdJson = allIssues => {
908
926
  const compiledRuleJson = {};
909
- const ruleIterator = rule => {
910
- const ruleId = rule.rule;
911
- let snippets = [];
912
- if (oobeeAiRules.includes(ruleId)) {
913
- const snippetsSet = new Set();
914
- rule.pagesAffected.forEach(page => {
915
- page.items.forEach(htmlItem => {
916
- snippetsSet.add(oobeeAiHtmlETL(htmlItem.html));
927
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
928
+ allIssues.items[category].rules.forEach(rule => {
929
+ const allItems = rule.pagesAffected.flatMap(page => page.items || []);
930
+ compiledRuleJson[rule.rule] = extractRuleAiData(rule.rule, rule.totalItems, allItems, () => {
931
+ rule.pagesAffected.forEach(p => {
932
+ delete p.items;
917
933
  });
918
934
  });
919
- snippets = [...snippetsSet];
920
- rule.pagesAffected.forEach(p => {
921
- delete p.items;
935
+ });
936
+ });
937
+ return compiledRuleJson;
938
+ };
939
+ // This is for telemetry purposes called from npmIndex (scanPage and scanHTML) where report is not generated
940
+ export const createBasicFormHTMLSnippet = filteredResults => {
941
+ const compiledRuleJson = {};
942
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
943
+ if (filteredResults[category] && filteredResults[category].rules) {
944
+ Object.entries(filteredResults[category].rules).forEach(([ruleId, ruleVal]) => {
945
+ compiledRuleJson[ruleId] = extractRuleAiData(ruleId, ruleVal.totalItems, ruleVal.items);
922
946
  });
923
947
  }
924
- compiledRuleJson[ruleId] = {
925
- snippets,
926
- occurrences: rule.totalItems,
927
- };
928
- };
929
- allIssues.items.mustFix.rules.forEach(ruleIterator);
930
- allIssues.items.goodToFix.rules.forEach(ruleIterator);
931
- allIssues.items.needsReview.rules.forEach(ruleIterator);
948
+ });
932
949
  return compiledRuleJson;
933
950
  };
934
951
  const moveElemScreenshots = (randomToken, storagePath) => {
@@ -1143,7 +1160,7 @@ function populateScanPagesDetail(allIssues) {
1143
1160
  };
1144
1161
  }
1145
1162
  // Send WCAG criteria breakdown to Sentry
1146
- const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson, scanInfo, allIssues, pagesScannedCount = 0) => {
1163
+ export const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson, scanInfo, allIssues, pagesScannedCount = 0) => {
1147
1164
  try {
1148
1165
  // Initialize Sentry
1149
1166
  Sentry.init(sentryConfig);
package/dist/npmIndex.js CHANGED
@@ -1,13 +1,14 @@
1
- import fs from 'fs';
2
1
  import path from 'path';
3
2
  import printMessage from 'print-message';
3
+ import axe from 'axe-core';
4
+ import { JSDOM } from 'jsdom';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { EnqueueStrategy } from 'crawlee';
6
7
  import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
7
8
  import { deleteClonedProfiles, getBrowserToRun, getPlaywrightLaunchOptions, submitForm, } from './constants/common.js';
8
9
  import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
9
- import { createAndUpdateResultsFolders } from './utils.js';
10
- import generateArtifacts from './mergeAxeResults.js';
10
+ import { createAndUpdateResultsFolders, getVersion } from './utils.js';
11
+ import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
11
12
  import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
12
13
  import { consoleLogger } from './logs.js';
13
14
  import { alertMessageOptions } from './constants/cliFunctions.js';
@@ -21,50 +22,11 @@ import { extractText } from './crawlers/custom/extractText.js';
21
22
  import { gradeReadability } from './crawlers/custom/gradeReadability.js';
22
23
  const filename = fileURLToPath(import.meta.url);
23
24
  const dirname = path.dirname(filename);
24
- export const init = async ({ entryUrl, testLabel, name, email, includeScreenshots = false, viewportSettings = { width: 1000, height: 660 }, // cypress' default viewport settings
25
- thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = undefined, zip = 'oobee-scan-results', deviceChosen, strategy = EnqueueStrategy.All, ruleset = [RuleFlags.DEFAULT], specifiedMaxConcurrency = 25, followRobots = false, }) => {
26
- consoleLogger.info('Starting Oobee');
27
- const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
28
- const domain = new URL(entryUrl).hostname;
29
- const sanitisedLabel = testLabel ? `_${testLabel.replaceAll(' ', '_')}` : '';
30
- const randomToken = `${date}_${time}${sanitisedLabel}_${domain}`;
31
- const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
32
- const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
33
- // max numbers of mustFix/goodToFix occurrences before test returns a fail
34
- const { mustFix: mustFixThreshold, goodToFix: goodToFixThreshold } = thresholds;
35
- process.env.CRAWLEE_STORAGE_DIR = randomToken;
36
- const scanDetails = {
37
- startTime: new Date(),
38
- endTime: new Date(),
39
- deviceChosen,
40
- crawlType: ScannerTypes.CUSTOM,
41
- requestUrl: entryUrl,
42
- urlsCrawled: { ...constants.urlsCrawledObj },
43
- isIncludeScreenshots: includeScreenshots,
44
- isAllowSubdomains: strategy,
45
- isEnableCustomChecks: ruleset,
46
- isEnableWcagAaa: ruleset,
47
- isSlowScanMode: specifiedMaxConcurrency,
48
- isAdhereRobots: followRobots,
49
- };
50
- const urlsCrawled = { ...constants.urlsCrawledObj };
51
- const { dataset } = await createCrawleeSubFolders(randomToken);
52
- let mustFixIssues = 0;
53
- let goodToFixIssues = 0;
54
- let isInstanceTerminated = false;
55
- const throwErrorIfTerminated = () => {
56
- if (isInstanceTerminated) {
57
- throw new Error('This instance of Oobee was terminated. Please start a new instance.');
58
- }
59
- };
60
- const getAxeScript = () => {
61
- throwErrorIfTerminated();
62
- const axeScript = fs.readFileSync(path.join(dirname, '../../../axe-core/axe.min.js'), 'utf-8');
63
- return axeScript;
64
- };
65
- const getOobeeFunctions = () => {
66
- throwErrorIfTerminated();
67
- return `
25
+ const getAxeScriptContent = () => {
26
+ return axe.source;
27
+ };
28
+ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
29
+ return `
68
30
  // Fix for missing __name function used by bundler
69
31
  if (typeof __name === 'undefined') {
70
32
  window.__name = function(fn, name) {
@@ -122,7 +84,7 @@ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = u
122
84
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
123
85
  },
124
86
  },
125
- ...(enableWcagAaa
87
+ ...((enableWcagAaa && !disableOobee)
126
88
  ? [
127
89
  {
128
90
  id: 'oobee-grading-text-contents',
@@ -170,19 +132,23 @@ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = u
170
132
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
171
133
  },
172
134
  },
173
- {
174
- id: 'oobee-grading-text-contents',
175
- selector: 'html',
176
- enabled: true,
177
- any: ['oobee-grading-text-contents'],
178
- tags: ['wcag2aaa', 'wcag315'],
179
- metadata: {
180
- description:
181
- 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
182
- help: 'Text content should be clear and plain to ensure that it is easily understood.',
183
- helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
184
- },
185
- },
135
+ ...((enableWcagAaa && !disableOobee)
136
+ ? [
137
+ {
138
+ id: 'oobee-grading-text-contents',
139
+ selector: 'html',
140
+ enabled: true,
141
+ any: ['oobee-grading-text-contents'],
142
+ tags: ['wcag2aaa', 'wcag315'],
143
+ metadata: {
144
+ description:
145
+ 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
146
+ help: 'Text content should be clear and plain to ensure that it is easily understood.',
147
+ helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
148
+ },
149
+ },
150
+ ]
151
+ : []),
186
152
  ]
187
153
  .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
188
154
  .concat(
@@ -235,6 +201,31 @@ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = u
235
201
  const axeScanResults = await (window).axe.run(elementsToScan, {
236
202
  resultTypes: ['violations', 'passes', 'incomplete'],
237
203
  });
204
+
205
+ if (axeScanResults) {
206
+ ['violations', 'incomplete'].forEach(type => {
207
+ if (axeScanResults[type]) {
208
+ axeScanResults[type].forEach(result => {
209
+ if (result.nodes) {
210
+ result.nodes.forEach(node => {
211
+ ['any', 'all', 'none'].forEach(key => {
212
+ if (node[key]) {
213
+ node[key].forEach(check => {
214
+ if (check.message && check.message.indexOf("Axe encountered an error") !== -1) {
215
+ if (check.data) {
216
+ // console.error(check.data);
217
+ console.error("Axe encountered an error: " + (check.data.stack || check.data.message || JSON.stringify(check.data)));
218
+ }
219
+ }
220
+ });
221
+ }
222
+ });
223
+ });
224
+ }
225
+ });
226
+ }
227
+ });
228
+ }
238
229
 
239
230
  // add custom Oobee violations
240
231
  if (!(window).disableOobee) {
@@ -284,6 +275,50 @@ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = u
284
275
  window.enableWcagAaa=${enableWcagAaa};
285
276
  window.runA11yScan = runA11yScan;
286
277
  `;
278
+ };
279
+ export const init = async ({ entryUrl, testLabel, name, email, includeScreenshots = false, viewportSettings = { width: 1000, height: 660 }, // cypress' default viewport settings
280
+ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = undefined, zip = 'oobee-scan-results', deviceChosen, strategy = EnqueueStrategy.All, ruleset = [RuleFlags.DEFAULT], specifiedMaxConcurrency = 25, followRobots = false, }) => {
281
+ consoleLogger.info('Starting Oobee');
282
+ const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
283
+ const domain = new URL(entryUrl).hostname;
284
+ const sanitisedLabel = testLabel ? `_${testLabel.replaceAll(' ', '_')}` : '';
285
+ const randomToken = `${date}_${time}${sanitisedLabel}_${domain}`;
286
+ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
287
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
288
+ // max numbers of mustFix/goodToFix occurrences before test returns a fail
289
+ const { mustFix: mustFixThreshold, goodToFix: goodToFixThreshold } = thresholds;
290
+ process.env.CRAWLEE_STORAGE_DIR = randomToken;
291
+ const scanDetails = {
292
+ startTime: new Date(),
293
+ endTime: new Date(),
294
+ deviceChosen,
295
+ crawlType: ScannerTypes.CUSTOM,
296
+ requestUrl: entryUrl,
297
+ urlsCrawled: { ...constants.urlsCrawledObj },
298
+ isIncludeScreenshots: includeScreenshots,
299
+ isAllowSubdomains: strategy,
300
+ isEnableCustomChecks: ruleset,
301
+ isEnableWcagAaa: ruleset,
302
+ isSlowScanMode: specifiedMaxConcurrency,
303
+ isAdhereRobots: followRobots,
304
+ };
305
+ const urlsCrawled = { ...constants.urlsCrawledObj };
306
+ const { dataset } = await createCrawleeSubFolders(randomToken);
307
+ let mustFixIssues = 0;
308
+ let goodToFixIssues = 0;
309
+ let isInstanceTerminated = false;
310
+ const throwErrorIfTerminated = () => {
311
+ if (isInstanceTerminated) {
312
+ throw new Error('This instance of Oobee was terminated. Please start a new instance.');
313
+ }
314
+ };
315
+ const getAxeScript = () => {
316
+ throwErrorIfTerminated();
317
+ return getAxeScriptContent();
318
+ };
319
+ const getOobeeFunctions = () => {
320
+ throwErrorIfTerminated();
321
+ return getOobeeFunctionsScript(disableOobee, enableWcagAaa);
287
322
  };
288
323
  // Helper script for manually copy-paste testing in Chrome browser
289
324
  /*
@@ -427,3 +462,179 @@ thresholds = { mustFix: undefined, goodToFix: undefined }, scanAboutMetadata = u
427
462
  };
428
463
  };
429
464
  export default init;
465
+ const processAndSubmitResults = async (scanData, name, email, metadata) => {
466
+ const items = Array.isArray(scanData) ? scanData : [scanData];
467
+ const numberOfPagesScanned = items.length;
468
+ const allFilteredResults = items.map((item, index) => {
469
+ const filtered = filterAxeResults(item.axeScanResults, item.pageTitle, { pageIndex: index + 1, metadata });
470
+ filtered.url = item.pageUrl;
471
+ return filtered;
472
+ });
473
+ const mergedResults = {
474
+ mustFix: { totalItems: 0, rules: {} },
475
+ goodToFix: { totalItems: 0, rules: {} },
476
+ needsReview: { totalItems: 0, rules: {} },
477
+ // omitting passed from being processed to reduce payload size
478
+ // passed: { totalItems: 0, rules: {} },
479
+ };
480
+ allFilteredResults.forEach(result => {
481
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
482
+ const categoryResult = result[category];
483
+ if (categoryResult) {
484
+ mergedResults[category].totalItems += categoryResult.totalItems;
485
+ Object.entries(categoryResult.rules).forEach(([ruleId, ruleVal]) => {
486
+ if (!mergedResults[category].rules[ruleId]) {
487
+ mergedResults[category].rules[ruleId] = JSON.parse(JSON.stringify(ruleVal));
488
+ // Map the description to the short description if available
489
+ if (constants.a11yRuleShortDescriptionMap[ruleId]) {
490
+ mergedResults[category].rules[ruleId].description = constants.a11yRuleShortDescriptionMap[ruleId];
491
+ }
492
+ // Add url to items
493
+ mergedResults[category].rules[ruleId].items.forEach((item) => {
494
+ item.url = result.url;
495
+ if (item.displayNeedsReview) {
496
+ delete item.displayNeedsReview;
497
+ }
498
+ });
499
+ }
500
+ else {
501
+ mergedResults[category].rules[ruleId].totalItems += ruleVal.totalItems;
502
+ const newItems = ruleVal.items.map((item) => {
503
+ const newItem = { ...item, url: result.url };
504
+ if (newItem.displayNeedsReview) {
505
+ delete newItem.displayNeedsReview;
506
+ }
507
+ return newItem;
508
+ });
509
+ mergedResults[category].rules[ruleId].items.push(...newItems);
510
+ }
511
+ });
512
+ }
513
+ });
514
+ });
515
+ const basicFormHTMLSnippet = createBasicFormHTMLSnippet(mergedResults);
516
+ const entryUrl = items[0].pageUrl;
517
+ await submitForm(BrowserTypes.CHROMIUM, '', entryUrl, null, ScannerTypes.CUSTOM, email, name, JSON.stringify(basicFormHTMLSnippet), numberOfPagesScanned, 0, 0, '{}');
518
+ // Generate WCAG breakdown for Sentry
519
+ const wcagOccurrencesMap = new Map();
520
+ // Iterate through relevant categories to collect WCAG violation occurrences
521
+ ['mustFix', 'goodToFix'].forEach(category => {
522
+ const rulesObj = mergedResults[category]?.rules;
523
+ if (rulesObj) {
524
+ Object.values(rulesObj).forEach((rule) => {
525
+ const count = rule.totalItems;
526
+ if (rule.conformance && Array.isArray(rule.conformance)) {
527
+ rule.conformance
528
+ .filter((c) => /wcag[0-9]{3,4}/.test(c))
529
+ .forEach((c) => {
530
+ const current = wcagOccurrencesMap.get(c) || 0;
531
+ wcagOccurrencesMap.set(c, current + count);
532
+ });
533
+ }
534
+ });
535
+ }
536
+ });
537
+ const oobeeAppVersion = getVersion();
538
+ await sendWcagBreakdownToSentry(oobeeAppVersion, wcagOccurrencesMap, basicFormHTMLSnippet, {
539
+ entryUrl: entryUrl,
540
+ scanType: ScannerTypes.CUSTOM,
541
+ browser: 'chromium', // Defaulting since we might scan HTML without browser or implicit browser
542
+ email: email,
543
+ name: name,
544
+ }, undefined, numberOfPagesScanned);
545
+ // Return original single result if only one page was scanning to maintain backward compatibility structure
546
+ if (numberOfPagesScanned === 1) {
547
+ const singleResult = allFilteredResults[0];
548
+ // Clean up displayNeedsReview from single result
549
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
550
+ const resultCategory = singleResult[category];
551
+ if (resultCategory && resultCategory.rules) {
552
+ Object.values(resultCategory.rules).forEach((rule) => {
553
+ // Map the description to the short description if available
554
+ if (constants.a11yRuleShortDescriptionMap[rule.rule]) {
555
+ rule.description = constants.a11yRuleShortDescriptionMap[rule.rule];
556
+ }
557
+ if (rule.items) {
558
+ rule.items.forEach((item) => {
559
+ // Ensure item URL matches the result URL
560
+ item.url = singleResult.url;
561
+ if (item.displayNeedsReview) {
562
+ delete item.displayNeedsReview;
563
+ }
564
+ });
565
+ }
566
+ });
567
+ }
568
+ });
569
+ return singleResult;
570
+ }
571
+ return mergedResults;
572
+ };
573
+ // This is an experimental feature to scan static HTML code without the need for Playwright browser
574
+ export const scanHTML = async (htmlContent, config) => {
575
+ const { name, email, pageUrl = 'raw-html', pageTitle = 'HTML Content', metadata = '', ruleset = [RuleFlags.DEFAULT], } = config;
576
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
577
+ const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
578
+ if (enableWcagAaa) {
579
+ tags.push('wcag2aaa');
580
+ }
581
+ const htmlItems = Array.isArray(htmlContent) ? htmlContent : [htmlContent];
582
+ const scanData = [];
583
+ for (let i = 0; i < htmlItems.length; i++) {
584
+ const htmlString = htmlItems[i];
585
+ const dom = new JSDOM(htmlString);
586
+ // Configure axe for node environment
587
+ // eslint-disable-next-line no-await-in-loop
588
+ const axeScanResults = await axe.run(dom.window.document.documentElement, {
589
+ runOnly: {
590
+ type: 'tag',
591
+ values: tags,
592
+ },
593
+ resultTypes: ['violations', 'passes', 'incomplete'],
594
+ });
595
+ scanData.push({
596
+ axeScanResults,
597
+ pageUrl: htmlItems.length > 1 ? `${pageUrl}-${i + 1}` : pageUrl,
598
+ pageTitle: htmlItems.length > 1 ? `${pageTitle} ${i + 1}` : pageTitle,
599
+ });
600
+ }
601
+ return processAndSubmitResults(scanData, name, email, metadata);
602
+ };
603
+ export const scanPage = async (pages, config) => {
604
+ const { name, email, pageTitle, metadata = '', ruleset = [RuleFlags.DEFAULT], } = config;
605
+ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
606
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
607
+ const axeScript = getAxeScriptContent();
608
+ const oobeeFunctions = getOobeeFunctionsScript(disableOobee, enableWcagAaa);
609
+ const pagesArray = Array.isArray(pages) ? pages : [pages];
610
+ const scanData = [];
611
+ for (const page of pagesArray) {
612
+ await page.evaluate(`${axeScript}\n${oobeeFunctions}`);
613
+ // Run the scan inside the page
614
+ const consoleListener = (msg) => {
615
+ if (msg.type() === 'error') {
616
+ console.error(`[Browser Console] ${msg.text()}`);
617
+ }
618
+ };
619
+ page.on('console', consoleListener);
620
+ try {
621
+ const scanResult = await page.evaluate(async () => {
622
+ return window.runA11yScan();
623
+ });
624
+ scanData.push({
625
+ axeScanResults: scanResult.axeScanResults,
626
+ pageUrl: page.url(),
627
+ pageTitle: await page.title(),
628
+ });
629
+ }
630
+ finally {
631
+ page.off('console', consoleListener);
632
+ }
633
+ }
634
+ // Allow override of page title if scanning a single page
635
+ if (!Array.isArray(pages) && pageTitle) {
636
+ scanData[0].pageTitle = pageTitle;
637
+ }
638
+ return processAndSubmitResults(scanData, name, email, metadata);
639
+ };
640
+ export { RuleFlags };
@@ -0,0 +1,51 @@
1
+ import { scanHTML } from '../dist/npmIndex.js';
2
+
3
+ const htmlContent = `
4
+ <!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <title>Test Page</title>
8
+ </head>
9
+ <body>
10
+ <h1>Accessibility Test</h1>
11
+ <button></button> <!-- Violation: button-name -->
12
+ <img src="test.jpg" /> <!-- Violation: image-alt -->
13
+ <div role="button">Fake</div> <!-- Violation: role-button (if interactive) -->
14
+ </body>
15
+ </html>
16
+ `;
17
+
18
+ const htmlContent2 = `
19
+ <!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <title>Test Page 2</title>
23
+ </head>
24
+ <body>
25
+ <h1>Accessibility Test 2</h1>
26
+ <a href="#">Click me</a> <!-- Violation: link-name (if vague) or empty href issues -->
27
+ <input type="text" /> <!-- Violation: label -->
28
+ </body>
29
+ </html>
30
+ `;
31
+
32
+ (async () => {
33
+ console.log("Scanning HTML string...");
34
+ try {
35
+ // Run scanHTML without needing full Oobee init
36
+ // Pass an array of HTML strings to demonstrate batch scanning
37
+ const results = await scanHTML(
38
+ [htmlContent, htmlContent2],
39
+ {
40
+ name: "Your Name",
41
+ email: "email@domain.com",
42
+ }
43
+ );
44
+ console.log(JSON.stringify(results, null, 2));
45
+
46
+ console.log(`\nScan Complete.`);
47
+
48
+ } catch (error) {
49
+ console.error("Error during scan:", error);
50
+ }
51
+ })();
@@ -0,0 +1,40 @@
1
+ import { chromium } from 'playwright';
2
+ import { scanPage } from '../dist/npmIndex.js';
3
+
4
+ (async () => {
5
+ console.log("Launching browser...");
6
+ const browser = await chromium.launch({
7
+ headless: false,
8
+ channel: 'chrome' // Use Chrome instead of Chromium
9
+ });
10
+ const page = await browser.newPage();
11
+
12
+ console.log("Navigating to test page...");
13
+ // Using a sample page that likely has accessibility issues
14
+ await page.goto('https://govtechsg.github.io/purple-banner-embeds/purple-integrated-scan-example.htm');
15
+
16
+ const page2 = await browser.newPage();
17
+ console.log("Navigating to second test page...");
18
+ await page2.goto('https://a11y.tech.gov.sg');
19
+
20
+ console.log("Scanning page...");
21
+ try {
22
+ // Run scanPage using the existing Playwright page
23
+ const results = await scanPage(
24
+ [page, page2],
25
+ {
26
+ name: "Your Name",
27
+ email: "email@domain.com",
28
+ }
29
+ );
30
+
31
+ console.log(JSON.stringify(results, null, 2));
32
+
33
+ console.log(`\nScan Complete.`);
34
+
35
+ } catch (error) {
36
+ console.error("Error during scan:", error);
37
+ } finally {
38
+ await browser.close();
39
+ }
40
+ })();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.77",
4
+ "version": "0.10.78-alpha1",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "bin": {
@@ -12,7 +12,6 @@ import { AsyncParser, ParserOptions } from '@json2csv/node';
12
12
  import zlib from 'zlib';
13
13
  import { Base64Encode } from 'base64-stream';
14
14
  import { pipeline } from 'stream/promises';
15
- // @ts-ignore
16
15
  import * as Sentry from '@sentry/node';
17
16
  import constants, {
18
17
  BrowserTypes,
@@ -1289,34 +1288,56 @@ function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<s
1289
1288
  });
1290
1289
  }
1291
1290
 
1291
+ const extractRuleAiData = (ruleId: string, totalItems: number, items: any[], callback?: () => void) => {
1292
+ let snippets = [];
1293
+
1294
+ if (oobeeAiRules.includes(ruleId)) {
1295
+ const snippetsSet = new Set();
1296
+ if (items) {
1297
+ items.forEach(item => {
1298
+ snippetsSet.add(oobeeAiHtmlETL(item.html));
1299
+ });
1300
+ }
1301
+ snippets = [...snippetsSet];
1302
+ if (callback) callback();
1303
+ }
1304
+ return {
1305
+ snippets,
1306
+ occurrences: totalItems,
1307
+ };
1308
+ };
1309
+
1310
+ // This is for telemetry purposes called within mergeAxeResults.ts
1311
+ export
1292
1312
  const createRuleIdJson = allIssues => {
1293
1313
  const compiledRuleJson = {};
1294
1314
 
1295
- const ruleIterator = rule => {
1296
- const ruleId = rule.rule;
1297
- let snippets = [];
1298
-
1299
- if (oobeeAiRules.includes(ruleId)) {
1300
- const snippetsSet = new Set();
1301
- rule.pagesAffected.forEach(page => {
1302
- page.items.forEach(htmlItem => {
1303
- snippetsSet.add(oobeeAiHtmlETL(htmlItem.html));
1315
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
1316
+ allIssues.items[category].rules.forEach(rule => {
1317
+ const allItems = rule.pagesAffected.flatMap(page => page.items || []);
1318
+ compiledRuleJson[rule.rule] = extractRuleAiData(rule.rule, rule.totalItems, allItems, () => {
1319
+ rule.pagesAffected.forEach(p => {
1320
+ delete p.items;
1304
1321
  });
1305
1322
  });
1306
- snippets = [...snippetsSet];
1307
- rule.pagesAffected.forEach(p => {
1308
- delete p.items;
1323
+ });
1324
+ });
1325
+
1326
+ return compiledRuleJson;
1327
+ };
1328
+
1329
+ // This is for telemetry purposes called from npmIndex (scanPage and scanHTML) where report is not generated
1330
+ export const createBasicFormHTMLSnippet = filteredResults => {
1331
+ const compiledRuleJson = {};
1332
+
1333
+ ['mustFix', 'goodToFix', 'needsReview'].forEach(category => {
1334
+ if (filteredResults[category] && filteredResults[category].rules) {
1335
+ Object.entries(filteredResults[category].rules).forEach(([ruleId, ruleVal]: [string, any]) => {
1336
+ compiledRuleJson[ruleId] = extractRuleAiData(ruleId, ruleVal.totalItems, ruleVal.items);
1309
1337
  });
1310
1338
  }
1311
- compiledRuleJson[ruleId] = {
1312
- snippets,
1313
- occurrences: rule.totalItems,
1314
- };
1315
- };
1339
+ });
1316
1340
 
1317
- allIssues.items.mustFix.rules.forEach(ruleIterator);
1318
- allIssues.items.goodToFix.rules.forEach(ruleIterator);
1319
- allIssues.items.needsReview.rules.forEach(ruleIterator);
1320
1341
  return compiledRuleJson;
1321
1342
  };
1322
1343
 
@@ -1587,7 +1608,7 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
1587
1608
  }
1588
1609
 
1589
1610
  // Send WCAG criteria breakdown to Sentry
1590
- const sendWcagBreakdownToSentry = async (
1611
+ export const sendWcagBreakdownToSentry = async (
1591
1612
  appVersion: string,
1592
1613
  wcagBreakdown: Map<string, number>,
1593
1614
  ruleIdJson: any,
package/src/npmIndex.ts CHANGED
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import printMessage from 'print-message';
4
4
  import axe, { AxeResults, ImpactValue } from 'axe-core';
5
+ import { JSDOM } from 'jsdom';
5
6
  import { fileURLToPath } from 'url';
6
7
  import { EnqueueStrategy } from 'crawlee';
7
8
  import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
@@ -12,8 +13,8 @@ import {
12
13
  submitForm,
13
14
  } from './constants/common.js';
14
15
  import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
15
- import { createAndUpdateResultsFolders } from './utils.js';
16
- import generateArtifacts from './mergeAxeResults.js';
16
+ import { createAndUpdateResultsFolders, getVersion } from './utils.js';
17
+ import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
17
18
  import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
18
19
  import { consoleLogger, silentLogger } from './logs.js';
19
20
  import { alertMessageOptions } from './constants/cliFunctions.js';
@@ -26,101 +27,42 @@ import xPathToCss from './crawlers/custom/xPathToCss.js';
26
27
  import { extractText } from './crawlers/custom/extractText.js';
27
28
  import { gradeReadability } from './crawlers/custom/gradeReadability.js';
28
29
  import { BrowserContext, Page } from 'playwright';
30
+ import { filter } from 'jszip';
31
+
32
+ // Define global window properties for Oobee injection functions
33
+ declare global {
34
+ interface Window {
35
+ runA11yScan: (
36
+ elements?: any[],
37
+ gradingReadabilityFlag?: string,
38
+ ) => Promise<{
39
+ pageUrl: string;
40
+ pageTitle: string;
41
+ axeScanResults: AxeResults;
42
+ }>;
43
+ axe: any;
44
+ getAxeConfiguration: any;
45
+ flagUnlabelledClickableElements: any;
46
+ disableOobee: boolean;
47
+ enableWcagAaa: boolean;
48
+ xPathToCss: any;
49
+ evaluateAltText: any;
50
+ escapeCssSelector: any;
51
+ framesCheck: any;
52
+ findElementByCssSelector: any;
53
+ extractText: any;
54
+ }
55
+ }
29
56
 
30
57
  const filename = fileURLToPath(import.meta.url);
31
58
  const dirname = path.dirname(filename);
32
59
 
33
- export const init = async ({
34
- entryUrl,
35
- testLabel,
36
- name,
37
- email,
38
- includeScreenshots = false,
39
- viewportSettings = { width: 1000, height: 660 }, // cypress' default viewport settings
40
- thresholds = { mustFix: undefined, goodToFix: undefined },
41
- scanAboutMetadata = undefined,
42
- zip = 'oobee-scan-results',
43
- deviceChosen,
44
- strategy = EnqueueStrategy.All,
45
- ruleset = [RuleFlags.DEFAULT],
46
- specifiedMaxConcurrency = 25,
47
- followRobots = false,
48
- }: {
49
- entryUrl: string;
50
- testLabel: string;
51
- name: string;
52
- email: string;
53
- includeScreenshots?: boolean;
54
- viewportSettings?: { width: number; height: number };
55
- thresholds?: { mustFix: number; goodToFix: number };
56
- scanAboutMetadata?: {
57
- browser?: string;
58
- viewport?: { width: number; height: number };
59
- };
60
- zip?: string;
61
- deviceChosen?: string;
62
- strategy?: EnqueueStrategy;
63
- ruleset?: RuleFlags[];
64
- specifiedMaxConcurrency?: number;
65
- followRobots?: boolean;
66
- }) => {
67
- consoleLogger.info('Starting Oobee');
68
-
69
- const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
70
- const domain = new URL(entryUrl).hostname;
71
- const sanitisedLabel = testLabel ? `_${testLabel.replaceAll(' ', '_')}` : '';
72
- const randomToken = `${date}_${time}${sanitisedLabel}_${domain}`;
73
-
74
- const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
75
- const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
76
-
77
- // max numbers of mustFix/goodToFix occurrences before test returns a fail
78
- const { mustFix: mustFixThreshold, goodToFix: goodToFixThreshold } = thresholds;
79
-
80
- process.env.CRAWLEE_STORAGE_DIR = randomToken;
81
-
82
- const scanDetails = {
83
- startTime: new Date(),
84
- endTime: new Date(),
85
- deviceChosen,
86
- crawlType: ScannerTypes.CUSTOM,
87
- requestUrl: entryUrl,
88
- urlsCrawled: { ...constants.urlsCrawledObj },
89
- isIncludeScreenshots: includeScreenshots,
90
- isAllowSubdomains: strategy,
91
- isEnableCustomChecks: ruleset,
92
- isEnableWcagAaa: ruleset,
93
- isSlowScanMode: specifiedMaxConcurrency,
94
- isAdhereRobots: followRobots,
95
- };
96
-
97
- const urlsCrawled = { ...constants.urlsCrawledObj };
98
-
99
- const { dataset } = await createCrawleeSubFolders(randomToken);
100
-
101
- let mustFixIssues = 0;
102
- let goodToFixIssues = 0;
103
-
104
- let isInstanceTerminated = false;
105
-
106
- const throwErrorIfTerminated = () => {
107
- if (isInstanceTerminated) {
108
- throw new Error('This instance of Oobee was terminated. Please start a new instance.');
109
- }
110
- };
111
-
112
- const getAxeScript = () => {
113
- throwErrorIfTerminated();
114
- const axeScript = fs.readFileSync(
115
- path.join(dirname, '../../../axe-core/axe.min.js'),
116
- 'utf-8',
117
- );
118
- return axeScript;
119
- };
60
+ const getAxeScriptContent = () => {
61
+ return axe.source;
62
+ };
120
63
 
121
- const getOobeeFunctions = () => {
122
- throwErrorIfTerminated();
123
- return `
64
+ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean) => {
65
+ return `
124
66
  // Fix for missing __name function used by bundler
125
67
  if (typeof __name === 'undefined') {
126
68
  window.__name = function(fn, name) {
@@ -178,7 +120,7 @@ export const init = async ({
178
120
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
179
121
  },
180
122
  },
181
- ...(enableWcagAaa
123
+ ...((enableWcagAaa && !disableOobee)
182
124
  ? [
183
125
  {
184
126
  id: 'oobee-grading-text-contents',
@@ -226,19 +168,23 @@ export const init = async ({
226
168
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
227
169
  },
228
170
  },
229
- {
230
- id: 'oobee-grading-text-contents',
231
- selector: 'html',
232
- enabled: true,
233
- any: ['oobee-grading-text-contents'],
234
- tags: ['wcag2aaa', 'wcag315'],
235
- metadata: {
236
- description:
237
- 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
238
- help: 'Text content should be clear and plain to ensure that it is easily understood.',
239
- helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
240
- },
241
- },
171
+ ...((enableWcagAaa && !disableOobee)
172
+ ? [
173
+ {
174
+ id: 'oobee-grading-text-contents',
175
+ selector: 'html',
176
+ enabled: true,
177
+ any: ['oobee-grading-text-contents'],
178
+ tags: ['wcag2aaa', 'wcag315'],
179
+ metadata: {
180
+ description:
181
+ 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
182
+ help: 'Text content should be clear and plain to ensure that it is easily understood.',
183
+ helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
184
+ },
185
+ },
186
+ ]
187
+ : []),
242
188
  ]
243
189
  .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
244
190
  .concat(
@@ -291,6 +237,31 @@ export const init = async ({
291
237
  const axeScanResults = await (window).axe.run(elementsToScan, {
292
238
  resultTypes: ['violations', 'passes', 'incomplete'],
293
239
  });
240
+
241
+ if (axeScanResults) {
242
+ ['violations', 'incomplete'].forEach(type => {
243
+ if (axeScanResults[type]) {
244
+ axeScanResults[type].forEach(result => {
245
+ if (result.nodes) {
246
+ result.nodes.forEach(node => {
247
+ ['any', 'all', 'none'].forEach(key => {
248
+ if (node[key]) {
249
+ node[key].forEach(check => {
250
+ if (check.message && check.message.indexOf("Axe encountered an error") !== -1) {
251
+ if (check.data) {
252
+ // console.error(check.data);
253
+ console.error("Axe encountered an error: " + (check.data.stack || check.data.message || JSON.stringify(check.data)));
254
+ }
255
+ }
256
+ });
257
+ }
258
+ });
259
+ });
260
+ }
261
+ });
262
+ }
263
+ });
264
+ }
294
265
 
295
266
  // add custom Oobee violations
296
267
  if (!(window).disableOobee) {
@@ -340,6 +311,95 @@ export const init = async ({
340
311
  window.enableWcagAaa=${enableWcagAaa};
341
312
  window.runA11yScan = runA11yScan;
342
313
  `;
314
+ };
315
+
316
+ export const init = async ({
317
+ entryUrl,
318
+ testLabel,
319
+ name,
320
+ email,
321
+ includeScreenshots = false,
322
+ viewportSettings = { width: 1000, height: 660 }, // cypress' default viewport settings
323
+ thresholds = { mustFix: undefined, goodToFix: undefined },
324
+ scanAboutMetadata = undefined,
325
+ zip = 'oobee-scan-results',
326
+ deviceChosen,
327
+ strategy = EnqueueStrategy.All,
328
+ ruleset = [RuleFlags.DEFAULT],
329
+ specifiedMaxConcurrency = 25,
330
+ followRobots = false,
331
+ }: {
332
+ entryUrl: string;
333
+ testLabel: string;
334
+ name: string;
335
+ email: string;
336
+ includeScreenshots?: boolean;
337
+ viewportSettings?: { width: number; height: number };
338
+ thresholds?: { mustFix: number; goodToFix: number };
339
+ scanAboutMetadata?: {
340
+ browser?: string;
341
+ viewport?: { width: number; height: number };
342
+ };
343
+ zip?: string;
344
+ deviceChosen?: string;
345
+ strategy?: EnqueueStrategy;
346
+ ruleset?: RuleFlags[];
347
+ specifiedMaxConcurrency?: number;
348
+ followRobots?: boolean;
349
+ }) => {
350
+ consoleLogger.info('Starting Oobee');
351
+
352
+ const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
353
+ const domain = new URL(entryUrl).hostname;
354
+ const sanitisedLabel = testLabel ? `_${testLabel.replaceAll(' ', '_')}` : '';
355
+ const randomToken = `${date}_${time}${sanitisedLabel}_${domain}`;
356
+
357
+ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
358
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
359
+
360
+ // max numbers of mustFix/goodToFix occurrences before test returns a fail
361
+ const { mustFix: mustFixThreshold, goodToFix: goodToFixThreshold } = thresholds;
362
+
363
+ process.env.CRAWLEE_STORAGE_DIR = randomToken;
364
+
365
+ const scanDetails = {
366
+ startTime: new Date(),
367
+ endTime: new Date(),
368
+ deviceChosen,
369
+ crawlType: ScannerTypes.CUSTOM,
370
+ requestUrl: entryUrl,
371
+ urlsCrawled: { ...constants.urlsCrawledObj },
372
+ isIncludeScreenshots: includeScreenshots,
373
+ isAllowSubdomains: strategy,
374
+ isEnableCustomChecks: ruleset,
375
+ isEnableWcagAaa: ruleset,
376
+ isSlowScanMode: specifiedMaxConcurrency,
377
+ isAdhereRobots: followRobots,
378
+ };
379
+
380
+ const urlsCrawled = { ...constants.urlsCrawledObj };
381
+
382
+ const { dataset } = await createCrawleeSubFolders(randomToken);
383
+
384
+ let mustFixIssues = 0;
385
+ let goodToFixIssues = 0;
386
+
387
+ let isInstanceTerminated = false;
388
+
389
+ const throwErrorIfTerminated = () => {
390
+ if (isInstanceTerminated) {
391
+ throw new Error('This instance of Oobee was terminated. Please start a new instance.');
392
+ }
393
+ };
394
+
395
+ const getAxeScript = () => {
396
+ throwErrorIfTerminated();
397
+ return getAxeScriptContent();
398
+ };
399
+
400
+ const getOobeeFunctions = () => {
401
+ throwErrorIfTerminated();
402
+ return getOobeeFunctionsScript(disableOobee, enableWcagAaa);
343
403
  };
344
404
 
345
405
  // Helper script for manually copy-paste testing in Chrome browser
@@ -527,4 +587,295 @@ export const init = async ({
527
587
  };
528
588
  };
529
589
 
530
- export default init;
590
+ export default init;
591
+
592
+ const processAndSubmitResults = async (
593
+ scanData: { axeScanResults: AxeResults; pageUrl: string; pageTitle: string } | { axeScanResults: AxeResults; pageUrl: string; pageTitle: string }[],
594
+ name: string,
595
+ email: string,
596
+ metadata: string,
597
+ ) => {
598
+ const items = Array.isArray(scanData) ? scanData : [scanData];
599
+ const numberOfPagesScanned = items.length;
600
+
601
+ const allFilteredResults = items.map((item, index) => {
602
+ const filtered = filterAxeResults(item.axeScanResults, item.pageTitle, { pageIndex: index + 1, metadata });
603
+ (filtered as any).url = item.pageUrl;
604
+ return filtered;
605
+ });
606
+
607
+ type Rule = {
608
+ totalItems: number;
609
+ items: any[];
610
+ [key: string]: any;
611
+ };
612
+
613
+ type ResultCategory = {
614
+ totalItems: number;
615
+ rules: Record<string, Rule>;
616
+ };
617
+
618
+ type CategoryKey = 'mustFix' | 'goodToFix' | 'needsReview';
619
+
620
+ const mergedResults: Record<CategoryKey, ResultCategory> = {
621
+ mustFix: { totalItems: 0, rules: {} },
622
+ goodToFix: { totalItems: 0, rules: {} },
623
+ needsReview: { totalItems: 0, rules: {} },
624
+ // omitting passed from being processed to reduce payload size
625
+ // passed: { totalItems: 0, rules: {} },
626
+ };
627
+
628
+ allFilteredResults.forEach(result => {
629
+ (['mustFix', 'goodToFix', 'needsReview'] as CategoryKey[]).forEach(category => {
630
+ const categoryResult = (result as any)[category];
631
+ if (categoryResult) {
632
+ mergedResults[category].totalItems += categoryResult.totalItems;
633
+ Object.entries(categoryResult.rules).forEach(([ruleId, ruleVal]: [string, any]) => {
634
+ if (!mergedResults[category].rules[ruleId]) {
635
+ mergedResults[category].rules[ruleId] = JSON.parse(JSON.stringify(ruleVal));
636
+
637
+ // Map the description to the short description if available
638
+ if (constants.a11yRuleShortDescriptionMap[ruleId]) {
639
+ mergedResults[category].rules[ruleId].description = constants.a11yRuleShortDescriptionMap[ruleId];
640
+ }
641
+
642
+ // Add url to items
643
+ mergedResults[category].rules[ruleId].items.forEach((item: any) => {
644
+ item.url = (result as any).url;
645
+ if (item.displayNeedsReview) {
646
+ delete item.displayNeedsReview;
647
+ }
648
+ });
649
+ } else {
650
+ mergedResults[category].rules[ruleId].totalItems += ruleVal.totalItems;
651
+ const newItems = ruleVal.items.map((item: any) => {
652
+ const newItem = { ...item, url: (result as any).url };
653
+ if (newItem.displayNeedsReview) {
654
+ delete newItem.displayNeedsReview;
655
+ }
656
+ return newItem;
657
+ });
658
+ mergedResults[category].rules[ruleId].items.push(...newItems);
659
+ }
660
+ });
661
+ }
662
+ });
663
+ });
664
+
665
+ const basicFormHTMLSnippet = createBasicFormHTMLSnippet(mergedResults);
666
+ const entryUrl = items[0].pageUrl;
667
+
668
+ await submitForm(
669
+ BrowserTypes.CHROMIUM,
670
+ '',
671
+ entryUrl,
672
+ null,
673
+ ScannerTypes.CUSTOM,
674
+ email,
675
+ name,
676
+ JSON.stringify(basicFormHTMLSnippet),
677
+ numberOfPagesScanned,
678
+ 0,
679
+ 0,
680
+ '{}',
681
+ );
682
+
683
+ // Generate WCAG breakdown for Sentry
684
+ const wcagOccurrencesMap = new Map<string, number>();
685
+
686
+ // Iterate through relevant categories to collect WCAG violation occurrences
687
+ (['mustFix', 'goodToFix'] as CategoryKey[]).forEach(category => {
688
+ const rulesObj = mergedResults[category]?.rules;
689
+ if (rulesObj) {
690
+ Object.values(rulesObj).forEach((rule: any) => {
691
+ const count = rule.totalItems;
692
+ if (rule.conformance && Array.isArray(rule.conformance)) {
693
+ rule.conformance
694
+ .filter((c: string) => /wcag[0-9]{3,4}/.test(c))
695
+ .forEach((c: string) => {
696
+ const current = wcagOccurrencesMap.get(c) || 0;
697
+ wcagOccurrencesMap.set(c, current + count);
698
+ });
699
+ }
700
+ });
701
+ }
702
+ });
703
+
704
+ const oobeeAppVersion = getVersion();
705
+
706
+ await sendWcagBreakdownToSentry(
707
+ oobeeAppVersion,
708
+ wcagOccurrencesMap,
709
+ basicFormHTMLSnippet,
710
+ {
711
+ entryUrl: entryUrl,
712
+ scanType: ScannerTypes.CUSTOM,
713
+ browser: 'chromium', // Defaulting since we might scan HTML without browser or implicit browser
714
+ email: email,
715
+ name: name,
716
+ },
717
+ undefined,
718
+ numberOfPagesScanned,
719
+ );
720
+
721
+ // Return original single result if only one page was scanning to maintain backward compatibility structure
722
+ if (numberOfPagesScanned === 1) {
723
+ const singleResult = allFilteredResults[0];
724
+
725
+ // Clean up displayNeedsReview from single result
726
+ (['mustFix', 'goodToFix', 'needsReview'] as CategoryKey[]).forEach(category => {
727
+ const resultCategory = (singleResult as any)[category];
728
+ if (resultCategory && resultCategory.rules) {
729
+ Object.values(resultCategory.rules).forEach((rule: any) => {
730
+
731
+ // Map the description to the short description if available
732
+ if (constants.a11yRuleShortDescriptionMap[rule.rule]) {
733
+ rule.description = constants.a11yRuleShortDescriptionMap[rule.rule];
734
+ }
735
+
736
+ if (rule.items) {
737
+ rule.items.forEach((item: any) => {
738
+ // Ensure item URL matches the result URL
739
+ item.url = (singleResult as any).url;
740
+
741
+ if (item.displayNeedsReview) {
742
+ delete item.displayNeedsReview;
743
+ }
744
+ });
745
+ }
746
+ });
747
+ }
748
+ });
749
+
750
+ return singleResult;
751
+ }
752
+
753
+ return mergedResults;
754
+ };
755
+
756
+ // This is an experimental feature to scan static HTML code without the need for Playwright browser
757
+ export const scanHTML = async (
758
+ htmlContent: string | string[],
759
+ config: {
760
+ name: string;
761
+ email: string;
762
+ pageUrl?: string; // If array, we will append index
763
+ pageTitle?: string; // If array, we will append index
764
+ metadata?: string;
765
+ ruleset?: RuleFlags[];
766
+ },
767
+ ) => {
768
+ const {
769
+ name,
770
+ email,
771
+ pageUrl = 'raw-html',
772
+ pageTitle = 'HTML Content',
773
+ metadata = '',
774
+ ruleset = [RuleFlags.DEFAULT],
775
+ } = config;
776
+
777
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
778
+ const tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
779
+
780
+ if (enableWcagAaa) {
781
+ tags.push('wcag2aaa');
782
+ }
783
+
784
+ const htmlItems = Array.isArray(htmlContent) ? htmlContent : [htmlContent];
785
+ const scanData = [];
786
+
787
+ for (let i = 0; i < htmlItems.length; i++) {
788
+ const htmlString = htmlItems[i];
789
+ const dom = new JSDOM(htmlString);
790
+
791
+ // Configure axe for node environment
792
+ // eslint-disable-next-line no-await-in-loop
793
+ const axeScanResults = await axe.run(
794
+ dom.window.document.documentElement as unknown as Element,
795
+ {
796
+ runOnly: {
797
+ type: 'tag',
798
+ values: tags,
799
+ },
800
+ resultTypes: ['violations', 'passes', 'incomplete'],
801
+ },
802
+ );
803
+
804
+ scanData.push({
805
+ axeScanResults,
806
+ pageUrl: htmlItems.length > 1 ? `${pageUrl}-${i + 1}` : pageUrl,
807
+ pageTitle: htmlItems.length > 1 ? `${pageTitle} ${i + 1}` : pageTitle,
808
+ });
809
+ }
810
+
811
+ return processAndSubmitResults(scanData, name, email, metadata);
812
+ };
813
+
814
+ export const scanPage = async (
815
+ pages: Page | Page[],
816
+ config: {
817
+ name: string;
818
+ email: string;
819
+ pageTitle?: string;
820
+ metadata?: string;
821
+ ruleset?: RuleFlags[];
822
+ },
823
+ ) => {
824
+ const {
825
+ name,
826
+ email,
827
+ pageTitle,
828
+ metadata = '',
829
+ ruleset = [RuleFlags.DEFAULT],
830
+ } = config;
831
+
832
+ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
833
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
834
+
835
+ const axeScript = getAxeScriptContent();
836
+ const oobeeFunctions = getOobeeFunctionsScript(disableOobee, enableWcagAaa);
837
+
838
+ const pagesArray = Array.isArray(pages) ? pages : [pages];
839
+ const scanData = [];
840
+
841
+ for (const page of pagesArray) {
842
+ await page.evaluate(`${axeScript}\n${oobeeFunctions}`);
843
+
844
+ // Run the scan inside the page
845
+ const consoleListener = (msg: any) => {
846
+ if (msg.type() === 'error') {
847
+ console.error(`[Browser Console] ${msg.text()}`);
848
+ }
849
+ };
850
+ page.on('console', consoleListener);
851
+
852
+ try {
853
+ const scanResult = await page.evaluate(async () => {
854
+ return window.runA11yScan();
855
+ });
856
+
857
+ scanData.push({
858
+ axeScanResults: scanResult.axeScanResults,
859
+ pageUrl: page.url(),
860
+ pageTitle: await page.title(),
861
+ });
862
+ } finally {
863
+ page.off('console', consoleListener);
864
+ }
865
+ }
866
+
867
+ // Allow override of page title if scanning a single page
868
+ if (!Array.isArray(pages) && pageTitle) {
869
+ scanData[0].pageTitle = pageTitle;
870
+ }
871
+
872
+ return processAndSubmitResults(
873
+ scanData,
874
+ name,
875
+ email,
876
+ metadata,
877
+ );
878
+ };
879
+
880
+ export { RuleFlags };
881
+