@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,152 @@
1
+ import * as Sentry from '@sentry/node';
2
+ import { sentryConfig, setSentryUser } from '../constants/constants.js';
3
+ import { categorizeWcagCriteria, getUserDataTxt, getWcagCriteriaMap } from '../utils.js';
4
+ // Format WCAG tag in requested format: wcag111a_Occurrences
5
+ const formatWcagTag = async (wcagId) => {
6
+ // Get dynamic WCAG criteria map
7
+ const wcagCriteriaMap = await getWcagCriteriaMap();
8
+ if (wcagCriteriaMap[wcagId]) {
9
+ const { level } = wcagCriteriaMap[wcagId];
10
+ return `${wcagId}${level}_Occurrences`;
11
+ }
12
+ return null;
13
+ };
14
+ // Send WCAG criteria breakdown to Sentry
15
+ const sendWcagBreakdownToSentry = async (appVersion, wcagBreakdown, ruleIdJson, scanInfo, allIssues, pagesScannedCount = 0) => {
16
+ try {
17
+ // Initialize Sentry
18
+ Sentry.init(sentryConfig);
19
+ // Set user ID for Sentry tracking
20
+ const userData = getUserDataTxt();
21
+ if (userData && userData.userId) {
22
+ setSentryUser(userData.userId);
23
+ }
24
+ // Prepare tags for the event
25
+ const tags = {};
26
+ const wcagCriteriaBreakdown = {};
27
+ // Tag app version
28
+ tags.version = appVersion;
29
+ // Get dynamic WCAG criteria map once
30
+ const wcagCriteriaMap = await getWcagCriteriaMap();
31
+ // Categorize all WCAG criteria for reporting
32
+ const wcagIds = Array.from(new Set([...Object.keys(wcagCriteriaMap), ...Array.from(wcagBreakdown.keys())]));
33
+ const categorizedWcag = await categorizeWcagCriteria(wcagIds);
34
+ // First ensure all WCAG criteria are included in the tags with a value of 0
35
+ // This ensures criteria with no violations are still reported
36
+ for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
37
+ const formattedTag = await formatWcagTag(wcagId);
38
+ if (formattedTag) {
39
+ // Initialize with zero
40
+ tags[formattedTag] = '0';
41
+ // Store in breakdown object with category information
42
+ wcagCriteriaBreakdown[formattedTag] = {
43
+ count: 0,
44
+ category: categorizedWcag[wcagId] || 'mustFix', // Default to mustFix if not found
45
+ };
46
+ }
47
+ }
48
+ // Now override with actual counts from the scan
49
+ for (const [wcagId, count] of wcagBreakdown.entries()) {
50
+ const formattedTag = await formatWcagTag(wcagId);
51
+ if (formattedTag) {
52
+ // Add as a tag with the count as value
53
+ tags[formattedTag] = String(count);
54
+ // Update count in breakdown object
55
+ if (wcagCriteriaBreakdown[formattedTag]) {
56
+ wcagCriteriaBreakdown[formattedTag].count = count;
57
+ }
58
+ else {
59
+ // If somehow this wasn't in our initial map
60
+ wcagCriteriaBreakdown[formattedTag] = {
61
+ count,
62
+ category: categorizedWcag[wcagId] || 'mustFix',
63
+ };
64
+ }
65
+ }
66
+ }
67
+ // Calculate category counts based on actual issue counts from the report
68
+ // rather than occurrence counts from wcagBreakdown
69
+ const categoryCounts = {
70
+ mustFix: 0,
71
+ goodToFix: 0,
72
+ needsReview: 0,
73
+ };
74
+ if (allIssues) {
75
+ // Use the actual report data for the counts
76
+ categoryCounts.mustFix = allIssues.items.mustFix.rules.length;
77
+ categoryCounts.goodToFix = allIssues.items.goodToFix.rules.length;
78
+ categoryCounts.needsReview = allIssues.items.needsReview.rules.length;
79
+ }
80
+ else {
81
+ // Fallback to the old way if allIssues not provided
82
+ Object.values(wcagCriteriaBreakdown).forEach(item => {
83
+ if (item.count > 0 && categoryCounts[item.category] !== undefined) {
84
+ categoryCounts[item.category] += 1; // Count rules, not occurrences
85
+ }
86
+ });
87
+ }
88
+ // Add category counts as tags
89
+ tags['WCAG-MustFix-Count'] = String(categoryCounts.mustFix);
90
+ tags['WCAG-GoodToFix-Count'] = String(categoryCounts.goodToFix);
91
+ tags['WCAG-NeedsReview-Count'] = String(categoryCounts.needsReview);
92
+ // Also add occurrence counts for reference
93
+ if (allIssues) {
94
+ tags['WCAG-MustFix-Occurrences'] = String(allIssues.items.mustFix.totalItems);
95
+ tags['WCAG-GoodToFix-Occurrences'] = String(allIssues.items.goodToFix.totalItems);
96
+ tags['WCAG-NeedsReview-Occurrences'] = String(allIssues.items.needsReview.totalItems);
97
+ // Add number of pages scanned tag
98
+ tags['Pages-Scanned-Count'] = String(allIssues.totalPagesScanned);
99
+ }
100
+ else if (pagesScannedCount > 0) {
101
+ // Still add the pages scanned count even if we don't have allIssues
102
+ tags['Pages-Scanned-Count'] = String(pagesScannedCount);
103
+ }
104
+ // Send the event to Sentry
105
+ await Sentry.captureEvent({
106
+ message: 'Accessibility Scan Completed',
107
+ level: 'info',
108
+ tags: {
109
+ ...tags,
110
+ event_type: 'accessibility_scan',
111
+ scanType: scanInfo.scanType,
112
+ browser: scanInfo.browser,
113
+ entryUrl: scanInfo.entryUrl,
114
+ },
115
+ user: {
116
+ ...(scanInfo.email && scanInfo.name
117
+ ? {
118
+ email: scanInfo.email,
119
+ username: scanInfo.name,
120
+ }
121
+ : {}),
122
+ ...(userData && userData.userId ? { id: userData.userId } : {}),
123
+ },
124
+ extra: {
125
+ additionalScanMetadata: ruleIdJson != null ? JSON.stringify(ruleIdJson) : '{}',
126
+ wcagBreakdown: wcagCriteriaBreakdown,
127
+ reportCounts: allIssues
128
+ ? {
129
+ mustFix: {
130
+ issues: allIssues.items.mustFix.rules?.length ?? 0,
131
+ occurrences: allIssues.items.mustFix.totalItems ?? 0,
132
+ },
133
+ goodToFix: {
134
+ issues: allIssues.items.goodToFix.rules?.length ?? 0,
135
+ occurrences: allIssues.items.goodToFix.totalItems ?? 0,
136
+ },
137
+ needsReview: {
138
+ issues: allIssues.items.needsReview.rules?.length ?? 0,
139
+ occurrences: allIssues.items.needsReview.totalItems ?? 0,
140
+ },
141
+ }
142
+ : undefined,
143
+ },
144
+ });
145
+ // Wait for events to be sent
146
+ await Sentry.flush(2000);
147
+ }
148
+ catch (error) {
149
+ console.error('Error sending WCAG breakdown to Sentry:', error);
150
+ }
151
+ };
152
+ export default sendWcagBreakdownToSentry;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,125 @@
1
+ import { createWriteStream } from 'fs';
2
+ import { AsyncParser } from '@json2csv/node';
3
+ import { a11yRuleShortDescriptionMap } from '../constants/constants.js';
4
+ const writeCsv = async (allIssues, storagePath) => {
5
+ const csvOutput = createWriteStream(`${storagePath}/report.csv`, { encoding: 'utf8' });
6
+ const formatPageViolation = (pageNum) => {
7
+ if (pageNum < 0)
8
+ return 'Document';
9
+ return `Page ${pageNum}`;
10
+ };
11
+ // transform allIssues into the form:
12
+ // [['mustFix', rule1], ['mustFix', rule2], ['goodToFix', rule3], ...]
13
+ const getRulesByCategory = (issues) => {
14
+ return Object.entries(issues.items)
15
+ .filter(([category]) => category !== 'passed')
16
+ .reduce((prev, [category, value]) => {
17
+ const rulesEntries = Object.entries(value.rules);
18
+ rulesEntries.forEach(([, ruleInfo]) => {
19
+ prev.push([category, ruleInfo]);
20
+ });
21
+ return prev;
22
+ }, [])
23
+ .sort((a, b) => {
24
+ // sort rules according to severity, then ruleId
25
+ const compareCategory = -a[0].localeCompare(b[0]);
26
+ return compareCategory === 0 ? a[1].rule.localeCompare(b[1].rule) : compareCategory;
27
+ });
28
+ };
29
+ const flattenRule = (catAndRule) => {
30
+ const [severity, rule] = catAndRule;
31
+ const results = [];
32
+ const { rule: issueId, description: issueDescription, axeImpact, conformance, pagesAffected, helpUrl: learnMore, } = rule;
33
+ // format clauses as a string
34
+ const wcagConformance = conformance.join(',');
35
+ pagesAffected.sort((a, b) => a.url.localeCompare(b.url));
36
+ pagesAffected.forEach(affectedPage => {
37
+ const { url, items } = affectedPage;
38
+ items.forEach(item => {
39
+ const { html, message, xpath } = item;
40
+ const page = item.page;
41
+ const howToFix = message.replace(/(\r\n|\n|\r)/g, '\\n'); // preserve newlines as \n
42
+ const violation = html || formatPageViolation(page); // page is a number, not a string
43
+ const context = violation.replace(/(\r\n|\n|\r)/g, ''); // remove newlines
44
+ results.push({
45
+ customFlowLabel: allIssues.customFlowLabel || '',
46
+ deviceChosen: allIssues.deviceChosen || '',
47
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
48
+ severity: severity || '',
49
+ issueId: issueId || '',
50
+ issueDescription: a11yRuleShortDescriptionMap[issueId] || issueDescription || '',
51
+ wcagConformance: wcagConformance || '',
52
+ url: url || '',
53
+ pageTitle: affectedPage.pageTitle || 'No page title',
54
+ context: context || '',
55
+ howToFix: howToFix || '',
56
+ axeImpact: axeImpact || '',
57
+ xpath: xpath || '',
58
+ learnMore: learnMore || '',
59
+ });
60
+ });
61
+ });
62
+ if (results.length === 0)
63
+ return {};
64
+ return results;
65
+ };
66
+ const opts = {
67
+ transforms: [getRulesByCategory, flattenRule],
68
+ fields: [
69
+ 'customFlowLabel',
70
+ 'deviceChosen',
71
+ 'scanCompletedAt',
72
+ 'severity',
73
+ 'issueId',
74
+ 'issueDescription',
75
+ 'wcagConformance',
76
+ 'url',
77
+ 'pageTitle',
78
+ 'context',
79
+ 'howToFix',
80
+ 'axeImpact',
81
+ 'xpath',
82
+ 'learnMore',
83
+ ],
84
+ includeEmptyRows: true,
85
+ };
86
+ // Create the parse stream (it's asynchronous)
87
+ const parser = new AsyncParser(opts);
88
+ const parseStream = parser.parse(allIssues);
89
+ // Pipe JSON2CSV output into the file, but don't end automatically
90
+ parseStream.pipe(csvOutput, { end: false });
91
+ // Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
92
+ parseStream.on('end', () => {
93
+ if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
94
+ csvOutput.write('\n');
95
+ allIssues.pagesNotScanned.forEach(page => {
96
+ const skippedPage = {
97
+ customFlowLabel: allIssues.customFlowLabel || '',
98
+ deviceChosen: allIssues.deviceChosen || '',
99
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
100
+ severity: 'error',
101
+ issueId: 'error-pages-skipped',
102
+ issueDescription: page.metadata
103
+ ? page.metadata
104
+ : 'An unknown error caused the page to be skipped',
105
+ wcagConformance: '',
106
+ url: page.url || page || '',
107
+ pageTitle: 'Error',
108
+ context: '',
109
+ howToFix: '',
110
+ axeImpact: '',
111
+ xpath: '',
112
+ learnMore: '',
113
+ };
114
+ csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
115
+ });
116
+ }
117
+ // Now close the CSV file
118
+ csvOutput.end();
119
+ });
120
+ parseStream.on('error', (err) => {
121
+ console.error('Error parsing CSV:', err);
122
+ csvOutput.end();
123
+ });
124
+ };
125
+ export default writeCsv;
@@ -0,0 +1,35 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ const streamEncodedDataToFile = async (inputFilePath, writeStream, appendComma) => {
4
+ const readStream = fs.createReadStream(inputFilePath, { encoding: 'utf8' });
5
+ let isFirstChunk = true;
6
+ for await (const chunk of readStream) {
7
+ if (isFirstChunk) {
8
+ isFirstChunk = false;
9
+ writeStream.write(chunk);
10
+ }
11
+ else {
12
+ writeStream.write(chunk);
13
+ }
14
+ }
15
+ if (appendComma) {
16
+ writeStream.write(',');
17
+ }
18
+ };
19
+ const writeScanDetailsCsv = async (scanDataFilePath, scanItemsFilePath, scanItemsSummaryFilePath, storagePath) => {
20
+ const filePath = path.join(storagePath, 'scanDetails.csv');
21
+ const csvWriteStream = fs.createWriteStream(filePath, { encoding: 'utf8' });
22
+ const directoryPath = path.dirname(filePath);
23
+ if (!fs.existsSync(directoryPath)) {
24
+ fs.mkdirSync(directoryPath, { recursive: true });
25
+ }
26
+ csvWriteStream.write('scanData_base64,scanItems_base64,scanItemsSummary_base64\n');
27
+ await streamEncodedDataToFile(scanDataFilePath, csvWriteStream, true);
28
+ await streamEncodedDataToFile(scanItemsFilePath, csvWriteStream, true);
29
+ await streamEncodedDataToFile(scanItemsSummaryFilePath, csvWriteStream, false);
30
+ await new Promise((resolve, reject) => {
31
+ csvWriteStream.end(resolve);
32
+ csvWriteStream.on('error', reject);
33
+ });
34
+ };
35
+ export default writeScanDetailsCsv;
@@ -0,0 +1,10 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { consoleLogger } from '../logs.js';
4
+ const writeSitemap = async (pagesScanned, storagePath) => {
5
+ const sitemapPath = path.join(storagePath, 'sitemap.txt');
6
+ const content = pagesScanned.map(p => p.url).join('\n');
7
+ await fs.writeFile(sitemapPath, content, { encoding: 'utf-8' });
8
+ consoleLogger.info(`Sitemap written to ${sitemapPath}`);
9
+ };
10
+ export default writeSitemap;