@govtechsg/oobee 0.10.87 → 0.10.88

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 (28) hide show
  1. package/.github/workflows/docker-push-ghcr.yml +49 -0
  2. package/DETAILS_OUTPUT_EXAMPLES.md +178 -0
  3. package/Dockerfile +6 -7
  4. package/dist/combine.js +1 -0
  5. package/dist/crawlers/commonCrawlerFunc.js +523 -2
  6. package/dist/crawlers/crawlLocalFile.js +2 -2
  7. package/dist/crawlers/custom/extractAndGradeText.js +1 -1
  8. package/dist/crawlers/custom/getAxeConfiguration.js +26 -21
  9. package/dist/crawlers/custom/gradeReadability.js +1 -1
  10. package/dist/npmIndex.js +16 -12
  11. package/dist/screenshotFunc/htmlScreenshotFunc.js +67 -0
  12. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
  13. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
  14. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  15. package/examples/oobee-test-details-runner.js +214 -0
  16. package/examples/test-violations.html +42 -0
  17. package/package.json +1 -1
  18. package/src/combine.ts +1 -0
  19. package/src/crawlers/commonCrawlerFunc.ts +625 -2
  20. package/src/crawlers/crawlLocalFile.ts +4 -1
  21. package/src/crawlers/custom/extractAndGradeText.ts +1 -1
  22. package/src/crawlers/custom/getAxeConfiguration.ts +25 -23
  23. package/src/crawlers/custom/gradeReadability.ts +1 -1
  24. package/src/npmIndex.ts +17 -12
  25. package/src/screenshotFunc/htmlScreenshotFunc.ts +81 -1
  26. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
  27. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
  28. package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
@@ -8,6 +8,7 @@ import constants, {
8
8
  UrlsCrawled,
9
9
  STATUS_CODE_METADATA,
10
10
  FileTypes,
11
+ RuleFlags,
11
12
  } from '../constants/constants.js';
12
13
  import { ViewportSettingsClass } from '../combine.js';
