@govtechsg/oobee 0.10.36 → 0.10.39

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 (33) hide show
  1. package/DETAILS.md +3 -3
  2. package/INTEGRATION.md +142 -53
  3. package/README.md +15 -0
  4. package/exclusions.txt +4 -1
  5. package/package.json +2 -2
  6. package/src/constants/cliFunctions.ts +0 -7
  7. package/src/constants/common.ts +39 -1
  8. package/src/constants/constants.ts +9 -8
  9. package/src/crawlers/commonCrawlerFunc.ts +66 -219
  10. package/src/crawlers/crawlDomain.ts +6 -2
  11. package/src/crawlers/crawlLocalFile.ts +2 -0
  12. package/src/crawlers/crawlSitemap.ts +5 -3
  13. package/src/crawlers/custom/escapeCssSelector.ts +10 -0
  14. package/src/crawlers/custom/evaluateAltText.ts +13 -0
  15. package/src/crawlers/custom/extractAndGradeText.ts +0 -2
  16. package/src/crawlers/custom/extractText.ts +28 -0
  17. package/src/crawlers/custom/findElementByCssSelector.ts +46 -0
  18. package/src/crawlers/custom/flagUnlabelledClickableElements.ts +1006 -901
  19. package/src/crawlers/custom/framesCheck.ts +51 -0
  20. package/src/crawlers/custom/getAxeConfiguration.ts +126 -0
  21. package/src/crawlers/custom/gradeReadability.ts +30 -0
  22. package/src/crawlers/custom/xPathToCss.ts +178 -0
  23. package/src/mergeAxeResults.ts +467 -129
  24. package/src/npmIndex.ts +130 -62
  25. package/src/static/ejs/partials/components/ruleOffcanvas.ejs +1 -1
  26. package/src/static/ejs/partials/components/scanAbout.ejs +1 -1
  27. package/src/static/ejs/partials/footer.ejs +3 -3
  28. package/src/static/ejs/partials/scripts/reportSearch.ejs +112 -74
  29. package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +2 -2
  30. package/src/static/ejs/partials/summaryMain.ejs +3 -3
  31. package/src/static/ejs/report.ejs +3 -3
  32. package/src/xPathToCssCypress.ts +178 -0
  33. package/src/crawlers/customAxeFunctions.ts +0 -82
@@ -13,7 +13,7 @@ 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,
@@ -34,19 +34,21 @@ export type ItemsInfo = {
34
34
  displayNeedsReview?: boolean;
35
35
  };
36
36
 
