@govtechsg/oobee 0.10.21 → 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/.github/workflows/docker-test.yml +1 -1
- package/DETAILS.md +40 -25
- package/Dockerfile +41 -47
- package/INSTALLATION.md +1 -1
- package/LICENSE-3RD-PARTY-REPORT.txt +448 -0
- package/LICENSE-3RD-PARTY.txt +19913 -0
- package/README.md +10 -2
- package/__mocks__/mock-report.html +1503 -1360
- package/package.json +8 -4
- package/scripts/decodeUnzipParse.js +29 -0
- package/scripts/install_oobee_dependencies.command +2 -2
- package/scripts/install_oobee_dependencies.ps1 +3 -3
- package/src/cli.ts +3 -2
- package/src/combine.ts +1 -0
- package/src/constants/cliFunctions.ts +17 -3
- package/src/constants/common.ts +29 -5
- package/src/constants/constants.ts +28 -26
- package/src/constants/questions.ts +4 -1
- package/src/crawlers/commonCrawlerFunc.ts +159 -187
- package/src/crawlers/crawlDomain.ts +29 -30
- package/src/crawlers/crawlIntelligentSitemap.ts +7 -1
- package/src/crawlers/crawlLocalFile.ts +1 -1
- package/src/crawlers/crawlSitemap.ts +1 -1
- package/src/crawlers/custom/flagUnlabelledClickableElements.ts +546 -472
- package/src/crawlers/customAxeFunctions.ts +2 -2
- package/src/index.ts +0 -2
- package/src/mergeAxeResults.ts +608 -220
- package/src/screenshotFunc/pdfScreenshotFunc.ts +3 -3
- package/src/static/ejs/partials/components/wcagCompliance.ejs +10 -29
- package/src/static/ejs/partials/footer.ejs +10 -13
- package/src/static/ejs/partials/scripts/categorySummary.ejs +2 -2
- package/src/static/ejs/partials/scripts/decodeUnzipParse.ejs +3 -0
- package/src/static/ejs/partials/scripts/reportSearch.ejs +1 -0
- package/src/static/ejs/partials/scripts/ruleOffcanvas.ejs +54 -52
- package/src/static/ejs/partials/styles/styles.ejs +4 -0
- package/src/static/ejs/partials/summaryMain.ejs +15 -42
- package/src/static/ejs/report.ejs +21 -12
- package/src/utils.ts +10 -2
- package/src/xPathToCss.ts +186 -0
- package/a11y-scan-results.zip +0 -0
- package/src/types/xpath-to-css.d.ts +0 -3
@@ -195,6 +195,7 @@
|
|
195
195
|
|
196
196
|
function resetIssueOccurrence(filteredItems) {
|
197
197
|
for (let category in filteredItems) {
|
198
|
+
if (!["mustFix", "goodToFix", "needsReview", "passed"].includes(category)) continue; // skip other props like pagesScanned, etc
|
198
199
|
const issueLabel = filteredItems[category].rules.length === 1 ? 'issue' : 'issues';
|
199
200
|
const occurrenceLabel =
|
200
201
|
filteredItems[category].totalItems === 1 ? 'occurrence' : 'occurrences';
|
@@ -12,31 +12,29 @@ category summary is clicked %>
|
|
12
12
|
wcag111: 'https://www.w3.org/TR/WCAG22/#non-text-content',
|
13
13
|
wcag122: 'https://www.w3.org/TR/WCAG22/#captions-prerecorded',
|
14
14
|
wcag131: 'https://www.w3.org/TR/WCAG22/#info-and-relationships',
|
15
|
-
wcag134: 'https://www.w3.org/TR/WCAG22/#orientation',
|
15
|
+
// wcag134: 'https://www.w3.org/TR/WCAG22/#orientation',
|
16
16
|
wcag135: 'https://www.w3.org/TR/WCAG22/#identify-input-purpose',
|
17
17
|
wcag141: 'https://www.w3.org/TR/WCAG22/#use-of-color',
|
18
18
|
wcag142: 'https://www.w3.org/TR/WCAG22/#audio-control',
|
19
19
|
wcag143: 'https://www.w3.org/TR/WCAG22/#contrast-minimum',
|
20
20
|
wcag144: 'https://www.w3.org/TR/WCAG22/#resize-text',
|
21
|
-
wcag146: 'https://www.w3.org/TR/
|
22
|
-
wcag1410: 'https://www.w3.org/TR/WCAG22/#reflow',
|
21
|
+
wcag146: 'https://www.w3.org/TR/WCAG22/#contrast-enhanced', // AAA
|
22
|
+
// wcag1410: 'https://www.w3.org/TR/WCAG22/#reflow',
|
23
23
|
wcag1412: 'https://www.w3.org/TR/WCAG22/#text-spacing',
|
24
24
|
wcag211: 'https://www.w3.org/TR/WCAG22/#keyboard',
|
25
25
|
wcag221: 'https://www.w3.org/TR/WCAG22/#timing-adjustable',
|
26
26
|
wcag222: 'https://www.w3.org/TR/WCAG22/#pause-stop-hide',
|
27
|
-
wcag224: 'https://www.w3.org/TR/
|
27
|
+
wcag224: 'https://www.w3.org/TR/WCAG22/#interruptions', // AAA
|
28
28
|
wcag241: 'https://www.w3.org/TR/WCAG22/#bypass-blocks',
|
29
29
|
wcag242: 'https://www.w3.org/TR/WCAG22/#page-titled',
|
30
|
-
wcag243: 'https://www.w3.org/TR/WCAG22/#focus-order',
|
31
30
|
wcag244: 'https://www.w3.org/TR/WCAG22/#link-purpose-in-context',
|
32
|
-
wcag249: 'https://www.w3.org/TR/
|
31
|
+
wcag249: 'https://www.w3.org/TR/WCAG22/#link-purpose-link-only', // AAA
|
33
32
|
wcag258: 'https://www.w3.org/TR/WCAG22/#target-size-minimum',
|
34
33
|
wcag311: 'https://www.w3.org/TR/WCAG22/#language-of-page',
|
35
34
|
wcag312: 'https://www.w3.org/TR/WCAG22/#language-of-parts',
|
36
|
-
wcag315: 'https://www.w3.org/TR/WCAG22/#reading-level',
|
35
|
+
wcag315: 'https://www.w3.org/TR/WCAG22/#reading-level', // AAA
|
36
|
+
wcag325: 'https://www.w3.org/TR/WCAG22/#change-on-request', // AAA
|
37
37
|
wcag332: 'https://www.w3.org/TR/WCAG22/#labels-or-instructions',
|
38
|
-
wcag325: 'https://www.w3.org/TR/WCAG21/#change-on-request',
|
39
|
-
wcag411: 'https://www.w3.org/TR/WCAG22/#parsing',
|
40
38
|
wcag412: 'https://www.w3.org/TR/WCAG22/#name-role-value',
|
41
39
|
};
|
42
40
|
|
@@ -385,20 +383,22 @@ category summary is clicked %>
|
|
385
383
|
var buttonAIId = `${ruleInCategory.rule}-${category}-button-AI-${index}`;
|
386
384
|
var errorAIId = `${ruleInCategory.rule}-${category}-error-AI-${index}`;
|
387
385
|
|
386
|
+
const pageItemsCount = page.items.length || page.itemsCount || 0;
|
387
|
+
const normalMode = page.itemsCount === undefined;
|
388
388
|
const accordion = createElementFromString(`
|
389
|
-
<li>
|
389
|
+
<li class="${normalMode ? '' : 'no-chevron'}">
|
390
390
|
<div class="accordion mt-2 ${category}">
|
391
391
|
<div class="accordion-item">
|
392
392
|
<div class="accordion-header" id="${accordionId}-title">
|
393
|
-
<button
|
394
|
-
aria-label="Page ${index + 1}: ${page.pageTitle}, ${
|
395
|
-
type="button"
|
393
|
+
${normalMode ? '<button' : '<div'}
|
394
|
+
aria-label="Page ${index + 1}: ${page.pageTitle}, ${pageItemsCount} ${pageItemsCount === 1 ? 'occurrence' : 'occurrences'}" class="accordion-button collapsed"
|
395
|
+
${normalMode ? 'type="button"' : ''}
|
396
396
|
|
397
397
|
>
|
398
|
-
<span class="sr-only visually-hidden">${
|
398
|
+
<span class="sr-only visually-hidden">${pageItemsCount} ${pageItemsCount === 1 ? 'occurrence' : 'occurrences'}</span>
|
399
399
|
<div class="me-3">${page.metadata ? page.metadata : page.pageTitle}</div>
|
400
|
-
<div class="ms-auto counter">${
|
401
|
-
</button>
|
400
|
+
<div class="ms-auto counter">${pageItemsCount}</div>
|
401
|
+
${normalMode ? '</button>' : '</div>'}
|
402
402
|
</div>
|
403
403
|
<div id="${accordionId}-content" class="accordion-collapse collapse" aria-labelledby="${accordionId}-title">
|
404
404
|
<div class="accordion-body p-3">
|
@@ -414,7 +414,7 @@ category summary is clicked %>
|
|
414
414
|
}
|
415
415
|
<div class="page-accordion-content-title">
|
416
416
|
<span>${getFormattedCategoryTitle(category)} elements</span>
|
417
|
-
<span class="page-items-count">${
|
417
|
+
<span class="page-items-count">${pageItemsCount}</span>
|
418
418
|
</div>
|
419
419
|
</div>
|
420
420
|
</div>
|
@@ -423,45 +423,47 @@ category summary is clicked %>
|
|
423
423
|
</li>
|
424
424
|
`);
|
425
425
|
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
426
|
+
if (normalMode) {
|
427
|
+
accordion.querySelector('button').addEventListener('click', function(event) {
|
428
|
+
var accordionBody = accordion.querySelector(".accordion-body");
|
429
|
+
|
430
|
+
// So that It does not keep adding
|
431
|
+
if (!accordionBody.querySelector(".unbulleted-list"))
|
432
|
+
{
|
433
|
+
buildExpandedRuleCategoryContentAccordian(accordionId,category,ruleInCategory,page,index);
|
434
|
+
|
435
|
+
this.setAttribute('data-bs-target', '#' + accordionId+"-content");
|
436
|
+
|
437
|
+
// Remove the event listener temporarily
|
438
|
+
this.removeEventListener('click', arguments.callee);
|
439
|
+
|
440
|
+
// Programmatically trigger a click on the button to open the accordion
|
441
|
+
this.click();
|
442
|
+
}
|
443
|
+
});
|
435
444
|
|
445
|
+
accordion.querySelector('button').addEventListener('click', function(event) {
|
446
|
+
// Set data attributes
|
447
|
+
this.setAttribute('data-bs-toggle', 'collapse');
|
448
|
+
this.setAttribute('data-bs-target', '#' + accordionId + "-content");
|
449
|
+
this.setAttribute('aria-expanded', 'false');
|
450
|
+
this.setAttribute('aria-controls', accordionId + "-content");
|
451
|
+
|
452
|
+
// Initialize the Collapse plugin on the button element
|
453
|
+
var collapse = new bootstrap.Collapse(this, {
|
454
|
+
toggle: false // Set to true if you want to toggle the collapsed state on initialization
|
455
|
+
});
|
456
|
+
|
436
457
|
// Remove the event listener temporarily
|
437
458
|
this.removeEventListener('click', arguments.callee);
|
438
|
-
|
459
|
+
|
439
460
|
// Programmatically trigger a click on the button to open the accordion
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
this.setAttribute('data-bs-toggle', 'collapse');
|
447
|
-
this.setAttribute('data-bs-target', '#' + accordionId + "-content");
|
448
|
-
this.setAttribute('aria-expanded', 'false');
|
449
|
-
this.setAttribute('aria-controls', accordionId + "-content");
|
450
|
-
|
451
|
-
// Initialize the Collapse plugin on the button element
|
452
|
-
var collapse = new bootstrap.Collapse(this, {
|
453
|
-
toggle: false // Set to true if you want to toggle the collapsed state on initialization
|
454
|
-
});
|
455
|
-
|
456
|
-
// Remove the event listener temporarily
|
457
|
-
this.removeEventListener('click', arguments.callee);
|
458
|
-
|
459
|
-
// Programmatically trigger a click on the button to open the accordion
|
460
|
-
setTimeout(() => { // Delaying to ensure the content is added before triggering the click
|
461
|
-
this.click();
|
462
|
-
}, 0);
|
463
|
-
|
464
|
-
})
|
461
|
+
setTimeout(() => { // Delaying to ensure the content is added before triggering the click
|
462
|
+
this.click();
|
463
|
+
}, 0);
|
464
|
+
|
465
|
+
})
|
466
|
+
}
|
465
467
|
|
466
468
|
if (isCustomFlow) {
|
467
469
|
const customScreenshotElem = accordion.getElementsByClassName(`custom-flow-screenshot`)[0];
|
@@ -1196,6 +1196,10 @@
|
|
1196
1196
|
content: none;
|
1197
1197
|
}
|
1198
1198
|
|
1199
|
+
#expandedRuleCategoryContent .no-chevron .accordion-button::before {
|
1200
|
+
background-image: none !important;
|
1201
|
+
}
|
1202
|
+
|
1199
1203
|
#expandedRuleCategoryContent .accordion-button::before {
|
1200
1204
|
content: '';
|
1201
1205
|
width: 0.75rem;
|
@@ -2,11 +2,7 @@
|
|
2
2
|
<main aria-label="Report main content" class="d-flex flex-column flex-grow-1">
|
3
3
|
<div class="row m-0 py-4 px-3 d-flex" style="flex-wrap: nowrap">
|
4
4
|
<div id="aboutScanDiv" class="pe-md-2 d-flex" style="flex: 1; flex-basis: 300px">
|
5
|
-
<div
|
6
|
-
id="scanabout-compliance-card"
|
7
|
-
class="card h-100"
|
8
|
-
style="width: -webkit-fill-available"
|
9
|
-
>
|
5
|
+
<div id="scanabout-compliance-card" class="card h-100" style="width: -webkit-fill-available">
|
10
6
|
<div class="card-body"><%- include("components/summaryScanAbout") %></div>
|
11
7
|
</div>
|
12
8
|
</div>
|
@@ -16,36 +12,21 @@
|
|
16
12
|
<h2>Scan results</h2>
|
17
13
|
<div class="d-flex justify-content-between align-items-center">
|
18
14
|
<span class="fw-bold"> WCAG (A & AA) Passes </span>
|
19
|
-
<span aria-label="Pass percentage" class="ms-2"
|
15
|
+
<span aria-label="Pass percentage" class="ms-2">
|
16
|
+
<%= wcagPassPercentage %>% of automated checks
|
17
|
+
</span>
|
20
18
|
</div>
|
21
19
|
<div class="wcag-compliance-passes-bar mb-5 d-flex">
|
22
|
-
<svg
|
23
|
-
|
24
|
-
|
25
|
-
height="6"
|
26
|
-
fill="none"
|
27
|
-
xmlns="http://www.w3.org/2000/svg"
|
28
|
-
style="display: flex; width: 100%; position: absolute"
|
29
|
-
>
|
30
|
-
<rect
|
31
|
-
width="100%"
|
32
|
-
height="10"
|
33
|
-
rx="3"
|
34
|
-
fill="#E7ECEE"
|
35
|
-
style="justify-content: left"
|
36
|
-
></rect>
|
37
|
-
<rect
|
38
|
-
width="<%= wcagPassPercentage %>%"
|
39
|
-
height="6"
|
40
|
-
rx="3"
|
41
|
-
fill="#9021a6"
|
42
|
-
style=""
|
43
|
-
></rect>
|
20
|
+
<svg width="500" role="none" height="6" fill="none" xmlns="http://www.w3.org/2000/svg"
|
21
|
+
style="display: flex; width: 100%; position: absolute">
|
22
|
+
<rect width="100%" height="10" rx="3" fill="#E7ECEE" style="justify-content: left"></rect>
|
23
|
+
<rect width="<%= wcagPassPercentage %>%" height="6" rx="3" fill="#9021a6" style=""></rect>
|
44
24
|
</svg>
|
45
25
|
</div>
|
46
26
|
<ul class="unbulleted-list">
|
47
|
-
<% Object.keys(items).forEach((category)
|
48
|
-
|
27
|
+
<% Object.keys(items).forEach((category)=> { %> <%- include("components/summaryScanResults", { category:
|
28
|
+
category }) %>
|
29
|
+
<% }) %>
|
49
30
|
</ul>
|
50
31
|
</div>
|
51
32
|
</div>
|
@@ -55,21 +36,13 @@
|
|
55
36
|
<h2 class="mb-2">Summary of issues:</h2>
|
56
37
|
<p>
|
57
38
|
Only a subset of
|
58
|
-
<a
|
59
|
-
href="https://www.w3.org/WAI/WCAG21/quickref/?currentsidebar=%23col_customize&versions=2.1&levels=aaa"
|
60
|
-
target="_blank"
|
61
|
-
>WCAG 2.1</a
|
62
|
-
>
|
39
|
+
<a href="https://www.w3.org/WAI/WCAG22/quickref/?versions=2.2" target=" _blank">WCAG 2.2</a>
|
63
40
|
(Conformance Level A & AA) Success Criteria can be automatically checked so
|
64
|
-
<a
|
65
|
-
|
66
|
-
href="http://go.gov.sg/oobee-manual-testing"
|
67
|
-
target="_blank"
|
68
|
-
>manual testing</a
|
69
|
-
>
|
41
|
+
<a aria-label="Manual testing guide" href="http://go.gov.sg/oobee-manual-testing" target="_blank">manual
|
42
|
+
testing</a>
|
70
43
|
is still required. For more details, please refer to the HTML report.
|
71
44
|
</p>
|
72
45
|
</div>
|
73
46
|
<%- include("components/summaryTable") %>
|
74
47
|
</main>
|
75
|
-
</div>
|
48
|
+
</div>
|
@@ -17,6 +17,7 @@
|
|
17
17
|
href="data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 48 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23.5 6C11.7707 6 10 11.1369 10 19.23V28.77C10 36.8631 11.7707 42 23.5 42C35.2293 42 37 36.8631 37 28.77V19.23C37 11.1369 35.2293 6 23.5 6ZM25.4903 14.5985V35.0562H21.5097V12.9438H27.8925L25.4903 14.5985Z' fill='%239021A6'/%3E%3C/svg%3E
|
18
18
|
"
|
19
19
|
/>
|
20
|
+
<%- include('partials/scripts/decodeUnzipParse') %>
|
20
21
|
<%- include('partials/styles/bootstrap') %> <%- include('partials/styles/highlightjs') %> <%-
|
21
22
|
include('partials/styles/styles') %>
|
22
23
|
</head>
|
@@ -100,7 +101,7 @@
|
|
100
101
|
const scategoryList = document.getElementById('categorySelector');
|
101
102
|
|
102
103
|
Object.keys(scanItems).forEach(category => {
|
103
|
-
if (
|
104
|
+
if (["mustFix", "goodToFix", "needsReview"].includes(category)) { // skip other keys like pagesScanned, etc
|
104
105
|
const categoryData = scanItems[category];
|
105
106
|
const listItem = document.createElement('div');
|
106
107
|
listItem.className = 'col-md-4 px-2';
|
@@ -169,12 +170,10 @@
|
|
169
170
|
spanInfo.id = `${category}ItemsInformation`;
|
170
171
|
spanInfo.className = 'category-information';
|
171
172
|
|
172
|
-
if (
|
173
|
+
if (categoryData.totalItems !== 0) {
|
173
174
|
spanInfo.textContent = `${categoryData.rules.length} ${categoryData.rules.length === 1 ? 'issue' : 'issues'} / ${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'}`;
|
174
|
-
} else if (
|
175
|
+
} else if (categoryData.totalItems === 0) {
|
175
176
|
spanInfo.textContent = `0 issues`;
|
176
|
-
} else {
|
177
|
-
spanInfo.textContent = `${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'}`;
|
178
177
|
}
|
179
178
|
|
180
179
|
button.appendChild(divFlex);
|
@@ -187,7 +186,8 @@
|
|
187
186
|
const categoryList = document.getElementById('issueTypeListbox');
|
188
187
|
|
189
188
|
Object.keys(scanItems).forEach(category => {
|
190
|
-
if (
|
189
|
+
if (["mustFix", "goodToFix", "needsReview"].includes(category)) { // skip other keys like pagesScanned, etc
|
190
|
+
|
191
191
|
const categoryData = scanItems[category];
|
192
192
|
const rulesLength = categoryData.rules ? categoryData.rules.length : 0;
|
193
193
|
|
@@ -206,12 +206,10 @@
|
|
206
206
|
spanInfo.id = `${category}ItemsInformation`;
|
207
207
|
spanInfo.className = 'category-information';
|
208
208
|
|
209
|
-
if (
|
209
|
+
if (categoryData.totalItems !== 0) {
|
210
210
|
spanInfo.textContent = `(${categoryData.rules.length} ${categoryData.rules.length === 1 ? 'issue' : 'issues'})`;
|
211
|
-
} else if (
|
211
|
+
} else if (categoryData.totalItems === 0) {
|
212
212
|
spanInfo.textContent = `(0 issues)`;
|
213
|
-
} else {
|
214
|
-
spanInfo.textContent = `(${categoryData.totalItems} ${categoryData.totalItems === 1 ? 'occurrence' : 'occurrences'})`;
|
215
213
|
}
|
216
214
|
|
217
215
|
listItem.appendChild(spanTitle);
|
@@ -408,9 +406,20 @@
|
|
408
406
|
};
|
409
407
|
|
410
408
|
document.addEventListener('DOMContentLoaded', () => {
|
411
|
-
|
412
|
-
|
409
|
+
scanDataPromise.then(() => {
|
410
|
+
console.log("scanData loaded.");
|
411
|
+
scanItemsPromise.then(() => {
|
412
|
+
console.log("scanItems loaded.");
|
413
|
+
initTooltips();
|
414
|
+
scanDataHTML();
|
415
|
+
}).error(e => {
|
416
|
+
console.error("Failed to load scanItems: ", e);
|
417
|
+
});
|
418
|
+
}).error(e => {
|
419
|
+
console.error("Failed to load scanData: ", e);
|
420
|
+
});
|
413
421
|
});
|
422
|
+
|
414
423
|
</script>
|
415
424
|
<!-- Checks if js runs -->
|
416
425
|
<script>
|
package/src/utils.ts
CHANGED
@@ -191,8 +191,15 @@ export const cleanUp = async pathToDelete => {
|
|
191
191
|
// });
|
192
192
|
|
193
193
|
export const getWcagPassPercentage = (wcagViolations: string[]): string => {
|
194
|
-
|
195
|
-
|
194
|
+
|
195
|
+
// These AAA rules should not be counted as WCAG Pass Percentage only contains A and AA
|
196
|
+
const wcagAAA = ['WCAG 1.4.6', 'WCAG 2.2.4', 'WCAG 2.4.9', 'WCAG 3.1.5', 'WCAG 3.2.5'];
|
197
|
+
|
198
|
+
const filteredWcagLinks = Object.keys(constants.wcagLinks).filter(key => !wcagAAA.includes(key));
|
199
|
+
const filteredWcagViolations = wcagViolations.filter(violation => !wcagAAA.includes(violation));
|
200
|
+
const totalChecks = filteredWcagLinks.length;
|
201
|
+
|
202
|
+
const passedChecks = totalChecks - filteredWcagViolations.length;
|
196
203
|
const passPercentage = (passedChecks / totalChecks) * 100;
|
197
204
|
|
198
205
|
return passPercentage.toFixed(2); // toFixed returns a string, which is correct here
|
@@ -241,6 +248,7 @@ export const setHeadlessMode = (browser: string, isHeadless: boolean): void => {
|
|
241
248
|
} else {
|
242
249
|
process.env.CRAWLEE_HEADLESS = '0';
|
243
250
|
}
|
251
|
+
|
244
252
|
};
|
245
253
|
|
246
254
|
export const setThresholdLimits = setWarnLevel => {
|
@@ -0,0 +1,186 @@
|
|
1
|
+
/**
|
2
|
+
* XPath to CSS
|
3
|
+
*
|
4
|
+
* Utility function for converting XPath expressions to CSS selectors
|
5
|
+
*
|
6
|
+
* Originally written in Python by [santiycr](https://github.com/santiycr) for
|
7
|
+
* [cssify](https://github.com/santiycr/cssify) and ported to JavaScript by
|
8
|
+
* [Dither](https://github.com/Dither). Converted to ES2015 and packaged as an npm module by
|
9
|
+
* [svenheden](https://github.com/svenheden)
|
10
|
+
*/
|
11
|
+
|
12
|
+
'use strict';
|
13
|
+
|
14
|
+
import { consoleLogger } from "./logs.js";
|
15
|
+
|
16
|
+
const isValidXPath = expr => (
|
17
|
+
typeof expr != 'undefined' &&
|
18
|
+
expr.replace(/[\s-_=]/g,'') !== '' &&
|
19
|
+
expr.length === expr.replace(/[-_\w:.]+\(\)\s*=|=\s*[-_\w:.]+\(\)|\sor\s|\sand\s|\[(?:[^\/\]]+[\/\[]\/?.+)+\]|starts-with\(|\[.*last\(\)\s*[-\+<>=].+\]|number\(\)|not\(|count\(|text\(|first\(|normalize-space|[^\/]following-sibling|concat\(|descendant::|parent::|self::|child::|/gi,'').length
|
20
|
+
);
|
21
|
+
|
22
|
+
const getValidationRegex = () => {
|
23
|
+
let regex =
|
24
|
+
"(?P<node>"+
|
25
|
+
"("+
|
26
|
+
"^id\\([\"\\']?(?P<idvalue>%(value)s)[\"\\']?\\)"+// special case! `id(idValue)`
|
27
|
+
"|"+
|
28
|
+
"(?P<nav>//?(?:following-sibling::)?)(?P<tag>%(tag)s)" + // `//div`
|
29
|
+
"(\\[("+
|
30
|
+
"(?P<matched>(?P<mattr>@?%(attribute)s=[\"\\'](?P<mvalue>%(value)s))[\"\\']"+ // `[@id="well"]` supported and `[text()="yes"]` is not
|
31
|
+
"|"+
|
32
|
+
"(?P<contained>contains\\((?P<cattr>@?%(attribute)s,\\s*[\"\\'](?P<cvalue>%(value)s)[\"\\']\\))"+// `[contains(@id, "bleh")]` supported and `[contains(text(), "some")]` is not
|
33
|
+
")\\])?"+
|
34
|
+
"(\\[\\s*(?P<nth>\\d+|last\\(\\s*\\))\\s*\\])?"+
|
35
|
+
")"+
|
36
|
+
")";
|
37
|
+
|
38
|
+
const subRegexes = {
|
39
|
+
"tag": "([a-zA-Z][a-zA-Z0-9:-]*|\\*)",
|
40
|
+
"attribute": "[.a-zA-Z_:][-\\w:.]*(\\(\\))?)",
|
41
|
+
"value": "\\s*[\\w/:][-/\\w\\s,:;.]*"
|
42
|
+
};
|
43
|
+
|
44
|
+
Object.keys(subRegexes).forEach(key => {
|
45
|
+
regex = regex.replace(new RegExp('%\\(' + key + '\\)s', 'gi'), subRegexes[key]);
|
46
|
+
});
|
47
|
+
|
48
|
+
regex = regex.replace(/\?P<node>|\?P<idvalue>|\?P<nav>|\?P<tag>|\?P<matched>|\?P<mattr>|\?P<mvalue>|\?P<contained>|\?P<cattr>|\?P<cvalue>|\?P<nth>/gi, '');
|
49
|
+
|
50
|
+
return new RegExp(regex, 'gi');
|
51
|
+
};
|
52
|
+
|
53
|
+
const preParseXpath = expr => (
|
54
|
+
expr.replace(/contains\s*\(\s*concat\(["']\s+["']\s*,\s*@class\s*,\s*["']\s+["']\)\s*,\s*["']\s+([a-zA-Z0-9-_]+)\s+["']\)/gi, '@class="$1"')
|
55
|
+
);
|
56
|
+
|
57
|
+
function escapeCssIdSelectors(cssSelector) {
|
58
|
+
return cssSelector.replace(/#([^ >]+)/g, (match, id) => {
|
59
|
+
// Escape special characters in the id part
|
60
|
+
return '#' + id.replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
61
|
+
});
|
62
|
+
}
|
63
|
+
|
64
|
+
export const xPathToCss = expr => {
|
65
|
+
if (!expr) {
|
66
|
+
throw new Error('Missing XPath expression');
|
67
|
+
}
|
68
|
+
|
69
|
+
expr = preParseXpath(expr);
|
70
|
+
|
71
|
+
if (!isValidXPath(expr)) {
|
72
|
+
consoleLogger.error(`Invalid or unsupported XPath: ${expr}`);
|
73
|
+
// do not throw error so that this function proceeds to convert xpath that it does not support
|
74
|
+
// for example, //*[@id="google_ads_iframe_/4654/dweb/imu1/homepage/landingpage/na_0"]/html/body/div[1]/a
|
75
|
+
// becomes #google_ads_iframe_/4654/dweb/imu1/homepage/landingpage/na_0 > html > body > div:first-of-type > div > a
|
76
|
+
// which is invalid because the slashes in the id selector are not escaped
|
77
|
+
// throw new Error('Invalid or unsupported XPath: ' + expr);
|
78
|
+
}
|
79
|
+
|
80
|
+
const xPathArr = expr.split('|');
|
81
|
+
const prog = getValidationRegex();
|
82
|
+
const cssSelectors = [];
|
83
|
+
let xindex = 0;
|
84
|
+
|
85
|
+
while (xPathArr[xindex]) {
|
86
|
+
const css = [];
|
87
|
+
let position = 0;
|
88
|
+
let nodes;
|
89
|
+
|
90
|
+
while (nodes = prog.exec(xPathArr[xindex])) {
|
91
|
+
let attr;
|
92
|
+
|
93
|
+
if (!nodes && position === 0) {
|
94
|
+
throw new Error('Invalid or unsupported XPath: ' + expr);
|
95
|
+
}
|
96
|
+
|
97
|
+
const match = {
|
98
|
+
node: nodes[5],
|
99
|
+
idvalue: nodes[12] || nodes[3],
|
100
|
+
nav: nodes[4],
|
101
|
+
tag: nodes[5],
|
102
|
+
matched: nodes[7],
|
103
|
+
mattr: nodes[10] || nodes[14],
|
104
|
+
mvalue: nodes[12] || nodes[16],
|
105
|
+
contained: nodes[13],
|
106
|
+
cattr: nodes[14],
|
107
|
+
cvalue: nodes[16],
|
108
|
+
nth: nodes[18]
|
109
|
+
};
|
110
|
+
|
111
|
+
let nav = '';
|
112
|
+
|
113
|
+
if (position != 0 && match['nav']) {
|
114
|
+
if (~match['nav'].indexOf('following-sibling::')) {
|
115
|
+
nav = ' + ';
|
116
|
+
} else {
|
117
|
+
nav = (match['nav'] == '//') ? ' ' : ' > ';
|
118
|
+
}
|
119
|
+
}
|
120
|
+
|
121
|
+
const tag = (match['tag'] === '*') ? '' : (match['tag'] || '');
|
122
|
+
|
123
|
+
if (match['contained']) {
|
124
|
+
if (match['cattr'].indexOf('@') === 0) {
|
125
|
+
attr = '[' + match['cattr'].replace(/^@/, '') + '*="' + match['cvalue'] + '"]';
|
126
|
+
} else {
|
127
|
+
throw new Error('Invalid or unsupported XPath attribute: ' + match['cattr']);
|
128
|
+
}
|
129
|
+
} else if (match['matched']) {
|
130
|
+
switch (match['mattr']) {
|
131
|
+
case '@id':
|
132
|
+
attr = '#' + match['mvalue'].replace(/^\s+|\s+$/,'').replace(/\s/g, '#');
|
133
|
+
break;
|
134
|
+
case '@class':
|
135
|
+
attr = '.' + match['mvalue'].replace(/^\s+|\s+$/,'').replace(/\s/g, '.');
|
136
|
+
break;
|
137
|
+
case 'text()':
|
138
|
+
case '.':
|
139
|
+
throw new Error('Invalid or unsupported XPath attribute: ' + match['mattr']);
|
140
|
+
default:
|
141
|
+
if (match['mattr'].indexOf('@') !== 0) {
|
142
|
+
throw new Error('Invalid or unsupported XPath attribute: ' + match['mattr']);
|
143
|
+
}
|
144
|
+
if (match['mvalue'].indexOf(' ') !== -1) {
|
145
|
+
match['mvalue'] = '\"' + match['mvalue'].replace(/^\s+|\s+$/,'') + '\"';
|
146
|
+
}
|
147
|
+
attr = '[' + match['mattr'].replace('@', '') + '="' + match['mvalue'] + '"]';
|
148
|
+
break;
|
149
|
+
}
|
150
|
+
} else if (match['idvalue']) {
|
151
|
+
attr = '#' + match['idvalue'].replace(/\s/, '#');
|
152
|
+
} else {
|
153
|
+
attr = '';
|
154
|
+
}
|
155
|
+
|
156
|
+
let nth = '';
|
157
|
+
|
158
|
+
if (match['nth']) {
|
159
|
+
if (match['nth'].indexOf('last') === -1) {
|
160
|
+
if (isNaN(parseInt(match['nth'], 10))) {
|
161
|
+
throw new Error('Invalid or unsupported XPath attribute: ' + match['nth']);
|
162
|
+
}
|
163
|
+
nth = parseInt(match['nth'], 10) !== 1 ? ':nth-of-type(' + match['nth'] + ')' : ':first-of-type';
|
164
|
+
} else {
|
165
|
+
nth = ':last-of-type';
|
166
|
+
}
|
167
|
+
}
|
168
|
+
|
169
|
+
css.push(nav + tag + attr + nth);
|
170
|
+
position++;
|
171
|
+
}
|
172
|
+
|
173
|
+
const result = css.join('');
|
174
|
+
|
175
|
+
if (result === '') {
|
176
|
+
throw new Error('Invalid or unsupported XPath');
|
177
|
+
}
|
178
|
+
|
179
|
+
cssSelectors.push(result);
|
180
|
+
xindex++;
|
181
|
+
}
|
182
|
+
|
183
|
+
// return cssSelectors.join(', ');
|
184
|
+
const originalResult = cssSelectors.join(', ');
|
185
|
+
return escapeCssIdSelectors(originalResult);
|
186
|
+
};
|
package/a11y-scan-results.zip
DELETED
Binary file
|