@govtechsg/oobee 0.10.20

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 (123) hide show
  1. package/.dockerignore +22 -0
  2. package/.github/pull_request_template.md +11 -0
  3. package/.github/workflows/docker-test.yml +54 -0
  4. package/.github/workflows/image.yml +107 -0
  5. package/.github/workflows/publish.yml +18 -0
  6. package/.idea/modules.xml +8 -0
  7. package/.idea/purple-a11y.iml +9 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/.prettierrc.json +12 -0
  10. package/.vscode/extensions.json +5 -0
  11. package/.vscode/settings.json +10 -0
  12. package/CODE_OF_CONDUCT.md +128 -0
  13. package/DETAILS.md +163 -0
  14. package/Dockerfile +60 -0
  15. package/INSTALLATION.md +146 -0
  16. package/INTEGRATION.md +785 -0
  17. package/LICENSE +22 -0
  18. package/README.md +587 -0
  19. package/SECURITY.md +5 -0
  20. package/__mocks__/mock-report.html +1431 -0
  21. package/__mocks__/mockFunctions.ts +32 -0
  22. package/__mocks__/mockIssues.ts +64 -0
  23. package/__mocks__/mock_all_issues/000000001.json +64 -0
  24. package/__mocks__/mock_all_issues/000000002.json +53 -0
  25. package/__mocks__/mock_all_issues/fake-file.txt +0 -0
  26. package/__tests__/logs.test.ts +25 -0
  27. package/__tests__/mergeAxeResults.test.ts +278 -0
  28. package/__tests__/utils.test.ts +118 -0
  29. package/a11y-scan-results.zip +0 -0
  30. package/eslint.config.js +53 -0
  31. package/exclusions.txt +2 -0
  32. package/gitlab-pipeline-template.yml +54 -0
  33. package/jest.config.js +1 -0
  34. package/package.json +96 -0
  35. package/scripts/copyFiles.js +44 -0
  36. package/scripts/install_oobee_dependencies.cmd +13 -0
  37. package/scripts/install_oobee_dependencies.command +101 -0
  38. package/scripts/install_oobee_dependencies.ps1 +110 -0
  39. package/scripts/oobee_shell.cmd +13 -0
  40. package/scripts/oobee_shell.command +11 -0
  41. package/scripts/oobee_shell.sh +55 -0
  42. package/scripts/oobee_shell_ps.ps1 +54 -0
  43. package/src/cli.ts +401 -0
  44. package/src/combine.ts +240 -0
  45. package/src/constants/__tests__/common.test.ts +44 -0
  46. package/src/constants/cliFunctions.ts +305 -0
  47. package/src/constants/common.ts +1840 -0
  48. package/src/constants/constants.ts +443 -0
  49. package/src/constants/errorMeta.json +319 -0
  50. package/src/constants/itemTypeDescription.ts +11 -0
  51. package/src/constants/oobeeAi.ts +141 -0
  52. package/src/constants/questions.ts +181 -0
  53. package/src/constants/sampleData.ts +187 -0
  54. package/src/crawlers/__tests__/commonCrawlerFunc.test.ts +51 -0
  55. package/src/crawlers/commonCrawlerFunc.ts +656 -0
  56. package/src/crawlers/crawlDomain.ts +877 -0
  57. package/src/crawlers/crawlIntelligentSitemap.ts +156 -0
  58. package/src/crawlers/crawlLocalFile.ts +193 -0
  59. package/src/crawlers/crawlSitemap.ts +356 -0
  60. package/src/crawlers/custom/extractAndGradeText.ts +57 -0
  61. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +964 -0
  62. package/src/crawlers/custom/utils.ts +486 -0
  63. package/src/crawlers/customAxeFunctions.ts +82 -0
  64. package/src/crawlers/pdfScanFunc.ts +468 -0
  65. package/src/crawlers/runCustom.ts +117 -0
  66. package/src/index.ts +173 -0
  67. package/src/logs.ts +66 -0
  68. package/src/mergeAxeResults.ts +964 -0
  69. package/src/npmIndex.ts +284 -0
  70. package/src/screenshotFunc/htmlScreenshotFunc.ts +411 -0
  71. package/src/screenshotFunc/pdfScreenshotFunc.ts +762 -0
  72. package/src/static/ejs/partials/components/categorySelector.ejs +4 -0
  73. package/src/static/ejs/partials/components/categorySelectorDropdown.ejs +57 -0
  74. package/src/static/ejs/partials/components/pagesScannedModal.ejs +70 -0
  75. package/src/static/ejs/partials/components/reportSearch.ejs +47 -0
  76. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +105 -0
  77. package/src/static/ejs/partials/components/scanAbout.ejs +263 -0
  78. package/src/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
  79. package/src/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
  80. package/src/static/ejs/partials/components/summaryScanResults.ejs +16 -0
  81. package/src/static/ejs/partials/components/summaryTable.ejs +20 -0
  82. package/src/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
  83. package/src/static/ejs/partials/components/topFive.ejs +6 -0
  84. package/src/static/ejs/partials/components/wcagCompliance.ejs +70 -0
  85. package/src/static/ejs/partials/footer.ejs +21 -0
  86. package/src/static/ejs/partials/header.ejs +230 -0
  87. package/src/static/ejs/partials/main.ejs +40 -0
  88. package/src/static/ejs/partials/scripts/bootstrap.ejs +8 -0
  89. package/src/static/ejs/partials/scripts/categorySelectorDropdownScript.ejs +190 -0
  90. package/src/static/ejs/partials/scripts/categorySummary.ejs +141 -0
  91. package/src/static/ejs/partials/scripts/highlightjs.ejs +335 -0
  92. package/src/static/ejs/partials/scripts/popper.ejs +7 -0
  93. package/src/static/ejs/partials/scripts/reportSearch.ejs +248 -0
  94. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +801 -0
  95. package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +71 -0
  96. package/src/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
  97. package/src/static/ejs/partials/scripts/summaryTable.ejs +78 -0
  98. package/src/static/ejs/partials/scripts/utils.ejs +441 -0
  99. package/src/static/ejs/partials/styles/bootstrap.ejs +12375 -0
  100. package/src/static/ejs/partials/styles/highlightjs.ejs +54 -0
  101. package/src/static/ejs/partials/styles/styles.ejs +1843 -0
  102. package/src/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
  103. package/src/static/ejs/partials/summaryHeader.ejs +70 -0
  104. package/src/static/ejs/partials/summaryMain.ejs +75 -0
  105. package/src/static/ejs/report.ejs +420 -0
  106. package/src/static/ejs/summary.ejs +47 -0
  107. package/src/static/mustache/.prettierrc +4 -0
  108. package/src/static/mustache/Attention Deficit.mustache +11 -0
  109. package/src/static/mustache/Blind.mustache +11 -0
  110. package/src/static/mustache/Cognitive.mustache +7 -0
  111. package/src/static/mustache/Colorblindness.mustache +20 -0
  112. package/src/static/mustache/Deaf.mustache +12 -0
  113. package/src/static/mustache/Deafblind.mustache +7 -0
  114. package/src/static/mustache/Dyslexia.mustache +14 -0
  115. package/src/static/mustache/Low Vision.mustache +7 -0
  116. package/src/static/mustache/Mobility.mustache +15 -0
  117. package/src/static/mustache/Sighted Keyboard Users.mustache +42 -0
  118. package/src/static/mustache/report.mustache +1709 -0
  119. package/src/types/print-message.d.ts +28 -0
  120. package/src/types/types.ts +46 -0
  121. package/src/types/xpath-to-css.d.ts +3 -0
  122. package/src/utils.ts +332 -0
  123. package/tsconfig.json +15 -0
