@govtechsg/oobee 0.10.36 → 0.10.42

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 (39) hide show
  1. package/.github/workflows/docker-test.yml +1 -1
  2. package/DETAILS.md +3 -3
  3. package/INTEGRATION.md +142 -53
  4. package/README.md +17 -0
  5. package/REPORTS.md +362 -0
  6. package/exclusions.txt +4 -1
  7. package/package.json +2 -2
  8. package/src/constants/cliFunctions.ts +0 -7
  9. package/src/constants/common.ts +39 -1
  10. package/src/constants/constants.ts +9 -8
  11. package/src/crawlers/commonCrawlerFunc.ts +95 -220
  12. package/src/crawlers/crawlDomain.ts +10 -23
  13. package/src/crawlers/crawlLocalFile.ts +2 -0
  14. package/src/crawlers/crawlSitemap.ts +6 -4
  15. package/src/crawlers/custom/escapeCssSelector.ts +10 -0
  16. package/src/crawlers/custom/evaluateAltText.ts +13 -0
  17. package/src/crawlers/custom/extractAndGradeText.ts +0 -2
  18. package/src/crawlers/custom/extractText.ts +28 -0
  19. package/src/crawlers/custom/findElementByCssSelector.ts +46 -0
  20. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +982 -842
  21. package/src/crawlers/custom/framesCheck.ts +51 -0
  22. package/src/crawlers/custom/getAxeConfiguration.ts +126 -0
  23. package/src/crawlers/custom/gradeReadability.ts +30 -0
  24. package/src/crawlers/custom/xPathToCss.ts +178 -0
  25. package/src/crawlers/pdfScanFunc.ts +67 -26
  26. package/src/mergeAxeResults.ts +535 -132
  27. package/src/npmIndex.ts +130 -62
  28. package/src/screenshotFunc/htmlScreenshotFunc.ts +1 -1
  29. package/src/screenshotFunc/pdfScreenshotFunc.ts +34 -1
  30. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +1 -1
  31. package/src/static/ejs/partials/components/scanAbout.ejs +1 -1
  32. package/src/static/ejs/partials/footer.ejs +3 -3
  33. package/src/static/ejs/partials/scripts/reportSearch.ejs +112 -74
  34. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +2 -2
  35. package/src/static/ejs/partials/summaryMain.ejs +3 -3
  36. package/src/static/ejs/report.ejs +3 -3
  37. package/src/utils.ts +289 -13
  38. package/src/xPathToCssCypress.ts +178 -0
  39. package/src/crawlers/customAxeFunctions.ts +0 -82
@@ -13,14 +13,16 @@ import zlib from 'zlib';
13
13
  import { Base64Encode } from 'base64-stream';
14
14
  import { pipeline } from 'stream/promises';
15
15
  import constants, { ScannerTypes } from './constants/constants.js';
16
- import { urlWithoutAuth, prepareData } from './constants/common.js';
16
+ import { urlWithoutAuth } from './constants/common.js';
17
17
  import {
18
18
  createScreenshotsFolder,
19
19
  getStoragePath,
20
20
  getVersion,
21
21
  getWcagPassPercentage,
22
+ getProgressPercentage,
22
23
  retryFunction,
23
24
  zipResults,
25
+ getIssuesPercentage,
24
26
  } from './utils.js';
25
27
  import { consoleLogger, silentLogger } from './logs.js';
26
28
  import itemTypeDescription from './constants/itemTypeDescription.js';
@@ -34,19 +36,21 @@ export type ItemsInfo = {
34
36
  displayNeedsReview?: boolean;
35
37
  };
36
38
 
