@govtechsg/oobee 0.10.86 → 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 (61) hide show
  1. package/.github/workflows/docker-push-ghcr.yml +49 -0
  2. package/.github/workflows/image.yml +2 -3
  3. package/DETAILS_OUTPUT_EXAMPLES.md +178 -0
  4. package/Dockerfile +6 -7
  5. package/dist/cli.js +18 -5
  6. package/dist/combine.js +3 -0
  7. package/dist/constants/cliFunctions.js +2 -2
  8. package/dist/constants/common.js +55 -13
  9. package/dist/crawlers/commonCrawlerFunc.js +523 -2
  10. package/dist/crawlers/crawlDomain.js +38 -13
  11. package/dist/crawlers/crawlIntelligentSitemap.js +62 -30
  12. package/dist/crawlers/crawlLocalFile.js +2 -2
  13. package/dist/crawlers/crawlSitemap.js +44 -5
  14. package/dist/crawlers/custom/extractAndGradeText.js +1 -1
  15. package/dist/crawlers/custom/getAxeConfiguration.js +26 -21
  16. package/dist/crawlers/custom/gradeReadability.js +1 -1
  17. package/dist/crawlers/custom/utils.js +81 -40
  18. package/dist/generateHtmlReport.js +18 -11
  19. package/dist/mergeAxeResults/itemReferences.js +60 -25
  20. package/dist/mergeAxeResults/sentryTelemetry.js +4 -1
  21. package/dist/mergeAxeResults.js +18 -9
  22. package/dist/npmIndex.js +16 -12
  23. package/dist/screenshotFunc/htmlScreenshotFunc.js +67 -0
  24. package/dist/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  25. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +45 -6
  26. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +8 -5
  27. package/dist/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  28. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  29. package/dist/static/ejs/summary.ejs +18 -12
  30. package/dist/utils.js +4 -3
  31. package/examples/oobee-test-details-runner.js +214 -0
  32. package/examples/test-violations.html +42 -0
  33. package/fix-summary-html-oom-pr.md +62 -0
  34. package/package.json +5 -5
  35. package/src/cli.ts +19 -5
  36. package/src/combine.ts +3 -0
  37. package/src/constants/cliFunctions.ts +2 -2
  38. package/src/constants/common.ts +65 -12
  39. package/src/crawlers/commonCrawlerFunc.ts +625 -2
  40. package/src/crawlers/crawlDomain.ts +39 -13
  41. package/src/crawlers/crawlIntelligentSitemap.ts +63 -30
  42. package/src/crawlers/crawlLocalFile.ts +4 -1
  43. package/src/crawlers/crawlSitemap.ts +50 -3
  44. package/src/crawlers/custom/extractAndGradeText.ts +1 -1
  45. package/src/crawlers/custom/getAxeConfiguration.ts +25 -23
  46. package/src/crawlers/custom/gradeReadability.ts +1 -1
  47. package/src/crawlers/custom/utils.ts +99 -43
  48. package/src/generateHtmlReport.ts +21 -11
  49. package/src/mergeAxeResults/itemReferences.ts +70 -26
  50. package/src/mergeAxeResults/sentryTelemetry.ts +4 -1
  51. package/src/mergeAxeResults.ts +21 -11
  52. package/src/npmIndex.ts +17 -12
  53. package/src/screenshotFunc/htmlScreenshotFunc.ts +81 -1
  54. package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +6 -3
  55. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +45 -6
  56. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +8 -5
  57. package/src/static/ejs/partials/scripts/ruleModal/ruleOffcanvas.ejs +4 -4
  58. package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  59. package/src/static/ejs/summary.ejs +18 -12
  60. package/src/utils.ts +4 -3
  61. package/testStaticJSScanner.html +1 -1
@@ -2,7 +2,7 @@ import { Dataset, RequestQueue, log, playwrightUtils } from 'crawlee';
2
2
  import axe from 'axe-core';
3
3
  import { axeScript, disallowedListOfPatterns, guiInfoStatusTypes, RuleFlags, saflyIconSelector, } from '../constants/constants.js';
4
4
  import { consoleLogger, guiInfoLog } from '../logs.js';
5
- import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
5
+ import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
6
6
  import { isFilePath } from '../constants/common.js';
7
7
  import { extractAndGradeText } from './custom/extractAndGradeText.js';
8
8
  import { evaluateAltText } from './custom/evaluateAltText.js';
@@ -35,6 +35,522 @@ const truncateHtml = (html, maxBytes = 1024, suffix = '…') => {
35
35
  }
36
36
  return result;
37
37
  };
