@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
@@ -25,31 +25,66 @@ export const buildHtmlGroups = (rule, items, pageUrl) => {
25
25
  }
26
26
  });
27
27
  };
28
+ /*
29
+ // Commenting this out for now as we are not including htmlGroups in the embedded report payload to keep it lean.
30
+ // We can revisit this if we want to include htmlGroups in the future and need a reference builder for it.
31
+ const toHtmlGroupReference = (item: any) => {
32
+ if (typeof item === 'string') {
33
+ return item;
34
+ }
35
+
36
+ return `${item?.html || 'No HTML element'}\x00${item?.xpath || ''}`;
37
+ };
38
+
39
+ const cloneCategoryWithReferenceItems = (category: ScanCategory): ScanCategory =>
40
+ ({
41
+ ...category,
42
+ rules: category.rules.map(
43
+ rule =>
44
+ ({
45
+ ...rule,
46
+ pagesAffected: rule.pagesAffected.map(
47
+ page => {
48
+ const { items, ...pageWithoutItems } = page;
49
+
50
+ return {
51
+ ...pageWithoutItems,
52
+ itemsCount: page.itemsCount ?? (Array.isArray(items) ? items.length : 0),
53
+ items: Array.isArray(items) ? items.map(toHtmlGroupReference) : items,
54
+ } as any;
55
+ },
56
+ ),
57
+ }) as any,
58
+ ),
59
+ }) as ScanCategory;
60
+ */
61
+ const cloneCategoryLight = (category, includeHtmlGroups) => ({
62
+ ...category,
63
+ rules: category.rules.map(rule => ({
64
+ rule: rule.rule,
65
+ description: rule.description,
66
+ helpUrl: rule.helpUrl,
67
+ conformance: rule.conformance,
68
+ totalItems: rule.totalItems,
69
+ axeImpact: rule.axeImpact,
70
+ ...(includeHtmlGroups && rule.htmlGroups ? { htmlGroups: rule.htmlGroups } : {}),
71
+ pagesAffected: rule.pagesAffected.map(page => ({
72
+ url: page.url,
73
+ pageTitle: page.pageTitle,
74
+ itemsCount: page.itemsCount ?? (Array.isArray(page.items) ? page.items.length : 0),
75
+ })),
76
+ })),
77
+ });
28
78
  /**
29
- * Converts items in pagesAffected to references (html\x00xpath composite keys) for embedding in HTML report.
30
- * Additionally, it deep-clones allIssues, replaces page.items objects with composite reference keys.
31
- * Those refs are specifically for htmlGroups lookup (html + xpath).
79
+ * Builds the embedded HTML-report payload from the full scan items.
80
+ * Includes htmlGroups for non-passed categories (Group by HTML Element),
81
+ * excludes them from passed to keep payload within browser memory limits.
32
82
  */
33
- export const convertItemsToReferences = (allIssues) => {
34
- const cloned = JSON.parse(JSON.stringify(allIssues));
35
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
36
- if (!cloned.items[category]?.rules)
37
- return;
38
- cloned.items[category].rules.forEach((rule) => {
39
- if (!rule.pagesAffected || !rule.htmlGroups)
40
- return;
41
- rule.pagesAffected.forEach((page) => {
42
- if (!page.items)
43
- return;
44
- page.items = page.items.map((item) => {
45
- if (typeof item === 'string')
46
- return item; // Already a reference
47
- // Use composite key matching buildHtmlGroups
48
- const htmlKey = `${item.html || 'No HTML element'}\x00${item.xpath || ''}`;
49
- return htmlKey;
50
- });
51
- });
52
- });
53
- });
54
- return cloned;
83
+ export const convertItemsToReferences = (source) => {
84
+ return {
85
+ mustFix: cloneCategoryLight(source.items.mustFix, true),
86
+ goodToFix: cloneCategoryLight(source.items.goodToFix, true),
87
+ needsReview: cloneCategoryLight(source.items.needsReview, true),
88
+ passed: cloneCategoryLight(source.items.passed, false),
89
+ };
55
90
  };
@@ -110,7 +110,10 @@ const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson,
110
110
  event_type: 'accessibility_scan',
111
111
  scanType: scanInfo.scanType,
112
112
  browser: scanInfo.browser,
