@govtechsg/oobee 0.10.76 → 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.
Files changed (137) hide show
  1. package/.github/workflows/publish.yml +8 -1
  2. package/INTEGRATION.md +50 -3
  3. package/dist/cli.js +252 -0
  4. package/dist/combine.js +221 -0
  5. package/dist/constants/cliFunctions.js +306 -0
  6. package/dist/constants/common.js +1669 -0
  7. package/dist/constants/constants.js +913 -0
  8. package/dist/constants/errorMeta.json +319 -0
  9. package/dist/constants/itemTypeDescription.js +7 -0
  10. package/dist/constants/oobeeAi.js +121 -0
  11. package/dist/constants/questions.js +151 -0
  12. package/dist/constants/sampleData.js +176 -0
  13. package/dist/crawlers/commonCrawlerFunc.js +428 -0
  14. package/dist/crawlers/crawlDomain.js +613 -0
  15. package/dist/crawlers/crawlIntelligentSitemap.js +135 -0
  16. package/dist/crawlers/crawlLocalFile.js +151 -0
  17. package/dist/crawlers/crawlSitemap.js +303 -0
  18. package/dist/crawlers/custom/escapeCssSelector.js +10 -0
  19. package/dist/crawlers/custom/evaluateAltText.js +11 -0
  20. package/dist/crawlers/custom/extractAndGradeText.js +44 -0
  21. package/dist/crawlers/custom/extractText.js +27 -0
  22. package/dist/crawlers/custom/findElementByCssSelector.js +36 -0
  23. package/dist/crawlers/custom/flagUnlabelledClickableElements.js +963 -0
  24. package/dist/crawlers/custom/framesCheck.js +37 -0
  25. package/dist/crawlers/custom/getAxeConfiguration.js +111 -0
  26. package/dist/crawlers/custom/gradeReadability.js +23 -0
  27. package/dist/crawlers/custom/utils.js +1024 -0
  28. package/dist/crawlers/custom/xPathToCss.js +147 -0
  29. package/dist/crawlers/guards/urlGuard.js +71 -0
  30. package/dist/crawlers/pdfScanFunc.js +276 -0
  31. package/dist/crawlers/runCustom.js +89 -0
  32. package/dist/exclusions.txt +7 -0
  33. package/dist/generateHtmlReport.js +144 -0
  34. package/dist/index.js +62 -0
  35. package/dist/logs.js +84 -0
  36. package/dist/mergeAxeResults.js +1588 -0
  37. package/dist/npmIndex.js +640 -0
  38. package/dist/proxyService.js +360 -0
  39. package/dist/runGenerateJustHtmlReport.js +16 -0
  40. package/dist/screenshotFunc/htmlScreenshotFunc.js +355 -0
  41. package/dist/screenshotFunc/pdfScreenshotFunc.js +645 -0
  42. package/dist/services/s3Uploader.js +127 -0
  43. package/dist/static/ejs/partials/components/allIssues/AllIssues.ejs +9 -0
  44. package/dist/static/ejs/partials/components/allIssues/CategoryBadges.ejs +82 -0
  45. package/dist/static/ejs/partials/components/allIssues/FilterBar.ejs +33 -0
  46. package/dist/static/ejs/partials/components/allIssues/IssuesTable.ejs +41 -0
  47. package/dist/static/ejs/partials/components/header/SiteInfo.ejs +119 -0
  48. package/dist/static/ejs/partials/components/header/aboutScanModal/AboutScanModal.ejs +15 -0
  49. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanConfiguration.ejs +44 -0
  50. package/dist/static/ejs/partials/components/header/aboutScanModal/ScanDetails.ejs +142 -0
  51. package/dist/static/ejs/partials/components/prioritiseIssues/IssueDetailCard.ejs +36 -0
  52. package/dist/static/ejs/partials/components/prioritiseIssues/PrioritiseIssues.ejs +47 -0
  53. package/dist/static/ejs/partials/components/ruleModal/ruleOffcanvas.ejs +196 -0
  54. package/dist/static/ejs/partials/components/scannedPagesSegmentedTabs.ejs +48 -0
  55. package/dist/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
  56. package/dist/static/ejs/partials/components/shared/InfoAlert.ejs +3 -0
  57. package/dist/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
  58. package/dist/static/ejs/partials/components/summaryScanResults.ejs +16 -0
  59. package/dist/static/ejs/partials/components/summaryTable.ejs +20 -0
  60. package/dist/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
  61. package/dist/static/ejs/partials/components/topTen.ejs +6 -0
  62. package/dist/static/ejs/partials/components/wcagCompliance/FailedCriteria.ejs +47 -0
  63. package/dist/static/ejs/partials/components/wcagCompliance/WcagCompliance.ejs +16 -0
  64. package/dist/static/ejs/partials/components/wcagCompliance/WcagGaugeBar.ejs +16 -0
  65. package/dist/static/ejs/partials/components/wcagCoverageDetails.ejs +18 -0
  66. package/dist/static/ejs/partials/footer.ejs +24 -0
  67. package/dist/static/ejs/partials/header.ejs +14 -0
  68. package/dist/static/ejs/partials/main.ejs +29 -0
  69. package/dist/static/ejs/partials/scripts/allIssues/AllIssues.ejs +376 -0
  70. package/dist/static/ejs/partials/scripts/bootstrap.ejs +8 -0
  71. package/dist/static/ejs/partials/scripts/categorySummary.ejs +141 -0
  72. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
  73. package/dist/static/ejs/partials/scripts/header/SiteInfo.ejs +44 -0
  74. package/dist/static/ejs/partials/scripts/header/aboutScanModal/AboutScanModal.ejs +51 -0
  75. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanConfiguration.ejs +127 -0
  76. package/dist/static/ejs/partials/scripts/header/aboutScanModal/ScanDetails.ejs +60 -0
  77. package/dist/static/ejs/partials/scripts/highlightjs.ejs +335 -0
  78. package/dist/static/ejs/partials/scripts/popper.ejs +7 -0
  79. package/dist/static/ejs/partials/scripts/prioritiseIssues/IssueDetailCard.ejs +137 -0
  80. package/dist/static/ejs/partials/scripts/prioritiseIssues/PrioritiseIssues.ejs +214 -0
  81. package/dist/static/ejs/partials/scripts/prioritiseIssues/wcagSvgMap.ejs +861 -0
  82. package/dist/static/ejs/partials/scripts/ruleModal/constants.ejs +957 -0
  83. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +353 -0
  84. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +468 -0
  85. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +306 -0
  86. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +483 -0
  87. package/dist/static/ejs/partials/scripts/scannedPagesSegmentedTabs.ejs +35 -0
  88. package/dist/static/ejs/partials/scripts/screenshotLightbox.ejs +75 -0
  89. package/dist/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
  90. package/dist/static/ejs/partials/scripts/summaryTable.ejs +78 -0
  91. package/dist/static/ejs/partials/scripts/topTen.ejs +61 -0
  92. package/dist/static/ejs/partials/scripts/utils.ejs +453 -0
  93. package/dist/static/ejs/partials/scripts/wcagCompliance/FailedCriteria.ejs +103 -0
  94. package/dist/static/ejs/partials/scripts/wcagCompliance/WcagGaugeBar.ejs +47 -0
  95. package/dist/static/ejs/partials/scripts/wcagCompliance.ejs +15 -0
  96. package/dist/static/ejs/partials/scripts/wcagCoverageDetails.ejs +75 -0
  97. package/dist/static/ejs/partials/styles/allIssues/AllIssues.ejs +384 -0
  98. package/dist/static/ejs/partials/styles/bootstrap.ejs +12391 -0
  99. package/dist/static/ejs/partials/styles/header/SiteInfo.ejs +121 -0
  100. package/dist/static/ejs/partials/styles/header/aboutScanModal/AboutScanModal.ejs +82 -0
  101. package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanConfiguration.ejs +50 -0
  102. package/dist/static/ejs/partials/styles/header/aboutScanModal/ScanDetails.ejs +149 -0
  103. package/dist/static/ejs/partials/styles/header.ejs +7 -0
  104. package/dist/static/ejs/partials/styles/highlightjs.ejs +54 -0
  105. package/dist/static/ejs/partials/styles/prioritiseIssues/IssueDetailCard.ejs +141 -0
  106. package/dist/static/ejs/partials/styles/prioritiseIssues/PrioritiseIssues.ejs +204 -0
  107. package/dist/static/ejs/partials/styles/ruleModal/ruleOffcanvas.ejs +456 -0
  108. package/dist/static/ejs/partials/styles/scannedPagesSegmentedTabs.ejs +46 -0
  109. package/dist/static/ejs/partials/styles/shared/InfoAlert.ejs +12 -0
  110. package/dist/static/ejs/partials/styles/styles.ejs +1607 -0
  111. package/dist/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
  112. package/dist/static/ejs/partials/styles/topTenCard.ejs +44 -0
  113. package/dist/static/ejs/partials/styles/wcagCompliance/FailedCriteria.ejs +59 -0
  114. package/dist/static/ejs/partials/styles/wcagCompliance/WcagGaugeBar.ejs +62 -0
  115. package/dist/static/ejs/partials/styles/wcagCompliance.ejs +36 -0
  116. package/dist/static/ejs/partials/styles/wcagCoverageDetails.ejs +33 -0
  117. package/dist/static/ejs/partials/summaryHeader.ejs +70 -0
  118. package/dist/static/ejs/partials/summaryMain.ejs +49 -0
  119. package/dist/static/ejs/report.ejs +226 -0
  120. package/dist/static/ejs/summary.ejs +47 -0
  121. package/dist/types/types.js +1 -0
  122. package/dist/utils.js +1070 -0
  123. package/examples/oobee-cypress-integration-js/cypress/support/e2e.js +36 -6
  124. package/examples/oobee-cypress-integration-js/cypress.config.js +45 -1
  125. package/examples/oobee-cypress-integration-ts/cypress.config.ts +47 -1
  126. package/examples/oobee-cypress-integration-ts/src/cypress/support/e2e.ts +36 -6
  127. package/examples/oobee-playwright-integration-js/oobee-playwright-demo.js +2 -1
  128. package/examples/oobee-playwright-integration-ts/src/oobee-playwright-demo.ts +2 -1
  129. package/examples/oobee-scan-html-demo.js +51 -0
  130. package/examples/oobee-scan-page-demo.js +40 -0
  131. package/package.json +9 -3
  132. package/src/constants/common.ts +2 -2
  133. package/src/constants/constants.ts +3 -1
  134. package/src/crawlers/crawlDomain.ts +1 -0
  135. package/src/crawlers/runCustom.ts +0 -1
  136. package/src/mergeAxeResults.ts +43 -22
  137. package/src/npmIndex.ts +500 -131