38
+ const formatContrastFontSize = (fontSize) => {
39
+ if (!fontSize)
40
+ return 'unknown size';
41
+ const pxMatch = fontSize.match(/\(([\d.]+px)\)/i);
42
+ return pxMatch ? pxMatch[1] : fontSize;
43
+ };
44
+ /**
45
+ * Parses a CSS color string into an [R, G, B] tuple (values 0–255).
46
+ *
47
+ * axe-core serialises colours via its internal `Color.toHexString()`, which
48
+ * always produces lowercase 6-digit hex (#rrggbb). The parser also accepts
49
+ * 3-digit hex (#rgb) and functional rgb() notation for robustness.
50
+ *
51
+ * Returns null if the string does not match any supported format.
52
+ */
53
+ const parseColor = (color) => {
54
+ const hex6 = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
55
+ if (hex6)
56
+ return [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
57
+ const hex3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
58
+ if (hex3)
59
+ return [
60
+ parseInt(hex3[1] + hex3[1], 16),
61
+ parseInt(hex3[2] + hex3[2], 16),
62
+ parseInt(hex3[3] + hex3[3], 16),
63
+ ];
64
+ const rgb = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
65
+ if (rgb)
66
+ return [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])];
67
+ return null;
68
+ };
69
+ /**
70
+ * Computes the WCAG 2.x relative luminance of an sRGB colour.
71
+ *
72
+ * Algorithm (WCAG 2.1 §1.4.3 / IEC 61966-2-1 sRGB standard):
73
+ * 1. Normalise each 8-bit channel to [0, 1] by dividing by 255.
74
+ * 2. Gamma-expand (linearise) each normalised channel:
75
+ * if sRGB ≤ 0.04045 → linear = sRGB / 12.92
76
+ * else → linear = ((sRGB + 0.055) / 1.055) ^ 2.4
77
+ * The threshold 0.04045 and the slope 1/12.92 describe the near-black
78
+ * linear segment of the sRGB electro-optical transfer function (EOTF).
79
+ * The power 2.4 (≈ gamma 2.2) and the offset 0.055 handle the rest.
80
+ * 3. Weight the linear channels by the CIE 1931 XYZ D65 Y-row coefficients
81
+ * projected onto the sRGB primaries:
82
+ * L = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin
83
+ * These weights reflect human eye sensitivity: the eye is most sensitive
84
+ * to green, moderately to red, and least to blue.
85
+ *
86
+ * Returns a value in [0, 1]: 0 = absolute black, 1 = perfect white.
87
+ */
88
+ const relativeLuminance = (r, g, b) => {
89
+ const linearise = (c) => {
90
+ const s = c / 255;
91
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
92
+ };
93
+ return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
94
+ };
95
+ /**
96
+ * Computes the WCAG 2.x contrast ratio between two relative luminances.
97
+ *
98
+ * Formula: (L_lighter + 0.05) / (L_darker + 0.05)
99
+ *
100
+ * The additive offset 0.05 models the luminance of ambient flare (reflected
101
+ * light) assumed in a reference viewing environment. It prevents the ratio
102
+ * from reaching infinity for pure black and anchors the scale so that
103
+ * white-on-black yields exactly 21:1. The lighter luminance is always placed
104
+ * in the numerator so the result is always ≥ 1.
105
+ *
106
+ * WCAG thresholds:
107
+ * AA normal text ≥ 4.5:1 | large text ≥ 3:1
108
+ * AAA normal text ≥ 7:1 | large text ≥ 4.5:1
109
+ */
110
+ const wcagContrastRatio = (l1, l2) => {
111
+ const lighter = Math.max(l1, l2);
112
+ const darker = Math.min(l1, l2);
113
+ return (lighter + 0.05) / (darker + 0.05);
114
+ };
115
+ /**
116
+ * Converts an sRGB colour to HSL cylindrical coordinates.
117
+ * H (hue) ∈ [0°, 360°)
118
+ * S (saturation) ∈ [0, 1]
119
+ * L (lightness) ∈ [0, 1]
120
+ *
121
+ * We work in HSL because adjusting L alone changes perceived brightness while
122
+ * preserving the hue and saturation of the original brand colour — the
123
+ * smallest perceptible change needed to satisfy the contrast requirement.
124
+ * Achromatic colours (R = G = B) return H = 0, S = 0.
125
+ */
126
+ const rgbToHsl = (r, g, b) => {
127
+ const R = r / 255, G = g / 255, B = b / 255;
128
+ const max = Math.max(R, G, B), min = Math.min(R, G, B);
129
+ const l = (max + min) / 2;
130
+ if (max === min)
131
+ return [0, 0, l];
132
+ const d = max - min;
133
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
134
+ let h;
135
+ if (max === R)
136
+ h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
137
+ else if (max === G)
138
+ h = ((B - R) / d + 2) / 6;
139
+ else
140
+ h = ((R - G) / d + 4) / 6;
141
+ return [h * 360, s, l];
142
+ };
143
+ /**
144
+ * Converts HSL back to sRGB (inverse of rgbToHsl).
145
+ *
146
+ * Uses the standard two-step piecewise-linear hue reconstruction:
147
+ * q = L < 0.5 ? L(1+S) : L+S−L·S (upper chroma boundary)
148
+ * p = 2L − q (lower chroma boundary)
149
+ * Each R, G, B channel is obtained by evaluating a piecewise hue function
150
+ * with offsets of +1/3 (120°) and −1/3 (−120°) for R and B respectively.
151
+ */
152
+ const hslToRgb = (h, s, l) => {
153
+ h = h / 360;
154
+ if (s === 0) {
155
+ const v = Math.round(l * 255);
156
+ return [v, v, v];
157
+ }
158
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
159
+ const p = 2 * l - q;
160
+ const hue2rgb = (p, q, t) => {
161
+ if (t < 0)
162
+ t += 1;
163
+ if (t > 1)
164
+ t -= 1;
165
+ if (t < 1 / 6)
166
+ return p + (q - p) * 6 * t;
167
+ if (t < 1 / 2)
168
+ return q;
169
+ if (t < 2 / 3)
170
+ return p + (q - p) * (2 / 3 - t) * 6;
171
+ return p;
172
+ };
173
+ return [
174
+ Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
175
+ Math.round(hue2rgb(p, q, h) * 255),
176
+ Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
177
+ ];
178
+ };
179
+ /** Converts an [R, G, B] tuple (0–255) to a lowercase 6-digit hex string. */
180
+ const rgbToHex = (r, g, b) => '#' +
181
+ [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
182
+ /**
183
+ * Finds the lightness-adjusted version of `adjustRgb` that is as close as
184
+ * possible to the original colour while achieving at least `requiredRatio`
185
+ * contrast against `fixedRgb`.
186
+ *
187
+ * Strategy — binary search on HSL lightness L ∈ [0, 1]:
188
+ * direction = 'darker': searches L ∈ [0, original_L] for the MAXIMUM L
189
+ * (least dark, closest to original) at which contrast ≥ required.
190
+ * direction = 'lighter': searches L ∈ [original_L, 1] for the MINIMUM L
191
+ * (least light, closest to original) at which contrast ≥ required.
192
+ *
193
+ * Hue (H) and saturation (S) are held constant so the result stays as close
194
+ * to the original brand/design colour as possible.
195
+ *
196
+ * 30 iterations yield a lightness precision of 1/2^30 ≈ 10⁻⁹, well below
197
+ * the 1/255 ≈ 0.004 resolution of 8-bit channels, so the result is
198
+ * effectively exact at 8-bit depth.
199
+ *
200
+ * Returns null if even the extreme value for this direction (pure black at
201
+ * L = 0 or pure white at L = 1 for the given H and S) cannot achieve the
202
+ * required ratio — an edge case that only arises for very low required ratios
203
+ * or highly chromatic near-neutral colours.
204
+ */
205
+ const findCompliantColorByLightness = (adjustRgb, fixedRgb, requiredRatio, direction) => {
206
+ const fixedLum = relativeLuminance(...fixedRgb);
207
+ const [h, s, origHslL] = rgbToHsl(...adjustRgb);
208
+ // Verify the extreme value in this direction is sufficient at all.
209
+ const extremeHslL = direction === 'darker' ? 0 : 1;
210
+ const extremeRgb = hslToRgb(h, s, extremeHslL);
211
+ if (wcagContrastRatio(fixedLum, relativeLuminance(...extremeRgb)) < requiredRatio) {
212
+ return null;
213
+ }
214
+ let lo = direction === 'darker' ? 0 : origHslL;
215
+ let hi = direction === 'darker' ? origHslL : 1;
216
+ let best = extremeHslL; // guaranteed-passing extreme
217
+ for (let i = 0; i < 30; i++) {
218
+ const mid = (lo + hi) / 2;
219
+ const midRgb = hslToRgb(h, s, mid);
220
+ const passes = wcagContrastRatio(fixedLum, relativeLuminance(...midRgb)) >= requiredRatio;
221
+ if (passes) {
222
+ best = mid; // This midpoint works; try to get even closer to the original.
223
+ if (direction === 'darker')
224
+ lo = mid; // Can we raise L further (less dark)?
225
+ else
226
+ hi = mid; // Can we lower L further (less light)?
227
+ }
228
+ else {
229
+ if (direction === 'darker')
230
+ hi = mid; // Too light; go darker.
231
+ else
232
+ lo = mid; // Too dark; go lighter.
233
+ }
234
+ }
235
+ return hslToRgb(h, s, best);
236
+ };
237
+ /**
238
+ * Builds a human-readable recommendation for a single failing contrast pair.
239
+ *
240
+ * WCAG 1.4.3 "Contrast (Minimum)" defines two situations based on font size
241
+ * and weight (1 pt = 1.333 px at 96 dpi):
242
+ *
243
+ * Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
244
+ * i.e. < ~24 px / < ~18.5 px):
245
+ * Required contrast ≥ 4.5:1 (G18)
246
+ *
247
+ * Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
248
+ * i.e. ≥ ~24 px / ≥ ~18.5 px):
249
+ * Required contrast ≥ 3:1 (G145)
250
+ *
251
+ * axe-core applies this rule upstream using:
252
+ * ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
253
+ * isSmallFont = (bold && ptSize < boldTextPt) // default 14 pt
254
+ * || (!bold && ptSize < largeTextPt) // default 18 pt
255
+ * bold = fontWeight ≥ 700 || fontWeight === 'bold' // boldValue = 700
256
+ * and stores the result as `expectedContrastRatio: "4.5:1"` (Situation A) or
257
+ * `"3:1"` (Situation B). The `required` value used here is derived from that
258
+ * field via `parseFloat("4.5:1") → 4.5` or `parseFloat("3:1") → 3`, so the
259
+ * binary search automatically targets the correct threshold for each
260
+ * combination's font size and weight — no re-classification is needed here.
261
+ *
262
+ * WCAG 1.4.3 exceptions (no contrast requirement — filtered by axe-core
263
+ * before the data ever reaches this function):
264
+ * • Pure decoration (no informational purpose, rearrangeable/substitutable)
265
+ * • Inactive / disabled user-interface components
266
+ * • Logotypes and brand names
267
+ * • Text inside photographs or images with significant other visual content
268
+ *
269
+ * Algorithm for each failing pair:
270
+ * 1. Parse both colours to [R, G, B] (axe-core supplies #rrggbb hex).
271
+ * 2. Convert each colour to HSL. Search for the nearest compliant
272
+ * foreground by binary-searching HSL lightness L in both the 'darker'
273
+ * and 'lighter' directions while keeping H and S constant (preserves
274
+ * hue/saturation of the original brand colour).
275
+ * 3. Repeat step 2 for the background (holding the foreground fixed).
276
+ * 4. For each colour role, pick whichever direction (darker/lighter)
277
+ * requires the smaller change in L — i.e. the least visually
278
+ * disruptive compliant alternative.
279
+ * 5. Report both the adjusted foreground and the adjusted background
280
+ * (hex + rgb) so developers can choose whichever fits their design
281
+ * system. The target ratio is included so the output is unambiguous
282
+ * when a page contains a mix of normal-text (4.5:1) and large-text
283
+ * (3:1) failures.
284
+ *
285
+ * Returns null if the colours cannot be parsed or no compliant alternative
286
+ * can be found (extremely rare; only arises when the colour is already at
287
+ * the luminance extreme for its hue/saturation).
288
+ */
289
+ const buildContrastRecommendation = (example) => {
290
+ const fgRgb = parseColor(example.fgColor);
291
+ const bgRgb = parseColor(example.bgColor);
292
+ if (!fgRgb || !bgRgb)
293
+ return null;
294
+ // parseFloat handles "4.5:1" → 4.5 because it stops at the non-numeric ':'.
295
+ // The value is either 4.5 (Situation A, normal text) or 3 (Situation B,
296
+ // large text), as determined by axe-core from the element's font metrics.
297
+ const CONTRAST_BUFFER = 1.05;
298
+ const required = parseFloat(example.expectedContrastRatio) * CONTRAST_BUFFER;
299
+ if (isNaN(required))
300
+ return null;
301
+ // Find the nearest compliant foreground (try both directions, pick closest).
302
+ const fgDarker = findCompliantColorByLightness(fgRgb, bgRgb, required, 'darker');
303
+ const fgLighter = findCompliantColorByLightness(fgRgb, bgRgb, required, 'lighter');
304
+ const [, , origFgHslL] = rgbToHsl(...fgRgb);
305
+ let recFg = null;
306
+ if (fgDarker && fgLighter) {
307
+ const [, , dL] = rgbToHsl(...fgDarker);
308
+ const [, , lL] = rgbToHsl(...fgLighter);
309
+ recFg = Math.abs(dL - origFgHslL) <= Math.abs(lL - origFgHslL) ? fgDarker : fgLighter;
310
+ }
311
+ else {
312
+ recFg = fgDarker ?? fgLighter;
313
+ }
314
+ // Find the nearest compliant background (try both directions, pick closest).
315
+ const bgDarker = findCompliantColorByLightness(bgRgb, fgRgb, required, 'darker');
316
+ const bgLighter = findCompliantColorByLightness(bgRgb, fgRgb, required, 'lighter');
317
+ const [, , origBgHslL] = rgbToHsl(...bgRgb);
318
+ let recBg = null;
319
+ if (bgDarker && bgLighter) {
320
+ const [, , dL] = rgbToHsl(...bgDarker);
321
+ const [, , lL] = rgbToHsl(...bgLighter);
322
+ recBg = Math.abs(dL - origBgHslL) <= Math.abs(lL - origBgHslL) ? bgDarker : bgLighter;
323
+ }
324
+ else {
325
+ recBg = bgDarker ?? bgLighter;
326
+ }
327
+ if (!recFg && !recBg)
328
+ return null;
329
+ const parts = [];
330
+ if (recFg) {
331
+ const [rr, gg, bb] = recFg;
332
+ parts.push(`foreground text color to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
333
+ }
334
+ if (recBg) {
335
+ const [rr, gg, bb] = recBg;
336
+ parts.push(`background to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
337
+ }
338
+ // Include the target ratio in the string so the message is unambiguous when
339
+ // a single element has a mix of normal-text (4.5:1) and large-text (3:1)
340
+ // failing combinations with different required thresholds.
341
+ return `${parts.join(' or ')}`;
342
+ };
343
+ /**
344
+ * Builds the augmented issue description for a single axe-core color-contrast
345
+ * violation node.
346
+ *
347
+ * ─── WCAG rules applied ──────────────────────────────────────────────────────
348
+ *
349
+ * WCAG 1.4.3 "Contrast (Minimum)" distinguishes two situations:
350
+ *
351
+ * Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
352
+ * i.e. < ~24 px / < ~18.5 px at 96 dpi):
353
+ * Required contrast ratio ≥ 4.5:1
354
+ *
355
+ * Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
356
+ * i.e. ≥ ~24 px / ≥ ~18.5 px at 96 dpi):
357
+ * Required contrast ratio ≥ 3:1
358
+ *
359
+ * axe-core classifies each element before this function runs:
360
+ * ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
361
+ * isSmallFont = (bold && ptSize < 14) // Situation A bold
362
+ * || (!bold && ptSize < 18) // Situation A normal
363
+ * bold = fontWeight ≥ 700 || fontWeight === 'bold'
364
+ * …and stores the result in check.data.expectedContrastRatio as "4.5:1" or "3:1".
365
+ *
366
+ * Exceptions (no contrast requirement — already excluded by axe-core upstream):
367
+ * • Pure decoration (no informational purpose)
368
+ * • Inactive / disabled UI components
369
+ * • Logotypes and brand names
370
+ * • Text inside photographs with significant other visual content
371
+ *
372
+ * ─── Function flow ───────────────────────────────────────────────────────────
373
+ *
374
+ * 1. Collect checks — flatten node.any, node.all, node.none into one array.
375
+ * Each entry may carry a ContrastCheckData payload with fgColor, bgColor,
376
+ * contrastRatio, fontSize, fontWeight, and expectedContrastRatio.
377
+ *
378
+ * 2. Deduplicate — key each combination on
379
+ * [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].
380
+ * A single DOM node can generate multiple identical checks; the Map ensures
381
+ * each distinct failing pair is reported once.
382
+ *
383
+ * 3. Build the base message — lists every unique failing combination with its
384
+ * current contrast ratio and the required ratio, and instructs the developer
385
+ * to fix all failing text in the component, not just the first element.
386
+ *
387
+ * 4. Build per-combo recommendations (via buildContrastRecommendation) —
388
+ * for each failing pair, binary-search HSL lightness to find the nearest
389
+ * compliant foreground (background fixed) and the nearest compliant
390
+ * background (foreground fixed), both expressed as hex + rgb(). The binary
391
+ * search targets the combination's own expectedContrastRatio (4.5 or 3),
392
+ * so large-text recommendations are correctly held to the 3:1 threshold
393
+ * and normal-text recommendations to 4.5:1.
394
+ *
395
+ * 5. Concatenate — append "Recommendation: …" after the base message so the
396
+ * existing description is never modified, only extended.
397
+ *
398
+ * Returns null when no check carries usable contrast data (axe-core may omit
399
+ * it for pseudo-element or out-of-viewport cases).
400
+ */
401
+ const buildColorContrastMessage = (node) => {
402
+ const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])];
403
+ const uniqueCombos = new Map();
404
+ checks.forEach(check => {
405
+ const data = check.data || {};
406
+ const hasContrastData = data.fgColor ||
407
+ data.bgColor ||
408
+ data.contrastRatio !== undefined ||
409
+ data.expectedContrastRatio;
410
+ if (!hasContrastData)
411
+ return;
412
+ const fgColor = data.fgColor || 'unknown foreground';
413
+ const bgColor = data.bgColor || 'unknown background';
414
+ const contrastRatio = String(data.contrastRatio ?? 'unknown');
415
+ const fontSize = formatContrastFontSize(data.fontSize);
416
+ const fontWeight = data.fontWeight || 'normal';
417
+ const expectedContrastRatio = data.expectedContrastRatio || '4.5:1';
418
+ const key = [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].join('|');
419
+ if (!uniqueCombos.has(key)) {
420
+ uniqueCombos.set(key, {
421
+ fgColor,
422
+ bgColor,
423
+ contrastRatio,
424
+ fontSize,
425
+ fontWeight,
426
+ expectedContrastRatio,
427
+ });
428
+ }
429
+ });
430
+ if (!uniqueCombos.size)
431
+ return null;
432
+ const combos = [...uniqueCombos.values()];
433
+ const examples = combos
434
+ .map(example => `foreground ${example.fgColor} on ${example.bgColor} at ${example.fontSize} ${example.fontWeight === 'bold' ? 'bold' : 'regular'} text`)
435
+ .join(', and ');
436
+ const targetRatio = combos[0]?.expectedContrastRatio || '4.5:1';
437
+ const currentRatio = combos[0]?.contrastRatio || 'unknown';
438
+ const base = `Multiple text elements in this component fail WCAG 1.4.3 Color Contrast Minimum.\n Normal text should meet or exceed ${targetRatio} contrast ratio against its actual background.\n The current text contrast ratio of ${currentRatio} does not meet requirements. Failing combinations in this snippet include ${examples}.`;
439
+ const recommendations = combos
440
+ .map(buildContrastRecommendation)
441
+ .filter((r) => r !== null);
442
+ const recSection = recommendations.length > 0
443
+ ? `\n Recommendation: Adjust ${recommendations.join('; ')}.`
444
+ : '';
445
+ const ctx = node.contrastDOMContext;
446
+ if (!ctx)
447
+ return `${base}${recSection}`;
448
+ const notes = [];
449
+ if (ctx.hasGradient) {
450
+ notes.push(`gradient background detected (${ctx.backgroundImage}): the sampled background color represents a single point — verify contrast at every gradient stop and position where text appears, then adjust the gradient stops or add a solid color fallback behind the text`);
451
+ }
452
+ else if (ctx.ancestorHasGradient) {
453
+ notes.push(`an ancestor provides a gradient background: the sampled background color may not match what is visually beneath the text — verify contrast against the actual rendered gradient`);
454
+ }
455
+ if (ctx.hasBackgroundImage) {
456
+ notes.push(`background image detected: contrast cannot be fully determined from a sampled color alone — ensure text remains readable across all image content and states`);
457
+ }
458
+ else if (ctx.ancestorHasBackgroundImage) {
459
+ notes.push(`an ancestor has a background image: the effective background under this text may differ from the sampled value`);
460
+ }
461
+ if (ctx.hasReducedOpacity) {
462
+ notes.push(`opacity less than 1 detected on this element or an ancestor: the rendered contrast is lower than the computed color values indicate`);
463
+ }
464
+ if (ctx.mixBlendMode) {
465
+ notes.push(`mix-blend-mode: ${ctx.mixBlendMode} is applied: actual rendered colors depend on the underlying layers`);
466
+ }
467
+ if (ctx.backdropFilter) {
468
+ notes.push(`backdrop-filter: ${ctx.backdropFilter} is applied: the effective background appearance is modified`);
469
+ }
470
+ if (ctx.filter) {
471
+ notes.push(`CSS filter: ${ctx.filter} is applied to this element: rendered colors may differ from computed values`);
472
+ }
473
+ const ctxSection = notes.length > 0
474
+ ? `\n Rendering complexity: ${notes.join('; ')}.\n The color fix recommendations above may not be accurate for this element — manual verification of the actual rendered contrast is strongly advised.`
475
+ : '';
476
+ return `${base}${recSection}${ctxSection}`;
477
+ };
478
+ // Enriches axe violation failureSummaries with additional DOM context gathered via Playwright,
479
+ // providing LLMs with the specific details they need to apply correct fixes.
480
+ export const enrichViolationMessages = async (results, page) => {
481
+ for (const violation of results.violations) {
482
+ if (violation.id !== 'target-size' && violation.id !== 'valid-lang')
483
+ continue;
484
+ for (const node of violation.nodes) {
485
+ const cssSelector = node.target.length === 1 && typeof node.target[0] === 'string' ? node.target[0] : null;
486
+ if (!cssSelector)
487
+ continue;
488
+ if (violation.id === 'target-size') {
489
+ const ctx = await page
490
+ .evaluate((sel) => {
491
+ try {
492
+ const el = document.querySelector(sel);
493
+ if (!el)
494
+ return null;
495
+ const rect = el.getBoundingClientRect();
496
+ const computed = window.getComputedStyle(el);
497
+ return {
498
+ renderedWidth: Math.round(rect.width),
499
+ renderedHeight: Math.round(rect.height),
500
+ boxSizing: computed.boxSizing,
501
+ inlineWidth: el.style.width || null,
502
+ inlineHeight: el.style.height || null,
503
+ tagName: el.tagName.toLowerCase(),
504
+ };
505
+ }
506
+ catch {
507
+ return null;
508
+ }
509
+ }, cssSelector)
510
+ .catch(() => null);
511
+ if (ctx) {
512
+ const spacingMatch = node.failureSummary?.match(/diameter of (\d+)px/);
513
+ const spacing = spacingMatch ? spacingMatch[1] : null;
514
+ let message = `Insufficient target size: ${ctx.renderedWidth}px by ${ctx.renderedHeight}px (box-sizing: ${ctx.boxSizing}).\n Ensure it is at least 24px by 24px.`;
515
+ if (spacing) {
516
+ message += `\n Target has insufficient space to its adjacent element of ${spacing}px. Ensure it has a safe clickable space of at least 24px.`;
517
+ }
518
+ if (ctx.boxSizing === 'border-box' &&
519
+ (ctx.inlineWidth !== null || ctx.inlineHeight !== null)) {
520
+ message += `\n Current button style code snippet does not increase the hit area.\n Remove the explicit width/height and use min-width: 24px; min-height: 24px instead.\n Or place the visual content in a child <span> element.`;
521
+ }
522
+ node.failureSummary = message;
523
+ }
524
+ }
525
+ else if (violation.id === 'valid-lang') {
526
+ const ctx = await page
527
+ .evaluate((sel) => {
528
+ try {
529
+ const el = document.querySelector(sel);
530
+ if (!el)
531
+ return null;
532
+ return {
533
+ langValue: el.getAttribute('lang') ?? '',
534
+ textSnippet: (el.textContent ?? '').trim().slice(0, 120),
535
+ };
536
+ }
537
+ catch {
538
+ return null;
539
+ }
540
+ }, cssSelector)
541
+ .catch(() => null);
542
+ if (ctx) {
543
+ let message = `Value of lang attribute is not a valid language.\n Use a registered IANA language code instead of "${ctx.langValue}".`;
544
+ if (ctx.langValue.startsWith('x-')) {
545
+ message += `\n Axe-core valid-lang rule also rejects private-use subtags.`;
546
+ }
547
+ message += `\n Identify the actual language of this text and use its registered BCP 47 code (e.g., lang="it" Italian, "es" Spanish, "fr" French, "de" German, "zh" Chinese, "ja" Japanese, "ko" Korean, "pt" Portuguese, "ar" Arabic).`;
548
+ node.failureSummary = message;
549
+ }
550
+ }
551
+ }
552
+ }
553
+ };
38
554
  export const filterAxeResults = (results, pageTitle, customFlowDetails) => {
39
555
  const { violations, passes, incomplete, url } = results;
40
556
  let totalItems = 0;
@@ -72,9 +588,12 @@ export const filterAxeResults = (results, pageTitle, customFlowDetails) => {
72
588
  items: [],
73
589
  };
74
590
  }