113
- entryUrl: scanInfo.entryUrl,
113
+ entryUrl: process.env.OOBEE_SCAN_METADATA ?? scanInfo.entryUrl,
114
+ ...(process.env.OOBEE_SCAN_PRODUCT && {
115
+ scanProduct: process.env.OOBEE_SCAN_PRODUCT,
116
+ }),
114
117
  },
115
118
  user: {
116
119
  ...(scanInfo.email && scanInfo.name
@@ -119,10 +119,10 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
119
119
  const { topFilePath, bottomFilePath } = await splitHtmlAndCreateFiles(htmlFilePath, storagePath);
120
120
  const prefixData = fs.readFileSync(path.join(storagePath, 'report-partial-top.htm.txt'), 'utf-8');
121
121
  const suffixData = fs.readFileSync(path.join(storagePath, 'report-partial-bottom.htm.txt'), 'utf-8');
122
- // Create lighter version with item references for embedding in HTML
123
- const scanItemsWithHtmlGroupRefs = convertItemsToReferences(allIssues);
122
+ // Create the lighter scanItems payload for embedding in the HTML report.
123
+ const lightScanItemsPayload = convertItemsToReferences(allIssues);
124
124
  // Write the lighter items to a file and get the base64 path
125
- const { jsonFilePath: scanItemsWithHtmlGroupRefsJsonFilePath, base64FilePath: scanItemsWithHtmlGroupRefsBase64FilePath, } = await writeJsonFileAndCompressedJsonFile(scanItemsWithHtmlGroupRefs.items, storagePath, 'scanItems-light');
125
+ const { jsonFilePath: lightScanItemsPayloadJsonFilePath, base64FilePath: lightScanItemsPayloadBase64FilePath, } = await writeJsonFileAndCompressedJsonFile(lightScanItemsPayload, storagePath, 'scanItems-light');
126
126
  return new Promise((resolve, reject) => {
127
127
  const scanDetailsReadStream = fs.createReadStream(scanDetailsFilePath, {
128
128
  encoding: 'utf8',
@@ -135,8 +135,8 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
135
135
  await Promise.all([
136
136
  fs.promises.unlink(topFilePath),
137
137
  fs.promises.unlink(bottomFilePath),
138
- fs.promises.unlink(scanItemsWithHtmlGroupRefsBase64FilePath),
139
- fs.promises.unlink(scanItemsWithHtmlGroupRefsJsonFilePath),
138
+ fs.promises.unlink(lightScanItemsPayloadBase64FilePath),
139
+ fs.promises.unlink(lightScanItemsPayloadJsonFilePath),
140
140
  ]);
141
141
  }
142
142
  catch (err) {
@@ -172,22 +172,28 @@ const writeHTML = async (allIssues, storagePath, htmlFilename = 'report', scanDe
172
172
  } else {
173
173
  console.warn('Skipping fetch GenAI feature as it is local report');
174
174
  }
175
+
176
+ var scanData = null;
177
+ var scanItems = null;
175
178
  \n`);
176
179
  outputStream.write('</script>\n<script type="text/plain" id="scanDataRaw">');
177
180
  scanDetailsReadStream.pipe(outputStream, { end: false });
178
181
  scanDetailsReadStream.on('end', async () => {
179
182
  outputStream.write('</script>\n<script>\n');
180
- outputStream.write("var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); })();\n");
183
+ outputStream.write("var scanDataPromise = (async () => { console.log('Loading scanData...'); scanData = await decodeUnzipParse(document.getElementById('scanDataRaw').textContent); console.log('[report] scanData loaded'); })();\n");
181
184
  outputStream.write('</script>\n');
182
185
  // Write scanItems in 2MB chunks using a stream to avoid loading entire file into memory
183
186
  try {
184
187
  let chunkIndex = 1;
185
- const scanItemsStream = fs.createReadStream(scanItemsWithHtmlGroupRefsBase64FilePath, {
188
+ const scanItemsStream = fs.createReadStream(lightScanItemsPayloadBase64FilePath, {
186
189
  encoding: 'utf8',
187
190
  highWaterMark: CHUNK_SIZE,
188
191
  });
189
192
  for await (const chunk of scanItemsStream) {
190
- outputStream.write(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
193
+ const ok = outputStream.write(`<script type="text/plain" id="scanItemsRaw${chunkIndex}">${chunk}</script>\n`);
194
+ if (!ok) {
195
+ await new Promise(resolve => outputStream.once('drain', resolve));
196
+ }
191
197
  chunkIndex++;
192
198
  }
193
199
  outputStream.write('<script>\n');
@@ -203,6 +209,7 @@ var scanItemsPromise = (async () => {
203
209
  i++;
204
210
  }
205
211
  scanItems = await decodeUnzipParse(chunks);
212
+ console.log('[report] scanItems loaded');
206
213
  })();\n`);
207
214
  outputStream.write(suffixData);
208
215
  outputStream.end();
@@ -722,11 +729,13 @@ generateJsonFiles = false) => {
722
729
  const browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
723
730
  // Should consider refactor constants.userDataDirectory to be a parameter in future
724
731
  await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length, 'summary', browserChannel, constants.userDataDirectory), 1);
732
+ // Brief delay to allow lingering async crawlee storage operations to flush
733
+ await new Promise(resolve => setTimeout(resolve, 3000));
725
734
  try {
726
735
  await fs.promises.rm(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
727
736
  }
728
737
  catch (error) {
729
- consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
738
+ // Silently ignore folder may already be gone or still locked
730
739
  }
731
740
  try {
732
741
  await fs.promises.rm(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
package/dist/npmIndex.js CHANGED
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url';
6
6
  import { EnqueueStrategy } from 'crawlee';
7
7
  import constants, { BrowserTypes, RuleFlags, ScannerTypes, a11yRuleShortDescriptionMap, a11yRuleLongDescriptionMap, a11yRuleStepByStepGuide } from './constants/constants.js';
8
8
  import { deleteClonedProfiles, getBrowserToRun, getPlaywrightLaunchOptions, submitForm, } from './constants/common.js';
9
- import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
9
+ import { createCrawleeSubFolders, enrichViolationMessages, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
10
10
  import { createAndUpdateResultsFolders, getVersion } from './utils.js';
11
11
  import generateArtifacts, { createBasicFormHTMLSnippet, sendWcagBreakdownToSentry } from './mergeAxeResults.js';
12
- import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
12
+ import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
13
13
  import { consoleLogger } from './logs.js';
14
14
  import { alertMessageOptions } from './constants/cliFunctions.js';
15
15
  import { evaluateAltText } from './crawlers/custom/evaluateAltText.js';
@@ -50,6 +50,13 @@ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
50
50
  window.xPathToCss = ${xPathToCss.toString()};
51
51
  window.extractText = ${extractText.toString()};
52
52
 
53
+ function getReadabilityInterpretation(score) {
54
+ const num = parseFloat(score);
55
+ if (Number.isNaN(num)) return '';
56
+ if (num > 30) return 'It is targeted for junior college (JC) level comprehension and above.';
57
+ return 'It is targeted for university graduate level comprehension and above.';
58
+ }
59
+
53
60
  function getAxeConfiguration({
54
61
  enableWcagAaa = false,
55
62
  gradingReadabilityFlag = '',
@@ -84,7 +91,7 @@ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
84
91
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
85
92
  },
86
93
  },
87
- ...((enableWcagAaa && !disableOobee)
94
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
88
95
  ? [
89
96
  {
90
97
  id: 'oobee-grading-text-contents',
@@ -92,16 +99,11 @@ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
92
99
  impact: 'moderate',
93
100
  messages: {
94
101
  pass: 'The text content is easy to understand.',
95
- fail: 'The text content is potentially difficult to understand.',
96
- incomplete: \`The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of \${gradingReadabilityFlag}.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.\`,
102
+ fail: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
103
+ incomplete: \`Text content is potentially difficult to read.\n It scored \${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n \${getReadabilityInterpretation(gradingReadabilityFlag)}\`,
97
104
  },
98
105
  },
99
- evaluate: (_node) => {
100
- if (gradingReadabilityFlag === '') {
101
- return true; // Pass if no readability issues
102
- }
103
- // Fail if readability issues are detected
104
- },
106
+ evaluate: (_node) => false,
105
107
  },
106
108
  ]
107
109
  : []),
@@ -132,7 +134,7 @@ const getOobeeFunctionsScript = (disableOobee, enableWcagAaa) => {
132
134
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
133
135
  },
134
136
  },
135
- ...((enableWcagAaa && !disableOobee)
137
+ ...((enableWcagAaa && !disableOobee && gradingReadabilityFlag !== '')
136
138
  ? [
137
139
  {
138
140
  id: 'oobee-grading-text-contents',
@@ -629,6 +631,8 @@ export const scanPage = async (pages, config) => {
629
631
  const scanResult = await page.evaluate(async () => {
630
632
  return window.runA11yScan();
631
633
  });
634
+ await enrichViolationMessages(scanResult.axeScanResults, page);
635
+ await enrichColorContrastDOMContext(scanResult.axeScanResults.violations, page);
632
636
  scanData.push({
633
637
  axeScanResults: scanResult.axeScanResults,
634
638
  pageUrl: page.url(),
@@ -3,6 +3,73 @@ import { createHash } from 'crypto';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  const screenshotMap = {}; // Map of screenshot hashkey to its buffer value and screenshot path
6
+ export const enrichColorContrastDOMContext = async (violations, page) => {
7
+ for (const violation of violations) {
8
+ if (violation.id !== 'color-contrast' && violation.id !== 'color-contrast-enhanced')
9
+ continue;
10
+ for (const node of violation.nodes) {
11
+ const { target } = node;
12
+ const selector = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;
13
+ if (!selector)
14
+ continue;
15
+ try {
16
+ const domContext = await page
17
+ .evaluate((sel) => {
18
+ const el = document.querySelector(sel);
19
+ if (!el)
20
+ return null;
21
+ const style = window.getComputedStyle(el);
22
+ const bgImage = style.backgroundImage;
23
+ const hasGradient = bgImage !== 'none' && bgImage.includes('gradient');
24
+ const hasBackgroundImage = bgImage !== 'none' && !hasGradient;
25
+ let hasReducedOpacity = parseFloat(style.opacity) < 1;
26
+ let ancestorHasGradient = false;
27
+ let ancestorHasBackgroundImage = false;
28
+ let ancestor = el.parentElement;
29
+ while (ancestor && ancestor.tagName !== 'HTML') {
30
+ const anStyle = window.getComputedStyle(ancestor);
31
+ if (!hasReducedOpacity && parseFloat(anStyle.opacity) < 1) {
32
+ hasReducedOpacity = true;
33
+ }
34
+ const anBgImg = anStyle.backgroundImage;
35
+ if (anBgImg !== 'none') {
36
+ if (!ancestorHasGradient && anBgImg.includes('gradient')) {
37
+ ancestorHasGradient = true;
38
+ }
39
+ else if (!ancestorHasBackgroundImage) {
40
+ ancestorHasBackgroundImage = true;
41
+ }
42
+ }
43
+ ancestor = ancestor.parentElement;
44
+ }
45
+ const mixBlendMode = style.mixBlendMode !== 'normal' ? style.mixBlendMode : null;
46
+ const backdropFilter = style.backdropFilter && style.backdropFilter !== 'none'
47
+ ? style.backdropFilter
48
+ : null;
49
+ const filter = style.filter && style.filter !== 'none' ? style.filter : null;
50
+ return {
51
+ backgroundImage: bgImage !== 'none' ? bgImage : '',
52
+ hasGradient,
53
+ hasBackgroundImage,
54
+ ancestorHasGradient,
55
+ ancestorHasBackgroundImage,
56
+ hasReducedOpacity,
57
+ mixBlendMode,
58
+ backdropFilter,
59
+ filter,
60
+ };
61
+ }, selector)
62
+ .catch(() => null);
63
+ if (domContext) {
64
+ node.contrastDOMContext = domContext;
65
+ }
66
+ }
67
+ catch {
68
+ // Non-critical; proceed without DOM context
69
+ }
70
+ }
71
+ }
72
+ };
6
73
  export const takeScreenshotForHTMLElements = async (violations, page, randomToken, locatorTimeout = 2000, maxScreenshots = 100) => {
7
74
  const newViolations = [];
8
75
  let screenshotCount = 0;
@@ -28,8 +28,11 @@ async function decodeUnzipParse(input) {
28
28
  offset += arr.length;
29
29
  }
30
30
 
31
- // Step 2: Decompress with pako (GZIP)
32
- const decompressed = pako.ungzip(merged, { to: 'string' });
31
+ // Step 2: Decompress with pako (GZIP) to bytes first to avoid large-string
32
+ // construction inside pako for very large payloads.
33
+ const decompressedBytes = pako.ungzip(merged);
34
+
35
+ const decompressed = new TextDecoder().decode(decompressedBytes);
33
36
 
34
37
  // Step 3: Parse JSON
35
38
  return JSON.parse(decompressed);
@@ -37,4 +40,4 @@ async function decodeUnzipParse(input) {
37
40
  throw new Error(`Failed to decode/unzip/parse: ${err.message}`);
38
41
  }
39
42
  }
40
- </script>
43
+ </script>
@@ -1,10 +1,44 @@
1
1
  <script>
2
2
  /**
3
- * Resolves item references (composite "html\x00xpath" strings) to full item data using htmlGroups.
3
+ * Rebuilds the item list for a page from pre-computed htmlGroups when the light report omits page.items.
4
+ */
5
+ function buildItemsFromHtmlGroupsForPage(page, ruleInCategory) {
6
+ const htmlGroups = ruleInCategory.htmlGroups || {};
7
+ const resolvedItems = [];
8
+
9
+ Object.values(htmlGroups).forEach(groupData => {
10
+ if (!Array.isArray(groupData.pageUrls) || !groupData.pageUrls.includes(page.url)) {
11
+ return;
12
+ }
13
+
14
+ resolvedItems.push({
15
+ html: groupData.html,
16
+ xpath: groupData.xpath,
17
+ message: groupData.message,
18
+ screenshotPath: groupData.screenshotPath,
19
+ displayNeedsReview: groupData.displayNeedsReview,
20
+ pageUrl: page.url,
21
+ pageTitle: page.pageTitle || page.metadata
22
+ });
23
+ });
24
+
25
+ return resolvedItems;
26
+ }
27
+
28
+ /**
29
+ * The embedded report payload now omits page.items and rebuilds occurrences from
30
+ * htmlGroups + page metadata. Keep the older page.items resolution logic below
31
+ * commented for an easy rollback if we need to restore mixed payload support.
4
32
  */
5
33
  function resolveItemReferencesForPage(page, ruleInCategory) {
34
+ return buildItemsFromHtmlGroupsForPage(page, ruleInCategory);
35
+
36
+ /*
6
37
  const items = page.items || [];
7
- if (items.length === 0) return [];
38
+
39
+ if (items.length === 0) {
40
+ return buildItemsFromHtmlGroupsForPage(page, ruleInCategory);
41
+ }
8
42
 
9
43
  const isReference = typeof items[0] === 'string';
10
44
 
@@ -27,6 +61,7 @@
27
61
  pageTitle: page.pageTitle || page.metadata
28
62
  };
29
63
  }
64
+
30
65
  // Fallback: parse composite key
31
66
  const nullByteIndex = compositeKey.indexOf('\x00');
32
67
  const html = nullByteIndex !== -1 ? compositeKey.slice(0, nullByteIndex) : compositeKey;
@@ -40,6 +75,7 @@
40
75
  pageTitle: page.pageTitle || page.metadata
41
76
  };
42
77
  });
78
+ */
43
79
  }
44
80
 
45
81
  function buildItemCardsWithPagination(accordionId, category, ruleInCategory, page, index) {
@@ -203,14 +239,17 @@
203
239
  ${item.xpath ? createXpathSection(item.xpath) : ''}
204
240
  ${createElementSection(item)}
205
241
  ${
206
- item.displayNeedsReview
207
- ? `<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">
208
246
  <div class="fw-semibold page-item-card-section-title">Details</div>
209
247
  <div class="page-item-card-section-content">
210
- ${generateItemMessageElement(item.displayNeedsReview, item.message)}
248
+ ${generateItemMessageElement(item.displayNeedsReview || true, item.message)}
211
249
  </div>
212
250
  </div>`
213
- : ''
251
+ : '';
252
+ })()
214
253
  }
215
254
  ${isPurpleAiRule ? createAiSuggestionSection(item, oobeeAiQueryLabel, aiConfig) : ''}
216
255
  ${showGenAiUI ? createGenAiSuggestFixSection(item, aiConfig) : ''}
@@ -86,7 +86,7 @@
86
86
  // Use pre-computed htmlGroups for count if available, otherwise use pages
87
87
  const count = isHtmlGrouping && selectedCategory.htmlGroups
88
88
  ? Object.keys(selectedCategory.htmlGroups).length
89
- : selectedCategory.pagesAffected.length;
89
+ : (selectedCategory.pagesAffectedCount || selectedCategory.pagesAffected.length);
90
90
  if (isHtmlGrouping) {
91
91
  dropdownTitle.innerText = `HTML elements affected by this issue (${count})`;
92
92
  } else {
@@ -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>
@@ -270,8 +270,8 @@ include('./pageAccordionBuilder') %> <%- include('./constants') %>
270
270
  if (!Array.isArray(rule.pagesAffected)) return;
271
271
 
272
272
  rule.pagesAffected.sort((a, b) => {
273
- const lenA = Array.isArray(a.items) ? a.items.length : 0;
274
- const lenB = Array.isArray(b.items) ? b.items.length : 0;
273
+ const lenA = Array.isArray(a.items) ? a.items.length : a.itemsCount || 0;
274
+ const lenB = Array.isArray(b.items) ? b.items.length : b.itemsCount || 0;
275
275
  return lenB - lenA; // DESC
276
276
  });
277
277
  });
@@ -295,10 +295,10 @@ include('./pageAccordionBuilder') %> <%- include('./constants') %>
295
295
  dropdownToggle.innerText = `${ruleInCategory.totalItems} Total occ.`;
296
296
  dropdownToggle.setAttribute('aria-label', occurrencesText);
297
297
  document.getElementById('expandedRuleDropdownTitle').innerText =
298
- `Pages affected by this issue (${ruleInCategory.pagesAffected.length})`;
298
+ `Pages affected by this issue (${(ruleInCategory.pagesAffectedCount || ruleInCategory.pagesAffected.length)})`;
299
299
  buildExpandedRuleCategoryContent(category, ruleInCategory);
300
300
  document.getElementById('expandedRulePageContent').innerText =
301
- `Total ${ruleInCategory.pagesAffected.length} affected pages`;
301
+ `Total ${(ruleInCategory.pagesAffectedCount || ruleInCategory.pagesAffected.length)} affected pages`;
302
302
  }
303
303
  }
304
304
  });
@@ -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;
@@ -21,18 +21,24 @@
21
21
  %>
22
22
  <script>
23
23
  const scanItems = <%- JSON.stringify(
24
- {
25
- ...items,
26
- ...['mustFix','goodToFix','needsReview','passed'].reduce((acc, cat) => {
27
- if (items[cat]) {
28
- acc[cat] = {
29
- ...items[cat],
30
- rules: (items[cat].rules || []).map(({ htmlGroups, ...rest }) => rest),
31
- };
32
- }
33
- return acc;
34
- }, {}),
35
- }
24
+ ['mustFix','goodToFix','needsReview','passed'].reduce((acc, cat) => {
25
+ if (items[cat]) {
26
+ acc[cat] = {
27
+ description: items[cat].description,
28
+ totalItems: items[cat].totalItems,
29
+ totalRuleIssues: items[cat].totalRuleIssues,
30
+ rules: (items[cat].rules || []).map(rule => ({
31
+ rule: rule.rule,
32
+ description: rule.description,
33
+ helpUrl: rule.helpUrl,
34
+ conformance: rule.conformance,
35
+ totalItems: rule.totalItems,
36
+ pagesAffected: { length: (rule.pagesAffected || []).length },
37
+ })),
38
+ };
39
+ }
40
+ return acc;
41
+ }, {})
36
42
  ).replace(/<\//g, '<\\/') %>
37
43
  </script>
38
44
  <%- include('partials/scripts/summaryTable') %>
package/dist/utils.js CHANGED
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
4
4
  import axe from 'axe-core';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
  import { getDomain } from 'tldts';
7
+ import { normalizeUrl } from '@apify/utilities';
7
8
  import constants, { destinationPath, getIntermediateScreenshotsPath, } from './constants/constants.js';
8
9
  import { consoleLogger, errorsTxtPath } from './logs.js';
9
10
  import { getAxeConfiguration } from './crawlers/custom/getAxeConfiguration.js';
@@ -852,13 +853,13 @@ export const randomThreeDigitNumberString = () => {
852
853
  const threeDigitNumber = Math.floor(scaledDecimal) + 100;
853
854
  return String(threeDigitNumber);
854
855
  };
856
+ export const normUrl = (u) => (u ? normalizeUrl(u) || u : '');
855
857
  export const isFollowStrategy = (link1, link2, rule) => {
858
+ if (rule === 'all')
859
+ return true;
856
860
  try {
857
861
  const parsedLink1 = new URL(link1);
858
862
  const parsedLink2 = new URL(link2);
859
- if (rule === 'all') {
860
- return true;
861
- }
862
863
  if (rule === 'same-origin') {
863
864
  return parsedLink1.origin === parsedLink2.origin;
864
865
  }