@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 +1 -1
- package/INSTALLATION.md +1 -1
- package/package.json +2 -2
- package/src/combine.ts +1 -0
- package/src/constants/cliFunctions.ts +1 -1
- package/src/crawlers/commonCrawlerFunc.ts +57 -47
- package/src/crawlers/crawlDomain.ts +4 -5
- package/src/crawlers/customAxeFunctions.ts +1 -1
- package/src/mergeAxeResults.ts +40 -2
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.
|
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](
|
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.
|
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.
|
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
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
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): {
|
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 =
|
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 =
|
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(
|
438
|
-
frameset = doc.querySelector(
|
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(
|
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(
|
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 =
|
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
|
-
|
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
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
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: ['
|
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.',
|
package/src/mergeAxeResults.ts
CHANGED
@@ -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)
|
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' });
|