37
- type PageInfo = {
38
- items: ItemsInfo[];
39
+ export type PageInfo = {
40
+ items?: ItemsInfo[];
39
41
  itemsCount?: number;
40
42
  pageTitle: string;
41
- url?: string;
43
+ url: string;
44
+ actualUrl: string;
42
45
  pageImagePath?: string;
43
46
  pageIndex?: number;
44
- metadata: string;
47
+ metadata?: string;
45
48
  };
46
49
 
47
50
  export type RuleInfo = {
48
51
  totalItems: number;
49
52
  pagesAffected: PageInfo[];
53
+ pagesAffectedCount: number;
50
54
  rule: string;
51
55
  description: string;
52
56
  axeImpact: string;
@@ -74,7 +78,6 @@ type AllIssues = {
74
78
  deviceChosen: string;
75
79
  formatAboutStartTime: (dateString: any) => string;
76
80
  isCustomFlow: boolean;
77
- viewport: string;
78
81
  pagesScanned: PageInfo[];
79
82
  pagesNotScanned: PageInfo[];
80
83
  totalPagesScanned: number;
@@ -85,17 +88,27 @@ type AllIssues = {
85
88
  topTenIssues: Array<any>;
86
89
  wcagViolations: string[];
87
90
  customFlowLabel: string;
88
- phAppVersion: string;
91
+ oobeeAppVersion: string;
89
92
  items: {
90
93
  mustFix: Category;
91
94
  goodToFix: Category;
92
95
  needsReview: Category;
93
96
  passed: Category;
94
97
  };
95
- cypressScanAboutMetadata: string;
98
+ cypressScanAboutMetadata: {
99
+ browser?: string;
100
+ viewport?: { width: number; height: number };
101
+ };
96
102
  wcagLinks: { [key: string]: string };
97
103
  [key: string]: any;
98
104
  advancedScanOptionsSummaryItems: { [key: string]: boolean };
105
+ scanPagesDetail: {
106
+ pagesAffected: any[];
107
+ pagesNotAffected: any[];
108
+ scannedPagesCount: number;
109
+ pagesNotScanned: any[];
110
+ pagesNotScannedCount: number;
111
+ };
99
112
  };
100
113
 
101
114
  const filename = fileURLToPath(import.meta.url);
@@ -160,13 +173,11 @@ const writeCsv = async (allIssues, storagePath) => {
160
173
  pagesAffected,
161
174
  helpUrl: learnMore,
162
175
  } = rule;
163
- // we filter out the below as it represents the A/AA/AAA level, not the clause itself
164
- const clausesArr = conformance.filter(
165
- clause => !['wcag2a', 'wcag2aa', 'wcag2aaa'].includes(clause),
166
- );
167
- pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
176
+
168
177
  // format clauses as a string
169
- const wcagConformance = clausesArr.join(',');
178
+ const wcagConformance = conformance.join(',');
179
+
180
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
170
181
 
171
182
  pagesAffected.forEach(affectedPage => {
172
183
  const { url, items } = affectedPage;
@@ -239,8 +250,8 @@ const writeCsv = async (allIssues, storagePath) => {
239
250
  issueId: 'error-pages-skipped',
240
251
  issueDescription: 'Page was skipped during the scan',
241
252
  wcagConformance: '',
242
- url: page.url || '',
243
- pageTitle: '',
253
+ url: page.url || page || '',
254
+ pageTitle: 'Error',
244
255
  context: '',
245
256
  howToFix: '',
246
257
  axeImpact: '',
@@ -544,86 +555,93 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
544
555
 
545
556
  keys.forEach((key, i) => {
546
557
  const value = obj[key];
547
- queueWrite(` "${key}": {\n`);
548
-
549
- const { rules, ...otherProperties } = value;
550
-
551
- // Write other properties
552
- Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
553
- const propValueString =
554
- propValue === null ||
555
- typeof propValue === 'function' ||
556
- typeof propValue === 'undefined'
557
- ? 'null'
558
- : JSON.stringify(propValue);
559
- queueWrite(` "${propKey}": ${propValueString}`);
560
- if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
561
- queueWrite(',\n');
562
- } else {
563
- queueWrite('\n');
564
- }
565
- });
566
-
567
- if (rules && Array.isArray(rules)) {
568
- queueWrite(' "rules": [\n');
569
-
570
- rules.forEach((rule, j) => {
571
- queueWrite(' {\n');
572
- const { pagesAffected, ...otherRuleProperties } = rule;
573
-
574
- Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
575
- const ruleValueString =
576
- ruleValue === null ||
577
- typeof ruleValue === 'function' ||
578
- typeof ruleValue === 'undefined'
579
- ? 'null'
580
- : JSON.stringify(ruleValue);
581
- queueWrite(` "${ruleKey}": ${ruleValueString}`);
582
- if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
583
- queueWrite(',\n');
584
- } else {
585
- queueWrite('\n');
586
- }
587
- });
588
558
 
589
- if (pagesAffected && Array.isArray(pagesAffected)) {
590
- queueWrite(' "pagesAffected": [\n');
591
-
592
- pagesAffected.forEach((page, p) => {
593
- const pageJson = JSON.stringify(page, null, 2)
594
- .split('\n')
595
- .map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
596
- .join('\n');
597
-
598
- queueWrite(pageJson);
559
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
560
+ queueWrite(` "${key}": ${JSON.stringify(value)}`);
561
+ } else {
562
+ queueWrite(` "${key}": {\n`);
563
+
564
+ const { rules, ...otherProperties } = value;
565
+
566
+ // Write other properties
567
+ Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
568
+ const propValueString =
569
+ propValue === null ||
570
+ typeof propValue === 'function' ||
571
+ typeof propValue === 'undefined'
572
+ ? 'null'
573
+ : JSON.stringify(propValue);
574
+ queueWrite(` "${propKey}": ${propValueString}`);
575
+ if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
576
+ queueWrite(',\n');
577
+ } else {
578
+ queueWrite('\n');
579
+ }
580
+ });
599
581
 
600
- if (p < pagesAffected.length - 1) {
582
+ if (rules && Array.isArray(rules)) {
583
+ queueWrite(' "rules": [\n');
584
+
585
+ rules.forEach((rule, j) => {
586
+ queueWrite(' {\n');
587
+ const { pagesAffected, ...otherRuleProperties } = rule;
588
+
589
+ Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
590
+ const ruleValueString =
591
+ ruleValue === null ||
592
+ typeof ruleValue === 'function' ||
593
+ typeof ruleValue === 'undefined'
594
+ ? 'null'
595
+ : JSON.stringify(ruleValue);
596
+ queueWrite(` "${ruleKey}": ${ruleValueString}`);
597
+ if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
601
598
  queueWrite(',\n');
602
599
  } else {
603
600
  queueWrite('\n');
604
601
  }
605
602
  });
606
603
 
607
- queueWrite(' ]');
608
- }
604
+ if (pagesAffected && Array.isArray(pagesAffected)) {
605
+ queueWrite(' "pagesAffected": [\n');
609
606
 
610
- queueWrite('\n }');
611
- if (j < rules.length - 1) {
612
- queueWrite(',\n');
613
- } else {
614
- queueWrite('\n');
615
- }
616
- });
607
+ pagesAffected.forEach((page, p) => {
608
+ const pageJson = JSON.stringify(page, null, 2)
609
+ .split('\n')
610
+ .map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
611
+ .join('\n');
612
+
613
+ queueWrite(pageJson);
614
+
615
+ if (p < pagesAffected.length - 1) {
616
+ queueWrite(',\n');
617
+ } else {
618
+ queueWrite('\n');
619
+ }
620
+ });
621
+
622
+ queueWrite(' ]');
623
+ }
624
+
625
+ queueWrite('\n }');
626
+ if (j < rules.length - 1) {
627
+ queueWrite(',\n');
628
+ } else {
629
+ queueWrite('\n');
630
+ }
631
+ });
632
+
633
+ queueWrite(' ]');
634
+ }
635
+ queueWrite('\n }');
636
+ }
637
+
638
+ if (i < keys.length - 1) {
639
+ queueWrite(',\n');
640
+ } else {
641
+ queueWrite('\n');
642
+ }
617
643
 