37
- type PageInfo = {
38
- items: ItemsInfo[];
37
+ export type PageInfo = {
38
+ items?: ItemsInfo[];
39
39
  itemsCount?: number;
40
40
  pageTitle: string;
41
- url?: string;
41
+ url: string;
42
+ actualUrl: string;
42
43
  pageImagePath?: string;
43
44
  pageIndex?: number;
44
- metadata: string;
45
+ metadata?: string;
45
46
  };
46
47
 
47
48
  export type RuleInfo = {
48
49
  totalItems: number;
49
50
  pagesAffected: PageInfo[];
51
+ pagesAffectedCount: number;
50
52
  rule: string;
51
53
  description: string;
52
54
  axeImpact: string;
@@ -74,7 +76,6 @@ type AllIssues = {
74
76
  deviceChosen: string;
75
77
  formatAboutStartTime: (dateString: any) => string;
76
78
  isCustomFlow: boolean;
77
- viewport: string;
78
79
  pagesScanned: PageInfo[];
79
80
  pagesNotScanned: PageInfo[];
80
81
  totalPagesScanned: number;
@@ -85,14 +86,17 @@ type AllIssues = {
85
86
  topTenIssues: Array<any>;
86
87
  wcagViolations: string[];
87
88
  customFlowLabel: string;
88
- phAppVersion: string;
89
+ oobeeAppVersion: string;
89
90
  items: {
90
91
  mustFix: Category;
91
92
  goodToFix: Category;
92
93
  needsReview: Category;
93
94
  passed: Category;
94
95
  };
95
- cypressScanAboutMetadata: string;
96
+ cypressScanAboutMetadata: {
97
+ browser?: string;
98
+ viewport?: { width: number; height: number };
99
+ };
96
100
  wcagLinks: { [key: string]: string };
97
101
  [key: string]: any;
98
102
  advancedScanOptionsSummaryItems: { [key: string]: boolean };
@@ -160,13 +164,11 @@ const writeCsv = async (allIssues, storagePath) => {
160
164
  pagesAffected,
161
165
  helpUrl: learnMore,
162
166
  } = 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));
167
+
168
168
  // format clauses as a string
169
- const wcagConformance = clausesArr.join(',');
169
+ const wcagConformance = conformance.join(',');
170
+
171
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
170
172
 
171
173
  pagesAffected.forEach(affectedPage => {
172
174
  const { url, items } = affectedPage;
@@ -239,8 +241,8 @@ const writeCsv = async (allIssues, storagePath) => {
239
241
  issueId: 'error-pages-skipped',
240
242
  issueDescription: 'Page was skipped during the scan',
241
243
  wcagConformance: '',
242
- url: page.url || '',
243
- pageTitle: '',
244
+ url: page.url || page || '',
245
+ pageTitle: 'Error',
244
246
  context: '',
245
247
  howToFix: '',
246
248
  axeImpact: '',
@@ -544,86 +546,93 @@ const writeLargeScanItemsJsonToFile = async (obj: object, filePath: string) => {
544
546
 
545
547
  keys.forEach((key, i) => {
546
548
  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
549
 
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);
550
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
551
+ queueWrite(` "${key}": ${JSON.stringify(value)}`);
552
+ } else {
553
+ queueWrite(` "${key}": {\n`);
554
+
555
+ const { rules, ...otherProperties } = value;
556
+
557
+ // Write other properties
558
+ Object.entries(otherProperties).forEach(([propKey, propValue], j) => {
559
+ const propValueString =
560
+ propValue === null ||
561
+ typeof propValue === 'function' ||
562
+ typeof propValue === 'undefined'
563
+ ? 'null'
564
+ : JSON.stringify(propValue);
565
+ queueWrite(` "${propKey}": ${propValueString}`);
566
+ if (j < Object.keys(otherProperties).length - 1 || (rules && rules.length >= 0)) {
567
+ queueWrite(',\n');
568
+ } else {
569
+ queueWrite('\n');
570
+ }
571
+ });
599
572
 
600
- if (p < pagesAffected.length - 1) {
573
+ if (rules && Array.isArray(rules)) {
574
+ queueWrite(' "rules": [\n');
575
+
576
+ rules.forEach((rule, j) => {
577
+ queueWrite(' {\n');
578
+ const { pagesAffected, ...otherRuleProperties } = rule;
579
+
580
+ Object.entries(otherRuleProperties).forEach(([ruleKey, ruleValue], k) => {
581
+ const ruleValueString =
582
+ ruleValue === null ||
583
+ typeof ruleValue === 'function' ||
584
+ typeof ruleValue === 'undefined'
585
+ ? 'null'
586
+ : JSON.stringify(ruleValue);
587
+ queueWrite(` "${ruleKey}": ${ruleValueString}`);
588
+ if (k < Object.keys(otherRuleProperties).length - 1 || pagesAffected) {
601
589
  queueWrite(',\n');
602
590
  } else {
603
591
  queueWrite('\n');
604
592
  }
605
593
  });
606
594
 
607
- queueWrite(' ]');
608
- }
595
+ if (pagesAffected && Array.isArray(pagesAffected)) {
596
+ queueWrite(' "pagesAffected": [\n');
609
597
 
610
- queueWrite('\n }');
611
- if (j < rules.length - 1) {
612
- queueWrite(',\n');
613
- } else {
614
- queueWrite('\n');
615
- }
616
- });
598
+ pagesAffected.forEach((page, p) => {
599
+ const pageJson = JSON.stringify(page, null, 2)
600
+ .split('\n')
601
+ .map((line, idx) => (idx === 0 ? ` ${line}` : ` ${line}`))
602
+ .join('\n');
603
+
604
+ queueWrite(pageJson);
605
+
606
+ if (p < pagesAffected.length - 1) {
607
+ queueWrite(',\n');
608
+ } else {
609
+ queueWrite('\n');
610
+ }
611
+ });
612
+
613
+ queueWrite(' ]');
614
+ }
615
+
616
+ queueWrite('\n }');
617
+ if (j < rules.length - 1) {
618
+ queueWrite(',\n');
619
+ } else {
620
+ queueWrite('\n');
621
+ }
622
+ });
623
+
624
+ queueWrite(' ]');
625
+ }
626
+ queueWrite('\n }');
627
+ }
628
+
629
+ if (i < keys.length - 1) {
630
+ queueWrite(',\n');
631
+ } else {
632
+ queueWrite('\n');
633
+ }
617
634
 
618
- queueWrite(' ]');
619
- }
620
635
 
621
- queueWrite('\n }');
622
- if (i < keys.length - 1) {
623
- queueWrite(',\n');
624
- } else {
625
- queueWrite('\n');
626
- }
627
636
  });
628
637
 
629
638
  queueWrite('}\n');
@@ -727,6 +736,12 @@ const writeJsonAndBase64Files = async (
727
736
  scanItemsSummaryBase64FilePath: string;
728
737
  scanItemsMiniReportJsonFilePath: string;
729
738
  scanItemsMiniReportBase64FilePath: string;
739
+ scanIssuesSummaryJsonFilePath: string;
740
+ scanIssuesSummaryBase64FilePath: string;
741
+ scanPagesDetailJsonFilePath: string;
742
+ scanPagesDetailBase64FilePath: string;
743
+ scanPagesSummaryJsonFilePath: string;
744
+ scanPagesSummaryBase64FilePath: string;
730
745
  scanDataJsonFileSize: number;
731
746
  scanItemsJsonFileSize: number;
732
747
  }> => {
@@ -734,7 +749,33 @@ const writeJsonAndBase64Files = async (
734
749
  const { jsonFilePath: scanDataJsonFilePath, base64FilePath: scanDataBase64FilePath } =
735
750
  await writeJsonFileAndCompressedJsonFile(rest, storagePath, 'scanData');
736
751
  const { jsonFilePath: scanItemsJsonFilePath, base64FilePath: scanItemsBase64FilePath } =
737
- await writeJsonFileAndCompressedJsonFile(items, storagePath, 'scanItems');
752
+ await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...items }, storagePath, 'scanItems');
753
+
754
+ // Add pagesAffectedCount to each rule in scanItemsMiniReport (items) and sort them in descending order of pagesAffectedCount
755
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
756
+ if (items[category].rules && Array.isArray(items[category].rules)) {
757
+ items[category].rules.forEach((rule) => {
758
+ rule.pagesAffectedCount = Array.isArray(rule.pagesAffected)
759
+ ? rule.pagesAffected.length
760
+ : 0;
761
+ });
762
+
763
+ // Sort in descending order of pagesAffectedCount
764
+ items[category].rules.sort((a, b) => (b.pagesAffectedCount || 0) - (a.pagesAffectedCount || 0));
765
+ }
766
+ });
767
+
768
+ // Refactor scanIssuesSummary to reuse the scanItemsMiniReport structure by stripping out pagesAffected
769
+ const scanIssuesSummary = {
770
+ mustFix: items.mustFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
771
+ goodToFix: items.goodToFix.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
772
+ needsReview: items.needsReview.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
773
+ passed: items.passed.rules.map(({ pagesAffected, ...ruleInfo }) => ruleInfo),
774
+ };
775
+
776
+ // Write out the scanIssuesSummary JSON using the new structure
777
+ const { jsonFilePath: scanIssuesSummaryJsonFilePath, base64FilePath: scanIssuesSummaryBase64FilePath } =
778
+ await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanIssuesSummary }, storagePath, 'scanIssuesSummary');
738
779
 
