@govtechsg/oobee 0.10.42 → 0.10.45
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.
- package/REPORTS.md +71 -2
- package/package.json +4 -2
- package/src/cli.ts +2 -11
- package/src/constants/common.ts +216 -76
- package/src/constants/constants.ts +89 -1
- package/src/constants/oobeeAi.ts +6 -6
- package/src/constants/questions.ts +3 -2
- package/src/crawlers/commonCrawlerFunc.ts +16 -15
- package/src/crawlers/crawlDomain.ts +82 -84
- package/src/crawlers/crawlIntelligentSitemap.ts +21 -19
- package/src/crawlers/crawlSitemap.ts +120 -109
- package/src/crawlers/custom/findElementByCssSelector.ts +1 -1
- package/src/crawlers/custom/flagUnlabelledClickableElements.ts +8 -8
- package/src/crawlers/custom/xPathToCss.ts +10 -10
- package/src/crawlers/runCustom.ts +1 -1
- package/src/index.ts +3 -4
- package/src/logs.ts +1 -1
- package/src/mergeAxeResults.ts +126 -7
- package/src/npmIndex.ts +12 -8
- package/src/screenshotFunc/htmlScreenshotFunc.ts +8 -20
- package/src/types/text-readability.d.ts +3 -0
- package/src/types/types.ts +1 -1
- package/src/utils.ts +254 -114
- package/src/xPathToCss.ts +0 -186
- package/src/xPathToCssCypress.ts +0 -178
package/src/mergeAxeResults.ts
CHANGED
@@ -12,8 +12,10 @@ import { AsyncParser, ParserOptions } from '@json2csv/node';
|
|
12
12
|
import zlib from 'zlib';
|
13
13
|
import { Base64Encode } from 'base64-stream';
|
14
14
|
import { pipeline } from 'stream/promises';
|
15
|
-
import constants, { ScannerTypes } from './constants/constants.js';
|
15
|
+
import constants, { ScannerTypes, sentryConfig } from './constants/constants.js';
|
16
16
|
import { urlWithoutAuth } from './constants/common.js';
|
17
|
+
// @ts-ignore
|
18
|
+
import * as Sentry from '@sentry/node';
|
17
19
|
import {
|
18
20
|
createScreenshotsFolder,
|
19
21
|
getStoragePath,
|
@@ -23,6 +25,7 @@ import {
|
|
23
25
|
retryFunction,
|
24
26
|
zipResults,
|
25
27
|
getIssuesPercentage,
|
28
|
+
getWcagCriteriaMap,
|
26
29
|
} from './utils.js';
|
27
30
|
import { consoleLogger, silentLogger } from './logs.js';
|
28
31
|
import itemTypeDescription from './constants/itemTypeDescription.js';
|
@@ -45,6 +48,7 @@ export type PageInfo = {
|
|
45
48
|
pageImagePath?: string;
|
46
49
|
pageIndex?: number;
|
47
50
|
metadata?: string;
|
51
|
+
httpStatusCode?: number;
|
48
52
|
};
|
49
53
|
|
50
54
|
export type RuleInfo = {
|
@@ -230,7 +234,7 @@ const writeCsv = async (allIssues, storagePath) => {
|
|
230
234
|
includeEmptyRows: true,
|
231
235
|
};
|
232
236
|
|
233
|
-
// Create the parse stream (it
|
237
|
+
// Create the parse stream (it's asynchronous)
|
234
238
|
const parser = new AsyncParser(opts);
|
235
239
|
const parseStream = parser.parse(allIssues);
|
236
240
|
|
@@ -248,7 +252,7 @@ const writeCsv = async (allIssues, storagePath) => {
|
|
248
252
|
scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
|
249
253
|
severity: 'error',
|
250
254
|
issueId: 'error-pages-skipped',
|
251
|
-
issueDescription: '
|
255
|
+
issueDescription: page.metadata ? page.metadata : 'An unknown error caused the page to be skipped',
|
252
256
|
wcagConformance: '',
|
253
257
|
url: page.url || page || '',
|
254
258
|
pageTitle: 'Error',
|
@@ -791,25 +795,21 @@ const writeJsonAndBase64Files = async (
|
|
791
795
|
items.mustFix.rules.forEach(rule => {
|
792
796
|
rule.pagesAffected.forEach(page => {
|
793
797
|
page.itemsCount = page.items.length;
|
794
|
-
page.items = [];
|
795
798
|
});
|
796
799
|
});
|
797
800
|
items.goodToFix.rules.forEach(rule => {
|
798
801
|
rule.pagesAffected.forEach(page => {
|
799
802
|
page.itemsCount = page.items.length;
|
800
|
-
page.items = [];
|
801
803
|
});
|
802
804
|
});
|
803
805
|
items.needsReview.rules.forEach(rule => {
|
804
806
|
rule.pagesAffected.forEach(page => {
|
805
807
|
page.itemsCount = page.items.length;
|
806
|
-
page.items = [];
|
807
808
|
});
|
808
809
|
});
|
809
810
|
items.passed.rules.forEach(rule => {
|
810
811
|
rule.pagesAffected.forEach(page => {
|
811
812
|
page.itemsCount = page.items.length;
|
812
|
-
page.items = [];
|
813
813
|
});
|
814
814
|
});
|
815
815
|
|
@@ -988,6 +988,21 @@ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filena
|
|
988
988
|
}
|
989
989
|
};
|
990
990
|
|
991
|
+
// Tracking WCAG occurrences
|
992
|
+
const wcagOccurrencesMap = new Map<string, number>();
|
993
|
+
|
994
|
+
// Format WCAG tag in requested format: wcag111a_Occurrences
|
995
|
+
const formatWcagTag = async (wcagId: string): Promise<string | null> => {
|
996
|
+
// Get dynamic WCAG criteria map
|
997
|
+
const wcagCriteriaMap = await getWcagCriteriaMap();
|
998
|
+
|
999
|
+
if (wcagCriteriaMap[wcagId]) {
|
1000
|
+
const { level } = wcagCriteriaMap[wcagId];
|
1001
|
+
return `${wcagId}${level}_Occurrences`;
|
1002
|
+
}
|
1003
|
+
return null;
|
1004
|
+
};
|
1005
|
+
|
991
1006
|
const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
992
1007
|
const { url, pageTitle, filePath } = pageResults;
|
993
1008
|
|
@@ -1039,6 +1054,10 @@ const pushResults = async (pageResults, allIssues, isCustomFlow) => {
|
|
1039
1054
|
if (!allIssues.wcagViolations.includes(c)) {
|
1040
1055
|
allIssues.wcagViolations.push(c);
|
1041
1056
|
}
|
1057
|
+
|
1058
|
+
// Track WCAG criteria occurrences for Sentry
|
1059
|
+
const currentCount = wcagOccurrencesMap.get(c) || 0;
|
1060
|
+
wcagOccurrencesMap.set(c, currentCount + count);
|
1042
1061
|
});
|
1043
1062
|
}
|
1044
1063
|
|
@@ -1205,6 +1224,7 @@ const createRuleIdJson = allIssues => {
|
|
1205
1224
|
});
|
1206
1225
|
});
|
1207
1226
|
snippets = [...snippetsSet];
|
1227
|
+
rule.pagesAffected.forEach(p => { delete p.items; });
|
1208
1228
|
}
|
1209
1229
|
compiledRuleJson[ruleId] = {
|
1210
1230
|
snippets,
|
@@ -1492,6 +1512,87 @@ function populateScanPagesDetail(allIssues: AllIssues): void {
|
|
1492
1512
|
};
|
1493
1513
|
}
|
1494
1514
|
|
1515
|
+
// Send WCAG criteria breakdown to Sentry
|
1516
|
+
const sendWcagBreakdownToSentry = async (
|
1517
|
+
wcagBreakdown: Map<string, number>,
|
1518
|
+
scanInfo: {
|
1519
|
+
entryUrl: string;
|
1520
|
+
scanType: string;
|
1521
|
+
browser: string;
|
1522
|
+
email?: string;
|
1523
|
+
name?: string;
|
1524
|
+
}
|
1525
|
+
) => {
|
1526
|
+
try {
|
1527
|
+
// Initialize Sentry
|
1528
|
+
Sentry.init(sentryConfig);
|
1529
|
+
|
1530
|
+
// Prepare tags for the event
|
1531
|
+
const tags: Record<string, string> = {};
|
1532
|
+
const wcagCriteriaBreakdown: Record<string, number> = {};
|
1533
|
+
|
1534
|
+
// Get dynamic WCAG criteria map once
|
1535
|
+
const wcagCriteriaMap = await getWcagCriteriaMap();
|
1536
|
+
|
1537
|
+
// First ensure all WCAG criteria are included in the tags with a value of 0
|
1538
|
+
// This ensures criteria with no violations are still reported
|
1539
|
+
for (const [wcagId, info] of Object.entries(wcagCriteriaMap)) {
|
1540
|
+
const formattedTag = await formatWcagTag(wcagId);
|
1541
|
+
if (formattedTag) {
|
1542
|
+
// Initialize with zero
|
1543
|
+
tags[formattedTag] = '0';
|
1544
|
+
wcagCriteriaBreakdown[formattedTag] = 0;
|
1545
|
+
}
|
1546
|
+
}
|
1547
|
+
|
1548
|
+
// Now override with actual counts from the scan
|
1549
|
+
for (const [wcagId, count] of wcagBreakdown.entries()) {
|
1550
|
+
const formattedTag = await formatWcagTag(wcagId);
|
1551
|
+
if (formattedTag) {
|
1552
|
+
// Add as a tag with the count as value
|
1553
|
+
tags[formattedTag] = String(count);
|
1554
|
+
|
1555
|
+
// Store in breakdown object for the extra data
|
1556
|
+
wcagCriteriaBreakdown[formattedTag] = count;
|
1557
|
+
}
|
1558
|
+
}
|
1559
|
+
|
1560
|
+
// Calculate the WCAG passing percentage
|
1561
|
+
const totalCriteria = Object.keys(wcagCriteriaMap).length;
|
1562
|
+
const violatedCriteria = wcagBreakdown.size;
|
1563
|
+
const passingPercentage = Math.round(((totalCriteria - violatedCriteria) / totalCriteria) * 100);
|
1564
|
+
|
1565
|
+
// Add the percentage as a tag
|
1566
|
+
tags['WCAG-Percentage-Passed'] = String(passingPercentage);
|
1567
|
+
|
1568
|
+
// Send the event to Sentry
|
1569
|
+
await Sentry.captureEvent({
|
1570
|
+
message: `WCAG Accessibility Scan Results for ${scanInfo.entryUrl}`,
|
1571
|
+
level: 'info',
|
1572
|
+
tags: {
|
1573
|
+
...tags,
|
1574
|
+
event_type: 'accessibility_scan',
|
1575
|
+
scanType: scanInfo.scanType,
|
1576
|
+
browser: scanInfo.browser,
|
1577
|
+
},
|
1578
|
+
user: scanInfo.email && scanInfo.name ? {
|
1579
|
+
email: scanInfo.email,
|
1580
|
+
username: scanInfo.name
|
1581
|
+
} : undefined,
|
1582
|
+
extra: {
|
1583
|
+
entryUrl: scanInfo.entryUrl,
|
1584
|
+
wcagBreakdown: wcagCriteriaBreakdown,
|
1585
|
+
wcagPassPercentage: passingPercentage
|
1586
|
+
}
|
1587
|
+
});
|
1588
|
+
|
1589
|
+
// Wait for events to be sent
|
1590
|
+
await Sentry.flush(2000);
|
1591
|
+
} catch (error) {
|
1592
|
+
console.error('Error sending WCAG breakdown to Sentry:', error);
|
1593
|
+
}
|
1594
|
+
};
|
1595
|
+
|
1495
1596
|
const generateArtifacts = async (
|
1496
1597
|
randomToken: string,
|
1497
1598
|
urlScanned: string,
|
@@ -1514,6 +1615,7 @@ const generateArtifacts = async (
|
|
1514
1615
|
isEnableWcagAaa: string[];
|
1515
1616
|
isSlowScanMode: number;
|
1516
1617
|
isAdhereRobots: boolean;
|
1618
|
+
nameEmail?: { name: string; email: string };
|
1517
1619
|
},
|
1518
1620
|
zip: string = undefined, // optional
|
1519
1621
|
generateJsonFiles = false,
|
@@ -1808,6 +1910,23 @@ const generateArtifacts = async (
|
|
1808
1910
|
printMessage([`Error in zipping results: ${error}`]);
|
1809
1911
|
});
|
1810
1912
|
|
1913
|
+
// At the end of the function where results are generated, add:
|
1914
|
+
try {
|
1915
|
+
// Always send WCAG breakdown to Sentry, even if no violations were found
|
1916
|
+
// This ensures that all criteria are reported, including those with 0 occurrences
|
1917
|
+
await sendWcagBreakdownToSentry(wcagOccurrencesMap, {
|
1918
|
+
entryUrl: urlScanned,
|
1919
|
+
scanType: scanType,
|
1920
|
+
browser: scanDetails.deviceChosen,
|
1921
|
+
...(scanDetails.nameEmail && {
|
1922
|
+
email: scanDetails.nameEmail.email,
|
1923
|
+
name: scanDetails.nameEmail.name
|
1924
|
+
})
|
1925
|
+
});
|
1926
|
+
} catch (error) {
|
1927
|
+
console.error('Error sending WCAG data to Sentry:', error);
|
1928
|
+
}
|
1929
|
+
|
1811
1930
|
return createRuleIdJson(allIssues);
|
1812
1931
|
};
|
1813
1932
|
|
package/src/npmIndex.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import fs from 'fs';
|
2
2
|
import path from 'path';
|
3
3
|
import printMessage from 'print-message';
|
4
|
-
import axe, { ImpactValue } from 'axe-core';
|
4
|
+
import axe, { AxeResults, ImpactValue } from 'axe-core';
|
5
5
|
import { fileURLToPath } from 'url';
|
6
6
|
import { EnqueueStrategy } from 'crawlee';
|
7
7
|
import constants, { BrowserTypes, RuleFlags, ScannerTypes } from './constants/constants.js';
|
@@ -16,7 +16,7 @@ import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCraw
|
|
16
16
|
import { createAndUpdateResultsFolders, createDetailsAndLogs } from './utils.js';
|
17
17
|
import generateArtifacts from './mergeAxeResults.js';
|
18
18
|
import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
|
19
|
-
import { silentLogger } from './logs.js';
|
19
|
+
import { consoleLogger, silentLogger } from './logs.js';
|
20
20
|
import { alertMessageOptions } from './constants/cliFunctions.js';
|
21
21
|
import { evaluateAltText } from './crawlers/custom/evaluateAltText.js';
|
22
22
|
import { escapeCssSelector } from './crawlers/custom/escapeCssSelector.js';
|
@@ -24,7 +24,7 @@ import { framesCheck } from './crawlers/custom/framesCheck.js';
|
|
24
24
|
import { findElementByCssSelector } from './crawlers/custom/findElementByCssSelector.js';
|
25
25
|
import { getAxeConfiguration } from './crawlers/custom/getAxeConfiguration.js';
|
26
26
|
import { flagUnlabelledClickableElements } from './crawlers/custom/flagUnlabelledClickableElements.js';
|
27
|
-
import
|
27
|
+
import xPathToCss from './crawlers/custom/xPathToCss.js';
|
28
28
|
import { extractText } from './crawlers/custom/extractText.js';
|
29
29
|
import { gradeReadability } from './crawlers/custom/gradeReadability.js';
|
30
30
|
|
@@ -65,7 +65,7 @@ export const init = async ({
|
|
65
65
|
specifiedMaxConcurrency?: number;
|
66
66
|
followRobots?: boolean;
|
67
67
|
}) => {
|
68
|
-
|
68
|
+
consoleLogger.info('Starting Oobee');
|
69
69
|
|
70
70
|
const [date, time] = new Date().toLocaleString('sv').replaceAll(/-|:/g, '').split(' ');
|
71
71
|
const domain = new URL(entryUrl).hostname;
|
@@ -126,7 +126,7 @@ export const init = async ({
|
|
126
126
|
const cssSelector = xPathToCss(xpath);
|
127
127
|
return cssSelector;
|
128
128
|
} catch (e) {
|
129
|
-
|
129
|
+
consoleLogger.error(`Error converting XPath to CSS: ${xpath} - ${e}`);
|
130
130
|
return '';
|
131
131
|
}
|
132
132
|
})
|
@@ -197,7 +197,11 @@ export const init = async ({
|
|
197
197
|
`;
|
198
198
|
};
|
199
199
|
|
200
|
-
const pushScanResults = async (
|
200
|
+
const pushScanResults = async (
|
201
|
+
res: { pageUrl: string; pageTitle: string; axeScanResults: AxeResults },
|
202
|
+
metadata: string,
|
203
|
+
elementsToClick: string[],
|
204
|
+
) => {
|
201
205
|
throwErrorIfTerminated();
|
202
206
|
if (includeScreenshots) {
|
203
207
|
// use chrome by default
|
@@ -211,7 +215,7 @@ export const init = async ({
|
|
211
215
|
await page.waitForLoadState('networkidle');
|
212
216
|
|
213
217
|
// click on elements to reveal hidden elements so screenshots can be taken
|
214
|
-
elementsToClick?.forEach(async elem => {
|
218
|
+
elementsToClick?.forEach(async (elem: string) => {
|
215
219
|
try {
|
216
220
|
await page.locator(elem).click();
|
217
221
|
} catch (e) {
|
@@ -259,7 +263,7 @@ export const init = async ({
|
|
259
263
|
|
260
264
|
const terminate = async () => {
|
261
265
|
throwErrorIfTerminated();
|
262
|
-
|
266
|
+
consoleLogger.info('Stopping Oobee');
|
263
267
|
isInstanceTerminated = true;
|
264
268
|
scanDetails.endTime = new Date();
|
265
269
|
scanDetails.urlsCrawled = urlsCrawled;
|
@@ -7,7 +7,7 @@ import { Result } from 'axe-core';
|
|
7
7
|
import { Page } from 'playwright';
|
8
8
|
import { NodeResultWithScreenshot, ResultWithScreenshot } from '../crawlers/commonCrawlerFunc.js';
|
9
9
|
|
10
|
-
const screenshotMap = {}; // Map of screenshot hashkey to its buffer value and screenshot path
|
10
|
+
const screenshotMap: Record<string, string> = {}; // Map of screenshot hashkey to its buffer value and screenshot path
|
11
11
|
|
12
12
|
export const takeScreenshotForHTMLElements = async (
|
13
13
|
violations: Result[],
|
@@ -32,7 +32,7 @@ export const takeScreenshotForHTMLElements = async (
|
|
32
32
|
|
33
33
|
// Check if rule ID is 'oobee-grading-text-contents' and skip screenshot logic
|
34
34
|
if (rule === 'oobee-grading-text-contents') {
|
35
|
-
consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
|
35
|
+
// consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
|
36
36
|
newViolations.push(violation); // Make sure it gets added
|
37
37
|
continue;
|
38
38
|
}
|
@@ -75,30 +75,18 @@ export const takeScreenshotForHTMLElements = async (
|
|
75
75
|
return newViolations;
|
76
76
|
};
|
77
77
|
|
78
|
-
const generateBufferHash = (buffer: Buffer) => {
|
78
|
+
const generateBufferHash = (buffer: Buffer): string => {
|
79
79
|
const hash = createHash('sha256');
|
80
80
|
hash.update(buffer);
|
81
81
|
return hash.digest('hex');
|
82
82
|
};
|
83
83
|
|
84
|
-
const
|
85
|
-
|
86
|
-
|
87
|
-
};
|
88
|
-
|
89
|
-
const getIdenticalScreenshotKey = (buffer: Buffer) => {
|
90
|
-
for (const hashKey in screenshotMap) {
|
91
|
-
const isIdentical = isSameBufferHash(buffer, hashKey);
|
92
|
-
if (isIdentical) return hashKey;
|
93
|
-
}
|
94
|
-
return undefined;
|
95
|
-
};
|
96
|
-
|
97
|
-
const getScreenshotPath = (buffer: Buffer, randomToken: string) => {
|
98
|
-
let hashKey = getIdenticalScreenshotKey(buffer);
|
84
|
+
const getScreenshotPath = (buffer: Buffer, randomToken: string): string => {
|
85
|
+
let hashKey = generateBufferHash(buffer);
|
86
|
+
const existingPath = screenshotMap[hashKey];
|
99
87
|
// If exists identical entry in screenshot map, get its filepath
|
100
|
-
if (
|
101
|
-
return
|
88
|
+
if (existingPath) {
|
89
|
+
return existingPath;
|
102
90
|
}
|
103
91
|
// Create new entry in screenshot map
|
104
92
|
hashKey = generateBufferHash(buffer);
|