@govtechsg/oobee 0.10.28 → 0.10.29

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/Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
1
  # Use Microsoft Playwright image as base image
2
2
  # Node version is v22
3
- FROM mcr.microsoft.com/playwright:v1.49.1-jammy
3
+ FROM mcr.microsoft.com/playwright:v1.50.0-noble
4
4
 
5
5
  # Installation of packages for oobee and runner
6
6
  RUN apt-get update && apt-get install -y zip git
package/INSTALLATION.md CHANGED
@@ -6,7 +6,7 @@ Oobee (CLI) is provided as a portable distribution which minimises installation
6
6
 
7
7
  Oobee is a customisable, automated accessibility testing tool that allows software development teams to find and fix accessibility problems to improve persons with disabilities (PWDs) access to digital services.
8
8
 
9
- Oobee (CLI) allows software engineers to run Oobee as part of their software development environment as the command line, as well as [integrate it into their CI/CD pipleline](https://github.com/GovTechSG/oobee/blob/master/INTEGRATION.md).
9
+ Oobee (CLI) allows software engineers to run Oobee as part of their software development environment as the command line, as well as [integrate it into their CI/CD pipleline](INTEGRATION.md).
10
10
 
11
11
  ## System Requirements
12
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@govtechsg/oobee",
3
3
  "main": "dist/npmIndex.js",
4
- "version": "0.10.28",
4
+ "version": "0.10.29",
5
5
  "type": "module",
6
6
  "author": "Government Technology Agency <info@tech.gov.sg>",
7
7
  "dependencies": {
@@ -23,7 +23,7 @@
23
23
  "mime-types": "^2.1.35",
24
24
  "minimatch": "^9.0.3",
25
25
  "pdfjs-dist": "github:veraPDF/pdfjs-dist#v4.4.168-taggedPdf-0.1.20",
26
- "playwright": "1.49.1",
26
+ "playwright": "1.50.1",
27
27
  "prettier": "^3.1.0",
28
28
  "print-message": "^3.0.1",
29
29
  "safe-regex": "^2.1.1",
package/src/combine.ts CHANGED
@@ -97,6 +97,7 @@ const combineRun = async (details: Data, deviceToScan: string) => {
97
97
  isEnableWcagAaa: envDetails.ruleset,
98
98
  isSlowScanMode: envDetails.specifiedMaxConcurrency,
99
99
  isAdhereRobots: envDetails.followRobots,
100
+ deviceChosen: deviceToScan,
100
101
  };
101
102
 
102
103
  const viewportSettings: ViewportSettingsClass = new ViewportSettingsClass(
@@ -269,7 +269,7 @@ export const cliOptions: { [key: string]: Options } = {
269
269
  default: 'default',
270
270
  coerce: option => {
271
271
  const validChoices = Object.values(RuleFlags);
272
- const userChoices: string[] = option.split(',');
272
+ const userChoices: string[] = String(option).split(',');
273
273
  const invalidUserChoices = userChoices.filter(
274
274
  choice => !validChoices.includes(choice as RuleFlags),
275
275
  );
@@ -1,7 +1,7 @@
1
1
  import crawlee, { CrawlingContext, PlaywrightGotoOptions } from 'crawlee';
2
2
  import axe, { AxeResults, ImpactValue, NodeResult, Result, resultGroups, TagValue } from 'axe-core';
3
- import { xPathToCss } from '../xPathToCss.js';
4
3
  import { BrowserContext, Page } from 'playwright';
4
+ import { xPathToCss } from '../xPathToCss.js';
5
5
  import {
6
6
  axeScript,
7
7
  guiInfoStatusTypes,
@@ -357,24 +357,28 @@ export const runAxeScript = async ({
357
357
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
358
358
  },
359
359
  },
360
- {
361
- ...customAxeConfig.checks[2],
362
- evaluate: (_node: HTMLElement) => {
363
- if (gradingReadabilityFlag === '') {
364
- return true; // Pass if no readability issues
365
- }
366
- // Dynamically update the grading messages
367
- const gradingCheck = customAxeConfig.checks.find(
368
- check => check.id === 'oobee-grading-text-contents',
369
- );
370
- if (gradingCheck) {
371
- gradingCheck.metadata.messages.incomplete = `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag
372
- }.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`;
373
- }
374
-
375
- // Fail if readability issues are detected
376
- },
377
- },
360
+ ...(enableWcagAaa
361
+ ? [
362
+ {
363
+ ...customAxeConfig.checks[2],
364
+ evaluate: (_node: HTMLElement) => {
365
+ if (gradingReadabilityFlag === '') {
366
+ return true; // Pass if no readability issues
367
+ }
368
+ // Dynamically update the grading messages
369
+ const gradingCheck = customAxeConfig.checks.find(
370
+ check => check.id === 'oobee-grading-text-contents',
371
+ );
372
+ if (gradingCheck) {
373
+ gradingCheck.metadata.messages.incomplete = `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag
374
+ }.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`;
375
+ }
376
+
377
+ // Fail if readability issues are detected
378
+ },
379
+ },
380
+ ]
381
+ : []),
378
382
  ],
379
383
  rules: customAxeConfig.rules
380
384
  .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
@@ -416,9 +420,12 @@ export const runAxeScript = async ({
416
420
  const escapedCssSelectors =
417
421
  oobeeAccessibleLabelFlaggedCssSelectors.map(escapeCSSSelector);
418
422
 
419
- function framesCheck(cssSelector: string): { doc: Document; remainingSelector: string } {
423
+ function framesCheck(cssSelector: string): {
424
+ doc: Document;
425
+ remainingSelector: string;
426
+ } {
420
427
  let doc = document; // Start with the main document
421
- let remainingSelector = ""; // To store the last part of the selector
428
+ let remainingSelector = ''; // To store the last part of the selector
422
429
  let targetIframe = null;
423
430
 
424
431
  // Split the selector into parts at "> html"
@@ -429,18 +436,18 @@ export const runAxeScript = async ({
429
436
 
430
437
  // Add back '> html' to the current part
431
438
  if (i > 0) {
432
- iframeSelector = "html > " + iframeSelector;
439
+ iframeSelector = `html > ${iframeSelector}`;
433
440
  }
434
441
 
435
442
  let frameset = null;
436
443
  // Find the iframe using the current document context
437
- if (doc.querySelector("frameset")) {
438
- frameset = doc.querySelector("frameset");
444
+ if (doc.querySelector('frameset')) {
445
+ frameset = doc.querySelector('frameset');
439
446
  }
440
447
 
441
448
  if (frameset) {
442
449
  doc = frameset;
443
- iframeSelector = iframeSelector.split("body >")[1].trim();
450
+ iframeSelector = iframeSelector.split('body >')[1].trim();
444
451
  }
445
452
  targetIframe = doc.querySelector(iframeSelector);
446
453
 
@@ -448,7 +455,9 @@ export const runAxeScript = async ({
448
455
  // Update the document to the iframe's contentDocument
449
456
  doc = targetIframe.contentDocument;
450
457
  } else {
451
- console.warn(`Iframe not found or contentDocument inaccessible for selector: ${iframeSelector}`);
458
+ console.warn(
459
+ `Iframe not found or contentDocument inaccessible for selector: ${iframeSelector}`,
460
+ );
452
461
  return { doc, remainingSelector: cssSelector }; // Return original selector if iframe not found
453
462
  }
454
463
  }
@@ -457,19 +466,18 @@ export const runAxeScript = async ({
457
466
  remainingSelector = diffParts[diffParts.length - 1].trim();
458
467
 
459
468
  // Remove any leading '>' combinators from remainingSelector
460
- remainingSelector = "html" + remainingSelector;
469
+ remainingSelector = `html${remainingSelector}`;
461
470
 
462
471
  return { doc, remainingSelector };
463
472
  }
464
473
 
465
-
466
474
  function findElementByCssSelector(cssSelector: string): string | null {
467
475
  let doc = document;
468
476
 
469
477
  // Check if the selector includes 'frame' or 'iframe' and update doc and selector
470
478
 
471
479
  if (/\s*>\s*html\s*/.test(cssSelector)) {
472
- let inFrames = framesCheck(cssSelector)
480
+ const inFrames = framesCheck(cssSelector);
473
481
  doc = inFrames.doc;
474
482
  cssSelector = inFrames.remainingSelector;
475
483
  }
@@ -515,24 +523,26 @@ export const runAxeScript = async ({
515
523
  description: 'Ensures clickable elements have an accessible label.',
516
524
  help: 'Clickable elements (i.e. elements with mouse-click interaction) must have accessible labels.',
517
525
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
518
- nodes: escapedCssSelectors.map(cssSelector => ({
519
- html: findElementByCssSelector(cssSelector),
520
- target: [cssSelector],
521
- impact: 'serious' as ImpactValue,
522
- failureSummary:
523
- 'Fix any of the following:\n The clickable element does not have an accessible label.',
524
- any: [
525
- {
526
- id: 'oobee-accessible-label',
527
- data: null,
528
- relatedNodes: [],
529
- impact: 'serious',
530
- message: 'The clickable element does not have an accessible label.',
531
- },
532
- ],
533
- all: [],
534
- none: [],
535
- })).filter(item => item.html)
526
+ nodes: escapedCssSelectors
527
+ .map(cssSelector => ({
528
+ html: findElementByCssSelector(cssSelector),
529
+ target: [cssSelector],
530
+ impact: 'serious' as ImpactValue,
531
+ failureSummary:
532
+ 'Fix any of the following:\n The clickable element does not have an accessible label.',
533
+ any: [
534
+ {
535
+ id: 'oobee-accessible-label',
536
+ data: null,
537
+ relatedNodes: [],
538
+ impact: 'serious',
539
+ message: 'The clickable element does not have an accessible label.',
540
+ },
541
+ ],
542
+ all: [],
543
+ none: [],
544
+ }))
545
+ .filter(item => item.html),
536
546
  };
537
547
 
538
548
  results.violations = [...results.violations, oobeeAccessibleLabelViolations];
@@ -40,8 +40,7 @@ import {
40
40
  import { silentLogger, guiInfoLog } from '../logs.js';
41
41
  import { ViewportSettingsClass } from '../combine.js';
42
42
 
43
- const isBlacklisted = (url: string) => {
44
- const blacklistedPatterns = getBlackListedPatterns(null);
43
+ const isBlacklisted = (url: string, blacklistedPatterns: string[]) => {
45
44
  if (!blacklistedPatterns) {
46
45
  return false;
47
46
  }
@@ -122,7 +121,7 @@ const crawlDomain = async ({
122
121
  const isScanPdfs = ['all', 'pdf-only'].includes(fileTypes);
123
122
  const { maxConcurrency } = constants;
124
123
  const { playwrightDeviceDetailsObject } = viewportSettings;
125
- const isBlacklistedUrl = isBlacklisted(url);
124
+ const isBlacklistedUrl = isBlacklisted(url, blacklistedPatterns);
126
125
 
127
126
  const httpsAgent = new https.Agent({ rejectUnauthorized: false });
128
127
 
@@ -315,7 +314,7 @@ const crawlDomain = async ({
315
314
 
316
315
  const isExcluded = (newPageUrl: string): boolean => {
317
316
  const isAlreadyScanned: boolean = urlsCrawled.scanned.some(item => item.url === newPageUrl);
318
- const isBlacklistedUrl: boolean = isBlacklisted(newPageUrl);
317
+ const isBlacklistedUrl: boolean = isBlacklisted(newPageUrl, blacklistedPatterns);
319
318
  const isNotFollowStrategy: boolean = !isFollowStrategy(newPageUrl, initialPageUrl, strategy);
320
319
  return isAlreadyScanned || isBlacklistedUrl || isNotFollowStrategy;
321
320
  };
@@ -615,7 +614,7 @@ const crawlDomain = async ({
615
614
  actualUrl = page.url();
616
615
  }
617
616
 
618
- if (isBlacklisted(actualUrl) || (isUrlPdf(actualUrl) && !isScanPdfs)) {
617
+ if (isBlacklisted(actualUrl, blacklistedPatterns) || (isUrlPdf(actualUrl) && !isScanPdfs)) {
619
618
  guiInfoLog(guiInfoStatusTypes.SKIPPED, {
620
619
  numScanned: urlsCrawled.scanned.length,
621
620
  urlScanned: actualUrl,
@@ -68,7 +68,7 @@ export const customAxeConfig: Spec = {
68
68
  selector: 'html',
69
69
  enabled: true,
70
70
  any: ['oobee-grading-text-contents'],
71
- tags: ['wcag2a', 'wcag315'],
71
+ tags: ['wcag2aaa', 'wcag315'],
72
72
  metadata: {
73
73
  description:
74
74
  'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
@@ -219,8 +219,46 @@ const writeCsv = async (allIssues, storagePath) => {
219
219
  includeEmptyRows: true,
220
220
  };
221
221
 
222
+ // Create the parse stream (it’s asynchronous)
222
223
  const parser = new AsyncParser(opts);
223
- parser.parse(allIssues).pipe(csvOutput);
224
+ const parseStream = parser.parse(allIssues);
225
+
226
+ // Pipe JSON2CSV output into the file, but don't end automatically
227
+ parseStream.pipe(csvOutput, { end: false });
228
+
229
+ // Once JSON2CSV is done writing all normal rows, append any "pagesNotScanned"
230
+ parseStream.on('end', () => {
231
+ if (allIssues.pagesNotScanned && allIssues.pagesNotScanned.length > 0) {
232
+ csvOutput.write('\n');
233
+ allIssues.pagesNotScanned.forEach(page => {
234
+ const skippedPage = {
235
+ customFlowLabel: allIssues.customFlowLabel || '',
236
+ deviceChosen: allIssues.deviceChosen || '',
237
+ scanCompletedAt: allIssues.endTime ? allIssues.endTime.toISOString() : '',
238
+ severity: 'error',
239
+ issueId: 'error-pages-skipped',
240
+ issueDescription: 'Page was skipped during the scan',
241
+ wcagConformance: '',
242
+ url: page.url || '',
243
+ pageTitle: '',
244
+ context: '',
245
+ howToFix: '',
246
+ axeImpact: '',
247
+ xpath: '',
248
+ learnMore: '',
249
+ };
250
+ csvOutput.write(`${Object.values(skippedPage).join(',')}\n`);
251
+ });
252
+ }
253
+
254
+ // Now close the CSV file
255
+ csvOutput.end();
256
+ });
257
+
258
+ parseStream.on('error', err => {
259
+ console.error('Error parsing CSV:', err);
260
+ csvOutput.end();
261
+ });
224
262
  };
225
263
 
226
264
  const compileHtmlWithEJS = async (
@@ -234,7 +272,7 @@ const compileHtmlWithEJS = async (
234
272
  filename: path.join(dirname, './static/ejs/report.ejs'),
235
273
  });
236
274
 
237
- const html = template({...allIssues, storagePath: JSON.stringify(storagePath)});
275
+ const html = template({ ...allIssues, storagePath: JSON.stringify(storagePath) });
238
276
  await fs.writeFile(htmlFilePath, html);
239
277
 
240
278
  let htmlContent = await fs.readFile(htmlFilePath, { encoding: 'utf8' });