739
780
  // scanItemsSummary
740
781
  // the below mutates the original items object, since it is expensive to clone
@@ -794,8 +835,7 @@ const writeJsonAndBase64Files = async (
794
835
  const {
795
836
  jsonFilePath: scanItemsMiniReportJsonFilePath,
796
837
  base64FilePath: scanItemsMiniReportBase64FilePath,
797
- } = await writeJsonFileAndCompressedJsonFile(summaryItemsMini, storagePath, 'scanItemsSummaryMiniReport');
798
-
838
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItemsMini }, storagePath, 'scanItemsSummaryMiniReport');
799
839
  const summaryItems = {
800
840
  mustFix: {
801
841
  totalItems: items.mustFix?.totalItems || 0,
@@ -820,7 +860,249 @@ const writeJsonAndBase64Files = async (
820
860
  const {
821
861
  jsonFilePath: scanItemsSummaryJsonFilePath,
822
862
  base64FilePath: scanItemsSummaryBase64FilePath,
823
- } = await writeJsonFileAndCompressedJsonFile(summaryItems, storagePath, 'scanItemsSummary');
863
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...summaryItems }, storagePath, 'scanItemsSummary');
864
+
865
+ // -----------------------------------------------------------------------------
866
+ // --- Scan Pages Summary and Scan Pages Detail ---
867
+ // -----------------------------------------------------------------------------
868
+
869
+ // 1) Gather your "scanned" pages from allIssues
870
+ const allScannedPages = Array.isArray(allIssues.pagesScanned)
871
+ ? allIssues.pagesScanned
872
+ : [];
873
+
874
+ // Define which categories map to which occurrence property
875
+ const mustFixCategory = "mustFix"; // => occurrencesMustFix
876
+ const goodToFixCategory = "goodToFix"; // => occurrencesGoodToFix
877
+ const needsReviewCategory = "needsReview"; // => occurrencesNeedsReview
878
+ const passedCategory = "passed"; // => occurrencesPassed
879
+
880
+ type RuleData = {
881
+ ruleId: string;
882
+ wagConformance: string[];
883
+ occurrencesMustFix: number;
884
+ occurrencesGoodToFix: number;
885
+ occurrencesNeedsReview: number;
886
+ occurrencesPassed: number;
887
+ };
888
+
889
+ type PageData = {
890
+ pageTitle: string;
891
+ url: string;
892
+ // Summaries
893
+ totalOccurrencesFailedIncludingNeedsReview: number; // mustFix + goodToFix + needsReview
894
+ totalOccurrencesFailedExcludingNeedsReview: number; // mustFix + goodToFix
895
+ totalOccurrencesNeedsReview: number; // needsReview
896
+ totalOccurrencesPassed: number; // passed only
897
+ typesOfIssues: Record<string, RuleData>;
898
+ };
899
+
900
+ // 2) We'll accumulate pages in a map keyed by URL
901
+ const pagesMap: Record<string, PageData> = {};
902
+
903
+ // 3) Build pagesMap by iterating over each category in allIssues.items
904
+ Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
905
+ if (!categoryData?.rules) return; // no rules in this category? skip
906
+
907
+ categoryData.rules.forEach((rule) => {
908
+ const { rule: ruleId, conformance = [] } = rule;
909
+
910
+ rule.pagesAffected.forEach((p) => {
911
+ const { url, pageTitle, itemsCount = 0 } = p;
912
+
913
+ // Ensure the page is in pagesMap
914
+ if (!pagesMap[url]) {
915
+ pagesMap[url] = {
916
+ pageTitle,
917
+ url,
918
+ totalOccurrencesFailedIncludingNeedsReview: 0,
919
+ totalOccurrencesFailedExcludingNeedsReview: 0,
920
+ totalOccurrencesNeedsReview: 0,
921
+ totalOccurrencesPassed: 0,
922
+ typesOfIssues: {},
923
+ };
924
+ }
925
+
926
+ // Ensure the rule is present for this page
927
+ if (!pagesMap[url].typesOfIssues[ruleId]) {
928
+ pagesMap[url].typesOfIssues[ruleId] = {
929
+ ruleId,
930
+ wagConformance: conformance,
931
+ occurrencesMustFix: 0,
932
+ occurrencesGoodToFix: 0,
933
+ occurrencesNeedsReview: 0,
934
+ occurrencesPassed: 0,
935
+ };
936
+ }
937
+
938
+ // Depending on the category, increment the relevant occurrence counts
939
+ if (categoryName === mustFixCategory) {
940
+ pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
941
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
942
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
943
+ } else if (categoryName === goodToFixCategory) {
944
+ pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
945
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
946
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
947
+ } else if (categoryName === needsReviewCategory) {
948
+ pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
949
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
950
+ pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
951
+ } else if (categoryName === passedCategory) {
952
+ pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
953
+ pagesMap[url].totalOccurrencesPassed += itemsCount;
954
+ }
955
+ });
956
+ });
957
+ });
958
+
959
+ // 4) Separate scanned pages into “affected” vs. “notAffected”
960
+ // - "affected" => totalOccurrencesFailedIncludingNeedsReview > 0
961
+ // - "notAffected" => totalOccurrencesFailedIncludingNeedsReview = 0 (only passed issues)
962
+ // or pages that never appeared in pagesMap at all
963
+
964
+ const pagesInMap = Object.values(pagesMap); // All pages that have some record in pagesMap
965
+ const pagesInMapUrls = new Set(Object.keys(pagesMap));
966
+
967
+ // (a) Pages that appear in pagesMap BUT have only passed (no mustFix/goodToFix/needsReview)
968
+ const pagesAllPassed = pagesInMap.filter(
969
+ (p) => p.totalOccurrencesFailedIncludingNeedsReview === 0
970
+ );
971
+
972
+ // (b) Pages that do NOT appear in pagesMap at all => scanned but no items found
973
+ // (This can happen if a page had 0 occurrences across all categories.)
974
+ const pagesNoEntries = allScannedPages
975
+ .filter((sp) => !pagesInMapUrls.has(sp.url))
976
+ .map((sp) => ({
977
+ // We'll create a PageData with everything zeroed out
978
+ pageTitle: sp.pageTitle,
979
+ url: sp.url,
980
+ totalOccurrencesFailedIncludingNeedsReview: 0,
981
+ totalOccurrencesFailedExcludingNeedsReview: 0,
982
+ totalOccurrencesNeedsReview: 0,
983
+ totalOccurrencesPassed: 0,
984
+ typesOfIssues: {},
985
+ }));
986
+
987
+ // Combine these into "notAffected"
988
+ const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
989
+
990
+ // "affected" pages => have at least 1 mustFix/goodToFix/needsReview
991
+ const pagesAffectedRaw = pagesInMap.filter(
992
+ (p) => p.totalOccurrencesFailedIncludingNeedsReview > 0
993
+ );
994
+
995
+ // 5) Transform both arrays to final shapes
996
+
997
+ function transformPageData(page: PageData) {
998
+ const typesOfIssuesArray = Object.values(page.typesOfIssues);
999
+
1000
+ // Compute sums for each failing category
1001
+ const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
1002
+ const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
1003
+ const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
1004
+
1005
+ // Build categoriesPresent based on nonzero failing counts
1006
+ const categoriesPresent: string[] = [];
1007
+ if (mustFixSum > 0) categoriesPresent.push("mustFix");
1008
+ if (goodToFixSum > 0) categoriesPresent.push("goodToFix");
1009
+ if (needsReviewSum > 0) categoriesPresent.push("needsReview");
1010
+
1011
+ // Count how many rules have failing issues (either mustFix or goodToFix)
1012
+ const failedRuleCount = typesOfIssuesArray.filter(
1013
+ (r) => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0
1014
+ ).length;
1015
+
1016
+ const typesOfIssuesExcludingNeedsReviewCount = failedRuleCount;
1017
+ const occurrencesExclusiveToNeedsReview =
1018
+ page.totalOccurrencesFailedExcludingNeedsReview === 0 &&
1019
+ page.totalOccurrencesFailedIncludingNeedsReview > 0;
1020
+
1021
+ // Aggregate wcag conformance values only for rules with failing issues.
1022
+ const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
1023
+ const nonPassedCount =
1024
+ (curr.occurrencesMustFix || 0) +
1025
+ (curr.occurrencesGoodToFix || 0) +
1026
+ (curr.occurrencesNeedsReview || 0);
1027
+ if (nonPassedCount > 0) {
1028
+ return acc.concat(curr.wagConformance || []);
1029
+ }
1030
+ return acc;
1031
+ }, []);
1032
+ // Remove duplicates.
1033
+ const conformance = Array.from(new Set(allConformance));
1034
+
1035
+ return {
1036
+ pageTitle: page.pageTitle,
1037
+ url: page.url,
1038
+ totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
1039
+ totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
1040
+ totalOccurrencesMustFix: mustFixSum,
1041
+ totalOccurrencesGoodToFix: goodToFixSum,
1042
+ totalOccurrencesNeedsReview: needsReviewSum,
1043
+ totalOccurrencesPassed: page.totalOccurrencesPassed,
1044
+ occurrencesExclusiveToNeedsReview,
1045
+ typesOfIssuesCount: failedRuleCount,
1046
+ typesOfIssuesExcludingNeedsReviewCount,
1047
+ categoriesPresent,
1048
+ conformance,
1049
+ typesOfIssues: typesOfIssuesArray, // full details for scanPagesDetail
1050
+ };
1051
+ }
1052
+
1053
+ const pagesAffected = pagesAffectedRaw.map(transformPageData);
1054
+ const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
1055
+
1056
+ // 6) SORT pages by typesOfIssuesCount (descending) for both arrays
1057
+ pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1058
+ pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
1059
+
1060
+ // 7) Compute scanned/ skipped counts
1061
+ const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
1062
+ const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
1063
+ ? allIssues.pagesNotScanned.length
1064
+ : 0;
1065
+
1066
+ // 8) Build scanPagesDetail (keeping full typesOfIssues)
1067
+ const scanPagesDetail = {
1068
+ pagesAffected,
1069
+ pagesNotAffected, // these pages are scanned but have no "fail/review" issues
1070
+ scannedPagesCount,
1071
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1072
+ ? allIssues.pagesNotScanned
1073
+ : [],
1074
+ pagesNotScannedCount,
1075
+ };
1076
+
1077
+ // 9) Build scanPagesSummary (remove “typesOfIssues” from both groups, but keep other fields)
1078
+ function stripTypesOfIssues(page: ReturnType<typeof transformPageData>) {
1079
+ const { typesOfIssues, ...rest } = page;
1080
+ return rest;
1081
+ }
1082
+
1083
+ const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
1084
+ const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
1085
+
1086
+ const scanPagesSummary = {
1087
+ pagesAffected: summaryPagesAffected,
1088
+ pagesNotAffected: summaryPagesNotAffected,
1089
+ scannedPagesCount,
1090
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned)
1091
+ ? allIssues.pagesNotScanned
1092
+ : [],
1093
+ pagesNotScannedCount,
1094
+ };
1095
+
1096
+ // 10) Write out the detail and summary JSON files
1097
+ const {
1098
+ jsonFilePath: scanPagesDetailJsonFilePath,
1099
+ base64FilePath: scanPagesDetailBase64FilePath
1100
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanPagesDetail }, storagePath, 'scanPagesDetail');
1101
+
1102
+ const {
1103
+ jsonFilePath: scanPagesSummaryJsonFilePath,
1104
+ base64FilePath: scanPagesSummaryBase64FilePath
1105
+ } = await writeJsonFileAndCompressedJsonFile({ oobeeAppVersion: allIssues.oobeeAppVersion, ...scanPagesSummary }, storagePath, 'scanPagesSummary');
824
1106
 