@@ -0,0 +1,656 @@
1
+ import crawlee, { CrawlingContext, PlaywrightGotoOptions } from 'crawlee';
2
+ import axe, { AxeResults, ImpactValue, NodeResult, Result, resultGroups, TagValue } from 'axe-core';
3
+ import xPathToCss from 'xpath-to-css';
4
+ import { Page } from 'playwright';
5
+ import {
6
+ axeScript,
7
+ guiInfoStatusTypes,
8
+ RuleFlags,
9
+ saflyIconSelector,
10
+ } from '../constants/constants.js';
11
+ import { consoleLogger, guiInfoLog, silentLogger } from '../logs.js';
12
+ import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
13
+ import { isFilePath } from '../constants/common.js';
14
+ import { customAxeConfig } from './customAxeFunctions.js';
15
+ import { flagUnlabelledClickableElements } from './custom/flagUnlabelledClickableElements.js';
16
+ import { extractAndGradeText } from './custom/extractAndGradeText.js';
17
+ import { ItemsInfo } from '../mergeAxeResults.js';
18
+
19
+ // types
20
+ interface AxeResultsWithScreenshot extends AxeResults {
21
+ passes: ResultWithScreenshot[];
22
+ incomplete: ResultWithScreenshot[];
23
+ violations: ResultWithScreenshot[];
24
+ }
25
+
26
+ export interface ResultWithScreenshot extends Result {
27
+ nodes: NodeResultWithScreenshot[];
28
+ }
29
+
30
+ export interface NodeResultWithScreenshot extends NodeResult {
31
+ screenshotPath?: string;
32
+ }
33
+
34
+ type RuleDetails = {
35
+ description: string;
36
+ axeImpact: ImpactValue;
37
+ helpUrl: string;
38
+ conformance: TagValue[];
39
+ totalItems: number;
40
+ items: ItemsInfo[];
41
+ };
42
+
43
+ type ResultCategory = {
44
+ totalItems: number;
45
+ rules: Record<string, RuleDetails>;
46
+ };
47
+
48
+ type CustomFlowDetails = {
49
+ pageIndex?: any;
50
+ metadata?: any;
51
+ pageImagePath?: any;
52
+ };
53
+
54
+ type FilteredResults = {
55
+ url: string;
56
+ pageTitle: string;
57
+ pageIndex?: any;
58
+ metadata?: any;
59
+ pageImagePath?: any;
60
+ totalItems: number;
61
+ mustFix: ResultCategory;
62
+ goodToFix: ResultCategory;
63
+ needsReview: ResultCategory;
64
+ passed: ResultCategory;
65
+ actualUrl?: string;
66
+ };
67
+
68
+ export const filterAxeResults = (
69
+ results: AxeResultsWithScreenshot,
70
+ pageTitle: string,
71
+ customFlowDetails?: CustomFlowDetails,
72
+ ): FilteredResults => {
73
+ const { violations, passes, incomplete, url } = results;
74
+
75
+ let totalItems = 0;
76
+ const mustFix: ResultCategory = { totalItems: 0, rules: {} };
77
+ const goodToFix: ResultCategory = { totalItems: 0, rules: {} };
78
+ const passed: ResultCategory = { totalItems: 0, rules: {} };
79
+ const needsReview: ResultCategory = { totalItems: 0, rules: {} };
80
+
81
+ const process = (item: ResultWithScreenshot, displayNeedsReview: boolean) => {
82
+ const { id: rule, help: description, helpUrl, tags, nodes } = item;
83
+
84
+ if (rule === 'frame-tested') return;
85
+
86
+ const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');
87
+
88
+ // handle rare cases where conformance level is not the first element
89
+ const levels = ['wcag2a', 'wcag2aa', 'wcag2aaa'];
90
+ if (conformance[0] !== 'best-practice' && !levels.includes(conformance[0])) {
91
+ conformance.sort((a, b) => {
92
+ if (levels.includes(a)) {
93
+ return -1;
94
+ }
95
+ if (levels.includes(b)) {
96
+ return 1;
97
+ }
98
+
99
+ return 0;
100
+ });
101
+ }
102
+
103
+ const addTo = (category: ResultCategory, node: NodeResultWithScreenshot) => {
104
+ const { html, failureSummary, screenshotPath, target, impact: axeImpact } = node;
105
+ if (!(rule in category.rules)) {
106
+ category.rules[rule] = {
107
+ description,
108
+ axeImpact,
109
+ helpUrl,
110
+ conformance,
111
+ totalItems: 0,
112
+ items: [],
113
+ };
114
+ }
115
+ const message = displayNeedsReview
116
+ ? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
117
+ : failureSummary;
118
+
119
+ let finalHtml = html;
120
+ if (html.includes('</script>')) {
121
+ finalHtml = html.replaceAll('</script>', '&lt;/script>');
122
+ }
123
+
124
+ const xpath = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;
125
+
126
+ // add in screenshot path
127
+ category.rules[rule].items.push({
128
+ html: finalHtml,
129
+ message,
130
+ screenshotPath,
131
+ xpath: xpath || undefined,
132
+ displayNeedsReview: displayNeedsReview || undefined,
133
+ });
134
+ category.rules[rule].totalItems += 1;
135
+ category.totalItems += 1;
136
+ totalItems += 1;
137
+ };
138
+
139
+ nodes.forEach(node => {
140
+ const { impact } = node;
141
+ if (displayNeedsReview) {
142
+ addTo(needsReview, node);
143
+ } else if (impact === 'critical' || impact === 'serious') {
144
+ addTo(mustFix, node);
145
+ } else {
146
+ addTo(goodToFix, node);
147
+ }
148
+ });
149
+ };
150
+
151
+ violations.forEach(item => process(item, false));
152
+ incomplete.forEach(item => process(item, true));
153
+
154
+ passes.forEach((item: Result) => {
155
+ const { id: rule, help: description, impact: axeImpact, helpUrl, tags, nodes } = item;
156
+
157
+ if (rule === 'frame-tested') return;
158
+
159
+ const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');
160
+
161
+ nodes.forEach(node => {
162
+ const { html } = node;
163
+ if (!(rule in passed.rules)) {
164
+ passed.rules[rule] = {
165
+ description,
166
+ axeImpact,
167
+ helpUrl,
168
+ conformance,
169
+ totalItems: 0,
170
+ items: [],
171
+ };
172
+ }
173
+ passed.rules[rule].items.push({ html, screenshotPath: '', message: '', xpath: '' });
174
+ passed.totalItems += 1;
175
+ passed.rules[rule].totalItems += 1;
176
+ totalItems += 1;
177
+ });
178
+ });
179
+
180
+ return {
181
+ url,
182
+ pageTitle: customFlowDetails ? `${customFlowDetails.pageIndex}: ${pageTitle}` : pageTitle,
183
+ pageIndex: customFlowDetails ? customFlowDetails.pageIndex : undefined,
184
+ metadata: customFlowDetails?.metadata
185
+ ? `${customFlowDetails.pageIndex}: ${customFlowDetails.metadata}`
186
+ : undefined,
187
+ pageImagePath: customFlowDetails ? customFlowDetails.pageImagePath : undefined,
188
+ totalItems,
189
+ mustFix,
190
+ goodToFix,
191
+ needsReview,
192
+ passed,
193
+ };
194
+ };
195
+
196
+ export const runAxeScript = async ({
197
+ includeScreenshots,
198
+ page,
199
+ randomToken,
200
+ customFlowDetails = null,
201
+ selectors = [],
202
+ ruleset = [],
203
+ }: {
204
+ includeScreenshots: boolean;
205
+ page: Page;
206
+ randomToken: string;
207
+ customFlowDetails?: CustomFlowDetails;
208
+ selectors?: string[];
209
+ ruleset?: RuleFlags[];
210
+ }) => {
211
+ // Checking for DOM mutations before proceeding to scan
212
+ await page.evaluate(() => {
213
+ return new Promise(resolve => {
214
+ let timeout: NodeJS.Timeout;
215
+ let mutationCount = 0;
216
+ const MAX_MUTATIONS = 100;
217
+ const MAX_SAME_MUTATION_LIMIT = 10;
218
+ const mutationHash = {};
219
+
220
+ const observer = new MutationObserver(mutationsList => {
221
+ clearTimeout(timeout);
222
+
223
+ mutationCount += 1;
224
+
225
+ if (mutationCount > MAX_MUTATIONS) {
226
+ observer.disconnect();
227
+ resolve('Too many mutations detected');
228
+ }
229
+
230
+ // To handle scenario where DOM elements are constantly changing and unable to exit
231
+ mutationsList.forEach(mutation => {
232
+ let mutationKey: string;
233
+
234
+ if (mutation.target instanceof Element) {
235
+ Array.from(mutation.target.attributes).forEach(attr => {
236
+ mutationKey = `${mutation.target.nodeName}-${attr.name}`;
237
+
238
+ if (mutationKey) {
239
+ if (!mutationHash[mutationKey]) {
240
+ mutationHash[mutationKey] = 1;
241
+ } else {
242
+ mutationHash[mutationKey] += 1;
243
+ }
244
+
245
+ if (mutationHash[mutationKey] >= MAX_SAME_MUTATION_LIMIT) {
246
+ observer.disconnect();
247
+ resolve(`Repeated mutation detected for ${mutationKey}`);
248
+ }
249
+ }
250
+ });
251
+ }
252
+ });
253
+
254
+ timeout = setTimeout(() => {
255
+ observer.disconnect();
256
+ resolve('DOM stabilized after mutations.');
257
+ }, 1000);
258
+ });
259
+
260
+ timeout = setTimeout(() => {
261
+ observer.disconnect();
262
+ resolve('No mutations detected, exit from idle state');
263
+ }, 1000);
264
+
265
+ observer.observe(document, { childList: true, subtree: true, attributes: true });
266
+ });
267
+ });
268
+
269
+ page.on('console', msg => {
270
+ const type = msg.type();
271
+ if (type === 'error') {
272
+ silentLogger.log({ level: 'error', message: msg.text() });
273
+ } else {
274
+ silentLogger.log({ level: 'info', message: msg.text() });
275
+ }
276
+ });
277
+
278
+ const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
279
+ const oobeeAccessibleLabelFlaggedXpaths = disableOobee
280
+ ? []
281
+ : (await flagUnlabelledClickableElements(page)).map(item => item.xpath);
282
+ const oobeeAccessibleLabelFlaggedCssSelectors = oobeeAccessibleLabelFlaggedXpaths
283
+ .map(xpath => {
284
+ try {
285
+ const cssSelector = xPathToCss(xpath);
286
+ return cssSelector;
287
+ } catch (e) {
288
+ console.error('Error converting XPath to CSS: ', xpath, e);
289
+ return '';
290
+ }
291
+ })
292
+ .filter(item => item !== '');
293
+
294
+ const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
295
+
296
+ const gradingReadabilityFlag = await extractAndGradeText(page); // Ensure flag is obtained before proceeding
297
+
298
+ await crawlee.playwrightUtils.injectFile(page, axeScript);
299
+
300
+ const results = await page.evaluate(
301
+ async ({
302
+ selectors,
303
+ saflyIconSelector,
304
+ customAxeConfig,
305
+ disableOobee,
306
+ enableWcagAaa,
307
+ oobeeAccessibleLabelFlaggedCssSelectors,
308
+ gradingReadabilityFlag,
309
+ }) => {
310
+ try {
311
+ const evaluateAltText = (node: Element) => {
312
+ const altText = node.getAttribute('alt');
313
+ const confusingTexts = ['img', 'image', 'picture', 'photo', 'graphic'];
314
+
315
+ if (altText) {
316
+ const trimmedAltText = altText.trim().toLowerCase();
317
+ if (confusingTexts.includes(trimmedAltText)) {
318
+ return false;
319
+ }
320
+ }
321
+ return true;
322
+ };
323
+
324
+ // for css id selectors starting with a digit, escape it with the unicode character e.g. #123 -> #\31 23
325
+ const escapeCSSSelector = (selector: string) => {
326
+ try {
327
+ return selector.replace(
328
+ /([#\.])(\d)/g,
329
+ (_match, prefix, digit) => `${prefix}\\3${digit} `,
330
+ );
331
+ } catch (e) {
332
+ console.error(`error escaping css selector: ${selector}`, e);
333
+ return selector;
334
+ }
335
+ };
336
+
337
+ // remove so that axe does not scan
338
+ document.querySelector(saflyIconSelector)?.remove();
339
+
340
+ axe.configure({
341
+ branding: customAxeConfig.branding,
342
+ checks: [
343
+ {
344
+ ...customAxeConfig.checks[0],
345
+ evaluate: evaluateAltText,
346
+ },
347
+ {
348
+ ...customAxeConfig.checks[1],
349
+ evaluate: (node: HTMLElement) => {
350
+ return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
351
+ },
352
+ },
353
+ {
354
+ ...customAxeConfig.checks[2],
355
+ evaluate: (_node: HTMLElement) => {
356
+ if (gradingReadabilityFlag === '') {
357
+ return true; // Pass if no readability issues
358
+ }
359
+ // Dynamically update the grading messages
360
+ const gradingCheck = customAxeConfig.checks.find(
361
+ check => check.id === 'oobee-grading-text-contents',
362
+ );
363
+ if (gradingCheck) {
364
+ gradingCheck.metadata.messages.incomplete = `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag
365
+ }.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`;
366
+ }
367
+
368
+ // Fail if readability issues are detected
369
+ },
370
+ },
371
+ ],
372
+ rules: customAxeConfig.rules
373
+ .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
374
+ .concat(
375
+ enableWcagAaa
376
+ ? [
377
+ {
378
+ id: 'color-contrast-enhanced',
379
+ enabled: true,
380
+ tags: ['wcag2aaa', 'wcag146'],
381
+ },
382
+ {
383
+ id: 'identical-links-same-purpose',
384
+ enabled: true,
385
+ tags: ['wcag2aaa', 'wcag249'],
386
+ },
387
+ {
388
+ id: 'meta-refresh-no-exceptions',
389
+ enabled: true,
390
+ tags: ['wcag2aaa', 'wcag224', 'wcag325'],
391
+ },
392
+ ]
393
+ : [],
394
+ ),
395
+ });
396
+
397
+ // removed needsReview condition
398
+ const defaultResultTypes: resultGroups[] = ['violations', 'passes', 'incomplete'];
399
+
400
+ return axe
401
+ .run(selectors, {
402
+ resultTypes: defaultResultTypes,
403
+ })
404
+ .then(results => {
405
+ if (disableOobee) {
406
+ return results;
407
+ }
408
+ // handle css id selectors that start with a digit
409
+ const escapedCssSelectors =
410
+ oobeeAccessibleLabelFlaggedCssSelectors.map(escapeCSSSelector);
411
+
412
+ function frameCheck(cssSelector: string): { doc: Document; remainingSelector: string } {
413
+ let doc = document; // Start with the main document
414
+ let frameSelector = ""; // To store the frame part of the selector
415
+
416
+ // Extract the 'frame' part of the selector
417
+ let frameMatch = cssSelector.match(/(frame[^>]*>)/i);
418
+ if (frameMatch) {
419
+ frameSelector = frameMatch[1].replace(">", "").trim(); // Clean up the frame part
420
+ cssSelector = cssSelector.split(frameMatch[1])[1].trim(); // Remove the frame portion
421
+ }
422
+
423
+ let targetFrame = null; // Target frame element
424
+
425
+ // Locate the frame based on the extracted frameSelector
426
+ if (frameSelector.includes("first-of-type")) {
427
+ // Select the first frame
428
+ targetFrame = document.querySelector("frame:first-of-type");
429
+ } else if (frameSelector.includes("nth-of-type")) {
430
+ // Select the nth frame
431
+ let nthIndex = frameSelector.match(/nth-of-type\((\d+)\)/);
432
+ if (nthIndex) {
433
+ let index = parseInt(nthIndex[1]) - 1; // Zero-based index
434
+ targetFrame = document.querySelectorAll("frame")[index];
435
+ }
436
+ } else if (frameSelector.includes("#")) {
437
+ // Frame with a specific ID
438
+ let idMatch = frameSelector.match(/#([\w-]+)/);
439
+ if (idMatch) {
440
+ targetFrame = document.getElementById(idMatch[1]);
441
+ }
442
+ } else if (frameSelector.includes('[name="')) {
443
+ // Frame with a specific name attribute
444
+ let nameMatch = frameSelector.match(/name="([\w-]+)"/);
445
+ if (nameMatch) {
446
+ targetFrame = document.querySelector(`frame[name="${nameMatch[1]}"]`);
447
+ }
448
+ } else {
449
+ // Default to the first frame
450
+ targetFrame = document.querySelector("frame");
451
+ }
452
+
453
+ // Update the document if the frame was found
454
+ if (targetFrame && targetFrame.contentDocument) {
455
+ doc = targetFrame.contentDocument;
456
+ } else {
457
+ console.warn("Frame not found or contentDocument inaccessible.");
458
+ }
459
+
460
+ return { doc, remainingSelector: cssSelector };
461
+ }
462
+
463
+ function iframeCheck(cssSelector: string): { doc: Document; remainingSelector: string } {
464
+ let doc = document; // Start with the main document
465
+ let iframeSelector = ""; // To store the iframe part of the selector
466
+
467
+ // Extract the 'iframe' part of the selector
468
+ let iframeMatch = cssSelector.match(/(iframe[^>]*>)/i);
469
+ if (iframeMatch) {
470
+ iframeSelector = iframeMatch[1].replace(">", "").trim(); // Clean up the iframe part
471
+ cssSelector = cssSelector.split(iframeMatch[1])[1].trim(); // Remove the iframe portion
472
+ }
473
+
474
+ let targetIframe = null; // Target iframe element
475
+
476
+ // Locate the iframe based on the extracted iframeSelector
477
+ if (iframeSelector.includes("first-of-type")) {
478
+ // Select the first iframe
479
+ targetIframe = document.querySelector("iframe:first-of-type");
480
+ } else if (iframeSelector.includes("nth-of-type")) {
481
+ // Select the nth iframe
482
+ let nthIndex = iframeSelector.match(/nth-of-type\((\d+)\)/);
483
+ if (nthIndex) {
484
+ let index = parseInt(nthIndex[1]) - 1; // Zero-based index
485
+ targetIframe = document.querySelectorAll("iframe")[index];
486
+ }
487
+ } else if (iframeSelector.includes("#")) {
488
+ // Iframe with a specific ID
489
+ let idMatch = iframeSelector.match(/#([\w-]+)/);
490
+ if (idMatch) {
491
+ targetIframe = document.getElementById(idMatch[1]);
492
+ }
493
+ } else if (iframeSelector.includes('[name="')) {
494
+ // Iframe with a specific name attribute
495
+ let nameMatch = iframeSelector.match(/name="([\w-]+)"/);
496
+ if (nameMatch) {
497
+ targetIframe = document.querySelector(`iframe[name="${nameMatch[1]}"]`);
498
+ }
499
+ } else {
500
+ // Default to the first iframe
501
+ targetIframe = document.querySelector("iframe");
502
+ }
503
+
504
+ // Update the document if the iframe was found
505
+ if (targetIframe && targetIframe.contentDocument) {
506
+ doc = targetIframe.contentDocument;
507
+ } else {
508
+ console.warn("Iframe not found or contentDocument inaccessible.");
509
+ }
510
+
511
+ return { doc, remainingSelector: cssSelector };
512
+ }
513
+
514
+ function findElementByCssSelector(cssSelector: string): string | null {
515
+ let doc = document;
516
+
517
+ // Check if the selector includes 'frame' and update doc and selector
518
+ if (cssSelector.includes("frame")) {
519
+ const result = frameCheck(cssSelector);
520
+ doc = result.doc;
521
+ cssSelector = result.remainingSelector;
522
+ }
523
+
524
+ // Check for iframe
525
+ if (cssSelector.includes("iframe")) {
526
+ const result = iframeCheck(cssSelector);
527
+ doc = result.doc;
528
+ cssSelector = result.remainingSelector;
529
+ }
530
+
531
+ // Query the element in the document (including inside frames)
532
+ let element = doc.querySelector(cssSelector);
533
+
534
+ // Handle Shadow DOM if the element is not found
535
+ if (!element) {
536
+ const shadowRoots = [];
537
+ const allElements = document.querySelectorAll('*');
538
+
539
+ // Look for elements with shadow roots
540
+ allElements.forEach(el => {
541
+ if (el.shadowRoot) {
542
+ shadowRoots.push(el.shadowRoot);
543
+ }
544
+ });
545
+
546
+ // Search inside each shadow root for the element
547
+ for (const shadowRoot of shadowRoots) {
548
+ const shadowElement = shadowRoot.querySelector(cssSelector);
549
+ if (shadowElement) {
550
+ element = shadowElement; // Found the element inside shadow DOM
551
+ break;
552
+ }
553
+ }
554
+ }
555
+
556
+ return element ? element.outerHTML : null;
557
+ }
558
+
559
+ // Add oobee violations to Axe's report
560
+ const oobeeAccessibleLabelViolations = {
561
+ id: 'oobee-accessible-label',
562
+ impact: 'serious' as ImpactValue,
563
+ tags: ['wcag2a', 'wcag211', 'wcag243', 'wcag412'],
564
+ description: 'Ensures clickable elements have an accessible label.',
565
+ help: 'Clickable elements (i.e. elements with mouse-click interaction) must have accessible labels.',
566
+ helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
567
+ nodes: escapedCssSelectors.map(cssSelector => ({
568
+ html: findElementByCssSelector(cssSelector),
569
+ target: [cssSelector],
570
+ impact: 'serious' as ImpactValue,
571
+ failureSummary:
572
+ 'Fix any of the following:\n The clickable element does not have an accessible label.',
573
+ any: [
574
+ {
575
+ id: 'oobee-accessible-label',
576
+ data: null,
577
+ relatedNodes: [],
578
+ impact: 'serious',
579
+ message: 'The clickable element does not have an accessible label.',
580
+ },
581
+ ],
582
+ all: [],
583
+ none: [],
584
+ })),
585
+ };
586
+
587
+ results.violations = [...results.violations, oobeeAccessibleLabelViolations];
588
+ return results;
589
+ })
590
+ .catch(e => {
591
+ console.error('Error at axe.run', e);
592
+ throw e;
593
+ });
594
+ } catch (e) {
595
+ console.error(e);
596
+ throw e;
597
+ }
598
+ },
599
+ {
600
+ selectors,
601
+ saflyIconSelector,
602
+ customAxeConfig,
603
+ disableOobee,
604
+ enableWcagAaa,
605
+ oobeeAccessibleLabelFlaggedCssSelectors,
606
+ gradingReadabilityFlag,
607
+ },
608
+ );
609
+
610
+ if (includeScreenshots) {
611
+ results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
612
+ results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
613
+ }
614
+
615
+ const pageTitle = await page.evaluate(() => document.title);
616
+
617
+ return filterAxeResults(results, pageTitle, customFlowDetails);
618
+ };
619
+
620
+ export const createCrawleeSubFolders = async (
621
+ randomToken: string,
622
+ ): Promise<{ dataset: crawlee.Dataset; requestQueue: crawlee.RequestQueue }> => {
623
+ const dataset = await crawlee.Dataset.open(randomToken);
624
+ const requestQueue = await crawlee.RequestQueue.open(randomToken);
625
+ return { dataset, requestQueue };
626
+ };
627
+
628
+ export const preNavigationHooks = (extraHTTPHeaders: Record<string, string>) => {
629
+ return [
630
+ async (crawlingContext: CrawlingContext, gotoOptions: PlaywrightGotoOptions) => {
631
+ if (extraHTTPHeaders) {
632
+ crawlingContext.request.headers = extraHTTPHeaders;
633
+ }
634
+ gotoOptions = { waitUntil: 'networkidle', timeout: 30000 };
635
+ },
636
+ ];
637
+ };
638
+
639
+ export const postNavigationHooks = [
640
+ async (_crawlingContext: CrawlingContext) => {
641
+ guiInfoLog(guiInfoStatusTypes.COMPLETED, {});
642
+ },
643
+ ];
644
+
645
+ export const failedRequestHandler = async ({ request }) => {
646
+ guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
647
+ crawlee.log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
648
+ };
649
+
650
+ export const isUrlPdf = (url: string) => {
651
+ if (isFilePath(url)) {
652
+ return /\.pdf$/i.test(url);
653
+ }
654
+ const parsedUrl = new URL(url);
655
+ return /\.pdf($|\?|#)/i.test(parsedUrl.pathname) || /\.pdf($|\?|#)/i.test(parsedUrl.href);
656
+ };