@govtechsg/oobee 0.10.83 → 0.10.84

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.
@@ -0,0 +1,207 @@
1
+ import type { AllIssues } from './types.js';
2
+
3
+ /**
4
+ * Build allIssues.scanPagesDetail and allIssues.scanPagesSummary
5
+ * by analyzing pagesScanned (including mustFix/goodToFix/etc.).
6
+ */
7
+ export default function populateScanPagesDetail(allIssues: AllIssues): void {
8
+ const allScannedPages = Array.isArray(allIssues.pagesScanned) ? allIssues.pagesScanned : [];
9
+
10
+ const mustFixCategory = 'mustFix';
11
+ const goodToFixCategory = 'goodToFix';
12
+ const needsReviewCategory = 'needsReview';
13
+ const passedCategory = 'passed';
14
+
15
+ type RuleData = {
16
+ ruleId: string;
17
+ wcagConformance: string[];
18
+ occurrencesMustFix: number;
19
+ occurrencesGoodToFix: number;
20
+ occurrencesNeedsReview: number;
21
+ occurrencesPassed: number;
22
+ };
23
+
24
+ type PageData = {
25
+ pageTitle: string;
26
+ url: string;
27
+ totalOccurrencesFailedIncludingNeedsReview: number;
28
+ totalOccurrencesFailedExcludingNeedsReview: number;
29
+ totalOccurrencesNeedsReview: number;
30
+ totalOccurrencesPassed: number;
31
+ typesOfIssues: Record<string, RuleData>;
32
+ };
33
+
34
+ const pagesMap: Record<string, PageData> = {};
35
+
36
+ Object.entries(allIssues.items).forEach(([categoryName, categoryData]) => {
37
+ if (!categoryData?.rules) return;
38
+
39
+ categoryData.rules.forEach(rule => {
40
+ const { rule: ruleId, conformance = [] } = rule;
41
+
42
+ rule.pagesAffected.forEach(p => {
43
+ const { url, pageTitle, items = [] } = p;
44
+ const itemsCount = items.length;
45
+
46
+ if (!pagesMap[url]) {
47
+ pagesMap[url] = {
48
+ pageTitle,
49
+ url,
50
+ totalOccurrencesFailedIncludingNeedsReview: 0,
51
+ totalOccurrencesFailedExcludingNeedsReview: 0,
52
+ totalOccurrencesNeedsReview: 0,
53
+ totalOccurrencesPassed: 0,
54
+ typesOfIssues: {},
55
+ };
56
+ }
57
+
58
+ if (!pagesMap[url].typesOfIssues[ruleId]) {
59
+ pagesMap[url].typesOfIssues[ruleId] = {
60
+ ruleId,
61
+ wcagConformance: conformance,
62
+ occurrencesMustFix: 0,
63
+ occurrencesGoodToFix: 0,
64
+ occurrencesNeedsReview: 0,
65
+ occurrencesPassed: 0,
66
+ };
67
+ }
68
+
69
+ if (categoryName === mustFixCategory) {
70
+ pagesMap[url].typesOfIssues[ruleId].occurrencesMustFix += itemsCount;
71
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
72
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
73
+ } else if (categoryName === goodToFixCategory) {
74
+ pagesMap[url].typesOfIssues[ruleId].occurrencesGoodToFix += itemsCount;
75
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
76
+ pagesMap[url].totalOccurrencesFailedExcludingNeedsReview += itemsCount;
77
+ } else if (categoryName === needsReviewCategory) {
78
+ pagesMap[url].typesOfIssues[ruleId].occurrencesNeedsReview += itemsCount;
79
+ pagesMap[url].totalOccurrencesFailedIncludingNeedsReview += itemsCount;
80
+ pagesMap[url].totalOccurrencesNeedsReview += itemsCount;
81
+ } else if (categoryName === passedCategory) {
82
+ pagesMap[url].typesOfIssues[ruleId].occurrencesPassed += itemsCount;
83
+ pagesMap[url].totalOccurrencesPassed += itemsCount;
84
+ }
85
+ });
86
+ });
87
+ });
88
+
89
+ const pagesInMap = Object.values(pagesMap);
90
+ const pagesInMapUrls = new Set(Object.keys(pagesMap));
91
+
92
+ const pagesAllPassed = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview === 0);
93
+
94
+ const pagesNoEntries = allScannedPages
95
+ .filter(sp => !pagesInMapUrls.has(sp.url))
96
+ .map(sp => ({
97
+ pageTitle: sp.pageTitle,
98
+ url: sp.url,
99
+ totalOccurrencesFailedIncludingNeedsReview: 0,
100
+ totalOccurrencesFailedExcludingNeedsReview: 0,
101
+ totalOccurrencesNeedsReview: 0,
102
+ totalOccurrencesPassed: 0,
103
+ typesOfIssues: {},
104
+ }));
105
+
106
+ const pagesNotAffectedRaw = [...pagesAllPassed, ...pagesNoEntries];
107
+ const pagesAffectedRaw = pagesInMap.filter(p => p.totalOccurrencesFailedIncludingNeedsReview > 0);
108
+
109
+ function transformPageData(page: PageData) {
110
+ const typesOfIssuesArray = Object.values(page.typesOfIssues);
111
+ const mustFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesMustFix, 0);
112
+ const goodToFixSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesGoodToFix, 0);
113
+ const needsReviewSum = typesOfIssuesArray.reduce((acc, r) => acc + r.occurrencesNeedsReview, 0);
114
+
115
+ const categoriesPresent: string[] = [];
116
+ if (mustFixSum > 0) categoriesPresent.push('mustFix');
117
+ if (goodToFixSum > 0) categoriesPresent.push('goodToFix');
118
+ if (needsReviewSum > 0) categoriesPresent.push('needsReview');
119
+
120
+ const failedRuleIds = new Set<string>();
121
+ typesOfIssuesArray.forEach(r => {
122
+ if (
123
+ (r.occurrencesMustFix || 0) > 0 ||
124
+ (r.occurrencesGoodToFix || 0) > 0 ||
125
+ (r.occurrencesNeedsReview || 0) > 0
126
+ ) {
127
+ failedRuleIds.add(r.ruleId);
128
+ }
129
+ });
130
+ const failedRuleCount = failedRuleIds.size;
131
+
132
+ const typesOfIssuesExcludingNeedsReviewCount = typesOfIssuesArray.filter(
133
+ r => (r.occurrencesMustFix || 0) + (r.occurrencesGoodToFix || 0) > 0,
134
+ ).length;
135
+
136
+ const typesOfIssuesExclusiveToNeedsReviewCount = typesOfIssuesArray.filter(
137
+ r =>
138
+ (r.occurrencesNeedsReview || 0) > 0 &&
139
+ (r.occurrencesMustFix || 0) === 0 &&
140
+ (r.occurrencesGoodToFix || 0) === 0,
141
+ ).length;
142
+
143
+ const allConformance = typesOfIssuesArray.reduce((acc, curr) => {
144
+ const nonPassedCount =
145
+ (curr.occurrencesMustFix || 0) +
146
+ (curr.occurrencesGoodToFix || 0) +
147
+ (curr.occurrencesNeedsReview || 0);
148
+
149
+ if (nonPassedCount > 0) {
150
+ return acc.concat(curr.wcagConformance || []);
151
+ }
152
+ return acc;
153
+ }, [] as string[]);
154
+ const conformance = Array.from(new Set(allConformance));
155
+
156
+ return {
157
+ pageTitle: page.pageTitle,
158
+ url: page.url,
159
+ totalOccurrencesFailedIncludingNeedsReview: page.totalOccurrencesFailedIncludingNeedsReview,
160
+ totalOccurrencesFailedExcludingNeedsReview: page.totalOccurrencesFailedExcludingNeedsReview,
161
+ totalOccurrencesMustFix: mustFixSum,
162
+ totalOccurrencesGoodToFix: goodToFixSum,
163
+ totalOccurrencesNeedsReview: needsReviewSum,
164
+ totalOccurrencesPassed: page.totalOccurrencesPassed,
165
+ typesOfIssuesExclusiveToNeedsReviewCount,
166
+ typesOfIssuesCount: failedRuleCount,
167
+ typesOfIssuesExcludingNeedsReviewCount,
168
+ categoriesPresent,
169
+ conformance,
170
+ typesOfIssues: typesOfIssuesArray,
171
+ };
172
+ }
173
+
174
+ const pagesAffected = pagesAffectedRaw.map(transformPageData);
175
+ const pagesNotAffected = pagesNotAffectedRaw.map(transformPageData);
176
+ pagesAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
177
+ pagesNotAffected.sort((a, b) => b.typesOfIssuesCount - a.typesOfIssuesCount);
178
+
179
+ const scannedPagesCount = pagesAffected.length + pagesNotAffected.length;
180
+ const pagesNotScannedCount = Array.isArray(allIssues.pagesNotScanned)
181
+ ? allIssues.pagesNotScanned.length
182
+ : 0;
183
+
184
+ allIssues.scanPagesDetail = {
185
+ pagesAffected,
186
+ pagesNotAffected,
187
+ scannedPagesCount,
188
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
189
+ pagesNotScannedCount,
190
+ };
191
+
192
+ function stripTypesOfIssues(page: ReturnType<typeof transformPageData>) {
193
+ const { typesOfIssues, ...rest } = page;
194
+ return rest;
195
+ }
196
+
197
+ const summaryPagesAffected = pagesAffected.map(stripTypesOfIssues);
198
+ const summaryPagesNotAffected = pagesNotAffected.map(stripTypesOfIssues);
199
+
200
+ allIssues.scanPagesSummary = {
201
+ pagesAffected: summaryPagesAffected,
202
+ pagesNotAffected: summaryPagesNotAffected,
203
+ scannedPagesCount,
204
+ pagesNotScanned: Array.isArray(allIssues.pagesNotScanned) ? allIssues.pagesNotScanned : [],
205
+ pagesNotScannedCount,
206
+ };
207
+ }
@@ -0,0 +1,183 @@
1
+ import * as Sentry from '@sentry/node';
2
+ import { sentryConfig, setSentryUser } from '../constants/constants.js';
3
+ import { categorizeWcagCriteria, getUserDataTxt, getWcagCriteriaMap } from '../utils.js';
4
+ import type { AllIssues } from './types.js';
5
+
6
+ // Format WCAG tag in requested format: wcag111a_Occurrences
7
+ const formatWcagTag = async (wcagId: string): Promise<string | null> => {
8
+ // Get dynamic WCAG criteria map
9
+ const wcagCriteriaMap = await getWcagCriteriaMap();
10
+
11
+ if (wcagCriteriaMap[wcagId]) {
12
+ const { level } = wcagCriteriaMap[wcagId];
13
+ return `${wcagId}${level}_Occurrences`;
14
+ }
15
+ return null;
16
+ };
17
+
18
+ // Send WCAG criteria breakdown to Sentry
19
+ const sendWcagBreakdownToSentry = async (
20
+ appVersion: string,
21
+ wcagBreakdown: Map<string, number>,
22
+ ruleIdJson: any,
23
+ scanInfo: {
24
+ entryUrl: string;
25
+ scanType: string;
26
+ browser: string;
27
+ email?: string;
28
+ name?: string;
29
+ },
30
+ allIssues?: AllIssues,
31
+ pagesScannedCount: number = 0,
32
+ ) => {
33
+ try {
34
+ // Initialize Sentry
35
+ Sentry.init(sentryConfig);
36
+ // Set user ID for Sentry tracking
37
+ const userData = getUserDataTxt();
38
+ if (userData && userData.userId) {
39
+ setSentryUser(userData.userId);
40
+ }
41
+
42
+ // Prepare tags for the event
43
+ const tags: Record<string, string> = {};
44
+ const wcagCriteriaBreakdown: Record<string, any> = {};
45
+
46
+ // Tag app version
47
+ tags.version = appVersion;
48
+
49
+ // Get dynamic WCAG criteria map once
50
+ const wcagCriteriaMap = await getWcagCriteriaMap();
51
+
52
+ // Categorize all WCAG criteria for reporting
53
+ const wcagIds = Array.from(
54
+ new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]),
55
+ );
56
+ const categorizedWcag = await categorizeWcagCriteria(wcagIds);
57
+
58
+ // First ensure all WCAG criteria are included in the tags with a value of 0
59
+ // This ensures criteria with no violations are still reported
60
+ for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
61
+ const formattedTag = await formatWcagTag(wcagId);
62
+ if (formattedTag) {
63
+ // Initialize with zero
64
+ tags[formattedTag] = '0';
65
+
66
+ // Store in breakdown object with category information
67
+ wcagCriteriaBreakdown[formattedTag] = {
68
+ count: 0,
69
+ category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
70
+ };
71
+ }
72
+ }
73
+
74
+ // Now override with actual counts from the scan
75
+ for (const [wcagId, count] of wcagBreakdown.entries()) {
76
+ const formattedTag = await formatWcagTag(wcagId);
77
+ if (formattedTag) {
78
+ // Add as a tag with the count as value
79
+ tags[formattedTag] = String(count);
80
+
81
+ // Update count in breakdown object
82
+ if (wcagCriteriaBreakdown[formattedTag]) {
83
+ wcagCriteriaBreakdown[formattedTag].count = count;
84
+ } else {
85
+ // If somehow this wasn't in our initial map
86
+ wcagCriteriaBreakdown[formattedTag] = {
87
+ count,
88
+ category: categorizedWcag[wcagId] || 'mustFix',
89
+ };
90
+ }
91
+ }
92
+ }
93
+
94
+ // Calculate category counts based on actual issue counts from the report
95
+ // rather than occurrence counts from wcagBreakdown
96
+ const categoryCounts = {
97
+ mustFix: 0,
98
+ goodToFix: 0,
99
+ needsReview: 0,
100
+ };
101
+
102
+ if (allIssues) {
103
+ // Use the actual report data for the counts
104
+ categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
105
+ categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
106
+ categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
107
+ } else {
108
+ // Fallback to the old way if allIssues not provided
109
+ Object.values(wcagCriteriaBreakdown).forEach(item => {
110
+ if (item.count > 0 && categoryCounts[item.category] !== undefined) {
111
+ categoryCounts[item.category] += 1; // Count rules, not occurrences
112
+ }
113
+ });
114
+ }
115
+
116
+ // Add category counts as tags
117
+ tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
118
+ tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
119
+ tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
120
+
121
+ // Also add occurrence counts for reference
122
+ if (allIssues) {
123
+ tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
124
+ tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
125
+ tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
126
+
127
+ // Add number of pages scanned tag
128
+ tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
129
+ } else if (pagesScannedCount > 0) {
130
+ // Still add the pages scanned count even if we don't have allIssues
131
+ tags['Pages-Scanned-Count'] = String(pagesScannedCount);
132
+ }
133
+
134
+ // Send the event to Sentry
135
+ await Sentry.captureEvent({
136
+ message: 'Accessibility Scan Completed',
137
+ level: 'info',
138
+ tags: {
139
+ ...tags,
140
+ event_type: 'accessibility_scan',
141
+ scanType: scanInfo.scanType,
142
+ browser: scanInfo.browser,
143
+ entryUrl: scanInfo.entryUrl,
144
+ },
145
+ user: {
146
+ ...(scanInfo.email && scanInfo.name
147
+ ? {
148
+ email: scanInfo.email,
149
+ username: scanInfo.name,
150
+ }
151
+ : {}),
152
+ ...(userData && userData.userId ? { id: userData.userId } : {}),
153
+ },
154
+ extra: {
155
+ additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : '{}',
156
+ wcagBreakdown: wcagCriteriaBreakdown,
157
+ reportCounts: allIssues
158
+ ? {
159
+ mustFix: {
160
+ issues: allIssues.items.mustFix.rules?.length ?? 0,
161
+ occurrences: allIssues.items.mustFix.totalItems ?? 0,
162
+ },
163
+ goodToFix: {
164
+ issues: allIssues.items.goodToFix.rules?.length ?? 0,
165
+ occurrences: allIssues.items.goodToFix.totalItems ?? 0,
166
+ },
167
+ needsReview: {
168
+ issues: allIssues.items.needsReview.rules?.length ?? 0,
169
+ occurrences: allIssues.items.needsReview.totalItems ?? 0,
170
+ },
171
+ }
172
+ : undefined,
173
+ },
174
+ });
175
+
176
+ // Wait for events to be sent
177
+ await Sentry.flush(2000);
178
+ } catch (error) {
179
+ console.error('Error sending WCAG breakdown to Sentry:', error);
180
+ }
181
+ };
182
+
183
+ export default sendWcagBreakdownToSentry;
@@ -0,0 +1,99 @@
1
+ export type ItemsInfo = {
2
+ html: string;
3
+ message: string;
4
+ screenshotPath: string;
5
+ xpath: string;
6
+ displayNeedsReview?: boolean;
7
+ };
8
+
9
+ export type PageInfo = {
10
+ items?: ItemsInfo[];
11
+ itemsCount?: number;
12
+ pageTitle: string;
13
+ url: string;
14
+ actualUrl: string;
15
+ pageImagePath?: string;
16
+ pageIndex?: number;
17
+ metadata?: string;
18
+ httpStatusCode?: number;
19
+ };
20
+
21
+ export type HtmlGroupItem = {
22
+ html: string;
23
+ xpath: string;
24
+ message: string;
25
+ screenshotPath: string;
26
+ displayNeedsReview?: boolean;
27
+ pageUrls: string[];
28
+ };
29
+
30
+ export type HtmlGroups = {
31
+ [htmlKey: string]: HtmlGroupItem;
32
+ };
33
+
34
+ export type RuleInfo = {
35
+ totalItems: number;
36
+ pagesAffected: PageInfo[];
37
+ pagesAffectedCount: number;
38
+ rule: string;
39
+ description: string;
40
+ axeImpact: string;
41
+ conformance: string[];
42
+ helpUrl: string;
43
+ htmlGroups?: HtmlGroups;
44
+ };
45
+
46
+ type Category = {
47
+ description: string;
48
+ totalItems: number;
49
+ totalRuleIssues: number;
50
+ rules: RuleInfo[];
51
+ };
52
+
53
+ export type AllIssues = {
54
+ storagePath: string;
55
+ oobeeAi: {
56
+ htmlETL: any;
57
+ rules: string[];
58
+ };
59
+ siteName: string;
60
+ startTime: Date;
61
+ endTime: Date;
62
+ urlScanned: string;
63
+ scanType: string;
64
+ deviceChosen: string;
65
+ formatAboutStartTime: (dateString: any) => string;
66
+ isCustomFlow: boolean;
67
+ pagesScanned: PageInfo[];
68
+ pagesNotScanned: PageInfo[];
69
+ totalPagesScanned: number;
70
+ totalPagesNotScanned: number;
71
+ totalItems: number;
72
+ topFiveMostIssues: Array<any>;
73
+ topTenPagesWithMostIssues: Array<any>;
74
+ topTenIssues: Array<any>;
75
+ wcagViolations: string[];
76
+ customFlowLabel: string;
77
+ oobeeAppVersion: string;
78
+ items: {
79
+ mustFix: Category;
80
+ goodToFix: Category;
81
+ needsReview: Category;
82
+ passed: Category;
83
+ };
84
+ cypressScanAboutMetadata: {
85
+ browser?: string;
86
+ viewport?: { width: number; height: number };
87
+ };
88
+ wcagLinks: { [key: string]: string };
89
+ wcagClauses: { [key: string]: string };
90
+ [key: string]: any;
91
+ advancedScanOptionsSummaryItems: { [key: string]: boolean };
92
+ scanPagesDetail: {
93
+ pagesAffected: any[];
94
+ pagesNotAffected: any[];
95
+ scannedPagesCount: number;
96
+ pagesNotScanned: any[];
97
+ pagesNotScannedCount: number;
98
+ };
99
+ };
@@ -0,0 +1,145 @@
1
+ import { createWriteStream } from 'fs';
2
+ import { AsyncParser, ParserOptions } from '@json2csv/node';
3
+ import { a11yRuleShortDescriptionMap } from '../constants/constants.js';
4
+ import type { AllIssues, RuleInfo } from './types.js';
5
+
6
+ const writeCsv = async (allIssues: AllIssues, storagePath: string): Promise<void> => {
7
+ const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
8
+ const formatPageViolation = (pageNum: number) => {
9
+ if (pageNum < 0) return 'Document';
10
+ return `Page ${pageNum}`;
11
+ };
12
+
13
+ // transform allIssues into the form:
14
+ // [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
15
+ const getRulesByCategory = (issues: AllIssues) => {
16
+ return Object.entries(issues.items)
17
+ .filter(([category]) => category !== 'passed')
18
+ .reduce((prev: [string, RuleInfo][], [category, value]) => {
19
+ const rulesEntries = Object.entries(value.rules);
20
+ rulesEntries.forEach(([, ruleInfo]) => {
21
+ prev.push([category, ruleInfo]);
22
+ });
23
+ return prev;
24
+ }, [])
25
+ .sort((a, b) => {
26
+ // sort rules according to severity, then ruleId
27
+ const compareCategory = -a[0].localeCompare(b[0]);
28
+ return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
29
+ });
30
+ };
31
+
32
+ const flattenRule = (catAndRule: [string, RuleInfo]) => {
33
+ const [severity, rule] = catAndRule;
34
+ const results = [];
35
+ const {
36
+ rule: issueId,
37
+ description: issueDescription,
38
+ axeImpact,
39
+ conformance,
40
+ pagesAffected,
41
+ helpUrl: learnMore,
42
+ } = rule;
43
+
44
+ // format clauses as a string
45
+ const wcagConformance = conformance.join(',');
46
+
47
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
48
+
49
+ pagesAffected.forEach(affectedPage => {
50
+ const { url, items } = affectedPage;
51
+ items.forEach(item => {
52
+ const { html, message, xpath } = item;
53
+ const page = (item as any).page;
54
+ const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
55
+ const violation = html || formatPageViolation(page); // page is a number, not a string
56
+ const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
57
+
58
+ results.push({
59
+ customFlowLabel: allIssues.customFlowLabel || '',
60
+ deviceChosen: allIssues.deviceChosen || '',
61
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
62
+ severity: severity || '',
63
+ issueId: issueId || '',
64
+ issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
65
+ wcagConformance: wcagConformance || '',
66
+ url: url || '',
67
+ pageTitle: affectedPage.pageTitle || 'No page title',
68
+ context: context || '',
69
+ howToFix: howToFix || '',
70
+ axeImpact: axeImpact || '',
71
+ xpath: xpath || '',
72
+ learnMore: learnMore || '',
73
+ });
74
+ });
75
+ });
76
+ if (results.length === 0) return {};
77
+ return results;
78
+ };
79
+
80
+ const opts: ParserOptions<any, any> = {
81
+ transforms: [getRulesByCategory, flattenRule],
82
+ fields: [
83
+ 'customFlowLabel',
84
+ 'deviceChosen',
85
+ 'scanCompletedAt',
86
+ 'severity',
87
+ 'issueId',
88
+ 'issueDescription',
89
+ 'wcagConformance',
90
+ 'url',
91
+ 'pageTitle',
92
+ 'context',
93
+ 'howToFix',
94
+ 'axeImpact',
95
+ 'xpath',
96
+ 'learnMore',
97
+ ],
98
+ includeEmptyRows: true,
99
+ };
100
+
101
+ // Create the parse stream (it's asynchronous)
102
+ const parser = new AsyncParser(opts);
103
+ const parseStream = parser.parse(allIssues);
104
+
105
+ // Pipe JSON2CSV output into the file, but don't end automatically
106
+ parseStream.pipe(csvOutput, { end: false });
107
+
108
+ // Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
109
+ parseStream.on('end', () => {
110
+ if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
111
+ csvOutput.write('\n');
112
+ allIssues.pagesNotScanned.forEach(page => {
113
+ const skippedPage = {
114
+ customFlowLabel: allIssues.customFlowLabel || '',
115
+ deviceChosen: allIssues.deviceChosen || '',
116
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
117
+ severity: 'error',
118
+ issueId: 'error-pages-skipped',
119
+ issueDescription: page.metadata
120
+ ? page.metadata
121
+ : 'An unknown error caused the page to be skipped',
122
+ wcagConformance: '',
123
+ url: page.url || page || '',
124
+ pageTitle: 'Error',
125
+ context: '',
126
+ howToFix: '',
127
+ axeImpact: '',
128
+ xpath: '',
129
+ learnMore: '',
130
+ };
131
+ csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
132
+ });
133
+ }
134
+
135
+ // Now close the CSV file
136
+ csvOutput.end();
137
+ });
138
+
139
+ parseStream.on('error', (err: unknown) => {
140
+ console.error('Error parsing CSV:', err);
141
+ csvOutput.end();
142
+ });
143
+ };
144
+
145
+ export default writeCsv;
@@ -0,0 +1,51 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ const streamEncodedDataToFile = async (
5
+ inputFilePath: string,
6
+ writeStream: fs.WriteStream,
7
+ appendComma: boolean,
8
+ ) => {
9
+ const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
10
+ let isFirstChunk = true;
11
+
12
+ for await (const chunk of readStream) {
13
+ if (isFirstChunk) {
14
+ isFirstChunk = false;
15
+ writeStream.write(chunk);
16
+ } else {
17
+ writeStream.write(chunk);
18
+ }
19
+ }
20
+
21
+ if (appendComma) {
22
+ writeStream.write(',');
23
+ }
24
+ };
25
+
26
+ const writeScanDetailsCsv = async (
27
+ scanDataFilePath: string,
28
+ scanItemsFilePath: string,
29
+ scanItemsSummaryFilePath: string,
30
+ storagePath: string,
31
+ ) => {
32
+ const filePath = path.join(storagePath, 'scanDetails.csv');
33
+ const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
34
+ const directoryPath = path.dirname(filePath);
35
+
36
+ if (!fs.existsSync(directoryPath)) {
37
+ fs.mkdirSync(directoryPath, { recursive: true });
38
+ }
39
+
40
+ csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
41
+ await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
42
+ await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
43
+ await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
44
+
45
+ await new Promise((resolve, reject) => {
46
+ csvWriteStream.end(resolve);
47
+ csvWriteStream.on('error', reject);
48
+ });
49
+ };
50
+
51
+ export default writeScanDetailsCsv;
@@ -0,0 +1,13 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { consoleLogger } from '../logs.js';
4
+ import type { PageInfo } from './types.js';
5
+
6
+ const writeSitemap = async (pagesScanned: PageInfo[], storagePath: string): Promise<void> => {
7
+ const sitemapPath = path.join(storagePath, 'sitemap.txt');
8
+ const content = pagesScanned.map(p => p.url).join('\n');
9
+ await fs.writeFile(sitemapPath, content, { encoding: 'utf-8' });
10
+ consoleLogger.info(`Sitemap written to ${sitemapPath}`);
11
+ };
12
+
13
+ export default writeSitemap;