825
1107
  return {
826
1108
  scanDataJsonFilePath,
@@ -831,6 +1113,12 @@ const writeJsonAndBase64Files = async (
831
1113
  scanItemsSummaryBase64FilePath,
832
1114
  scanItemsMiniReportJsonFilePath,
833
1115
  scanItemsMiniReportBase64FilePath,
1116
+ scanIssuesSummaryJsonFilePath,
1117
+ scanIssuesSummaryBase64FilePath,
1118
+ scanPagesDetailJsonFilePath,
1119
+ scanPagesDetailBase64FilePath,
1120
+ scanPagesSummaryJsonFilePath,
1121
+ scanPagesSummaryBase64FilePath,
834
1122
  scanDataJsonFileSize: fs.statSync(scanDataJsonFilePath).size,
835
1123
  scanItemsJsonFileSize: fs.statSync(scanItemsJsonFilePath).size,
836
1124
  };
@@ -1015,6 +1303,8 @@ const getTopTenIssues = allIssues => {
1015
1303
  const categories = ['mustFix', 'goodToFix'];
1016
1304
  const rulesWithCounts = [];
1017
1305
 
1306
+ // This is no longer required and shall not be maintained in future
1307
+ /*
1018
1308
  const conformanceLevels = {
1019
1309
  wcag2a: 'A',
1020
1310
  wcag2aa: 'AA',
@@ -1022,20 +1312,24 @@ const getTopTenIssues = allIssues => {
1022
1312
  wcag22aa: 'AA',
1023
1313
  wcag2aaa: 'AAA',
1024
1314
  };
1315
+ */
1025
1316
 
1026
1317
  categories.forEach(category => {
1027
1318
  const rules = allIssues.items[category]?.rules || [];
1028
1319
 
1029
1320
  rules.forEach(rule => {
1321
+ // This is not needed anymore since we want to have the clause number too
1322
+ /*
1030
1323
  const wcagLevel = rule.conformance[0];
1031
1324
  const aLevel = conformanceLevels[wcagLevel] || wcagLevel;
1325
+ */
1032
1326
 
1033
1327
  rulesWithCounts.push({
1034
1328
  category,
1035
1329
  ruleId: rule.rule,
1036
1330
  description: rule.description,
1037
1331
  axeImpact: rule.axeImpact,
1038
- conformance: aLevel,
1332
+ conformance: rule.conformance,
1039
1333
  totalItems: rule.totalItems,
1040
1334
  });
1041
1335
  });
@@ -1047,48 +1341,71 @@ const getTopTenIssues = allIssues => {
1047
1341
  };
1048
1342
 
1049
1343
  const flattenAndSortResults = (allIssues: AllIssues, isCustomFlow: boolean) => {
1344
+ // Create a map that will sum items only from mustFix, goodToFix, and needsReview.
1050
1345
  const urlOccurrencesMap = new Map<string, number>();
1051
1346
 
1052
- ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach(category => {
1347
+ // Iterate over all categories; update the map only if the category is not "passed"
1348
+ ['mustFix', 'goodToFix', 'needsReview', 'passed'].forEach((category) => {
1349
+ // Accumulate totalItems regardless of category.
1053
1350
  allIssues.totalItems += allIssues.items[category].totalItems;
1054
1351
 
1055
1352
  allIssues.items[category].rules = Object.entries(allIssues.items[category].rules)
1056
- .map(ruleEntry => {
1353
+ .map((ruleEntry) => {
1057
1354
  const [rule, ruleInfo] = ruleEntry as [string, RuleInfo];
1058
1355
  ruleInfo.pagesAffected = Object.entries(ruleInfo.pagesAffected)
1059
- .map(pageEntry => {
1356
+ .map((pageEntry) => {
1060
1357
  if (isCustomFlow) {
1061
1358
  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
- );
1359
+ // Only update the occurrences map if not passed.
1360
+ if (category !== 'passed') {
1361
+ urlOccurrencesMap.set(
1362
+ pageInfo.url!,
1363
+ (urlOccurrencesMap.get(pageInfo.url!) || 0) + pageInfo.items.length
1364
+ );
1365
+ }
1066
1366
  return { pageIndex, ...pageInfo };
1367
+ } else {
1368
+ const [url, pageInfo] = pageEntry as unknown as [string, PageInfo];
1369
+ if (category !== 'passed') {
1370
+ urlOccurrencesMap.set(
1371
+ url,
1372
+ (urlOccurrencesMap.get(url) || 0) + pageInfo.items.length
1373
+ );
1374
+ }
1375
+ return { url, ...pageInfo };
1067
1376
  }
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
1377
  })
1378
+ // Sort pages so that those with the most items come first
1072
1379
  .sort((page1, page2) => page2.items.length - page1.items.length);
1073
1380
  return { rule, ...ruleInfo };
1074
1381
  })
1382
+ // Sort the rules by totalItems (descending)
1075
1383
  .sort((rule1, rule2) => rule2.totalItems - rule1.totalItems);
1076
1384
  });
1077
1385
 
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);
1386
+ // Sort top pages (assumes topFiveMostIssues is already populated)
1387
+ allIssues.topFiveMostIssues.sort((p1, p2) => p2.totalIssues - p1.totalIssues);
1085
1388
  allIssues.topTenPagesWithMostIssues = allIssues.topFiveMostIssues.slice(0, 10);
1086
1389
  allIssues.topFiveMostIssues = allIssues.topFiveMostIssues.slice(0, 5);
1087
- updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues);
1390
+
1391
+ // Update each issue in topTenPagesWithMostIssues with the computed occurrences,
1392
+ // excluding passed items.
1393
+ updateIssuesWithOccurrences(allIssues.topTenPagesWithMostIssues, urlOccurrencesMap);
1394
+
1395
+ // Get and assign the topTenIssues (using your existing helper)
1088
1396
  const topTenIssues = getTopTenIssues(allIssues);
1089
1397
  allIssues.topTenIssues = topTenIssues;
1090
1398
  };
1091
1399
 
1400
+ // Helper: Update totalOccurrences for each issue using our urlOccurrencesMap.
1401
+ // For pages that have only passed items, the map will return undefined, so default to 0.
1402
+ function updateIssuesWithOccurrences(issuesList: any[], urlOccurrencesMap: Map<string, number>) {
1403
+ issuesList.forEach((issue) => {
1404
+ issue.totalOccurrences = urlOccurrencesMap.get(issue.url) || 0;
1405
+ });
1406
+ }
1407
+
1408
+
1092
1409
  const createRuleIdJson = allIssues => {
1093
1410
  const compiledRuleJson = {};
1094
1411
 
@@ -1117,7 +1434,7 @@ const createRuleIdJson = allIssues => {
1117
1434
  return compiledRuleJson;
1118
1435
  };
1119
1436
 
1120
- const moveElemScreenshots = (randomToken, storagePath) => {
1437
+ const moveElemScreenshots = (randomToken: string, storagePath: string) => {
1121
1438
  const currentScreenshotsPath = `${randomToken}/elemScreenshots`;
1122
1439
  const resultsScreenshotsPath = `${storagePath}/elemScreenshots`;
1123
1440
  if (fs.existsSync(currentScreenshotsPath)) {
@@ -1126,20 +1443,33 @@ const moveElemScreenshots = (randomToken, storagePath) => {
1126
1443
  };
1127
1444
 
1128
1445
  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
1446
+ randomToken: string,
1447
+ urlScanned: string,
1448
+ scanType: ScannerTypes,
1449
+ viewport: string,
1450
+ pagesScanned: PageInfo[],
1451
+ pagesNotScanned: PageInfo[],
1452
+ customFlowLabel: string,
1453
+ cypressScanAboutMetadata: {
1454
+ browser?: string;
1455
+ viewport: { width: number; height: number };
1456
+ },
1457
+ scanDetails: {
1458
+ startTime: Date;
1459
+ endTime: Date;
1460
+ deviceChosen: string;
1461
+ isIncludeScreenshots: boolean;
1462
+ isAllowSubdomains: string;
1463
+ isEnableCustomChecks: string[];
1464
+ isEnableWcagAaa: string[];
1465
+ isSlowScanMode: number;
1466
+ isAdhereRobots: boolean;
1467
+ },
1468
+ zip: string = undefined, // optional
1139
1469
  generateJsonFiles = false,
1140
1470
  ) => {
1141
1471
  const intermediateDatasetsPath = `${randomToken}/datasets/${randomToken}`;
1142
- const phAppVersion = getVersion();
1472
+ const oobeeAppVersion = getVersion();
1143
1473
  const storagePath = getStoragePath(randomToken);
1144
1474
 
1145
1475
  urlScanned =
@@ -1147,7 +1477,7 @@ const generateArtifacts = async (
1147
1477
  ? urlScanned
1148
1478
  : urlWithoutAuth(urlScanned);
1149
1479
 
1150
- const formatAboutStartTime = dateString => {
1480
+ const formatAboutStartTime = (dateString: string) => {
1151
1481
  const utcStartTimeDate = new Date(dateString);
1152
1482
  const formattedStartTime = utcStartTimeDate.toLocaleTimeString('en-GB', {
1153
1483
  year: 'numeric',
@@ -1200,7 +1530,7 @@ const generateArtifacts = async (
1200
1530
  topTenIssues: [],
1201
1531
  wcagViolations: [],
1202
1532
  customFlowLabel,
1203
- phAppVersion,
1533
+ oobeeAppVersion,
1204
1534
  items: {
1205
1535
  mustFix: {
1206
1536
  description: itemTypeDescription.mustFix,
@@ -1325,6 +1655,12 @@ const generateArtifacts = async (
1325
1655
  scanItemsSummaryBase64FilePath,
1326
1656
  scanItemsMiniReportJsonFilePath,
1327
1657
  scanItemsMiniReportBase64FilePath,
1658
+ scanIssuesSummaryJsonFilePath,
1659
+ scanIssuesSummaryBase64FilePath,
1660
+ scanPagesDetailJsonFilePath,
1661
+ scanPagesDetailBase64FilePath,
1662
+ scanPagesSummaryJsonFilePath,
1663
+ scanPagesSummaryBase64FilePath,
1328
1664
  scanDataJsonFileSize,
1329
1665
  scanItemsJsonFileSize,
1330
1666
  } = await writeJsonAndBase64Files(allIssues, storagePath);
@@ -1355,6 +1691,14 @@ const generateArtifacts = async (
1355
1691
  scanItemsBase64FilePath,
1356
1692
  scanItemsSummaryJsonFilePath,
1357
1693
  scanItemsSummaryBase64FilePath,
1694
+ scanItemsMiniReportJsonFilePath,
1695
+ scanItemsMiniReportBase64FilePath,
1696
+ scanIssuesSummaryJsonFilePath,
1697
+ scanIssuesSummaryBase64FilePath,
1698
+ scanPagesDetailJsonFilePath,
1699
+ scanPagesDetailBase64FilePath,
1700
+ scanPagesSummaryJsonFilePath,
1701
+ scanPagesSummaryBase64FilePath,
1358
1702
  ]);
1359
1703
  }
1360
1704
 
@@ -1378,13 +1722,7 @@ const generateArtifacts = async (
1378
1722
  `Results directory is at ${storagePath}`,
1379
1723
  ];
1380
1724
 
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') {
1725
+ if (process.send && process.env.OOBEE_VERBOSE) {
1388
1726
  const zipFileNameMessage = {
1389
1727
  type: 'zipFileName',
1390
1728
  payload: `${constants.cliZipFileName}`,