@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.
@@ -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 (its asynchronous)
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: 'Page was skipped during the scan',
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 { xPathToCss } from './crawlers/custom/xPathToCss.js';
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
- console.log('Starting Oobee');
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
- console.error('Error converting XPath to CSS: ', xpath, e);
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 (res, metadata, elementsToClick) => {
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
- console.log('Stopping Oobee');
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 isSameBufferHash = (buffer: Buffer, hash: string) => {
85
- const bufferHash = generateBufferHash(buffer);
86
- return hash === bufferHash;
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 (hashKey) {
101
- return screenshotMap[hashKey];
88
+ if (existingPath) {
89
+ return existingPath;
102
90
  }
103
91
  // Create new entry in screenshot map
104
92
  hashKey = generateBufferHash(buffer);
@@ -0,0 +1,3 @@
1
+ declare module 'text-readability' {
2
+ function fleschReadingEase(text: string): number;
3
+ }
@@ -21,7 +21,7 @@ export type StructureTree = {
21
21
  pageIndex?: number;
22
22
  };
23
23
 
24
- type DeviceDescriptor = {
24
+ export type DeviceDescriptor = {
25
25
  viewport: ViewportSize;
26
26
  userAgent: string;
27
27
  deviceScaleFactor: number;