618
- queueWrite(' ]');
619
- }
620
644
 
621
- queueWrite('\n }');
622
- if (i < keys.length - 1) {
623
- queueWrite(',\n');
624
- } else {
625
- queueWrite('\n');
626
- }
627
645
  });
628
646
 
629
647
  queueWrite('}\n');
@@ -727,6 +745,12 @@ const writeJsonAndBase64Files = async (
727
745
  scanItemsSummaryBase64FilePath: string;
728
746
  scanItemsMiniReportJsonFilePath: string;
729
747
  scanItemsMiniReportBase64FilePath: string;
748
+ scanIssuesSummaryJsonFilePath: string;
749
+ scanIssuesSummaryBase64FilePath: string;
750
+ scanPagesDetailJsonFilePath: string;
751
+ scanPagesDetailBase64FilePath: string;
752
+ scanPagesSummaryJsonFilePath: string;
753
+ scanPagesSummaryBase64FilePath: string;
730
754
  scanDataJsonFileSize: number;
731
755
  scanItemsJsonFileSize: number;
732
756
  }> => {
@@ -734,7 +758,33 @@ const writeJsonAndBase64Files = async (
734
758
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
735
759
  await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
736
760
  const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
737
- await writeJsonFileAndCompressedJsonFile(items, storagePath, 'scanItems');
761
+ await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
762
+
763
+ // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
764
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
765
+ if (items[category].rules && Array.isArray(items[category].rules)) {
766
+ items[category].rules.forEach((rule) => {
767
+ rule.pagesAffectedCount = Array.isArray(rule.pagesAffected)
768
+ ? rule.pagesAffected.length
769
+ : 0;
770
+ });
771
+
772
+ // Sort in descending order of pagesAffectedCount
773
+ items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
774
+ }
775
+ });
776
+
777
+ // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
778
+ const scanIssuesSummary = {
779
+ mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
780
+ goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
781
+ needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
782
+ passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
783
+ };
784
+
785
+ // Write out the scanIssuesSummary JSON using the new structure
786
+ const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath } =
787
+ await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
738
788
 
739
789
  // scanItemsSummary
740
790
  // the below mutates the original items object, since it is expensive to clone