75
- const message = displayNeedsReview
591
+ const defaultMessage = displayNeedsReview
76
592
  ? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
77
593
  : failureSummary;
594
+ const message = rule === 'color-contrast' || rule === 'color-contrast-enhanced'
595
+ ? buildColorContrastMessage(node) || defaultMessage
596
+ : defaultMessage;
78
597
  let finalHtml = html;
79
598
  if (html.includes('</script>')) {
80
599
  finalHtml = html.replaceAll('</script>', '&lt;/script>');
@@ -320,6 +839,8 @@ export const runAxeScript = async ({ includeScreenshots, page, randomToken, cust
320
839
  flagUnlabelledClickableElementsFunctionString: flagUnlabelledClickableElements.toString(),
321
840
  xPathToCssFunctionString: xPathToCss.toString(),
322
841
  });
842
+ await enrichViolationMessages(results, page);
843
+ await enrichColorContrastDOMContext(results.violations, page);
323
844
  if (includeScreenshots) {
324
845
  results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
325
846
  results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
@@ -4,7 +4,7 @@ import fsp from 'fs/promises';
4
4
  import { createCrawleeSubFolders, runAxeScript, isUrlPdf, shouldSkipClickDueToDisallowedHref, shouldSkipDueToUnsupportedContent, } from './commonCrawlerFunc.js';
5
5
  import constants, { blackListedFileExtensions, guiInfoStatusTypes, cssQuerySelectors, STATUS_CODE_METADATA, disallowedListOfPatterns, disallowedSelectorPatterns, FileTypes, } from '../constants/constants.js';
6
6
  import { getPlaywrightLaunchOptions, isBlacklistedFileExtensions, isSkippedUrl, isDisallowedInRobotsTxt, getUrlsFromRobotsTxt, waitForPageLoaded, } from '../constants/common.js';
7
- import { areLinksEqual, isFollowStrategy, register } from '../utils.js';
7
+ import { areLinksEqual, isFollowStrategy, normUrl, register } from '../utils.js';
8
8
  import { handlePdfDownload, runPdfScan, mapPdfScanResults, doPdfScreenshots, } from './pdfScanFunc.js';
9
9
  import { consoleLogger, guiInfoLog } from '../logs.js';
10
10
  const isBlacklisted = (url, blacklistedPatterns) => {
@@ -37,8 +37,8 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
37
37
  const pdfDownloads = [];
38
38
  const uuidToPdfMapping = {};
39
39
  const queuedUrlSet = new Set();
40
- const scannedUrlSet = new Set(urlsCrawled.scanned.map(item => item.url));
41
- const scannedResolvedUrlSet = new Set(urlsCrawled.scanned.map(item => item.actualUrl || item.url));
40
+ const scannedUrlSet = new Set(urlsCrawled.scanned.map(item => normUrl(item.url)));
41
+ const scannedResolvedUrlSet = new Set(urlsCrawled.scanned.map(item => normUrl(item.actualUrl || item.url)));
42
42
  const isScanHtml = [FileTypes.All, FileTypes.HtmlOnly].includes(fileTypes);
43
43
  const isScanPdfs = [FileTypes.All, FileTypes.PdfOnly].includes(fileTypes);
44
44
  const { maxConcurrency } = constants;
@@ -70,11 +70,12 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
70
70
  const initialPageUrl = workingPage.url().toString();
71
71
  const selectedElementsString = cssQuerySelectors.join(', ');
72
72
  const isExcluded = (newPageUrl) => {
73
- const isAlreadyScanned = urlsCrawled.scanned.some(item => item.url === newPageUrl);
73
+ const isAlreadyScanned = scannedUrlSet.has(normUrl(newPageUrl));
74
74
  const isBlacklistedUrl = isBlacklisted(newPageUrl, blacklistedPatterns);
75
75
  const isNotFollowStrategy = !isFollowStrategy(newPageUrl, initialPageUrl, strategy);
76
76
  const isNotSupportedDocument = disallowedListOfPatterns.some(pattern => newPageUrl.toLowerCase().startsWith(pattern));
77
- return isNotSupportedDocument || isAlreadyScanned || isBlacklistedUrl || isNotFollowStrategy;
77
+ const isRobotsDisallowed = isDisallowedInRobotsTxt(newPageUrl);
78
+ return isNotSupportedDocument || isAlreadyScanned || isBlacklistedUrl || isNotFollowStrategy || isRobotsDisallowed;
78
79
  };
79
80
  const setPageListeners = (pageListener) => {
80
81
  // event listener to handle new page popups upon button click
@@ -235,7 +236,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
235
236
  catch (e) {
236
237
  consoleLogger.error(e);
237
238
  }
238
- if (scannedUrlSet.has(req.url)) {
239
+ if (scannedUrlSet.has(normUrl(req.url))) {
239
240
  req.skipNavigation = true;
240
241
  }
241
242
  if (isDisallowedInRobotsTxt(req.url))
@@ -358,7 +359,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
358
359
  finalUrl = requestLabelUrl;
359
360
  }
360
361
  const isRedirected = !areLinksEqual(finalUrl, requestLabelUrl);
361
- if (isRedirected) {
362
+ if (isRedirected && !isDisallowedInRobotsTxt(finalUrl)) {
362
363
  await enqueueUniqueRequest({ url: finalUrl, label: finalUrl });
363
364
  }
364
365
  else {
@@ -399,7 +400,7 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
399
400
  return;
400
401
  }
401
402
  // if URL has already been scanned
402
- if (scannedUrlSet.has(request.url)) {
403
+ if (scannedUrlSet.has(normUrl(request.url))) {
403
404
  await enqueueProcess(page, enqueueLinks, browserContext);
404
405
  return;
405
406
  }
@@ -493,8 +494,32 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
493
494
  return;
494
495
  }
495
496
  const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
497
+ // Detect JS redirects that fire during/after axe scan.
498
+ // Listen for navigation, then give a brief window for pending redirects to complete.
499
+ try {
500
+ let navigatedToUrl = null;
501
+ const onFrameNavigated = (frame) => {
502
+ if (frame === page.mainFrame()) {
503
+ navigatedToUrl = frame.url();
504
+ }
505
+ };
506
+ page.on('framenavigated', onFrameNavigated);
507
+ await page.waitForTimeout(1000);
508
+ page.off('framenavigated', onFrameNavigated);
509
+ const postScanUrl = navigatedToUrl || page.url();
510
+ if (postScanUrl && postScanUrl !== 'about:blank' && !isFollowStrategy(postScanUrl, request.url, 'same-hostname')) {
511
+ urlsCrawled.notScannedRedirects.push({
512
+ fromUrl: request.url,
513
+ toUrl: postScanUrl,
514
+ });
515
+ return;
516
+ }
517
+ }
518
+ catch (_) {
519
+ // Page/context was destroyed during navigation — handled by outer catch
520
+ }
496
521
  if (isRedirected) {
497
- const isLoadedUrlInCrawledUrls = scannedResolvedUrlSet.has(actualUrl);
522
+ const isLoadedUrlInCrawledUrls = scannedResolvedUrlSet.has(normUrl(actualUrl));
498
523
  if (isLoadedUrlInCrawledUrls) {
499
524
  urlsCrawled.notScannedRedirects.push({
500
525
  fromUrl: request.url,
@@ -513,8 +538,8 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
513
538
  pageTitle: results.pageTitle,
514
539
  actualUrl, // i.e. actualUrl
515
540
  });
516
- scannedUrlSet.add(request.url);
517
- scannedResolvedUrlSet.add(actualUrl);
541
+ scannedUrlSet.add(normUrl(request.url));
542
+ scannedResolvedUrlSet.add(normUrl(actualUrl));
518
543
  urlsCrawled.scannedRedirects.push({
519
544
  fromUrl: request.url,
520
545
  toUrl: actualUrl, // i.e. actualUrl
@@ -535,8 +560,8 @@ const crawlDomain = async ({ url, randomToken, host: _host, viewportSettings, ma
535
560
  actualUrl: request.url,
536
561
  pageTitle: results.pageTitle,
537
562
  });
538
- scannedUrlSet.add(request.url);
539
- scannedResolvedUrlSet.add(request.url);
563
+ scannedUrlSet.add(normUrl(request.url));
564
+ scannedResolvedUrlSet.add(normUrl(request.url));
540
565
  await dataset.pushData(results);
541
566
  }
542
567
  }