13
14
  import {
@@ -35,6 +36,7 @@ export const crawlLocalFile = async ({
35
36
  includeScreenshots,
36
37
  extraHTTPHeaders,
37
38
  scanDuration = 0,
39
+ ruleset = [],
38
40
  fromCrawlIntelligentSitemap = false,
39
41
  userUrlInputFromIntelligent = null,
40
42
  datasetFromIntelligent = null,
@@ -53,6 +55,7 @@ export const crawlLocalFile = async ({
53
55
  includeScreenshots: boolean;
54
56
  extraHTTPHeaders: Record<string, string>;
55
57
  scanDuration?: number;
58
+ ruleset?: RuleFlags[];
56
59
  fromCrawlIntelligentSitemap?: boolean;
57
60
  userUrlInputFromIntelligent?: string | null;
58
61
  datasetFromIntelligent?: Dataset | null;
@@ -178,7 +181,7 @@ export const crawlLocalFile = async ({
178
181
  return urlsCrawled;
179
182
  }
180
183
 
181
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
184
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
182
185
 
183
186
  const actualUrl = page.url() || request.loadedUrl || url;
184
187
 
@@ -45,7 +45,7 @@ export async function extractAndGradeText(page: Page): Promise<string> {
45
45
 
46
46
  // Determine the return value
47
47
  const result =
48
- readabilityScore === 0 || readabilityScore > 50 ? '' : readabilityScore.toString(); // Convert readabilityScore to string
48
+ readabilityScore <= 0 || readabilityScore > 50 ? '' : readabilityScore.toString();
49
49
 
50
50
  return result;
51
51
  } catch (error) {
@@ -10,6 +10,12 @@ export function getAxeConfiguration({
10
10
  gradingReadabilityFlag?: string;
11
11
  disableOobee?: boolean;
12
12
  }) {
13
+ function getReadabilityInterpretation(score: string): string {
14
+ const num = parseFloat(score);
15
+ if (Number.isNaN(num)) return '';
16
+ if (num > 30) return 'It is targeted for junior college (JC) level comprehension and above.';
17
+ return 'It is targeted for university graduate level comprehension and above.';
18
+ }
13
19
  return {
14
20
  branding: {
15
21
  application: 'oobee',
@@ -39,7 +45,7 @@ export function getAxeConfiguration({
39
45
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
40
46
  },
41
47
  },
42
- ...(enableWcagAaa
48
+ ...((enableWcagAaa && gradingReadabilityFlag !== '')
43
49
  ? [
44
50
  {
45
51
  id: 'oobee-grading-text-contents',
@@ -47,17 +53,11 @@ export function getAxeConfiguration({
47
53
  impact: 'moderate' as ImpactValue,
48
54
  messages: {
49
55
  pass: 'The text content is easy to understand.',
50
- fail: 'The text content is potentially difficult to understand.',
51
- incomplete: `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag
52
- }.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`,
56
+ fail: `Text content is potentially difficult to read.\n It scored ${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n ${getReadabilityInterpretation(gradingReadabilityFlag)}`,
57
+ incomplete: `Text content is potentially difficult to read.\n It scored ${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n ${getReadabilityInterpretation(gradingReadabilityFlag)}`,
53
58
  },
54
59
  },
55
- evaluate: (_node: HTMLElement) => {
56
- if (gradingReadabilityFlag === '') {
57
- return true; // Pass if no readability issues
58
- }
59
- // Fail if readability issues are detected
60
- },
60
+ evaluate: (_node: HTMLElement) => false,
61
61
  },
62
62
  ]
63
63
  : []),
@@ -88,19 +88,21 @@ export function getAxeConfiguration({
88
88
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
89
89
  },
90
90
  },
91
- {
92
- id: 'oobee-grading-text-contents',
93
- selector: 'html',
94
- enabled: true,
95
- any: ['oobee-grading-text-contents'],
96
- tags: ['wcag2aaa', 'wcag315'],
97
- metadata: {
98
- description:
99
- '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.',
100
- help: 'Text content should be clear and plain to ensure that it is easily understood.',
101
- helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
102
- },
103
- },
91
+ ...((enableWcagAaa && gradingReadabilityFlag !== '')
92
+ ? [{
93
+ id: 'oobee-grading-text-contents',
94
+ selector: 'html',
95
+ enabled: true,
96
+ any: ['oobee-grading-text-contents'],
97
+ tags: ['wcag2aaa', 'wcag315'],
98
+ metadata: {
99
+ description:
100
+ '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.',
101
+ help: 'Text content should be clear and plain to ensure that it is easily understood.',
102
+ helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
103
+ },
104
+ }]
105
+ : []),
104
106
  ]
105
107
  .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
106
108
  .concat(
@@ -20,7 +20,7 @@ export function gradeReadability(sentences: string[]): string {
20
20
 
21
21
  // Determine the return value
22
22
  const result =
23
- readabilityScore === 0 || readabilityScore > 50 ? '' : readabilityScore.toString(); // Convert readabilityScore to string
23
+ readabilityScore <= 0 || readabilityScore > 50 ? '' : readabilityScore.toString();
24
24
 
25
25
  return result;
26
26
  } catch (error) {
package/src/npmIndex.ts CHANGED
@@ -12,10 +12,10 @@ import {
12
12
  getPlaywrightLaunchOptions,
13
13
  submitForm,
14
14
  } from './constants/common.js';
15
- import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
15
+ import { createCrawleeSubFolders, enrichViolationMessages, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
16
16
  import { createAndUpdateResultsFolders, getVersion } from './utils.js';
17
17
  import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
18
- import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
18
+ import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
19
19
  import { consoleLogger, silentLogger } from './logs.js';
20
20
  import { alertMessageOptions } from './constants/cliFunctions.js';
21
21
  import { evaluateAltText } from './crawlers/custom/evaluateAltText.js';
@@ -86,6 +86,13 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
86
86
  window.xPathToCss = ${xPathToCss.toString()};
87
87
  window.extractText = ${extractText.toString()};
88
88
 
89
+ function getReadabilityInterpretation(score) {
90
+ const num = parseFloat(score);
91
+ if (Number.isNaN(num)) return '';
92
+ if (num > 30) return 'It is targeted for junior college (JC) level comprehension and above.';
93
+ return 'It is targeted for university graduate level comprehension and above.';
94
+ }
95
+
89
96
  function getAxeConfiguration({
90
97
  enableWcagAaa = false,
91
98
  gradingReadabilityFlag = '',
@@ -120,7 +127,7 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
120
127
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
121
128
  },
122
129
  },
123
- ...((enableWcagAaa && !disableOobee)
130
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
124
131
  ? [
125
132
  {
126
133
  id: 'oobee-grading-text-contents',
@@ -128,16 +135,11 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
128
135
  impact: 'moderate',
129
136
  messages: {
130
137
  pass: 'The text content is easy to understand.',
131
- fail: 'The text content is potentially difficult to understand.',
132
- 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.\`,
138
+ fail: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
139
+ incomplete: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
133
140
  },
134
141
  },
135
- evaluate: (_node) => {
136
- if (gradingReadabilityFlag === '') {
137
- return true; // Pass if no readability issues
138
- }
139
- // Fail if readability issues are detected
140
- },
142
+ evaluate: (_node) => false,
141
143
  },
142
144
  ]
143
145
  : []),
@@ -168,7 +170,7 @@ const getOobeeFunctionsScript = (disableOobee: boolean, enableWcagAaa: boolean)
168
170
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
169
171
  },
170
172
  },
171
- ...((enableWcagAaa && !disableOobee)
173
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
172
174
  ? [
173
175
  {
174
176
  id: 'oobee-grading-text-contents',
@@ -864,6 +866,9 @@ export const scanPage = async (
864
866
  return window.runA11yScan();
865
867
  });
866
868
 
869
+ await enrichViolationMessages(scanResult.axeScanResults, page);
870
+ await enrichColorContrastDOMContext(scanResult.axeScanResults.violations, page);
871
+
867
872
  scanData.push({
868
873
  axeScanResults: scanResult.axeScanResults,
869
874
  pageUrl: page.url(),
@@ -5,10 +5,89 @@ import path from 'path';
5
5
  // import { silentLogger } from '../logs.js';
6
6
  import { Result } from 'axe-core';
7
7
  import { Page } from 'playwright';
8
- import { NodeResultWithScreenshot, ResultWithScreenshot } from '../crawlers/commonCrawlerFunc.js';
8
+ import {
9
+ ContrastDOMContext,
10
+ NodeResultWithScreenshot,
11
+ ResultWithScreenshot,
12
+ } from '../crawlers/commonCrawlerFunc.js';
9
13
 
10
14
  const screenshotMap: Record<string, string> = {}; // Map of screenshot hashkey to its buffer value and screenshot path
11
15
 
16
+ export const enrichColorContrastDOMContext = async (
17
+ violations: Result[],
18
+ page: Page,
19
+ ): Promise<void> => {
20
+ for (const violation of violations) {
21
+ if (violation.id !== 'color-contrast' && violation.id !== 'color-contrast-enhanced') continue;
22
+
23
+ for (const node of violation.nodes) {
24
+ const { target } = node;
25
+ const selector = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;
26
+ if (!selector) continue;
27
+
28
+ try {
29
+ const domContext = await page
30
+ .evaluate((sel: string): ContrastDOMContext | null => {
31
+ const el = document.querySelector(sel);
32
+ if (!el) return null;
33
+
34
+ const style = window.getComputedStyle(el);
35
+ const bgImage = style.backgroundImage;
36
+ const hasGradient = bgImage !== 'none' && bgImage.includes('gradient');
37
+ const hasBackgroundImage = bgImage !== 'none' && !hasGradient;
38
+
39
+ let hasReducedOpacity = parseFloat(style.opacity) < 1;
40
+ let ancestorHasGradient = false;
41
+ let ancestorHasBackgroundImage = false;
42
+
43
+ let ancestor = el.parentElement;
44
+ while (ancestor && ancestor.tagName !== 'HTML') {
45
+ const anStyle = window.getComputedStyle(ancestor);
46
+ if (!hasReducedOpacity && parseFloat(anStyle.opacity) < 1) {
47
+ hasReducedOpacity = true;
48
+ }
49
+ const anBgImg = anStyle.backgroundImage;
50
+ if (anBgImg !== 'none') {
51
+ if (!ancestorHasGradient && anBgImg.includes('gradient')) {
52
+ ancestorHasGradient = true;
53
+ } else if (!ancestorHasBackgroundImage) {
54
+ ancestorHasBackgroundImage = true;
55
+ }
56
+ }
57
+ ancestor = ancestor.parentElement;
58
+ }
59
+
60
+ const mixBlendMode = style.mixBlendMode !== 'normal' ? style.mixBlendMode : null;
61
+ const backdropFilter =
62
+ style.backdropFilter && style.backdropFilter !== 'none'
63
+ ? style.backdropFilter
64
+ : null;
65
+ const filter = style.filter && style.filter !== 'none' ? style.filter : null;
66
+
67
+ return {
68
+ backgroundImage: bgImage !== 'none' ? bgImage : '',
69
+ hasGradient,
70
+ hasBackgroundImage,
71
+ ancestorHasGradient,
72
+ ancestorHasBackgroundImage,
73
+ hasReducedOpacity,
74
+ mixBlendMode,
75
+ backdropFilter,
76
+ filter,
77
+ };
78
+ }, selector)
79
+ .catch(() => null);
80
+
81
+ if (domContext) {
82
+ (node as NodeResultWithScreenshot).contrastDOMContext = domContext;
83
+ }
84
+ } catch {
85
+ // Non-critical; proceed without DOM context
86
+ }
87
+ }
88
+ }
89
+ };
90
+
12
91
  export const takeScreenshotForHTMLElements = async (
13
92
  violations: Result[],
14
93
  page: Page,
@@ -67,6 +146,7 @@ export const takeScreenshotForHTMLElements = async (
67
146
  } catch (e) {
68
147
  // consoleLogger.info(`Unable to take element screenshot at ${selector}`);
69
148
  }
149
+
70
150
  }
71
151
  newViolationNodes.push(nodeWithScreenshotPath);
72
152
  }
@@ -239,14 +239,17 @@
239
239
  ${item.xpath ? createXpathSection(item.xpath) : ''}
240
240
  ${createElementSection(item)}
241
241
  ${
242
- item.displayNeedsReview
243
- ? `<div class="d-flex justify-content-between g-one">
242
+ (() => {
243
+ const showDetails = item.displayNeedsReview || ['color-contrast', 'color-contrast-enhanced', 'oobee-grading-text-contents', 'target-size', 'valid-lang'].includes(aiConfig.ruleInCategory.rule);
244
+ return showDetails && item.message
245
+ ? `<div class="d-flex justify-content-between g-one">
244
246
  <div class="fw-semibold page-item-card-section-title">Details</div>
245
247
  <div class="page-item-card-section-content">
246
- ${generateItemMessageElement(item.displayNeedsReview, item.message)}
248
+ ${generateItemMessageElement(item.displayNeedsReview || true, item.message)}
247
249
  </div>
248
250
  </div>`
249
- : ''
251
+ : '';
252
+ })()
250
253
  }
251
254
  ${isPurpleAiRule ? createAiSuggestionSection(item, oobeeAiQueryLabel, aiConfig) : ''}
252
255
  ${showGenAiUI ? createGenAiSuggestFixSection(item, aiConfig) : ''}
@@ -393,14 +393,17 @@
393
393
  </div>
394
394
 
395
395
  ${
396
- item.displayNeedsReview
397
- ? `<div class="d-flex justify-content-between g-one modal-view">
396
+ (() => {
397
+ const showDetails = item.displayNeedsReview || ['color-contrast', 'color-contrast-enhanced', 'oobee-grading-text-contents', 'target-size', 'valid-lang'].includes(aiConfig.ruleInCategory.rule);
398
+ return showDetails && item.message
399
+ ? `<div class="d-flex justify-content-between g-one modal-view">
398
400
  <div class="fw-semibold page-item-card-section-title">Details</div>
399
401
  <div class="page-item-card-section-content">
400
- ${generateItemMessageElement(item.displayNeedsReview, item.message)}
402
+ ${generateItemMessageElement(item.displayNeedsReview || true, item.message)}
401
403
  </div>
402
404
  </div>`
403
- : ''
405
+ : '';
406
+ })()
404
407
  }
405
408
  ${isPurpleAiRule ? createAiSuggestionSection(item, oobeeAiQueryLabel, aiConfig) : ''}
406
409
  </div>
@@ -53,7 +53,8 @@
53
53
  if (htmlEscapedMessageArray.length === 1) {
54
54
  return `<p class="mb-0">${htmlEscapedMessageArray[0]}</p>`;
55
55
  } else {
56
- return `<ul>${htmlEscapedMessageArray.map(m => `<li>${m}</li>`).join('')}</ul>`;
56
+ const [first, ...rest] = htmlEscapedMessageArray;
57
+ return `<p class="mb-0">${first}</p><ul>${rest.map(m => `<li>${m}</li>`).join('')}</ul>`;
57
58
  }
58
59
  } else {
59
60
  let i = 0;