@@ -0,0 +1,640 @@
1
+ import path from 'path';
2
+ import printMessage from 'print-message';
3
+ import axe from 'axe-core';
4
+ import { JSDOM } from 'jsdom';
5
+ import { fileURLToPath } from 'url';
6
+ import { EnqueueStrategy } from 'crawlee';
7
+ import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
8
+ import { deleteClonedProfiles, getBrowserToRun, getPlaywrightLaunchOptions, submitForm, } from './constants/common.js';
9
+ import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
10
+ import { createAndUpdateResultsFolders, getVersion } from './utils.js';
11
+ import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
12
+ import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
13
+ import { consoleLogger } from './logs.js';
14
+ import { alertMessageOptions } from './constants/cliFunctions.js';
15
+ import { evaluateAltText } from './crawlers/custom/evaluateAltText.js';
16
+ import { escapeCssSelector } from './crawlers/custom/escapeCssSelector.js';
17
+ import { framesCheck } from './crawlers/custom/framesCheck.js';
18
+ import { findElementByCssSelector } from './crawlers/custom/findElementByCssSelector.js';
19
+ import { flagUnlabelledClickableElements } from './crawlers/custom/flagUnlabelledClickableElements.js';
20
+ import xPathToCss from './crawlers/custom/xPathToCss.js';
21
+ import { extractText } from './crawlers/custom/extractText.js';
22
+ import { gradeReadability } from './crawlers/custom/gradeReadability.js';
23
+ const filename = fileURLToPath(import.meta.url);
24
+ const dirname = path.dirname(filename);
25
+ const getAxeScriptContent = () => {
26
+ return axe.source;
27
+ };
28
+ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
29
+ return `
30
+ // Fix for missing __name function used by bundler
31
+ if (typeof __name === 'undefined') {
32
+ window.__name = function(fn, name) {
33
+ if (fn && typeof fn === 'function' && name) {
34
+ try {
35
+ Object.defineProperty(fn, 'name', { value: name, configurable: true });
36
+ } catch (e) {
37
+ // Ignore errors if name property cannot be set
38
+ }
39
+ }
40
+ return fn;
41
+ };
42
+ }
43
+
44
+ window.flagUnlabelledClickableElements = ${flagUnlabelledClickableElements.toString()};
45
+ window.evaluateAltText = ${evaluateAltText.toString()};
46
+ window.escapeCssSelector = ${escapeCssSelector.toString()};
47
+ window.framesCheck = ${framesCheck.toString()};
48
+ window.findElementByCssSelector = ${findElementByCssSelector.toString()};
49
+
50
+ window.xPathToCss = ${xPathToCss.toString()};
51
+ window.extractText = ${extractText.toString()};
52
+
53
+ function getAxeConfiguration({
54
+ enableWcagAaa = false,
55
+ gradingReadabilityFlag = '',
56
+ disableOobee = false,
57
+ }) {
58
+ return {
59
+ branding: {
60
+ application: 'oobee',
61
+ },
62
+ checks: [
63
+ {
64
+ id: 'oobee-confusing-alt-text',
65
+ metadata: {
66
+ impact: 'serious',
67
+ messages: {
68
+ pass: 'The image alt text is probably useful.',
69
+ fail: "The image alt text set as 'img', 'image', 'picture', 'photo', or 'graphic' is confusing or not useful.",
70
+ },
71
+ },
72
+ evaluate: window.evaluateAltText,
73
+ },
74
+ {
75
+ id: 'oobee-accessible-label',
76
+ metadata: {
77
+ impact: 'serious',
78
+ messages: {
79
+ pass: 'The clickable element has an accessible label.',
80
+ fail: 'The clickable element does not have an accessible label.',
81
+ },
82
+ },
83
+ evaluate: (node) => {
84
+ return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
85
+ },
86
+ },
87
+ ...((enableWcagAaa && !disableOobee)
88
+ ? [
89
+ {
90
+ id: 'oobee-grading-text-contents',
91
+ metadata: {
92
+ impact: 'moderate',
93
+ messages: {
94
+ pass: 'The text content is easy to understand.',
95
+ fail: 'The text content is potentially difficult to understand.',
96
+ incomplete: \`The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of \${gradingReadabilityFlag}.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.\`,
97
+ },
98
+ },
99
+ evaluate: (_node) => {
100
+ if (gradingReadabilityFlag === '') {
101
+ return true; // Pass if no readability issues
102
+ }
103
+ // Fail if readability issues are detected
104
+ },
105
+ },
106
+ ]
107
+ : []),
108
+ ],
109
+ rules: [
110
+ { id: 'target-size', enabled: true },
111
+ {
112
+ id: 'oobee-confusing-alt-text',
113
+ selector: 'img[alt]',
114
+ enabled: true,
115
+ any: ['oobee-confusing-alt-text'],
116
+ tags: ['wcag2a', 'wcag111'],
117
+ metadata: {
118
+ description: 'Ensures image alt text is clear and useful.',
119
+ help: 'Image alt text must not be vague or unhelpful.',
120
+ helpUrl: 'https://www.deque.com/blog/great-alt-text-introduction/',
121
+ },
122
+ },
123
+ {
124
+ id: 'oobee-accessible-label',
125
+ // selector: '*', // to be set with the checker function output xpaths converted to css selectors
126
+ enabled: true,
127
+ any: ['oobee-accessible-label'],
128
+ tags: ['wcag2a', 'wcag211', 'wcag412'],
129
+ metadata: {
130
+ description: 'Ensures clickable elements have an accessible label.',
131
+ help: 'Clickable elements must have accessible labels.',
132
+ helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
133
+ },
134
+ },
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
+ : []),
152
+ ]
153
+ .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
154
+ .concat(
155
+ enableWcagAaa
156
+ ? [
157
+ {
158
+ id: 'color-contrast-enhanced',
159
+ enabled: true,
160
+ },
161
+ {
162
+ id: 'identical-links-same-purpose',
163
+ enabled: true,
164
+ },
165
+ {
166
+ id: 'meta-refresh-no-exceptions',
167
+ enabled: true,
168
+ },
169
+ ]
170
+ : [],
171
+ ),
172
+ };
173
+ }
174
+ window.getAxeConfiguration = getAxeConfiguration;
175
+
176
+ async function runA11yScan(elementsToScan = [], gradingReadabilityFlag = '') {
177
+
178
+ const oobeeAccessibleLabelFlaggedXpaths = (window).disableOobee
179
+ ? []
180
+ : (await (window).flagUnlabelledClickableElements()).map(item => item.xpath);
181
+ console.log('OOBEE DEBUG: Flagged XPaths count:', oobeeAccessibleLabelFlaggedXpaths.length);
182
+ console.log('OOBEE DEBUG: Flagged XPaths:', oobeeAccessibleLabelFlaggedXpaths);
183
+
184
+ // Force visibility of the result in Cypress by adding to page title temporarily
185
+ const originalTitle = document.title;
186
+ document.title = '[OOBEE: ' + oobeeAccessibleLabelFlaggedXpaths.length + ' flagged] ' + originalTitle;
187
+ setTimeout(function() { document.title = originalTitle; }, 1000);
188
+ const oobeeAccessibleLabelFlaggedCssSelectors = oobeeAccessibleLabelFlaggedXpaths
189
+ .map(xpath => {
190
+ try {
191
+ const cssSelector = (window).xPathToCss(xpath);
192
+ return cssSelector;
193
+ } catch (e) {
194
+ // console.error(\`Error converting XPath to CSS: \${xpath} - \${e}\`);
195
+ return '';
196
+ }
197
+ })
198
+ .filter(item => item !== '');
199
+
200
+ (window).axe.configure((window).getAxeConfiguration({ disableOobee: (window).disableOobee, enableWcagAaa: (window).enableWcagAaa, gradingReadabilityFlag }));
201
+ const axeScanResults = await (window).axe.run(elementsToScan, {
202
+ resultTypes: ['violations', 'passes', 'incomplete'],
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
+ }
229
+
230
+ // add custom Oobee violations
231
+ if (!(window).disableOobee) {
232
+ // handle css id selectors that start with a digit
233
+ const escapedCssSelectors = oobeeAccessibleLabelFlaggedCssSelectors.map((window).escapeCssSelector);
234
+
235
+ // Add oobee violations to Axe's report
236
+ const oobeeAccessibleLabelViolations = {
237
+ id: 'oobee-accessible-label',
238
+ impact: 'serious',
239
+ tags: ['wcag2a', 'wcag211', 'wcag412'],
240
+ description: 'Ensures clickable elements have an accessible label.',
241
+ help: 'Clickable elements (i.e. elements with mouse-click interaction) must have accessible labels.',
242
+ helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
243
+ nodes: escapedCssSelectors
244
+ .map(cssSelector => ({
245
+ html: (window).findElementByCssSelector(cssSelector),
246
+ target: [cssSelector],
247
+ impact: 'serious',
248
+ failureSummary:
249
+ 'Fix any of the following:\\n The clickable element does not have an accessible label.',
250
+ any: [
251
+ {
252
+ id: 'oobee-accessible-label',
253
+ data: null,
254
+ relatedNodes: [],
255
+ impact: 'serious',
256
+ message: 'The clickable element does not have an accessible label.',
257
+ },
258
+ ],
259
+ all: [],
260
+ none: [],
261
+ }))
262
+ .filter(item => item.html),
263
+ };
264
+
265
+ axeScanResults.violations = [...axeScanResults.violations, oobeeAccessibleLabelViolations];
266
+ }
267
+
268
+ return {
269
+ pageUrl: window.location.href,
270
+ pageTitle: document.title,
271
+ axeScanResults,
272
+ };
273
+ }
274
+ window.disableOobee=${disableOobee};
275
+ window.enableWcagAaa=${enableWcagAaa};
276
+ window.runA11yScan = runA11yScan;
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);
322
+ };
323
+ // Helper script for manually copy-paste testing in Chrome browser
324
+ /*
325
+ const scripts = `${getAxeScript()}\n${getOobeeFunctions()}`;
326
+ fs.writeFileSync(path.join(dirname, 'testScripts.txt'), scripts);
327
+ */
328
+ const pushScanResults = async (res, metadata, elementsToClick, page, disableScreenshots = false) => {
329
+ throwErrorIfTerminated();
330
+ if (includeScreenshots && !disableScreenshots) {
331
+ let browserContext;
332
+ let browserToRun;
333
+ let clonedBrowserDataDir;
334
+ let pageToScan;
335
+ if (page) {
336
+ pageToScan = page;
337
+ }
338
+ else {
339
+ // use chrome by default
340
+ const browserData = getBrowserToRun(randomToken, BrowserTypes.CHROME, false);
341
+ browserToRun = browserData.browserToRun;
342
+ clonedBrowserDataDir = browserData.clonedBrowserDataDir;
343
+ browserContext = await constants.launcher.launchPersistentContext(clonedBrowserDataDir, {
344
+ viewport: viewportSettings,
345
+ ...getPlaywrightLaunchOptions(browserToRun),
346
+ });
347
+ const newPage = await browserContext.newPage();
348
+ await newPage.goto(res.pageUrl);
349
+ try {
350
+ await newPage.waitForLoadState('networkidle', { timeout: 10000 });
351
+ }
352
+ catch (e) {
353
+ console.log('Network idle timeout, continuing with screenshot capture...');
354
+ // Fall back to domcontentloaded if networkidle times out
355
+ await newPage.waitForLoadState('domcontentloaded', { timeout: 5000 });
356
+ } // click on elements to reveal hidden elements so screenshots can be taken
357
+ if (elementsToClick) {
358
+ for (const elem of elementsToClick) {
359
+ try {
360
+ await newPage.locator(elem).click();
361
+ }
362
+ catch (e) {
363
+ // do nothing if element is not found or not clickable
364
+ }
365
+ }
366
+ }
367
+ pageToScan = newPage;
368
+ }
369
+ res.axeScanResults.violations = await takeScreenshotForHTMLElements(res.axeScanResults.violations, pageToScan, randomToken, 3000);
370
+ res.axeScanResults.incomplete = await takeScreenshotForHTMLElements(res.axeScanResults.incomplete, pageToScan, randomToken, 3000);
371
+ if (browserContext && browserToRun) {
372
+ await browserContext.close();
373
+ deleteClonedProfiles(browserToRun, randomToken);
374
+ }
375
+ }
376
+ const pageIndex = urlsCrawled.scanned.length + 1;
377
+ const filteredResults = filterAxeResults(res.axeScanResults, res.pageTitle, {
378
+ pageIndex,
379
+ metadata,
380
+ });
381
+ urlsCrawled.scanned.push({
382
+ url: res.pageUrl.toString(),
383
+ actualUrl: 'tbd',
384
+ pageTitle: `${pageIndex}: ${res.pageTitle}`,
385
+ });
386
+ mustFixIssues += filteredResults.mustFix ? filteredResults.mustFix.totalItems : 0;
387
+ goodToFixIssues += filteredResults.goodToFix ? filteredResults.goodToFix.totalItems : 0;
388
+ await dataset.pushData(filteredResults);
389
+ // return counts for users to perform custom assertions if needed
390
+ return {
391
+ mustFix: filteredResults.mustFix ? filteredResults.mustFix.totalItems : 0,
392
+ goodToFix: filteredResults.goodToFix ? filteredResults.goodToFix.totalItems : 0,
393
+ };
394
+ };
395
+ const terminate = async () => {
396
+ throwErrorIfTerminated();
397
+ consoleLogger.info('Stopping Oobee');
398
+ isInstanceTerminated = true;
399
+ scanDetails.endTime = new Date();
400
+ scanDetails.urlsCrawled = urlsCrawled;
401
+ if (urlsCrawled.scanned.length === 0) {
402
+ printMessage([`No pages were scanned.`], alertMessageOptions);
403
+ }
404
+ else {
405
+ await createAndUpdateResultsFolders(randomToken);
406
+ const pagesNotScanned = [
407
+ ...scanDetails.urlsCrawled.error,
408
+ ...scanDetails.urlsCrawled.invalid,
409
+ ...scanDetails.urlsCrawled.forbidden,
410
+ ...scanDetails.urlsCrawled.userExcluded,
411
+ ];
412
+ const updatedScanAboutMetadata = {
413
+ viewport: {
414
+ width: viewportSettings.width,
415
+ height: viewportSettings.height,
416
+ },
417
+ ...scanAboutMetadata,
418
+ };
419
+ const basicFormHTMLSnippet = await generateArtifacts(randomToken, scanDetails.requestUrl, scanDetails.crawlType, deviceChosen, scanDetails.urlsCrawled.scanned, pagesNotScanned, testLabel, updatedScanAboutMetadata, scanDetails, zip);
420
+ await submitForm(BrowserTypes.CHROMIUM, // browserToRun
421
+ '', // userDataDirectory
422
+ scanDetails.requestUrl, // scannedUrl
423
+ null, // entryUrl
424
+ scanDetails.crawlType, // scanType
425
+ email, // email
426
+ name, // name
427
+ JSON.stringify(basicFormHTMLSnippet), // scanResultsKson
428
+ urlsCrawled.scanned.length, // numberOfPagesScanned
429
+ 0, 0, '{}');
430
+ }
431
+ return randomToken;
432
+ };
433
+ const testThresholds = () => {
434
+ // check against thresholds to fail tests
435
+ let isThresholdExceeded = false;
436
+ let thresholdFailMessage = 'Exceeded thresholds:\n';
437
+ if (mustFixThreshold !== undefined && mustFixIssues > mustFixThreshold) {
438
+ isThresholdExceeded = true;
439
+ thresholdFailMessage += `mustFix occurrences found: ${mustFixIssues} > ${mustFixThreshold}\n`;
440
+ }
441
+ if (goodToFixThreshold !== undefined && goodToFixIssues > goodToFixThreshold) {
442
+ isThresholdExceeded = true;
443
+ thresholdFailMessage += `goodToFix occurrences found: ${goodToFixIssues} > ${goodToFixThreshold}\n`;
444
+ }
445
+ // uncomment to reset counts if you do not want violations count to be cumulative across other pages
446
+ // mustFixIssues = 0;
447
+ // goodToFixIssues = 0;
448
+ if (isThresholdExceeded) {
449
+ terminate(); // terminate if threshold exceeded
450
+ throw new Error(thresholdFailMessage);
451
+ }
452
+ };
453
+ return {
454
+ getAxeScript,
455
+ getOobeeFunctions,
456
+ gradeReadability,
457
+ pushScanResults,
458
+ terminate,
459
+ scanDetails,
460
+ randomToken,
461
+ testThresholds,
462
+ };
463
+ };
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 };