@@ -774,6 +824,8 @@ const writeJsonAndBase64Files = async (
774
824
  pagesNotScanned,
775
825
  wcagLinks,
776
826
  wcagPassPercentage,
827
+ progressPercentage,
828
+ issuesPercentage,
777
829
  totalPagesScanned,
778
830
  totalPagesNotScanned,
779
831
  topTenIssues,
@@ -786,6 +838,8 @@ const writeJsonAndBase64Files = async (
786
838
  pagesNotScanned,
787
839
  wcagLinks,
788
840
  wcagPassPercentage,
841
+ progressPercentage,
842
+ issuesPercentage,
789
843
  totalPagesScanned,
790
844
  totalPagesNotScanned,
791
845
  topTenIssues,
@@ -794,8 +848,8 @@ const writeJsonAndBase64Files = async (
794
848
  const {
795
849
  jsonFilePath: scanItemsMiniReportJsonFilePath,
796
850
  base64FilePath: scanItemsMiniReportBase64FilePath,
797
- } = await writeJsonFileAndCompressedJsonFile(summaryItemsMini, storagePath, 'scanItemsSummaryMiniReport');
798
-
851
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini }, storagePath, 'scanItemsSummaryMiniReport');
852
+
799
853
  const summaryItems = {
800
854
  mustFix: {
801
855
  totalItems: items.mustFix?.totalItems || 0,
@@ -812,6 +866,8 @@ const writeJsonAndBase64Files = async (
812
866
  topTenPagesWithMostIssues,
813
867
  wcagLinks,
814
868
  wcagPassPercentage,
869
+ progressPercentage,
870
+ issuesPercentage,
815
871
  totalPagesScanned,
816
872
  totalPagesNotScanned,
817
873
  topTenIssues,
@@ -820,7 +876,17 @@ const writeJsonAndBase64Files = async (
820
876
  const {
821
877
  jsonFilePath: scanItemsSummaryJsonFilePath,
822
878
  base64FilePath: scanItemsSummaryBase64FilePath,
823
- } = await writeJsonFileAndCompressedJsonFile(summaryItems, storagePath, 'scanItemsSummary');
879
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
880
+
881
+ const {
882
+ jsonFilePath: scanPagesDetailJsonFilePath,
883
+ base64FilePath: scanPagesDetailBase64FilePath
884
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesDetail }, storagePath, 'scanPagesDetail');
885
+
886
+ const {
887
+ jsonFilePath: scanPagesSummaryJsonFilePath,
888
+ base64FilePath: scanPagesSummaryBase64FilePath
889
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...allIssues.scanPagesSummary }, storagePath, 'scanPagesSummary');
824
890
 
825
891
  return {
826
892
  scanDataJsonFilePath,
@@ -831,6 +897,12 @@ const writeJsonAndBase64Files = async (
831
897
  scanItemsSummaryBase64FilePath,
832
898
  scanItemsMiniReportJsonFilePath,
833
899
  scanItemsMiniReportBase64FilePath,
900
+ scanIssuesSummaryJsonFilePath,
901
+ scanIssuesSummaryBase64FilePath,
902
+ scanPagesDetailJsonFilePath,
903
+ scanPagesDetailBase64FilePath,
904
+ scanPagesSummaryJsonFilePath,
905
+ scanPagesSummaryBase64FilePath,
834
906
  scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
835
907
  scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
836
908
  };
@@ -1015,6 +1087,8 @@ const getTopTenIssues = allIssues => {
1015
1087
  const categories = ['mustFix', 'goodToFix'];
1016
1088
  const rulesWithCounts = [];
1017
1089
 
1090
+ // This is no longer required and shall not be maintained in future
1091
+ /*
1018
1092
  const conformanceLevels = {
1019
1093
  wcag2a: 'A',
1020
1094
  wcag2aa: 'AA',
@@ -1022,20 +1096,24 @@ const getTopTenIssues = allIssues => {
1022
1096
  wcag22aa: 'AA',
1023
1097
  wcag2aaa: 'AAA',
1024
1098
  };
1099
+ */
1025
1100
 
1026
1101
  categories.forEach(category => {
1027
1102
  const rules = allIssues.items[category]?.rules || [];
1028
1103
 
1029
1104
  rules.forEach(rule => {
1105
+ // This is not needed anymore since we want to have the clause number too
1106
+ /*
1030
1107
  const wcagLevel = rule.conformance[0];
1031
1108
  const aLevel = conformanceLevels[wcagLevel] || wcagLevel;
1109
+ */
1032
1110
 
1033
1111
  rulesWithCounts.push({
1034
1112
  category,
1035
1113
  ruleId: rule.rule,
1036
1114
  description: rule.description,
1037
1115
  axeImpact: rule.axeImpact,
1038
- conformance: aLevel,
1116
+ conformance: rule.conformance,
1039
1117
  totalItems: rule.totalItems,
1040
1118
  });
1041
1119
  });
@@ -1047,48 +1125,71 @@ const getTopTenIssues = allIssues => {
1047
1125
  };
1048
1126
 
1049
1127
  const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1128
+ // Create a map that will sum items only from mustFix, goodToFix, and needsReview.
1050
1129
  const urlOccurrencesMap = new Map<string, number>();
1051
1130
 
1052
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
1131
+ // Iterate over all categories; update the map only if the category is not "passed"
1132
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
1133
+ // Accumulate totalItems regardless of category.
1053
1134
  allIssues.totalItems += allIssues.items[category].totalItems;
1054
1135
 
1055
1136
  allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
1056
- .map(ruleEntry => {
1137
+ .map((ruleEntry) => {
1057
1138
  const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
1058
1139
  ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
1059
- .map(pageEntry => {
1140
+ .map((pageEntry) => {
1060
1141
  if (isCustomFlow) {
1061
1142
  const [pageIndex, pageInfo] = pageEntry as unknown as [number, PageInfo];
1062
- urlOccurrencesMap.set(
1063
- pageInfo.url!,
1064
- (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length,
1065
- );
1143
+ // Only update the occurrences map if not passed.
1144
+ if (category !== 'passed') {
1145
+ urlOccurrencesMap.set(
1146
+ pageInfo.url!,
1147
+ (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length
1148
+ );
1149
+ }
1066
1150
  return { pageIndex, ...pageInfo };
1151
+ } else {
1152
+ const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1153
+ if (category !== 'passed') {
1154
+ urlOccurrencesMap.set(
1155
+ url,
1156
+ (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length
1157
+ );
1158
+ }
1159
+ return { url, ...pageInfo };
1067
1160
  }
1068
- const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1069
- urlOccurrencesMap.set(url, (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length);
1070
- return { url, ...pageInfo };
1071
1161
  })
1162
+ // Sort pages so that those with the most items come first
1072
1163
  .sort((page1, page2) => page2.items.length - page1.items.length);
1073
1164
  return { rule, ...ruleInfo };
1074
1165
  })
1166
+ // Sort the rules by totalItems (descending)
1075
1167
  .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
1076
1168
  });
1077
1169
 
1078
- const updateIssuesWithOccurrences = (issuesList: Array<any>) => {
1079
- issuesList.forEach(issue => {
1080
- issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1081
- });
1082
- };
1083
-
1084
- allIssues.topFiveMostIssues.sort((page1, page2) => page2.totalIssues - page1.totalIssues);
1170
+ // Sort top pages (assumes topFiveMostIssues is already populated)
1171
+ allIssues.topFiveMostIssues.sort((p1, p2) => p2.totalIssues - p1.totalIssues);
1085
1172
  allIssues.topTenPagesWithMostIssues = allIssues.topFiveMostIssues.slice(0, 10);
1086
1173
  allIssues.topFiveMostIssues = allIssues.topFiveMostIssues.slice(0, 5);
1087
- updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues);
1174
+
1175
+ // Update each issue in topTenPagesWithMostIssues with the computed occurrences,
1176
+ // excluding passed items.
1177
+ updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues, urlOccurrencesMap);
1178
+
1179
+ // Get and assign the topTenIssues (using your existing helper)
1088
1180
  const topTenIssues = getTopTenIssues(allIssues);
1089
1181
  allIssues.topTenIssues = topTenIssues;
1090
1182
  };
1091
1183
 
1184
+ // Helper: Update totalOccurrences for each issue using our urlOccurrencesMap.
1185
+ // For pages that have only passed items, the map will return undefined, so default to 0.
1186
+ function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<string, number>) {
1187
+ issuesList.forEach((issue) => {
1188
+ issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1189
+ });
1190
+ }
1191
+
1192
+
1092
1193
  const createRuleIdJson = allIssues => {
1093
1194
  const compiledRuleJson = {};
1094
1195
 
@@ -1117,7 +1218,7 @@ const createRuleIdJson = allIssues => {
1117
1218
  return compiledRuleJson;
1118
1219
  };
1119
1220
 
1120
- const moveElemScreenshots = (randomToken, storagePath) => {
1221
+ const moveElemScreenshots = (randomToken: string, storagePath: string) => {
1121
1222
  const currentScreenshotsPath = `${randomToken}/elemScreenshots`;
1122
1223
  const resultsScreenshotsPath = `${storagePath}/elemScreenshots`;
1123
1224
  if (fs.existsSync(currentScreenshotsPath)) {
@@ -1125,21 +1226,300 @@ const moveElemScreenshots = (randomToken, storagePath) => {
1125
1226
  }
1126
1227
  };
1127
1228
 
1229
+ /**
1230
+ * Build allIssues.scanPagesDetail and allIssues.scanPagesSummary
1231
+ * by analyzing pagesScanned (including mustFix/goodToFix/etc.).
1232
+ */
1233
+ function populateScanPagesDetail(allIssues: AllIssues): void {
1234
+ // --------------------------------------------
1235
+ // 1) Gather your "scanned" pages from allIssues
1236
+ // --------------------------------------------
1237
+ const allScannedPages = Array.isArray(allIssues.pagesScanned)
1238
+ ? allIssues.pagesScanned
1239
+ : [];
1240
+
1241
+ // --------------------------------------------
1242
+ // 2) Define category constants (optional, just for clarity)
1243
+ // --------------------------------------------
1244
+ const mustFixCategory = "mustFix";
1245
+ const goodToFixCategory = "goodToFix";
1246
+ const needsReviewCategory = "needsReview";
1247
+ const passedCategory = "passed";
1248
+
1249
+ // --------------------------------------------
1250
+ // 3) Set up type declarations (if you want them local to this function)
1251
+ // --------------------------------------------
1252
+ type RuleData = {
1253
+ ruleId: string;
1254
+ wcagConformance: string[];
1255
+ occurrencesMustFix: number;
1256
+ occurrencesGoodToFix: number;
1257
+ occurrencesNeedsReview: number;
1258
+ occurrencesPassed: number;
1259
+ };
1260
+
1261
+ type PageData = {
1262
+ pageTitle: string;
1263
+ url: string;
1264
+ // Summaries
1265
+ totalOccurrencesFailedIncludingNeedsReview: number; // mustFix + goodToFix + needsReview
1266
+ totalOccurrencesFailedExcludingNeedsReview: number; // mustFix + goodToFix
1267
+ totalOccurrencesNeedsReview: number; // needsReview
1268
+ totalOccurrencesPassed: number; // passed only
1269
+ typesOfIssues: Record<string, RuleData>;
1270
+ };
1271
+
1272
+ // --------------------------------------------
1273
+ // 4) We'll accumulate pages in a map keyed by URL
1274
+ // --------------------------------------------
1275
+ const pagesMap: Record<string, PageData> = {};
1276
+
1277
+ // --------------------------------------------
1278
+ // 5) Build pagesMap by iterating over each category in allIssues.items
1279
+ // --------------------------------------------
1280
+ Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
1281
+ if (!categoryData?.rules) return; // no rules in this category? skip
1282
+
1283
+ categoryData.rules.forEach(rule => {
1284
+ const { rule: ruleId, conformance = [] } = rule;
1285
+
1286
+ rule.pagesAffected.forEach(p => {
1287
+ const { url, pageTitle, items = [] } = p;
1288
+ const itemsCount = items.length;
1289
+
1290
+ // Ensure the page is in pagesMap
1291
+ if (!pagesMap[url]) {
1292
+ pagesMap[url] = {
1293
+ pageTitle,
1294
+ url,
1295
+ totalOccurrencesFailedIncludingNeedsReview: 0,
1296
+ totalOccurrencesFailedExcludingNeedsReview: 0,
1297
+ totalOccurrencesNeedsReview: 0,
1298
+ totalOccurrencesPassed: 0,
1299
+ typesOfIssues: {},
1300
+ };
1301
+ }
1302
+
1303
+ // Ensure the rule is present for this page
1304
+ if (!pagesMap[url].typesOfIssues[ruleId]) {
1305
+ pagesMap[url].typesOfIssues[ruleId] = {
1306
+ ruleId,
1307
+ wcagConformance: conformance,
1308
+ occurrencesMustFix: 0,
1309
+ occurrencesGoodToFix: 0,
1310
+ occurrencesNeedsReview: 0,
1311
+ occurrencesPassed: 0,
1312
+ };
1313
+ }
1314
+
1315
+ // Depending on the category, increment the relevant occurrence counts
1316
+ if (categoryName === mustFixCategory) {
1317
+ pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
1318
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
1319
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
1320
+ } else if (categoryName === goodToFixCategory) {
1321
+ pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
1322
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
1323
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
1324
+ } else if (categoryName === needsReviewCategory) {
1325
+ pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
1326
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
1327
+ pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
1328
+ } else if (categoryName === passedCategory) {
1329
+ pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
1330
+ pagesMap[url].totalOccurrencesPassed += itemsCount;
1331
+ }
1332
+ });
1333
+ });
1334
+ });
1335
+
1336
+ // --------------------------------------------
1337
+ // 6) Separate scanned pages into “affected” vs. “notAffected”
1338
+ // --------------------------------------------
1339
+ const pagesInMap = Object.values(pagesMap); // All pages that have some record in pagesMap
1340
+ const pagesInMapUrls = new Set(Object.keys(pagesMap));
1341
+
1342
+ // (a) Pages with only passed (no mustFix/goodToFix/needsReview)
1343
+ const pagesAllPassed = pagesInMap.filter(
1344
+ p => p.totalOccurrencesFailedIncludingNeedsReview === 0
1345
+ );
1346
+
1347
+ // (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
1348
+ const pagesNoEntries = allScannedPages
1349
+ .filter(sp => !pagesInMapUrls.has(sp.url))
1350
+ .map(sp => ({
1351
+ pageTitle: sp.pageTitle,
1352
+ url: sp.url,
1353
+ totalOccurrencesFailedIncludingNeedsReview: 0,
1354
+ totalOccurrencesFailedExcludingNeedsReview: 0,
1355
+ totalOccurrencesNeedsReview: 0,
1356
+ totalOccurrencesPassed: 0,
1357
+ typesOfIssues: {},
1358
+ }));
1359
+
1360
+ // Combine these into "notAffected"
1361
+ const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
1362
+
1363
+ // "affected" pages => have at least 1 mustFix/goodToFix/needsReview
1364
+ const pagesAffectedRaw = pagesInMap.filter(
1365
+ p => p.totalOccurrencesFailedIncludingNeedsReview > 0
1366
+ );
1367
+
1368
+ // --------------------------------------------
1369
+ // 7) Transform both arrays to the final shape
1370
+ // --------------------------------------------
1371
+ function transformPageData(page: PageData) {
1372
+ const typesOfIssuesArray = Object.values(page.typesOfIssues);
1373
+
1374
+ // Compute sums for each failing category
1375
+ const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
1376
+ const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
1377
+ const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
1378
+
1379
+ // Build categoriesPresent based on nonzero failing counts
1380
+ const categoriesPresent: string[] = [];
1381
+ if (mustFixSum > 0) categoriesPresent.push("mustFix");
1382
+ if (goodToFixSum > 0) categoriesPresent.push("goodToFix");
1383
+ if (needsReviewSum > 0) categoriesPresent.push("needsReview");
1384
+
1385
+ // Count how many rules have failing issues
1386
+ const failedRuleIds = new Set<string>();
1387
+ typesOfIssuesArray.forEach(r => {
1388
+ if ((r.occurrencesMustFix || 0) > 0 ||
1389
+ (r.occurrencesGoodToFix || 0) > 0 ||
1390
+ (r.occurrencesNeedsReview || 0) > 0) {
1391
+ failedRuleIds.add(r.ruleId); // Ensure ruleId is unique
1392
+ }
1393
+ });
1394
+ const failedRuleCount = failedRuleIds.size;
1395
+
1396
+ // Possibly these two for future convenience
1397
+ const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(
1398
+ r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0
1399
+ ).length;
1400
+
1401
+ const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(
1402
+ r =>
1403
+ (r.occurrencesNeedsReview || 0) > 0 &&
1404
+ (r.occurrencesMustFix || 0) === 0 &&
1405
+ (r.occurrencesGoodToFix || 0) === 0
1406
+ ).length;
1407
+
1408
+ // Aggregate wcagConformance for rules that actually fail
1409
+ const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
1410
+ const nonPassedCount =
1411
+ (curr.occurrencesMustFix || 0) +
1412
+ (curr.occurrencesGoodToFix || 0) +
1413
+ (curr.occurrencesNeedsReview || 0);
1414
+
1415
+ if (nonPassedCount > 0) {
1416
+ return acc.concat(curr.wcagConformance || []);
1417
+ }
1418
+ return acc;
1419
+ }, [] as string[]);
1420
+ // Remove duplicates
1421
+ const conformance = Array.from(new Set(allConformance));
1422
+
1423
+ return {
1424
+ pageTitle: page.pageTitle,
1425
+ url: page.url,
1426
+ totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
1427
+ totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
1428
+ totalOccurrencesMustFix: mustFixSum,
1429
+ totalOccurrencesGoodToFix: goodToFixSum,
1430
+ totalOccurrencesNeedsReview: needsReviewSum,
1431
+ totalOccurrencesPassed: page.totalOccurrencesPassed,
1432
+ typesOfIssuesExclusiveToNeedsReviewCount,
1433
+ typesOfIssuesCount: failedRuleCount,
1434
+ typesOfIssuesExcludingNeedsReviewCount,
1435
+ categoriesPresent,
1436
+ conformance,
1437
+ // Keep full detail for "scanPagesDetail"
1438
+ typesOfIssues: typesOfIssuesArray,
1439
+ };
1440
+ }
1441
+
1442
+ // Transform raw pages
1443
+ const pagesAffected = pagesAffectedRaw.map(transformPageData);
1444
+ const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
1445
+
1446
+ // --------------------------------------------
1447
+ // 8) Sort pages by typesOfIssuesCount (descending) for both arrays
1448
+ // --------------------------------------------
1449
+ pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1450
+ pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1451
+
1452
+ // --------------------------------------------
1453
+ // 9) Compute scanned/ skipped counts
1454
+ // --------------------------------------------
1455
+ const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
1456
+ const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
1457
+ ? allIssues.pagesNotScanned.length
1458
+ : 0;
1459
+
1460
+ // --------------------------------------------
1461
+ // 10) Build scanPagesDetail (with full "typesOfIssues")
1462
+ // --------------------------------------------
1463
+ allIssues.scanPagesDetail = {
1464
+ pagesAffected,
1465
+ pagesNotAffected,
1466
+ scannedPagesCount,
1467
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1468
+ ? allIssues.pagesNotScanned
1469
+ : [],
1470
+ pagesNotScannedCount,
1471
+ };
1472
+
1473
+ // --------------------------------------------
1474
+ // 11) Build scanPagesSummary (strip out "typesOfIssues")
1475
+ // --------------------------------------------
1476
+ function stripTypesOfIssues(page: ReturnType<typeof transformPageData>) {
1477
+ const { typesOfIssues, ...rest } = page;
1478
+ return rest;
1479
+ }
1480
+
1481
+ const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
1482
+ const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
1483
+
1484
+ allIssues.scanPagesSummary = {
1485
+ pagesAffected: summaryPagesAffected,
1486
+ pagesNotAffected: summaryPagesNotAffected,
1487
+ scannedPagesCount,
1488
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1489
+ ? allIssues.pagesNotScanned
1490
+ : [],
1491
+ pagesNotScannedCount,
1492
+ };
1493
+ }
1494
+
1128
1495
  const generateArtifacts = async (
1129
- randomToken,
1130
- urlScanned,
1131
- scanType,
1132
- viewport,
1133
- pagesScanned,
1134
- pagesNotScanned,
1135
- customFlowLabel,
1136
- cypressScanAboutMetadata,
1137
- scanDetails,
1138
- zip = undefined, // optional
1496
+ randomToken: string,
1497
+ urlScanned: string,
1498
+ scanType: ScannerTypes,
1499
+ viewport: string,
1500
+ pagesScanned: PageInfo[],
1501
+ pagesNotScanned: PageInfo[],
1502
+ customFlowLabel: string,
1503
+ cypressScanAboutMetadata: {
1504
+ browser?: string;
1505
+ viewport: { width: number; height: number };
1506
+ },
1507
+ scanDetails: {
1508
+ startTime: Date;
1509
+ endTime: Date;
1510
+ deviceChosen: string;
1511
+ isIncludeScreenshots: boolean;
1512
+ isAllowSubdomains: string;
1513
+ isEnableCustomChecks: string[];
1514
+ isEnableWcagAaa: string[];
1515
+ isSlowScanMode: number;
1516
+ isAdhereRobots: boolean;
1517
+ },
1518
+ zip: string = undefined, // optional
1139
1519
  generateJsonFiles = false,
1140
1520
  ) => {
1141
1521
  const intermediateDatasetsPath = `${randomToken}/datasets/${randomToken}`;
1142
- const phAppVersion = getVersion();
1522
+ const oobeeAppVersion = getVersion();
1143
1523
  const storagePath = getStoragePath(randomToken);
1144
1524
 
1145
1525
  urlScanned =
@@ -1147,7 +1527,7 @@ const generateArtifacts = async (
1147
1527
  ? urlScanned
1148
1528
  : urlWithoutAuth(urlScanned);
1149
1529
 
1150
- const formatAboutStartTime = dateString => {
1530
+ const formatAboutStartTime = (dateString: string) => {
1151
1531
  const utcStartTimeDate = new Date(dateString);
1152
1532
  const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
1153
1533
  year: 'numeric',
@@ -1200,7 +1580,7 @@ const generateArtifacts = async (
1200
1580
  topTenIssues: [],
1201
1581
  wcagViolations: [],
1202
1582
  customFlowLabel,
1203
- phAppVersion,
1583
+ oobeeAppVersion,
1204
1584
  items: {
1205
1585
  mustFix: {
1206
1586
  description: itemTypeDescription.mustFix,
@@ -1229,6 +1609,13 @@ const generateArtifacts = async (
1229
1609
  },
1230
1610
  cypressScanAboutMetadata,
1231
1611
  wcagLinks: constants.wcagLinks,
1612
+ scanPagesDetail: {
1613
+ pagesAffected: [],
1614
+ pagesNotAffected: [],
1615
+ scannedPagesCount: 0,
1616
+ pagesNotScanned: [],
1617
+ pagesNotScannedCount: 0,
1618
+ },
1232
1619
  // Populate boolean values for id="advancedScanOptionsSummary"
1233
1620
  advancedScanOptionsSummaryItems: {
1234
1621
  showIncludeScreenshots: [true].includes(scanDetails.isIncludeScreenshots),
@@ -1274,10 +1661,18 @@ const generateArtifacts = async (
1274
1661
  createScreenshotsFolder(randomToken);
1275
1662
  }
1276
1663
 
1664
+ populateScanPagesDetail(allIssues);
1665
+
1277
1666
  allIssues.wcagPassPercentage = getWcagPassPercentage(allIssues.wcagViolations, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1278
- consoleLogger.info(
1279
- `advancedScanOptionsSummaryItems is ${allIssues.advancedScanOptionsSummaryItems}`,
1280
- );
1667
+ allIssues.progressPercentage = getProgressPercentage(allIssues.scanPagesDetail, allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa);
1668
+
1669
+ allIssues.issuesPercentage = await getIssuesPercentage(
1670
+ allIssues.scanPagesDetail,
1671
+ allIssues.advancedScanOptionsSummaryItems.showEnableWcagAaa,
1672
+ allIssues.advancedScanOptionsSummaryItems.disableOobee);
1673
+
1674
+ // console.log(allIssues.progressPercentage);
1675
+ // console.log(allIssues.issuesPercentage);
1281
1676
 
1282
1677
  const getAxeImpactCount = (allIssues: AllIssues) => {
1283
1678
  const impactCount = {
@@ -1325,6 +1720,12 @@ const generateArtifacts = async (
1325
1720
  scanItemsSummaryBase64FilePath,
1326
1721
  scanItemsMiniReportJsonFilePath,
1327
1722
  scanItemsMiniReportBase64FilePath,
1723
+ scanIssuesSummaryJsonFilePath,
1724
+ scanIssuesSummaryBase64FilePath,
1725
+ scanPagesDetailJsonFilePath,
1726
+ scanPagesDetailBase64FilePath,
1727
+ scanPagesSummaryJsonFilePath,
1728
+ scanPagesSummaryBase64FilePath,
1328
1729
  scanDataJsonFileSize,
1329
1730
  scanItemsJsonFileSize,
1330
1731
  } = await writeJsonAndBase64Files(allIssues, storagePath);
@@ -1355,6 +1756,14 @@ const generateArtifacts = async (
1355
1756
  scanItemsBase64FilePath,
1356
1757
  scanItemsSummaryJsonFilePath,
1357
1758
  scanItemsSummaryBase64FilePath,
1759
+ scanItemsMiniReportJsonFilePath,
1760
+ scanItemsMiniReportBase64FilePath,
1761
+ scanIssuesSummaryJsonFilePath,
1762
+ scanIssuesSummaryBase64FilePath,
1763
+ scanPagesDetailJsonFilePath,
1764
+ scanPagesDetailBase64FilePath,
1765
+ scanPagesSummaryJsonFilePath,
1766
+ scanPagesSummaryBase64FilePath,
1358
1767
  ]);
1359
1768
  }
1360
1769
 
@@ -1378,13 +1787,7 @@ const generateArtifacts = async (
1378
1787
  `Results directory is at ${storagePath}`,
1379
1788
  ];
1380
1789
 
1381
- if (process.env.REPORT_BREAKDOWN === '1') {
1382
- messageToDisplay.push(
1383
- 'Reports have been further broken down according to their respective impact level.',
1384
- );
1385
- }
1386
-
1387
- if (process.send && process.env.OOBEE_VERBOSE && process.env.REPORT_BREAKDOWN != '1') {
1790
+ if (process.send && process.env.OOBEE_VERBOSE) {
1388
1791
  const zipFileNameMessage = {
1389
1792
  type: 'zipFileName',
1390
1793
  payload: `${constants.cliZipFileName}`,