@govtechsg/oobee 0.10.20
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/.dockerignore +22 -0
- package/.github/pull_request_template.md +11 -0
- package/.github/workflows/docker-test.yml +54 -0
- package/.github/workflows/image.yml +107 -0
- package/.github/workflows/publish.yml +18 -0
- package/.idea/modules.xml +8 -0
- package/.idea/purple-a11y.iml +9 -0
- package/.idea/vcs.xml +6 -0
- package/.prettierrc.json +12 -0
- package/.vscode/extensions.json +5 -0
- package/.vscode/settings.json +10 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/DETAILS.md +163 -0
- package/Dockerfile +60 -0
- package/INSTALLATION.md +146 -0
- package/INTEGRATION.md +785 -0
- package/LICENSE +22 -0
- package/README.md +587 -0
- package/SECURITY.md +5 -0
- package/__mocks__/mock-report.html +1431 -0
- package/__mocks__/mockFunctions.ts +32 -0
- package/__mocks__/mockIssues.ts +64 -0
- package/__mocks__/mock_all_issues/000000001.json +64 -0
- package/__mocks__/mock_all_issues/000000002.json +53 -0
- package/__mocks__/mock_all_issues/fake-file.txt +0 -0
- package/__tests__/logs.test.ts +25 -0
- package/__tests__/mergeAxeResults.test.ts +278 -0
- package/__tests__/utils.test.ts +118 -0
- package/a11y-scan-results.zip +0 -0
- package/eslint.config.js +53 -0
- package/exclusions.txt +2 -0
- package/gitlab-pipeline-template.yml +54 -0
- package/jest.config.js +1 -0
- package/package.json +96 -0
- package/scripts/copyFiles.js +44 -0
- package/scripts/install_oobee_dependencies.cmd +13 -0
- package/scripts/install_oobee_dependencies.command +101 -0
- package/scripts/install_oobee_dependencies.ps1 +110 -0
- package/scripts/oobee_shell.cmd +13 -0
- package/scripts/oobee_shell.command +11 -0
- package/scripts/oobee_shell.sh +55 -0
- package/scripts/oobee_shell_ps.ps1 +54 -0
- package/src/cli.ts +401 -0
- package/src/combine.ts +240 -0
- package/src/constants/__tests__/common.test.ts +44 -0
- package/src/constants/cliFunctions.ts +305 -0
- package/src/constants/common.ts +1840 -0
- package/src/constants/constants.ts +443 -0
- package/src/constants/errorMeta.json +319 -0
- package/src/constants/itemTypeDescription.ts +11 -0
- package/src/constants/oobeeAi.ts +141 -0
- package/src/constants/questions.ts +181 -0
- package/src/constants/sampleData.ts +187 -0
- package/src/crawlers/__tests__/commonCrawlerFunc.test.ts +51 -0
- package/src/crawlers/commonCrawlerFunc.ts +656 -0
- package/src/crawlers/crawlDomain.ts +877 -0
- package/src/crawlers/crawlIntelligentSitemap.ts +156 -0
- package/src/crawlers/crawlLocalFile.ts +193 -0
- package/src/crawlers/crawlSitemap.ts +356 -0
- package/src/crawlers/custom/extractAndGradeText.ts +57 -0
- package/src/crawlers/custom/flagUnlabelledClickableElements.ts +964 -0
- package/src/crawlers/custom/utils.ts +486 -0
- package/src/crawlers/customAxeFunctions.ts +82 -0
- package/src/crawlers/pdfScanFunc.ts +468 -0
- package/src/crawlers/runCustom.ts +117 -0
- package/src/index.ts +173 -0
- package/src/logs.ts +66 -0
- package/src/mergeAxeResults.ts +964 -0
- package/src/npmIndex.ts +284 -0
- package/src/screenshotFunc/htmlScreenshotFunc.ts +411 -0
- package/src/screenshotFunc/pdfScreenshotFunc.ts +762 -0
- package/src/static/ejs/partials/components/categorySelector.ejs +4 -0
- package/src/static/ejs/partials/components/categorySelectorDropdown.ejs +57 -0
- package/src/static/ejs/partials/components/pagesScannedModal.ejs +70 -0
- package/src/static/ejs/partials/components/reportSearch.ejs +47 -0
- package/src/static/ejs/partials/components/ruleOffcanvas.ejs +105 -0
- package/src/static/ejs/partials/components/scanAbout.ejs +263 -0
- package/src/static/ejs/partials/components/screenshotLightbox.ejs +13 -0
- package/src/static/ejs/partials/components/summaryScanAbout.ejs +141 -0
- package/src/static/ejs/partials/components/summaryScanResults.ejs +16 -0
- package/src/static/ejs/partials/components/summaryTable.ejs +20 -0
- package/src/static/ejs/partials/components/summaryWcagCompliance.ejs +94 -0
- package/src/static/ejs/partials/components/topFive.ejs +6 -0
- package/src/static/ejs/partials/components/wcagCompliance.ejs +70 -0
- package/src/static/ejs/partials/footer.ejs +21 -0
- package/src/static/ejs/partials/header.ejs +230 -0
- package/src/static/ejs/partials/main.ejs +40 -0
- package/src/static/ejs/partials/scripts/bootstrap.ejs +8 -0
- package/src/static/ejs/partials/scripts/categorySelectorDropdownScript.ejs +190 -0
- package/src/static/ejs/partials/scripts/categorySummary.ejs +141 -0
- package/src/static/ejs/partials/scripts/highlightjs.ejs +335 -0
- package/src/static/ejs/partials/scripts/popper.ejs +7 -0
- package/src/static/ejs/partials/scripts/reportSearch.ejs +248 -0
- package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +801 -0
- package/src/static/ejs/partials/scripts/screenshotLightbox.ejs +71 -0
- package/src/static/ejs/partials/scripts/summaryScanResults.ejs +14 -0
- package/src/static/ejs/partials/scripts/summaryTable.ejs +78 -0
- package/src/static/ejs/partials/scripts/utils.ejs +441 -0
- package/src/static/ejs/partials/styles/bootstrap.ejs +12375 -0
- package/src/static/ejs/partials/styles/highlightjs.ejs +54 -0
- package/src/static/ejs/partials/styles/styles.ejs +1843 -0
- package/src/static/ejs/partials/styles/summaryBootstrap.ejs +12458 -0
- package/src/static/ejs/partials/summaryHeader.ejs +70 -0
- package/src/static/ejs/partials/summaryMain.ejs +75 -0
- package/src/static/ejs/report.ejs +420 -0
- package/src/static/ejs/summary.ejs +47 -0
- package/src/static/mustache/.prettierrc +4 -0
- package/src/static/mustache/Attention Deficit.mustache +11 -0
- package/src/static/mustache/Blind.mustache +11 -0
- package/src/static/mustache/Cognitive.mustache +7 -0
- package/src/static/mustache/Colorblindness.mustache +20 -0
- package/src/static/mustache/Deaf.mustache +12 -0
- package/src/static/mustache/Deafblind.mustache +7 -0
- package/src/static/mustache/Dyslexia.mustache +14 -0
- package/src/static/mustache/Low Vision.mustache +7 -0
- package/src/static/mustache/Mobility.mustache +15 -0
- package/src/static/mustache/Sighted Keyboard Users.mustache +42 -0
- package/src/static/mustache/report.mustache +1709 -0
- package/src/types/print-message.d.ts +28 -0
- package/src/types/types.ts +46 -0
- package/src/types/xpath-to-css.d.ts +3 -0
- package/src/utils.ts +332 -0
- package/tsconfig.json +15 -0
@@ -0,0 +1,656 @@
|
|
1
|
+
import crawlee, { CrawlingContext, PlaywrightGotoOptions } from 'crawlee';
|
2
|
+
import axe, { AxeResults, ImpactValue, NodeResult, Result, resultGroups, TagValue } from 'axe-core';
|
3
|
+
import xPathToCss from 'xpath-to-css';
|
4
|
+
import { Page } from 'playwright';
|
5
|
+
import {
|
6
|
+
axeScript,
|
7
|
+
guiInfoStatusTypes,
|
8
|
+
RuleFlags,
|
9
|
+
saflyIconSelector,
|
10
|
+
} from '../constants/constants.js';
|
11
|
+
import { consoleLogger, guiInfoLog, silentLogger } from '../logs.js';
|
12
|
+
import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
|
13
|
+
import { isFilePath } from '../constants/common.js';
|
14
|
+
import { customAxeConfig } from './customAxeFunctions.js';
|
15
|
+
import { flagUnlabelledClickableElements } from './custom/flagUnlabelledClickableElements.js';
|
16
|
+
import { extractAndGradeText } from './custom/extractAndGradeText.js';
|
17
|
+
import { ItemsInfo } from '../mergeAxeResults.js';
|
18
|
+
|
19
|
+
// types
|
20
|
+
interface AxeResultsWithScreenshot extends AxeResults {
|
21
|
+
passes: ResultWithScreenshot[];
|
22
|
+
incomplete: ResultWithScreenshot[];
|
23
|
+
violations: ResultWithScreenshot[];
|
24
|
+
}
|
25
|
+
|
26
|
+
export interface ResultWithScreenshot extends Result {
|
27
|
+
nodes: NodeResultWithScreenshot[];
|
28
|
+
}
|
29
|
+
|
30
|
+
export interface NodeResultWithScreenshot extends NodeResult {
|
31
|
+
screenshotPath?: string;
|
32
|
+
}
|
33
|
+
|
34
|
+
type RuleDetails = {
|
35
|
+
description: string;
|
36
|
+
axeImpact: ImpactValue;
|
37
|
+
helpUrl: string;
|
38
|
+
conformance: TagValue[];
|
39
|
+
totalItems: number;
|
40
|
+
items: ItemsInfo[];
|
41
|
+
};
|
42
|
+
|
43
|
+
type ResultCategory = {
|
44
|
+
totalItems: number;
|
45
|
+
rules: Record<string, RuleDetails>;
|
46
|
+
};
|
47
|
+
|
48
|
+
type CustomFlowDetails = {
|
49
|
+
pageIndex?: any;
|
50
|
+
metadata?: any;
|
51
|
+
pageImagePath?: any;
|
52
|
+
};
|
53
|
+
|
54
|
+
type FilteredResults = {
|
55
|
+
url: string;
|
56
|
+
pageTitle: string;
|
57
|
+
pageIndex?: any;
|
58
|
+
metadata?: any;
|
59
|
+
pageImagePath?: any;
|
60
|
+
totalItems: number;
|
61
|
+
mustFix: ResultCategory;
|
62
|
+
goodToFix: ResultCategory;
|
63
|
+
needsReview: ResultCategory;
|
64
|
+
passed: ResultCategory;
|
65
|
+
actualUrl?: string;
|
66
|
+
};
|
67
|
+
|
68
|
+
export const filterAxeResults = (
|
69
|
+
results: AxeResultsWithScreenshot,
|
70
|
+
pageTitle: string,
|
71
|
+
customFlowDetails?: CustomFlowDetails,
|
72
|
+
): FilteredResults => {
|
73
|
+
const { violations, passes, incomplete, url } = results;
|
74
|
+
|
75
|
+
let totalItems = 0;
|
76
|
+
const mustFix: ResultCategory = { totalItems: 0, rules: {} };
|
77
|
+
const goodToFix: ResultCategory = { totalItems: 0, rules: {} };
|
78
|
+
const passed: ResultCategory = { totalItems: 0, rules: {} };
|
79
|
+
const needsReview: ResultCategory = { totalItems: 0, rules: {} };
|
80
|
+
|
81
|
+
const process = (item: ResultWithScreenshot, displayNeedsReview: boolean) => {
|
82
|
+
const { id: rule, help: description, helpUrl, tags, nodes } = item;
|
83
|
+
|
84
|
+
if (rule === 'frame-tested') return;
|
85
|
+
|
86
|
+
const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');
|
87
|
+
|
88
|
+
// handle rare cases where conformance level is not the first element
|
89
|
+
const levels = ['wcag2a', 'wcag2aa', 'wcag2aaa'];
|
90
|
+
if (conformance[0] !== 'best-practice' && !levels.includes(conformance[0])) {
|
91
|
+
conformance.sort((a, b) => {
|
92
|
+
if (levels.includes(a)) {
|
93
|
+
return -1;
|
94
|
+
}
|
95
|
+
if (levels.includes(b)) {
|
96
|
+
return 1;
|
97
|
+
}
|
98
|
+
|
99
|
+
return 0;
|
100
|
+
});
|
101
|
+
}
|
102
|
+
|
103
|
+
const addTo = (category: ResultCategory, node: NodeResultWithScreenshot) => {
|
104
|
+
const { html, failureSummary, screenshotPath, target, impact: axeImpact } = node;
|
105
|
+
if (!(rule in category.rules)) {
|
106
|
+
category.rules[rule] = {
|
107
|
+
description,
|
108
|
+
axeImpact,
|
109
|
+
helpUrl,
|
110
|
+
conformance,
|
111
|
+
totalItems: 0,
|
112
|
+
items: [],
|
113
|
+
};
|
114
|
+
}
|
115
|
+
const message = displayNeedsReview
|
116
|
+
? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
|
117
|
+
: failureSummary;
|
118
|
+
|
119
|
+
let finalHtml = html;
|
120
|
+
if (html.includes('</script>')) {
|
121
|
+
finalHtml = html.replaceAll('</script>', '</script>');
|
122
|
+
}
|
123
|
+
|
124
|
+
const xpath = target.length === 1 && typeof target[0] === 'string' ? target[0] : null;
|
125
|
+
|
126
|
+
// add in screenshot path
|
127
|
+
category.rules[rule].items.push({
|
128
|
+
html: finalHtml,
|
129
|
+
message,
|
130
|
+
screenshotPath,
|
131
|
+
xpath: xpath || undefined,
|
132
|
+
displayNeedsReview: displayNeedsReview || undefined,
|
133
|
+
});
|
134
|
+
category.rules[rule].totalItems += 1;
|
135
|
+
category.totalItems += 1;
|
136
|
+
totalItems += 1;
|
137
|
+
};
|
138
|
+
|
139
|
+
nodes.forEach(node => {
|
140
|
+
const { impact } = node;
|
141
|
+
if (displayNeedsReview) {
|
142
|
+
addTo(needsReview, node);
|
143
|
+
} else if (impact === 'critical' || impact === 'serious') {
|
144
|
+
addTo(mustFix, node);
|
145
|
+
} else {
|
146
|
+
addTo(goodToFix, node);
|
147
|
+
}
|
148
|
+
});
|
149
|
+
};
|
150
|
+
|
151
|
+
violations.forEach(item => process(item, false));
|
152
|
+
incomplete.forEach(item => process(item, true));
|
153
|
+
|
154
|
+
passes.forEach((item: Result) => {
|
155
|
+
const { id: rule, help: description, impact: axeImpact, helpUrl, tags, nodes } = item;
|
156
|
+
|
157
|
+
if (rule === 'frame-tested') return;
|
158
|
+
|
159
|
+
const conformance = tags.filter(tag => tag.startsWith('wcag') || tag === 'best-practice');
|
160
|
+
|
161
|
+
nodes.forEach(node => {
|
162
|
+
const { html } = node;
|
163
|
+
if (!(rule in passed.rules)) {
|
164
|
+
passed.rules[rule] = {
|
165
|
+
description,
|
166
|
+
axeImpact,
|
167
|
+
helpUrl,
|
168
|
+
conformance,
|
169
|
+
totalItems: 0,
|
170
|
+
items: [],
|
171
|
+
};
|
172
|
+
}
|
173
|
+
passed.rules[rule].items.push({ html, screenshotPath: '', message: '', xpath: '' });
|
174
|
+
passed.totalItems += 1;
|
175
|
+
passed.rules[rule].totalItems += 1;
|
176
|
+
totalItems += 1;
|
177
|
+
});
|
178
|
+
});
|
179
|
+
|
180
|
+
return {
|
181
|
+
url,
|
182
|
+
pageTitle: customFlowDetails ? `${customFlowDetails.pageIndex}: ${pageTitle}` : pageTitle,
|
183
|
+
pageIndex: customFlowDetails ? customFlowDetails.pageIndex : undefined,
|
184
|
+
metadata: customFlowDetails?.metadata
|
185
|
+
? `${customFlowDetails.pageIndex}: ${customFlowDetails.metadata}`
|
186
|
+
: undefined,
|
187
|
+
pageImagePath: customFlowDetails ? customFlowDetails.pageImagePath : undefined,
|
188
|
+
totalItems,
|
189
|
+
mustFix,
|
190
|
+
goodToFix,
|
191
|
+
needsReview,
|
192
|
+
passed,
|
193
|
+
};
|
194
|
+
};
|
195
|
+
|
196
|
+
export const runAxeScript = async ({
|
197
|
+
includeScreenshots,
|
198
|
+
page,
|
199
|
+
randomToken,
|
200
|
+
customFlowDetails = null,
|
201
|
+
selectors = [],
|
202
|
+
ruleset = [],
|
203
|
+
}: {
|
204
|
+
includeScreenshots: boolean;
|
205
|
+
page: Page;
|
206
|
+
randomToken: string;
|
207
|
+
customFlowDetails?: CustomFlowDetails;
|
208
|
+
selectors?: string[];
|
209
|
+
ruleset?: RuleFlags[];
|
210
|
+
}) => {
|
211
|
+
// Checking for DOM mutations before proceeding to scan
|
212
|
+
await page.evaluate(() => {
|
213
|
+
return new Promise(resolve => {
|
214
|
+
let timeout: NodeJS.Timeout;
|
215
|
+
let mutationCount = 0;
|
216
|
+
const MAX_MUTATIONS = 100;
|
217
|
+
const MAX_SAME_MUTATION_LIMIT = 10;
|
218
|
+
const mutationHash = {};
|
219
|
+
|
220
|
+
const observer = new MutationObserver(mutationsList => {
|
221
|
+
clearTimeout(timeout);
|
222
|
+
|
223
|
+
mutationCount += 1;
|
224
|
+
|
225
|
+
if (mutationCount > MAX_MUTATIONS) {
|
226
|
+
observer.disconnect();
|
227
|
+
resolve('Too many mutations detected');
|
228
|
+
}
|
229
|
+
|
230
|
+
// To handle scenario where DOM elements are constantly changing and unable to exit
|
231
|
+
mutationsList.forEach(mutation => {
|
232
|
+
let mutationKey: string;
|
233
|
+
|
234
|
+
if (mutation.target instanceof Element) {
|
235
|
+
Array.from(mutation.target.attributes).forEach(attr => {
|
236
|
+
mutationKey = `${mutation.target.nodeName}-${attr.name}`;
|
237
|
+
|
238
|
+
if (mutationKey) {
|
239
|
+
if (!mutationHash[mutationKey]) {
|
240
|
+
mutationHash[mutationKey] = 1;
|
241
|
+
} else {
|
242
|
+
mutationHash[mutationKey] += 1;
|
243
|
+
}
|
244
|
+
|
245
|
+
if (mutationHash[mutationKey] >= MAX_SAME_MUTATION_LIMIT) {
|
246
|
+
observer.disconnect();
|
247
|
+
resolve(`Repeated mutation detected for ${mutationKey}`);
|
248
|
+
}
|
249
|
+
}
|
250
|
+
});
|
251
|
+
}
|
252
|
+
});
|
253
|
+
|
254
|
+
timeout = setTimeout(() => {
|
255
|
+
observer.disconnect();
|
256
|
+
resolve('DOM stabilized after mutations.');
|
257
|
+
}, 1000);
|
258
|
+
});
|
259
|
+
|
260
|
+
timeout = setTimeout(() => {
|
261
|
+
observer.disconnect();
|
262
|
+
resolve('No mutations detected, exit from idle state');
|
263
|
+
}, 1000);
|
264
|
+
|
265
|
+
observer.observe(document, { childList: true, subtree: true, attributes: true });
|
266
|
+
});
|
267
|
+
});
|
268
|
+
|
269
|
+
page.on('console', msg => {
|
270
|
+
const type = msg.type();
|
271
|
+
if (type === 'error') {
|
272
|
+
silentLogger.log({ level: 'error', message: msg.text() });
|
273
|
+
} else {
|
274
|
+
silentLogger.log({ level: 'info', message: msg.text() });
|
275
|
+
}
|
276
|
+
});
|
277
|
+
|
278
|
+
const disableOobee = ruleset.includes(RuleFlags.DISABLE_OOBEE);
|
279
|
+
const oobeeAccessibleLabelFlaggedXpaths = disableOobee
|
280
|
+
? []
|
281
|
+
: (await flagUnlabelledClickableElements(page)).map(item => item.xpath);
|
282
|
+
const oobeeAccessibleLabelFlaggedCssSelectors = oobeeAccessibleLabelFlaggedXpaths
|
283
|
+
.map(xpath => {
|
284
|
+
try {
|
285
|
+
const cssSelector = xPathToCss(xpath);
|
286
|
+
return cssSelector;
|
287
|
+
} catch (e) {
|
288
|
+
console.error('Error converting XPath to CSS: ', xpath, e);
|
289
|
+
return '';
|
290
|
+
}
|
291
|
+
})
|
292
|
+
.filter(item => item !== '');
|
293
|
+
|
294
|
+
const enableWcagAaa = ruleset.includes(RuleFlags.ENABLE_WCAG_AAA);
|
295
|
+
|
296
|
+
const gradingReadabilityFlag = await extractAndGradeText(page); // Ensure flag is obtained before proceeding
|
297
|
+
|
298
|
+
await crawlee.playwrightUtils.injectFile(page, axeScript);
|
299
|
+
|
300
|
+
const results = await page.evaluate(
|
301
|
+
async ({
|
302
|
+
selectors,
|
303
|
+
saflyIconSelector,
|
304
|
+
customAxeConfig,
|
305
|
+
disableOobee,
|
306
|
+
enableWcagAaa,
|
307
|
+
oobeeAccessibleLabelFlaggedCssSelectors,
|
308
|
+
gradingReadabilityFlag,
|
309
|
+
}) => {
|
310
|
+
try {
|
311
|
+
const evaluateAltText = (node: Element) => {
|
312
|
+
const altText = node.getAttribute('alt');
|
313
|
+
const confusingTexts = ['img', 'image', 'picture', 'photo', 'graphic'];
|
314
|
+
|
315
|
+
if (altText) {
|
316
|
+
const trimmedAltText = altText.trim().toLowerCase();
|
317
|
+
if (confusingTexts.includes(trimmedAltText)) {
|
318
|
+
return false;
|
319
|
+
}
|
320
|
+
}
|
321
|
+
return true;
|
322
|
+
};
|
323
|
+
|
324
|
+
// for css id selectors starting with a digit, escape it with the unicode character e.g. #123 -> #\31 23
|
325
|
+
const escapeCSSSelector = (selector: string) => {
|
326
|
+
try {
|
327
|
+
return selector.replace(
|
328
|
+
/([#\.])(\d)/g,
|
329
|
+
(_match, prefix, digit) => `${prefix}\\3${digit} `,
|
330
|
+
);
|
331
|
+
} catch (e) {
|
332
|
+
console.error(`error escaping css selector: ${selector}`, e);
|
333
|
+
return selector;
|
334
|
+
}
|
335
|
+
};
|
336
|
+
|
337
|
+
// remove so that axe does not scan
|
338
|
+
document.querySelector(saflyIconSelector)?.remove();
|
339
|
+
|
340
|
+
axe.configure({
|
341
|
+
branding: customAxeConfig.branding,
|
342
|
+
checks: [
|
343
|
+
{
|
344
|
+
...customAxeConfig.checks[0],
|
345
|
+
evaluate: evaluateAltText,
|
346
|
+
},
|
347
|
+
{
|
348
|
+
...customAxeConfig.checks[1],
|
349
|
+
evaluate: (node: HTMLElement) => {
|
350
|
+
return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
|
351
|
+
},
|
352
|
+
},
|
353
|
+
{
|
354
|
+
...customAxeConfig.checks[2],
|
355
|
+
evaluate: (_node: HTMLElement) => {
|
356
|
+
if (gradingReadabilityFlag === '') {
|
357
|
+
return true; // Pass if no readability issues
|
358
|
+
}
|
359
|
+
// Dynamically update the grading messages
|
360
|
+
const gradingCheck = customAxeConfig.checks.find(
|
361
|
+
check => check.id === 'oobee-grading-text-contents',
|
362
|
+
);
|
363
|
+
if (gradingCheck) {
|
364
|
+
gradingCheck.metadata.messages.incomplete = `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag
|
365
|
+
}.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`;
|
366
|
+
}
|
367
|
+
|
368
|
+
// Fail if readability issues are detected
|
369
|
+
},
|
370
|
+
},
|
371
|
+
],
|
372
|
+
rules: customAxeConfig.rules
|
373
|
+
.filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
|
374
|
+
.concat(
|
375
|
+
enableWcagAaa
|
376
|
+
? [
|
377
|
+
{
|
378
|
+
id: 'color-contrast-enhanced',
|
379
|
+
enabled: true,
|
380
|
+
tags: ['wcag2aaa', 'wcag146'],
|
381
|
+
},
|
382
|
+
{
|
383
|
+
id: 'identical-links-same-purpose',
|
384
|
+
enabled: true,
|
385
|
+
tags: ['wcag2aaa', 'wcag249'],
|
386
|
+
},
|
387
|
+
{
|
388
|
+
id: 'meta-refresh-no-exceptions',
|
389
|
+
enabled: true,
|
390
|
+
tags: ['wcag2aaa', 'wcag224', 'wcag325'],
|
391
|
+
},
|
392
|
+
]
|
393
|
+
: [],
|
394
|
+
),
|
395
|
+
});
|
396
|
+
|
397
|
+
// removed needsReview condition
|
398
|
+
const defaultResultTypes: resultGroups[] = ['violations', 'passes', 'incomplete'];
|
399
|
+
|
400
|
+
return axe
|
401
|
+
.run(selectors, {
|
402
|
+
resultTypes: defaultResultTypes,
|
403
|
+
})
|
404
|
+
.then(results => {
|
405
|
+
if (disableOobee) {
|
406
|
+
return results;
|
407
|
+
}
|
408
|
+
// handle css id selectors that start with a digit
|
409
|
+
const escapedCssSelectors =
|
410
|
+
oobeeAccessibleLabelFlaggedCssSelectors.map(escapeCSSSelector);
|
411
|
+
|
412
|
+
function frameCheck(cssSelector: string): { doc: Document; remainingSelector: string } {
|
413
|
+
let doc = document; // Start with the main document
|
414
|
+
let frameSelector = ""; // To store the frame part of the selector
|
415
|
+
|
416
|
+
// Extract the 'frame' part of the selector
|
417
|
+
let frameMatch = cssSelector.match(/(frame[^>]*>)/i);
|
418
|
+
if (frameMatch) {
|
419
|
+
frameSelector = frameMatch[1].replace(">", "").trim(); // Clean up the frame part
|
420
|
+
cssSelector = cssSelector.split(frameMatch[1])[1].trim(); // Remove the frame portion
|
421
|
+
}
|
422
|
+
|
423
|
+
let targetFrame = null; // Target frame element
|
424
|
+
|
425
|
+
// Locate the frame based on the extracted frameSelector
|
426
|
+
if (frameSelector.includes("first-of-type")) {
|
427
|
+
// Select the first frame
|
428
|
+
targetFrame = document.querySelector("frame:first-of-type");
|
429
|
+
} else if (frameSelector.includes("nth-of-type")) {
|
430
|
+
// Select the nth frame
|
431
|
+
let nthIndex = frameSelector.match(/nth-of-type\((\d+)\)/);
|
432
|
+
if (nthIndex) {
|
433
|
+
let index = parseInt(nthIndex[1]) - 1; // Zero-based index
|
434
|
+
targetFrame = document.querySelectorAll("frame")[index];
|
435
|
+
}
|
436
|
+
} else if (frameSelector.includes("#")) {
|
437
|
+
// Frame with a specific ID
|
438
|
+
let idMatch = frameSelector.match(/#([\w-]+)/);
|
439
|
+
if (idMatch) {
|
440
|
+
targetFrame = document.getElementById(idMatch[1]);
|
441
|
+
}
|
442
|
+
} else if (frameSelector.includes('[name="')) {
|
443
|
+
// Frame with a specific name attribute
|
444
|
+
let nameMatch = frameSelector.match(/name="([\w-]+)"/);
|
445
|
+
if (nameMatch) {
|
446
|
+
targetFrame = document.querySelector(`frame[name="${nameMatch[1]}"]`);
|
447
|
+
}
|
448
|
+
} else {
|
449
|
+
// Default to the first frame
|
450
|
+
targetFrame = document.querySelector("frame");
|
451
|
+
}
|
452
|
+
|
453
|
+
// Update the document if the frame was found
|
454
|
+
if (targetFrame && targetFrame.contentDocument) {
|
455
|
+
doc = targetFrame.contentDocument;
|
456
|
+
} else {
|
457
|
+
console.warn("Frame not found or contentDocument inaccessible.");
|
458
|
+
}
|
459
|
+
|
460
|
+
return { doc, remainingSelector: cssSelector };
|
461
|
+
}
|
462
|
+
|
463
|
+
function iframeCheck(cssSelector: string): { doc: Document; remainingSelector: string } {
|
464
|
+
let doc = document; // Start with the main document
|
465
|
+
let iframeSelector = ""; // To store the iframe part of the selector
|
466
|
+
|
467
|
+
// Extract the 'iframe' part of the selector
|
468
|
+
let iframeMatch = cssSelector.match(/(iframe[^>]*>)/i);
|
469
|
+
if (iframeMatch) {
|
470
|
+
iframeSelector = iframeMatch[1].replace(">", "").trim(); // Clean up the iframe part
|
471
|
+
cssSelector = cssSelector.split(iframeMatch[1])[1].trim(); // Remove the iframe portion
|
472
|
+
}
|
473
|
+
|
474
|
+
let targetIframe = null; // Target iframe element
|
475
|
+
|
476
|
+
// Locate the iframe based on the extracted iframeSelector
|
477
|
+
if (iframeSelector.includes("first-of-type")) {
|
478
|
+
// Select the first iframe
|
479
|
+
targetIframe = document.querySelector("iframe:first-of-type");
|
480
|
+
} else if (iframeSelector.includes("nth-of-type")) {
|
481
|
+
// Select the nth iframe
|
482
|
+
let nthIndex = iframeSelector.match(/nth-of-type\((\d+)\)/);
|
483
|
+
if (nthIndex) {
|
484
|
+
let index = parseInt(nthIndex[1]) - 1; // Zero-based index
|
485
|
+
targetIframe = document.querySelectorAll("iframe")[index];
|
486
|
+
}
|
487
|
+
} else if (iframeSelector.includes("#")) {
|
488
|
+
// Iframe with a specific ID
|
489
|
+
let idMatch = iframeSelector.match(/#([\w-]+)/);
|
490
|
+
if (idMatch) {
|
491
|
+
targetIframe = document.getElementById(idMatch[1]);
|
492
|
+
}
|
493
|
+
} else if (iframeSelector.includes('[name="')) {
|
494
|
+
// Iframe with a specific name attribute
|
495
|
+
let nameMatch = iframeSelector.match(/name="([\w-]+)"/);
|
496
|
+
if (nameMatch) {
|
497
|
+
targetIframe = document.querySelector(`iframe[name="${nameMatch[1]}"]`);
|
498
|
+
}
|
499
|
+
} else {
|
500
|
+
// Default to the first iframe
|
501
|
+
targetIframe = document.querySelector("iframe");
|
502
|
+
}
|
503
|
+
|
504
|
+
// Update the document if the iframe was found
|
505
|
+
if (targetIframe && targetIframe.contentDocument) {
|
506
|
+
doc = targetIframe.contentDocument;
|
507
|
+
} else {
|
508
|
+
console.warn("Iframe not found or contentDocument inaccessible.");
|
509
|
+
}
|
510
|
+
|
511
|
+
return { doc, remainingSelector: cssSelector };
|
512
|
+
}
|
513
|
+
|
514
|
+
function findElementByCssSelector(cssSelector: string): string | null {
|
515
|
+
let doc = document;
|
516
|
+
|
517
|
+
// Check if the selector includes 'frame' and update doc and selector
|
518
|
+
if (cssSelector.includes("frame")) {
|
519
|
+
const result = frameCheck(cssSelector);
|
520
|
+
doc = result.doc;
|
521
|
+
cssSelector = result.remainingSelector;
|
522
|
+
}
|
523
|
+
|
524
|
+
// Check for iframe
|
525
|
+
if (cssSelector.includes("iframe")) {
|
526
|
+
const result = iframeCheck(cssSelector);
|
527
|
+
doc = result.doc;
|
528
|
+
cssSelector = result.remainingSelector;
|
529
|
+
}
|
530
|
+
|
531
|
+
// Query the element in the document (including inside frames)
|
532
|
+
let element = doc.querySelector(cssSelector);
|
533
|
+
|
534
|
+
// Handle Shadow DOM if the element is not found
|
535
|
+
if (!element) {
|
536
|
+
const shadowRoots = [];
|
537
|
+
const allElements = document.querySelectorAll('*');
|
538
|
+
|
539
|
+
// Look for elements with shadow roots
|
540
|
+
allElements.forEach(el => {
|
541
|
+
if (el.shadowRoot) {
|
542
|
+
shadowRoots.push(el.shadowRoot);
|
543
|
+
}
|
544
|
+
});
|
545
|
+
|
546
|
+
// Search inside each shadow root for the element
|
547
|
+
for (const shadowRoot of shadowRoots) {
|
548
|
+
const shadowElement = shadowRoot.querySelector(cssSelector);
|
549
|
+
if (shadowElement) {
|
550
|
+
element = shadowElement; // Found the element inside shadow DOM
|
551
|
+
break;
|
552
|
+
}
|
553
|
+
}
|
554
|
+
}
|
555
|
+
|
556
|
+
return element ? element.outerHTML : null;
|
557
|
+
}
|
558
|
+
|
559
|
+
// Add oobee violations to Axe's report
|
560
|
+
const oobeeAccessibleLabelViolations = {
|
561
|
+
id: 'oobee-accessible-label',
|
562
|
+
impact: 'serious' as ImpactValue,
|
563
|
+
tags: ['wcag2a', 'wcag211', 'wcag243', 'wcag412'],
|
564
|
+
description: 'Ensures clickable elements have an accessible label.',
|
565
|
+
help: 'Clickable elements (i.e. elements with mouse-click interaction) must have accessible labels.',
|
566
|
+
helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
|
567
|
+
nodes: escapedCssSelectors.map(cssSelector => ({
|
568
|
+
html: findElementByCssSelector(cssSelector),
|
569
|
+
target: [cssSelector],
|
570
|
+
impact: 'serious' as ImpactValue,
|
571
|
+
failureSummary:
|
572
|
+
'Fix any of the following:\n The clickable element does not have an accessible label.',
|
573
|
+
any: [
|
574
|
+
{
|
575
|
+
id: 'oobee-accessible-label',
|
576
|
+
data: null,
|
577
|
+
relatedNodes: [],
|
578
|
+
impact: 'serious',
|
579
|
+
message: 'The clickable element does not have an accessible label.',
|
580
|
+
},
|
581
|
+
],
|
582
|
+
all: [],
|
583
|
+
none: [],
|
584
|
+
})),
|
585
|
+
};
|
586
|
+
|
587
|
+
results.violations = [...results.violations, oobeeAccessibleLabelViolations];
|
588
|
+
return results;
|
589
|
+
})
|
590
|
+
.catch(e => {
|
591
|
+
console.error('Error at axe.run', e);
|
592
|
+
throw e;
|
593
|
+
});
|
594
|
+
} catch (e) {
|
595
|
+
console.error(e);
|
596
|
+
throw e;
|
597
|
+
}
|
598
|
+
},
|
599
|
+
{
|
600
|
+
selectors,
|
601
|
+
saflyIconSelector,
|
602
|
+
customAxeConfig,
|
603
|
+
disableOobee,
|
604
|
+
enableWcagAaa,
|
605
|
+
oobeeAccessibleLabelFlaggedCssSelectors,
|
606
|
+
gradingReadabilityFlag,
|
607
|
+
},
|
608
|
+
);
|
609
|
+
|
610
|
+
if (includeScreenshots) {
|
611
|
+
results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
|
612
|
+
results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
|
613
|
+
}
|
614
|
+
|
615
|
+
const pageTitle = await page.evaluate(() => document.title);
|
616
|
+
|
617
|
+
return filterAxeResults(results, pageTitle, customFlowDetails);
|
618
|
+
};
|
619
|
+
|
620
|
+
export const createCrawleeSubFolders = async (
|
621
|
+
randomToken: string,
|
622
|
+
): Promise<{ dataset: crawlee.Dataset; requestQueue: crawlee.RequestQueue }> => {
|
623
|
+
const dataset = await crawlee.Dataset.open(randomToken);
|
624
|
+
const requestQueue = await crawlee.RequestQueue.open(randomToken);
|
625
|
+
return { dataset, requestQueue };
|
626
|
+
};
|
627
|
+
|
628
|
+
export const preNavigationHooks = (extraHTTPHeaders: Record<string, string>) => {
|
629
|
+
return [
|
630
|
+
async (crawlingContext: CrawlingContext, gotoOptions: PlaywrightGotoOptions) => {
|
631
|
+
if (extraHTTPHeaders) {
|
632
|
+
crawlingContext.request.headers = extraHTTPHeaders;
|
633
|
+
}
|
634
|
+
gotoOptions = { waitUntil: 'networkidle', timeout: 30000 };
|
635
|
+
},
|
636
|
+
];
|
637
|
+
};
|
638
|
+
|
639
|
+
export const postNavigationHooks = [
|
640
|
+
async (_crawlingContext: CrawlingContext) => {
|
641
|
+
guiInfoLog(guiInfoStatusTypes.COMPLETED, {});
|
642
|
+
},
|
643
|
+
];
|
644
|
+
|
645
|
+
export const failedRequestHandler = async ({ request }) => {
|
646
|
+
guiInfoLog(guiInfoStatusTypes.ERROR, { numScanned: 0, urlScanned: request.url });
|
647
|
+
crawlee.log.error(`Failed Request - ${request.url}: ${request.errorMessages}`);
|
648
|
+
};
|
649
|
+
|
650
|
+
export const isUrlPdf = (url: string) => {
|
651
|
+
if (isFilePath(url)) {
|
652
|
+
return /\.pdf$/i.test(url);
|
653
|
+
}
|
654
|
+
const parsedUrl = new URL(url);
|
655
|
+
return /\.pdf($|\?|#)/i.test(parsedUrl.pathname) || /\.pdf($|\?|#)/i.test(parsedUrl.href);
|
656
|
+
};
|