@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,964 @@
1
+ import { Page } from 'playwright';
2
+
3
+ export const flagUnlabelledClickableElements = async (page: Page) => {
4
+ // Just paste the entire script into the body of the page.evaluate callback below
5
+ // There's some code that is not needed when running this on backend but
6
+ // we avoid changing the script for now to make it easy to update
7
+ return await page.evaluate(() => {
8
+ const allowNonClickableFlagging = true; // Change this to true to flag non-clickable images
9
+ const landmarkElements = [
10
+ 'header',
11
+ 'footer',
12
+ 'nav',
13
+ 'main',
14
+ 'article',
15
+ 'section',
16
+ 'aside',
17
+ 'form',
18
+ ];
19
+ const loggingEnabled = false; // Set to true to enable console warnings
20
+
21
+ let previousFlaggedXPathsByDocument = {}; // Object to hold previous flagged XPaths
22
+ const previousAllFlaggedElementsXPaths = []; // Array to store all flagged XPaths
23
+
24
+ function getXPath(element: Node) {
25
+ if (!element) return null;
26
+ if (element instanceof HTMLElement && element.id) {
27
+ return `//*[@id="${element.id}"]`;
28
+ }
29
+ if (element === element.ownerDocument.body) {
30
+ return '/html/body';
31
+ }
32
+ if (!element.parentNode || element.parentNode.nodeType !== 1) {
33
+ return '';
34
+ }
35
+
36
+ const siblings: Node[] = Array.from(element.parentNode.childNodes).filter(
37
+ node => node.nodeName === element.nodeName,
38
+ );
39
+ const ix = siblings.indexOf(element) + 1;
40
+ const siblingIndex = siblings.length > 1 ? `[${ix}]` : '';
41
+ return `${getXPath(element.parentNode)}/${element.nodeName.toLowerCase()}${siblingIndex}`;
42
+ }
43
+
44
+ function customConsoleWarn(message: string, data?: any) {
45
+ if (loggingEnabled) {
46
+ if (data) {
47
+ console.warn(message, data);
48
+ } else {
49
+ console.warn(message);
50
+ }
51
+ }
52
+ }
53
+
54
+ function hasPointerCursor(element: Element) {
55
+ const computedStyle = element.ownerDocument.defaultView.getComputedStyle(element);
56
+ const hasPointerStyle = computedStyle.cursor === 'pointer';
57
+ const hasOnClick = element.hasAttribute('onclick');
58
+ const hasEventListeners = Object.keys(element).some(prop => prop.startsWith('on'));
59
+
60
+ // Check if the element is inherently interactive
61
+ const isClickableRole = ['button', 'link', 'menuitem'].includes(element.getAttribute('role'));
62
+ const isNativeClickableElement =
63
+ ['a', 'button', 'input'].includes(element.nodeName.toLowerCase()) &&
64
+ (element.nodeName.toLowerCase() !== 'a' || element.hasAttribute('href'));
65
+ const hasTabIndex =
66
+ element.hasAttribute('tabindex') && element.getAttribute('tabindex') !== '-1';
67
+
68
+ return (
69
+ hasPointerStyle ||
70
+ hasOnClick ||
71
+ hasEventListeners ||
72
+ isClickableRole ||
73
+ isNativeClickableElement ||
74
+ hasTabIndex
75
+ );
76
+ }
77
+
78
+ function isAccessibleText(value: string) {
79
+ if (!value || value.trim().length === 0) {
80
+ return false;
81
+ }
82
+
83
+ const trimmedValue = value.trim();
84
+
85
+ // Check if the text contains any private use characters
86
+ const privateUseRegex = /\p{Private_Use}/u;
87
+ if (privateUseRegex.test(trimmedValue)) {
88
+ return false;
89
+ }
90
+
91
+ // Check if the text contains at least one letter or number
92
+ const accessibleTextRegex = /[\p{L}\p{N}]/u;
93
+ if (accessibleTextRegex.test(trimmedValue)) {
94
+ return true;
95
+ }
96
+
97
+ // If it doesn't contain letters or numbers, consider it not accessible
98
+ return false;
99
+ }
100
+
101
+ function getElementById(element: Element, id: string) {
102
+ return element.ownerDocument.getElementById(id);
103
+ }
104
+
105
+ function getAriaLabelledByText(element: Element) {
106
+ const labelledById = element.getAttribute('aria-labelledby');
107
+ if (labelledById) {
108
+ const labelledByElement = getElementById(element, labelledById);
109
+ if (labelledByElement) {
110
+ const ariaLabel = labelledByElement.getAttribute('aria-label');
111
+ return ariaLabel ? ariaLabel.trim() : labelledByElement.textContent.trim();
112
+ }
113
+ }
114
+ return '';
115
+ }
116
+
117
+ function hasAccessibleLabel(element: Element) {
118
+ const ariaLabel = element.getAttribute('aria-label');
119
+ const ariaLabelledByText = getAriaLabelledByText(element);
120
+ const altText = element.getAttribute('alt');
121
+ const title = element.getAttribute('title');
122
+
123
+ return (
124
+ isAccessibleText(ariaLabel) ||
125
+ isAccessibleText(ariaLabelledByText) ||
126
+ isAccessibleText(altText) ||
127
+ isAccessibleText(title)
128
+ );
129
+ }
130
+
131
+ function hasSummaryOrDetailsLabel(element: Element) {
132
+ const summary = element.closest('summary, details');
133
+ return summary && hasAccessibleLabel(summary);
134
+ }
135
+
136
+ function hasSiblingWithAccessibleLabel(element: Element) {
137
+ // Check all siblings (previous and next)
138
+ let sibling = element.previousElementSibling;
139
+ while (sibling) {
140
+ if (hasAccessibleLabel(sibling)) {
141
+ return true;
142
+ }
143
+ sibling = sibling.previousElementSibling;
144
+ }
145
+
146
+ sibling = element.nextElementSibling;
147
+ while (sibling) {
148
+ if (hasAccessibleLabel(sibling)) {
149
+ return true;
150
+ }
151
+ sibling = sibling.nextElementSibling;
152
+ }
153
+
154
+ return false;
155
+ }
156
+
157
+ function hasSiblingOrParentAccessibleLabel(element: Element) {
158
+ // Check previous and next siblings
159
+ const previousSibling = element.previousElementSibling;
160
+ const nextSibling = element.nextElementSibling;
161
+ if (
162
+ (previousSibling && hasAccessibleLabel(previousSibling)) ||
163
+ (nextSibling && hasAccessibleLabel(nextSibling))
164
+ ) {
165
+ return true;
166
+ }
167
+
168
+ // Check the parent element
169
+ const parent = element.parentElement;
170
+ if (parent && hasAccessibleLabel(parent)) {
171
+ return true;
172
+ }
173
+
174
+ return false;
175
+ }
176
+
177
+ function hasChildWithAccessibleText(element: Element) {
178
+ // Check element children
179
+ const hasAccessibleChildElement = Array.from(element.children).some(child => {
180
+ if (child.nodeName.toLowerCase() === 'style' || child.nodeName.toLowerCase() === 'script') {
181
+ return false;
182
+ }
183
+ // Skip children that are aria-hidden
184
+ if (child.getAttribute('aria-hidden') === 'true') {
185
+ return false;
186
+ }
187
+ return (
188
+ isAccessibleText(child.textContent) || hasAccessibleLabel(child) || hasCSSContent(child)
189
+ );
190
+ });
191
+
192
+ // Check direct text nodes
193
+ const hasDirectAccessibleText = Array.from(element.childNodes).some(node => {
194
+ if (node.nodeType === Node.TEXT_NODE) {
195
+ return isAccessibleText(node.textContent);
196
+ }
197
+ return false;
198
+ });
199
+
200
+ return hasAccessibleChildElement || hasDirectAccessibleText;
201
+ }
202
+
203
+ function hasAllChildrenAccessible(element: Element) {
204
+ // If the element is aria-hidden, consider it accessible
205
+ if (element.getAttribute('aria-hidden') === 'true') {
206
+ return true;
207
+ }
208
+
209
+ // Check if the element itself has an accessible label, text content, or CSS content
210
+ if (
211
+ hasAccessibleLabel(element) ||
212
+ isAccessibleText(element.textContent) ||
213
+ hasCSSContent(element)
214
+ ) {
215
+ return true;
216
+ }
217
+
218
+ // If the element has children, ensure at least one of them is accessible
219
+ if (element.children.length > 0) {
220
+ return Array.from(element.children).some(child => {
221
+ // If child is aria-hidden, skip it in the accessibility check
222
+ if (child.getAttribute('aria-hidden') === 'true') {
223
+ return true;
224
+ }
225
+ // Recursively check if the child or any of its descendants are accessible
226
+ return hasAllChildrenAccessible(child);
227
+ });
228
+ }
229
+
230
+ // If the element and all its children have no accessible labels or text, it's not accessible
231
+ return false;
232
+ }
233
+
234
+ const style = document.createElement('style');
235
+ style.innerHTML = `
236
+ .highlight-flagged {
237
+ outline: 4px solid rgba(128, 0, 128, 1) !important; /* Thicker primary outline with purple in rgba format */
238
+ box-shadow:
239
+ 0 0 25px 15px rgba(255, 255, 255, 1), /* White glow for contrast */
240
+ 0 0 15px 10px rgba(144, 33, 166, 1) !important; /* Consistent purple glow in rgba format */
241
+ }
242
+ `;
243
+ document.head.appendChild(style);
244
+
245
+ function injectStylesIntoFrame(frame: HTMLIFrameElement) {
246
+ try {
247
+ const frameDocument = frame.contentDocument || frame.contentWindow.document;
248
+ if (frameDocument) {
249
+ const frameStyle = frameDocument.createElement('style');
250
+ frameStyle.innerHTML = `
251
+ .highlight-flagged {
252
+ outline: 4px solid rgba(128, 0, 128, 1) !important; /* Thicker primary outline with purple in rgba format */
253
+ box-shadow:
254
+ 0 0 25px 15px rgba(255, 255, 255, 1), /* White glow for contrast */
255
+ 0 0 15px 10px rgba(144, 33, 166, 1) !important; /* Consistent purple glow in rgba format */
256
+ }
257
+ `;
258
+ frameDocument.head.appendChild(frameStyle);
259
+ }
260
+ } catch (error) {
261
+ customConsoleWarn(`Cannot access frame document: ${error}`);
262
+ }
263
+ }
264
+
265
+ function hasCSSContent(element: Element) {
266
+ const beforeContent = window
267
+ .getComputedStyle(element, '::before')
268
+ .getPropertyValue('content');
269
+ const afterContent = window.getComputedStyle(element, '::after').getPropertyValue('content');
270
+
271
+ function isAccessibleContent(value: string) {
272
+ if (!value || value === 'none' || value === 'normal') {
273
+ return false;
274
+ }
275
+ // Remove quotes from the content value
276
+ const unquotedValue = value.replace(/^['"]|['"]$/g, '').trim();
277
+
278
+ // Use the isAccessibleText function
279
+ return isAccessibleText(unquotedValue);
280
+ }
281
+
282
+ return isAccessibleContent(beforeContent) || isAccessibleContent(afterContent);
283
+ }
284
+
285
+ function isElementTooSmall(element: Element) {
286
+ // Get the bounding rectangle of the element
287
+ const rect = element.getBoundingClientRect();
288
+
289
+ // Check if width or height is less than 1
290
+ return rect.width < 1 || rect.height < 1;
291
+ }
292
+
293
+ function shouldFlagElement(element: HTMLElement, allowNonClickableFlagging: boolean) {
294
+ // if (!element || !(element instanceof Element)) {
295
+ // customConsoleWarn("Element is null or not a valid Element.");
296
+ // return false;
297
+ // }
298
+
299
+ // if (element.nodeName.toLowerCase() === "a")
300
+ // {
301
+ // }
302
+
303
+ if (isElementTooSmall(element)) {
304
+ return false;
305
+ }
306
+
307
+ // Skip non-clickable elements if allowNonClickableFlagging is false
308
+ if (allowNonClickableFlagging && !hasPointerCursor(element)) {
309
+ customConsoleWarn(
310
+ 'Element is not clickable and allowNonClickableFlagging is false, skipping flagging.',
311
+ );
312
+ return false;
313
+ }
314
+
315
+ // Do not flag elements if any ancestor has aria-hidden="true"
316
+ if (element.closest('[aria-hidden="true"]')) {
317
+ customConsoleWarn("An ancestor element has aria-hidden='true', skipping flagging.");
318
+ return false;
319
+ }
320
+
321
+ let parents = element.parentElement;
322
+
323
+ // Causing false negative of svg
324
+ if (parents) {
325
+ // Check if the parent has an accessible label
326
+ if (hasAccessibleLabel(parents) || hasChildWithAccessibleText(parents)) {
327
+ customConsoleWarn(
328
+ 'Parent element has an accessible label, skipping flagging of this element.',
329
+ );
330
+ return false;
331
+ }
332
+
333
+ /* TODO: Ask if this condition is needed cause this is what is causing the hamburger to not */
334
+ // Check if any sibling (that is not an interactable) has an accessible label
335
+ // const siblings = Array.from(parents.children);
336
+ // const hasAccessibleSibling = siblings.some(sibling =>
337
+ // sibling !== element && (hasAccessibleLabel(sibling) || hasChildWithAccessibleText(sibling))
338
+ // );
339
+ // if (hasAccessibleSibling) {
340
+ // customConsoleWarn("A sibling element has an accessible label, skipping flagging.");
341
+ // return false;
342
+ // }
343
+ }
344
+
345
+ while (parents) {
346
+ // Skip flagging if the parent or the element itself has an accessible label
347
+ if (hasAccessibleLabel(parents) || hasAccessibleLabel(element)) {
348
+ customConsoleWarn('Parent or element has an accessible label, skipping flagging.');
349
+ return false;
350
+ }
351
+
352
+ // Skip flagging if the parent is a button-like element with aria-expanded
353
+ if (
354
+ parents.getAttribute('role') === 'button' &&
355
+ (parents.hasAttribute('aria-expanded') || parents.hasAttribute('aria-controls'))
356
+ ) {
357
+ customConsoleWarn(
358
+ 'Parent element is an interactive button with aria-expanded or aria-controls, skipping flagging.',
359
+ );
360
+ return false;
361
+ }
362
+
363
+ // Skip flagging if an ancestor has an accessible label or an interactive role (e.g., button, link)
364
+ if (
365
+ ['div', 'section', 'article', 'nav'].includes(parents.nodeName.toLowerCase()) &&
366
+ hasAccessibleLabel(parents)
367
+ ) {
368
+ customConsoleWarn(
369
+ 'Ancestor element with contextual role has an accessible label, skipping flagging.',
370
+ );
371
+ return false;
372
+ }
373
+
374
+ parents = parents.parentElement;
375
+ }
376
+
377
+ // Skip elements with role="menuitem" if an accessible sibling, parent, or child is present
378
+ if (element.getAttribute('role') === 'menuitem') {
379
+ if (
380
+ hasSiblingWithAccessibleLabel(element) ||
381
+ hasChildWithAccessibleText(element) ||
382
+ hasAccessibleLabel(element.parentElement)
383
+ ) {
384
+ customConsoleWarn(
385
+ 'Menuitem element or its sibling/parent has an accessible label, skipping flagging.',
386
+ );
387
+ return false;
388
+ }
389
+ }
390
+
391
+ // Skip flagging child elements if the parent element has role="menuitem" and is accessible
392
+ const parentMenuItem = element.closest('[role="menuitem"]');
393
+ if (
394
+ parentMenuItem &&
395
+ (hasAccessibleLabel(parentMenuItem) || hasChildWithAccessibleText(parentMenuItem))
396
+ ) {
397
+ customConsoleWarn(
398
+ 'Parent menuitem element has an accessible label or child with accessible text, skipping flagging of its children.',
399
+ );
400
+ return false;
401
+ }
402
+
403
+ // Add the new condition for empty div or span elements without any accessible text or children with accessible labels
404
+ if (
405
+ (element.nodeName.toLowerCase() === 'span' || element.nodeName.toLowerCase() === 'div') &&
406
+ element.children.length === 0 &&
407
+ element.textContent.trim().length === 0
408
+ ) {
409
+ const parent = element.parentElement;
410
+ if (parent) {
411
+ const hasAccessibleChild = Array.from(parent.children).some(
412
+ child => child !== element && hasAccessibleLabel(child),
413
+ );
414
+
415
+ if (hasAccessibleChild) {
416
+ customConsoleWarn(
417
+ 'Parent element has an accessible child, skipping flagging of empty span or div.',
418
+ );
419
+ return false;
420
+ }
421
+ }
422
+ }
423
+
424
+ // Do not flag elements with aria-hidden="true"
425
+ if (element.getAttribute('aria-hidden') === 'true') {
426
+ customConsoleWarn('Element is aria-hidden, skipping flagging.');
427
+ return false;
428
+ }
429
+
430
+ // Do not flag elements with role="presentation"
431
+ if (element.getAttribute('role') === 'presentation') {
432
+ customConsoleWarn("Element has role='presentation', skipping flagging.");
433
+ return false;
434
+ }
435
+
436
+ if (element.dataset.flagged === 'true') {
437
+ customConsoleWarn('Element is already flagged.');
438
+ return false;
439
+ }
440
+
441
+ // If an ancestor element is flagged, do not flag this element
442
+ if (element.closest('[data-flagged="true"]')) {
443
+ customConsoleWarn('An ancestor element is already flagged.');
444
+ return false;
445
+ }
446
+
447
+ // Skip elements that are not visible (e.g., display:none)
448
+ const computedStyle = element.ownerDocument.defaultView.getComputedStyle(element);
449
+ if (
450
+ computedStyle.display === 'none' ||
451
+ computedStyle.visibility === 'hidden' ||
452
+ element.offsetParent === null
453
+ ) {
454
+ customConsoleWarn('Element is not visible, skipping flagging.');
455
+ return false;
456
+ }
457
+
458
+ // Skip empty <div> or <span> elements without any accessible text or children with accessible labels, unless they have a pointer cursor
459
+ if (
460
+ (element.nodeName.toLowerCase() === 'div' || element.nodeName.toLowerCase() === 'span') &&
461
+ element.children.length === 0 &&
462
+ element.textContent.trim().length === 0
463
+ ) {
464
+ if (!hasPointerCursor(element)) {
465
+ customConsoleWarn(
466
+ 'Empty div or span without accessible text and without pointer cursor, skipping flagging.',
467
+ );
468
+ return false;
469
+ }
470
+
471
+ // **New background-image check**
472
+ const backgroundImage = window
473
+ .getComputedStyle(element)
474
+ .getPropertyValue('background-image');
475
+ if (backgroundImage && backgroundImage !== 'none') {
476
+ customConsoleWarn('Element has a background image.');
477
+
478
+ // Check if the element has accessible labels or text content
479
+ if (
480
+ !hasAccessibleLabel(element) &&
481
+ !hasChildWithAccessibleText(element) &&
482
+ !isAccessibleText(element.textContent)
483
+ ) {
484
+ customConsoleWarn(
485
+ 'Flagging element with background image but without accessible label or text.',
486
+ );
487
+ return true; // Flag the element
488
+ }
489
+ customConsoleWarn(
490
+ 'Element with background image has accessible label or text, skipping flagging.',
491
+ );
492
+ return false; // Do not flag
493
+ }
494
+
495
+ // **Proceed with ancestor traversal if no background image is found**
496
+ // Traverse ancestors to check for interactive elements with accessible labels
497
+ let ancestor = element.parentElement;
498
+ let depth = 0;
499
+ const maxDepth = 4; // Limit the depth to prevent skipping elements incorrectly
500
+ while (ancestor && depth < maxDepth) {
501
+ // Determine if ancestor is interactive
502
+ const isAncestorInteractive =
503
+ hasPointerCursor(ancestor) ||
504
+ ancestor.hasAttribute('onclick') ||
505
+ ancestor.hasAttribute('role') ||
506
+ (ancestor.hasAttribute('tabindex') && ancestor.getAttribute('tabindex') !== '-1') ||
507
+ ancestor.hasAttribute('jsaction') ||
508
+ ancestor.hasAttribute('jscontroller');
509
+
510
+ if (isAncestorInteractive) {
511
+ // Check if ancestor has accessible label or text content
512
+ if (
513
+ hasAccessibleLabel(ancestor) ||
514
+ isAccessibleText(ancestor.textContent) ||
515
+ hasChildWithAccessibleText(ancestor)
516
+ ) {
517
+ customConsoleWarn(
518
+ 'Ancestor interactive element has accessible label or text content, skipping flagging.',
519
+ );
520
+ return false;
521
+ }
522
+ // Ancestor is interactive but lacks accessible labeling
523
+ customConsoleWarn(
524
+ 'Ancestor interactive element lacks accessible label, continue flagging.',
525
+ );
526
+ // Do not skip flagging
527
+ }
528
+ ancestor = ancestor.parentElement;
529
+ depth++;
530
+ }
531
+
532
+ // If no interactive ancestor with accessible label is found, flag the element
533
+ customConsoleWarn(
534
+ 'Flagging clickable div or span with pointer cursor and no accessible text.',
535
+ );
536
+ return true;
537
+ }
538
+
539
+ // Skip elements with role="menuitem" and ensure accessibility label for any nested elements
540
+ if (element.getAttribute('role') === 'menuitem') {
541
+ if (hasChildWithAccessibleText(element)) {
542
+ customConsoleWarn('Menuitem element has child with accessible text, skipping flagging.');
543
+ return false;
544
+ }
545
+ }
546
+
547
+ // Check if the parent element has an accessible label
548
+ const parent = element.closest('[aria-label], [role="button"], [role="link"], a, button');
549
+
550
+ if (parent && (hasAccessibleLabel(parent) || hasChildWithAccessibleText(parent))) {
551
+ customConsoleWarn(
552
+ 'Parent element has an accessible label or accessible child, skipping flagging.',
553
+ );
554
+ return false;
555
+ }
556
+
557
+ // Skip flagging if any child has an accessible label (e.g., <img alt="...">
558
+ if (hasAllChildrenAccessible(element)) {
559
+ customConsoleWarn('Element has child nodes with accessible text.');
560
+ return false;
561
+ }
562
+
563
+ // Check if the <a> element has all children accessible
564
+ if (element.nodeName.toLowerCase() === 'a' && hasAllChildrenAccessible(element)) {
565
+ customConsoleWarn('Hyperlink has all children with accessible labels, skipping flagging.');
566
+ return false;
567
+ }
568
+
569
+ if (element.hasAttribute('tabindex') && element.getAttribute('tabindex') === '-1') {
570
+ customConsoleWarn("Element has tabindex='-1'.");
571
+ return false;
572
+ }
573
+
574
+ const childWithTabindexNegativeOne = Array.from(element.children).some(
575
+ child => child.hasAttribute('tabindex') && child.getAttribute('tabindex') === '-1',
576
+ );
577
+ if (childWithTabindexNegativeOne) {
578
+ customConsoleWarn("Element has a child with tabindex='-1'.");
579
+ return false;
580
+ }
581
+
582
+ if (landmarkElements.includes(element.nodeName.toLowerCase())) {
583
+ customConsoleWarn('Element is a landmark element.');
584
+ return false;
585
+ }
586
+
587
+ // Prevent flagging <svg> or <icon> if a sibling or parent has an accessible label or if it is part of a button-like element
588
+ if (
589
+ (element.nodeName.toLowerCase() === 'svg' || element.nodeName.toLowerCase() === 'icon') &&
590
+ (element.getAttribute('focusable') === 'false' ||
591
+ hasSiblingOrParentAccessibleLabel(element) ||
592
+ element.closest('[role="button"]') ||
593
+ element.closest('button'))
594
+ ) {
595
+ customConsoleWarn(
596
+ 'Sibling or parent element has an accessible label or svg is part of a button, skipping flagging of svg or icon.',
597
+ );
598
+ return false;
599
+ }
600
+
601
+ if (element.nodeName.toLowerCase() === 'svg') {
602
+ const parentGroup = element.closest('g');
603
+ if (parentGroup && parentGroup.querySelector('title')) {
604
+ customConsoleWarn('Parent group element has a <title>, skipping flagging of svg.');
605
+ return false;
606
+ }
607
+ }
608
+
609
+ if (element.nodeName.toLowerCase() === 'button') {
610
+ const hasAccessibleLabelForButton =
611
+ hasAccessibleLabel(element) || isAccessibleText(element.textContent);
612
+ if (hasAccessibleLabelForButton) {
613
+ customConsoleWarn('Button has an accessible label, skipping flagging.');
614
+ return false;
615
+ }
616
+
617
+ const hasSvgChildWithoutLabel = Array.from(element.children).some(
618
+ child => child.nodeName.toLowerCase() === 'svg' && !hasAccessibleLabel(child),
619
+ );
620
+ if (hasSvgChildWithoutLabel) {
621
+ customConsoleWarn('Flagging button with child SVG lacking accessible label.');
622
+ return true;
623
+ }
624
+ }
625
+
626
+ if (
627
+ element instanceof HTMLInputElement &&
628
+ // element.nodeName.toLowerCase() === 'input' &&
629
+ element.type === 'image' &&
630
+ !hasAccessibleLabel(element)
631
+ ) {
632
+ customConsoleWarn("Flagging <input type='image'> without accessible label.");
633
+ return true;
634
+ }
635
+
636
+ if (element.nodeName.toLowerCase() === 'a') {
637
+ const img = element.querySelector('img');
638
+
639
+ // Log to verify visibility and pointer checks
640
+ customConsoleWarn('Processing <a> element.');
641
+
642
+ // Ensure this <a> does not have an accessible label
643
+ const linkHasAccessibleLabel = hasAccessibleLabel(element);
644
+
645
+ // Ensure the <img> inside <a> does not have an accessible label
646
+ const imgHasAccessibleLabel = img ? hasAccessibleLabel(img) : false;
647
+
648
+ // Log to verify if <img> has accessible label
649
+ if (img) {
650
+ customConsoleWarn(`Found <img> inside <a>. Accessible label: ${imgHasAccessibleLabel}`);
651
+ } else {
652
+ customConsoleWarn('No <img> found inside <a>.');
653
+ }
654
+
655
+ // Flag if both <a> and <img> inside lack accessible labels
656
+ if (!linkHasAccessibleLabel && img && !imgHasAccessibleLabel) {
657
+ customConsoleWarn('Flagging <a> with inaccessible <img>.');
658
+ return true;
659
+ }
660
+
661
+ // Skip flagging if <a> has an accessible label or all children are accessible
662
+ if (linkHasAccessibleLabel || hasAllChildrenAccessible(element)) {
663
+ customConsoleWarn('Hyperlink has an accessible label, skipping flagging.');
664
+ return false;
665
+ }
666
+ }
667
+
668
+ // Modify this section for generic elements
669
+ if (['span', 'div', 'icon', 'svg', 'button'].includes(element.nodeName.toLowerCase())) {
670
+ if (element.nodeName.toLowerCase() === 'icon' || element.nodeName.toLowerCase() === 'svg') {
671
+ // Check if the element has an accessible label or if it has a sibling, parent, or summary/related element that provides an accessible label
672
+ if (
673
+ !hasAccessibleLabel(element) &&
674
+ !hasSiblingOrParentAccessibleLabel(element) &&
675
+ !hasSummaryOrDetailsLabel(element) &&
676
+ element.getAttribute('focusable') !== 'false'
677
+ ) {
678
+ customConsoleWarn('Flagging icon or svg without accessible label.');
679
+ return true;
680
+ }
681
+ return false;
682
+ }
683
+
684
+ if (element.textContent.trim().length > 0) {
685
+ customConsoleWarn('Element has valid text content.');
686
+ return false;
687
+ }
688
+
689
+ if (
690
+ element.hasAttribute('aria-label') &&
691
+ element.getAttribute('aria-label').trim().length > 0
692
+ ) {
693
+ customConsoleWarn('Element has an aria-label attribute, skipping flagging.');
694
+ return false;
695
+ }
696
+ }
697
+
698
+ if (element.nodeName.toLowerCase() === 'div') {
699
+ const flaggedChild = Array.from(element.children).some(
700
+ (child: HTMLElement) => child.dataset.flagged === 'true',
701
+ );
702
+ if (flaggedChild) {
703
+ customConsoleWarn('Div contains a flagged child, flagging only outermost element.');
704
+ return false;
705
+ }
706
+
707
+ // Update this condition to include hasChildWithAccessibleText
708
+ if (element.textContent.trim().length > 0 || hasChildWithAccessibleText(element)) {
709
+ customConsoleWarn('Div has valid text content or child with accessible text.');
710
+ return false;
711
+ }
712
+
713
+ const img = element.querySelector('img');
714
+ if (img) {
715
+ const altText = img.getAttribute('alt');
716
+ const ariaLabel = img.getAttribute('aria-label');
717
+ const ariaLabelledByText = getAriaLabelledByText(img);
718
+ if (altText !== null || ariaLabel || ariaLabelledByText) {
719
+ customConsoleWarn(
720
+ 'Div contains an accessible img or an img with an alt attribute (even if empty).',
721
+ );
722
+ return false;
723
+ }
724
+ }
725
+
726
+ const svg = element.querySelector('svg');
727
+ if (svg) {
728
+ if (
729
+ hasPointerCursor(element) &&
730
+ !hasAccessibleLabel(svg) &&
731
+ !hasSummaryOrDetailsLabel(svg) &&
732
+ svg.getAttribute('focusable') !== 'false'
733
+ ) {
734
+ customConsoleWarn('Flagging clickable div with SVG without accessible label.');
735
+ return true;
736
+ }
737
+ }
738
+
739
+ if (
740
+ hasPointerCursor(element) &&
741
+ !hasAccessibleLabel(element) &&
742
+ !isAccessibleText(element.textContent)
743
+ ) {
744
+ customConsoleWarn('Clickable div without accessible label or text content.');
745
+ return true;
746
+ }
747
+ }
748
+
749
+ if (
750
+ element.nodeName.toLowerCase() === 'img' ||
751
+ element.nodeName.toLowerCase() === 'picture'
752
+ ) {
753
+ const imgElement =
754
+ element.nodeName.toLowerCase() === 'picture' ? element.querySelector('img') : element;
755
+ const altText = imgElement.getAttribute('alt');
756
+ const ariaLabel = imgElement.getAttribute('aria-label');
757
+ const ariaLabelledByText = getAriaLabelledByText(imgElement);
758
+
759
+ if (!allowNonClickableFlagging) {
760
+ if (
761
+ !imgElement.closest('a') &&
762
+ !imgElement.closest('button') &&
763
+ !hasPointerCursor(imgElement) &&
764
+ !(altText !== null) &&
765
+ !(ariaLabel && ariaLabel.trim().length > 0) &&
766
+ !(ariaLabelledByText && ariaLabelledByText.length > 0)
767
+ ) {
768
+ customConsoleWarn('Non-clickable image ignored.');
769
+ return false;
770
+ }
771
+ }
772
+
773
+ if (
774
+ !imgElement.closest('a') &&
775
+ !imgElement.closest('button') &&
776
+ !(altText !== null) &&
777
+ !(ariaLabel && ariaLabel.trim().length > 0) &&
778
+ !(ariaLabelledByText && ariaLabelledByText.length > 0)
779
+ ) {
780
+ customConsoleWarn('Flagging img or picture without accessible label.');
781
+ return true;
782
+ }
783
+ }
784
+
785
+ // Additional check to skip divs with empty children or child-child elements
786
+ const areAllDescendantsEmpty = Array.from(element.querySelectorAll('*')).every(
787
+ child => child.textContent.trim().length === 0 && !hasAccessibleLabel(child),
788
+ );
789
+ if (element.nodeName.toLowerCase() === 'div' && areAllDescendantsEmpty) {
790
+ customConsoleWarn('Div with empty descendants, skipping flagging.');
791
+ return false;
792
+ }
793
+
794
+ if (hasCSSContent(element)) {
795
+ customConsoleWarn('Element has CSS ::before or ::after content, skipping flagging.');
796
+ return false;
797
+ }
798
+
799
+ return false; // Default case: do not flag
800
+ }
801
+
802
+ function flagElements() {
803
+ console.time('Accessibility Check Time');
804
+
805
+ const currentFlaggedElementsByDocument: Record<string, HTMLElement[]> = {}; // Temporary object to hold current flagged elements
806
+
807
+ /*
808
+ Collects all the elements and places then into an array
809
+ Then places the array in the correct frame
810
+ */
811
+ // Process main document
812
+ const currentFlaggedElements: HTMLElement[] = [];
813
+ const allElements = Array.from(document.querySelectorAll<HTMLElement>('*'));
814
+ let indexofAllElements: number = 0;
815
+
816
+ while (indexofAllElements < allElements.length) {
817
+ const element = allElements[indexofAllElements] as HTMLElement;
818
+ // if it selects a frameset
819
+ if (
820
+ shouldFlagElement(element, allowNonClickableFlagging) ||
821
+ element.dataset.flagged === 'true'
822
+ ) {
823
+ element.dataset.flagged = 'true'; // Mark element as flagged
824
+ currentFlaggedElements.push(element);
825
+ }
826
+
827
+ // If the element has a shadowRoot, add its children
828
+ if (element.shadowRoot) {
829
+ allElements.push(
830
+ ...(Array.from(element.shadowRoot.querySelectorAll('*')) as HTMLElement[]),
831
+ );
832
+ }
833
+ indexofAllElements++;
834
+ }
835
+ currentFlaggedElementsByDocument[''] = currentFlaggedElements; // Key "" represents the main document
836
+
837
+ // Process iframes
838
+ const iframes = document.querySelectorAll('iframe');
839
+ iframes.forEach((iframe, index) => {
840
+ injectStylesIntoFrame(iframe);
841
+ try {
842
+ const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
843
+ if (iframeDocument) {
844
+ const iframeFlaggedElements: HTMLElement[] = [];
845
+ const iframeElements = Array.from(iframeDocument.querySelectorAll<HTMLElement>('*'));
846
+ let indexOfIframeElements: number = 0;
847
+ while (indexOfIframeElements < iframeElements.length) {
848
+ const element = iframeElements[indexOfIframeElements] as HTMLElement;
849
+ if (
850
+ shouldFlagElement(element, allowNonClickableFlagging) ||
851
+ element.dataset.flagged === 'true'
852
+ ) {
853
+ element.dataset.flagged = 'true'; // Mark element as flagged
854
+ iframeFlaggedElements.push(element);
855
+ }
856
+ // If the element has a shadowRoot, add its children
857
+ if (element.shadowRoot) {
858
+ iframeElements.push(
859
+ ...(Array.from(element.shadowRoot.querySelectorAll('*')) as HTMLElement[]),
860
+ );
861
+ }
862
+ indexOfIframeElements++;
863
+ }
864
+ const iframeXPath = getXPath(iframe);
865
+ currentFlaggedElementsByDocument[iframeXPath] = iframeFlaggedElements;
866
+ }
867
+ } catch (error) {
868
+ console.warn(`Cannot access iframe document (${index}): ${error.message}`);
869
+ }
870
+ });
871
+
872
+ // Process frames
873
+ const frames = document.querySelectorAll('frame');
874
+ frames.forEach((frame, index) => {
875
+ // injectStylesIntoFrame(frame);
876
+ try {
877
+ const iframeDocument = frame.contentDocument || frame.contentWindow.document;
878
+ if (iframeDocument) {
879
+ const iframeFlaggedElements: HTMLElement[] = [];
880
+ const iframeElements = Array.from(iframeDocument.querySelectorAll<HTMLElement>('*'));
881
+ let indexOfIframeElements: number = 0;
882
+ while (indexOfIframeElements < iframeElements.length) {
883
+ const element = iframeElements[indexOfIframeElements] as HTMLElement;
884
+ if (
885
+ shouldFlagElement(element, allowNonClickableFlagging) ||
886
+ element.dataset.flagged === 'true'
887
+ ) {
888
+ element.dataset.flagged = 'true'; // Mark element as flagged
889
+ iframeFlaggedElements.push(element);
890
+ }
891
+ // If the element has a shadowRoot, add its children
892
+ if (element.shadowRoot) {
893
+ iframeElements.push(
894
+ ...(Array.from(element.shadowRoot.querySelectorAll('*')) as HTMLElement[]),
895
+ );
896
+ }
897
+ indexOfIframeElements++;
898
+ }
899
+ const iframeXPath = getXPath(frame);
900
+ currentFlaggedElementsByDocument[iframeXPath] = iframeFlaggedElements;
901
+ }
902
+ } catch (error) {
903
+ console.warn(`Cannot access iframe document (${index}): ${error.message}`);
904
+ }
905
+ });
906
+
907
+ // Collect XPaths and outerHTMLs of flagged elements per document
908
+ const flaggedXPathsByDocument = {};
909
+
910
+ for (const docKey in currentFlaggedElementsByDocument) {
911
+ const elements = currentFlaggedElementsByDocument[docKey];
912
+ const flaggedInfo = []; // Array to hold flagged element info
913
+ elements.forEach(flaggedElement => {
914
+ const parentFlagged = flaggedElement.closest('[data-flagged="true"]');
915
+ if (!parentFlagged || parentFlagged === flaggedElement) {
916
+ let xpath = getXPath(flaggedElement);
917
+ if (docKey !== '') {
918
+ // For elements in iframes, adjust XPath
919
+ xpath = docKey + xpath;
920
+ }
921
+ if (xpath && flaggedElement !== null && flaggedElement.outerHTML) {
922
+ const { outerHTML } = flaggedElement; // Get outerHTML
923
+ flaggedInfo.push({ xpath, code: outerHTML }); // Store xpath and outerHTML
924
+
925
+ // Check if the xpath already exists in previousAllFlaggedElementsXPaths
926
+ const alreadyExists = previousAllFlaggedElementsXPaths.some(
927
+ entry => entry.xpath === xpath,
928
+ );
929
+ if (!alreadyExists) {
930
+ // Add to previousAllFlaggedElementsXPaths only if not already present
931
+ previousAllFlaggedElementsXPaths.push({ xpath, code: outerHTML });
932
+ }
933
+ }
934
+ }
935
+ });
936
+ flaggedXPathsByDocument[docKey] = flaggedInfo; // Store all flagged element info
937
+ }
938
+
939
+ // Update previousFlaggedXPathsByDocument before finishing
940
+ previousFlaggedXPathsByDocument = { ...flaggedXPathsByDocument };
941
+
942
+ cleanupFlaggedElements();
943
+ console.timeEnd('Accessibility Check Time');
944
+ return previousAllFlaggedElementsXPaths;
945
+ }
946
+
947
+ // Clean up [data-flagged="true"] attribute added by this script
948
+ function cleanupFlaggedElements() {
949
+ const flaggedElements = document.querySelectorAll('[data-flagged="true"]');
950
+ flaggedElements.forEach(flaggedElement => {
951
+ flaggedElement.removeAttribute('data-flagged');
952
+ });
953
+ }
954
+ function debounce(func, wait) {
955
+ let timeout;
956
+ return function (...args) {
957
+ clearTimeout(timeout);
958
+ timeout = setTimeout(() => func.apply(this, args), wait);
959
+ };
960
+ }
961
+
962
+ return flagElements();
963
+ });
964
+ };