frontend-go-live-check 1.2.0 → 1.4.0
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/CHANGELOG.md +19 -0
- package/README.md +12 -7
- package/docs/finding.png +0 -0
- package/package.json +1 -1
- package/src/checks/browser.mjs +23 -2
- package/src/checks/evidence.mjs +153 -15
- package/src/checks/in-page.mjs +179 -0
- package/src/core/explain.mjs +303 -0
- package/src/core/rules.mjs +28 -0
- package/src/export/docx.mjs +30 -19
- package/src/export/model.mjs +17 -11
- package/src/export/pdf.mjs +13 -7
- package/src/report/report.mjs +47 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
- New layout checks on every page at every screen size, each with Actual and Expected pictures where possible:
|
|
6
|
+
- text that spills out of its box (for example a button with a fixed size that is too small for its label);
|
|
7
|
+
- text drawn on top of other text;
|
|
8
|
+
- content cut off at the edge of the screen where the user cannot scroll to it.
|
|
9
|
+
- New content checks:
|
|
10
|
+
- buttons and links covered by another element;
|
|
11
|
+
- unfinished text: `undefined`, `NaN`, `null`, `[object Object]`, `Invalid Date`, `{{ }}`, translation keys, lorem ipsum, TODO;
|
|
12
|
+
- stretched or squashed images;
|
|
13
|
+
- loading indicators still showing after the page finished loading.
|
|
14
|
+
- Evidence outlines now include text that runs outside its element.
|
|
15
|
+
|
|
16
|
+
## 1.3.0
|
|
17
|
+
|
|
18
|
+
- Every problem is shown as **Actual** next to **Expected**: a close-up of the problem outlined in red, and the same spot corrected (focus ring, contrast, text that fits, page that fits the screen, right-to-left…) outlined in green. Problems without a picture get one Expected sentence.
|
|
19
|
+
- Plain-language explanations for every problem: what is wrong, who it affects, where (element name and CSS selector), how to see it yourself, and the standard behind it (WCAG, OWASP, OAuth).
|
|
20
|
+
- The same layout in the HTML report, the PDF and the Word file.
|
|
21
|
+
|
|
3
22
|
## 1.1.0
|
|
4
23
|
|
|
5
24
|
- **Sign-in with Google, Microsoft, Apple, UAE PASS and any OpenID Connect server.**
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Have an API? Save your Postman collection as **`api/postman_collection.json`** (
|
|
|
54
54
|
| --- | --- |
|
|
55
55
|
| "Looks fine on my screen" | Every page at phone, tablet and desktop size, in a real browser |
|
|
56
56
|
| Accessibility, security and speed are checked by different people, if at all | One run, one report, one verdict: **READY**, **NEEDS ATTENTION** or **NOT READY** |
|
|
57
|
-
| Bugs reported as "the page is broken" |
|
|
57
|
+
| Bugs reported as "the page is broken" | Actual and Expected pictures side by side, the element and its CSS selector, and steps to see it |
|
|
58
58
|
| Writing and maintaining end-to-end tests for every page | Nothing to write: pages are found by following links, your routes and your sitemap |
|
|
59
59
|
| Releases go out on hope | A pipeline step that **blocks the merge** when there are blocking problems |
|
|
60
60
|
|
|
@@ -142,9 +142,9 @@ The report lists every sign-in method found (*email and password · Google · UA
|
|
|
142
142
|
|
|
143
143
|
| | Area | What is checked |
|
|
144
144
|
| :-: | --- | --- |
|
|
145
|
-
| 🧭 | **Functionality** | JavaScript errors, console errors, failed requests, broken links, images and anchors, blank pages, the production build, the "page not found" page |
|
|
145
|
+
| 🧭 | **Functionality** | JavaScript errors, console errors, failed requests, broken links, images and anchors, blank pages, buttons covered by something else, unfinished text (`undefined`, `NaN`, `[object Object]`, translation keys, lorem ipsum, TODO), loading spinners that never stop, the production build, the "page not found" page |
|
|
146
146
|
| 🛟 | **Resilience** | Each page opened again with its API calls failing: does it crash, go blank, or tell the user? |
|
|
147
|
-
| 📱 | **Design and responsive layout** | Every page at every screen size: sideways scrolling, cut
|
|
147
|
+
| 📱 | **Design and responsive layout** | Every page at every screen size: sideways scrolling, text that spills out of its box (a button too small for its label), text on top of other text, content cut off at the screen edge, text cut off inside its box, overlapping buttons, stretched or squashed images, tiny text on phones, right-to-left layout (Arabic, Hebrew…), fonts that fail to load, too many fonts and sizes |
|
|
148
148
|
| 🖼️ | **Visual changes** | A pixel comparison of every page with the saved baseline, with the changed areas in red |
|
|
149
149
|
| ♿ | **Accessibility** | axe-core against WCAG 2.0 / 2.1 / 2.2 A and AA; keyboard-only use: visible focus, no traps, nothing that only a mouse can click; form field types |
|
|
150
150
|
| ⚡ | **Performance** | Size, JavaScript, requests, load time, LCP, layout shift and frozen-page time for every page, against budgets; Lighthouse as a phone and as a desktop |
|
|
@@ -199,12 +199,17 @@ The report has:
|
|
|
199
199
|
- the API results, the speed of every page, and the load test;
|
|
200
200
|
- screenshots of every page at every size.
|
|
201
201
|
|
|
202
|
-
### Every problem
|
|
202
|
+
### Every problem: Actual next to Expected
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
Each problem is shown so a developer understands it at a glance, without knowing the rule behind it:
|
|
205
|
+
|
|
206
|
+
- **one sentence** on what is wrong, in plain words;
|
|
207
|
+
- **Actual**: a close-up of the page as it is now, with the problem outlined in red, at the screen size where it happens;
|
|
208
|
+
- **Expected**: the same spot as it should be, outlined in green. For visual problems the tool corrects the page in the browser for this one picture: a visible focus ring, readable contrast, text that fits, a page that fits the screen, right-to-left layout. Other problems (APIs, security, sign-in) get one sentence saying what is expected;
|
|
209
|
+
- **where** (page, screen size, the element by name and CSS selector), **who it affects**, and **how to see it yourself**.
|
|
205
210
|
|
|
206
211
|
<p align="center">
|
|
207
|
-
<img src="https://unpkg.com/frontend-go-live-check@1/docs/finding.png" alt="A
|
|
212
|
+
<img src="https://unpkg.com/frontend-go-live-check@1/docs/finding.png" alt="A problem card: Actual (focus not visible, outlined in red) next to Expected (a clear focus ring, outlined in green)" width="860">
|
|
208
213
|
</p>
|
|
209
214
|
|
|
210
215
|
### Share it as PDF or Word
|
|
@@ -222,7 +227,7 @@ Both documents follow a standard test-report layout:
|
|
|
222
227
|
- contents;
|
|
223
228
|
- an executive summary;
|
|
224
229
|
- results by area;
|
|
225
|
-
- every finding with an ID,
|
|
230
|
+
- every finding with an ID, Actual and Expected pictures, where it is and how to see it;
|
|
226
231
|
- problems per page;
|
|
227
232
|
- a fix checklist;
|
|
228
233
|
- appendices with screenshots.
|
package/docs/finding.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frontend-go-live-check",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Is your frontend ready to go live? One command tests any web frontend (React, Next.js, Vue, Angular, Svelte, plain HTML…): pages, responsive design, accessibility, keyboard, performance and load, API calls, security, SEO, sign-in and APIs from Postman/OpenAPI. Report with evidence, PDF and Word, and a pipeline gate that approves or rejects the merge.",
|
|
5
5
|
"homepage": "https://www.npmjs.com/package/frontend-go-live-check",
|
|
6
6
|
"repository": {
|
package/src/checks/browser.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { normaliseLogin, signIn } from '../core/login.mjs';
|
|
|
11
11
|
import { inPageAudit } from './in-page.mjs';
|
|
12
12
|
import { keyboardAudit } from './keyboard.mjs';
|
|
13
13
|
import { compareWithBaseline } from './visual.mjs';
|
|
14
|
-
import { captureEvidence, capturePage } from './evidence.mjs';
|
|
14
|
+
import { captureEvidence, captureExpected, capturePage } from './evidence.mjs';
|
|
15
15
|
import { rule } from '../core/rules.mjs';
|
|
16
16
|
|
|
17
17
|
export { signIn };
|
|
@@ -283,8 +283,17 @@ export async function runBrowserChecks({ base, config, reportDir, log, starts =
|
|
|
283
283
|
const key = `${finding.ruleId}|${finding.page}|${finding.detail ?? ''}|${(finding.items ?? []).join('|')}`;
|
|
284
284
|
if (config.evidence !== false && !evidenceTaken.has(key)) {
|
|
285
285
|
evidenceTaken.add(key);
|
|
286
|
-
|
|
286
|
+
const shot = selectors === 'page' ? await capturePage(page, reportDir) : await captureEvidence(page, reportDir, { selectors, label: `${rule(finding.ruleId).title} · ${tag}`, ...options });
|
|
287
|
+
finding.evidence = typeof shot === 'string' ? shot : shot?.file ?? '';
|
|
288
|
+
if (shot?.zoom) finding.evidenceZoom = shot.zoom;
|
|
287
289
|
if (finding.evidence) finding.evidenceView = tag;
|
|
290
|
+
// The same spot as it should look (corrected for this one picture).
|
|
291
|
+
const kind = finding.ruleId.startsWith('a11y-') ? String(finding.detail ?? '').split(':')[0].trim() : finding.ruleId;
|
|
292
|
+
const expected = await captureExpected(page, reportDir, finding.evidence, { kind, selectors: Array.isArray(selectors) ? selectors : [], label: `Expected · ${tag}`, screenEdge: options.screenEdge ?? 0 }).catch(() => null);
|
|
293
|
+
if (expected?.file) {
|
|
294
|
+
finding.expected = expected.file;
|
|
295
|
+
if (expected.zoom) finding.expectedZoom = expected.zoom;
|
|
296
|
+
}
|
|
288
297
|
}
|
|
289
298
|
add(finding);
|
|
290
299
|
};
|
|
@@ -292,6 +301,18 @@ export async function runBrowserChecks({ base, config, reportDir, log, starts =
|
|
|
292
301
|
if (!audit.viewportMeta) add({ ruleId: 'missing-viewport-meta', page: landedKey, view: tag });
|
|
293
302
|
if (audit.overflow.length) await withEvidence({ ruleId: 'horizontal-overflow', page: landedKey, view: tag, detail: `The page is ${audit.docWidth}px wide on a ${audit.width}px screen.`, items: audit.overflow, selectors: audit.targets.overflow }, audit.targets.overflow, { screenEdge: audit.width });
|
|
294
303
|
if (audit.clipped.length) await withEvidence({ ruleId: 'clipped-text', page: landedKey, view: tag, items: audit.clipped, selectors: audit.targets.clipped }, audit.targets.clipped);
|
|
304
|
+
if (audit.outside.length) await withEvidence({ ruleId: 'text-outside-box', page: landedKey, view: tag, items: audit.outside, selectors: audit.targets.outside }, audit.targets.outside);
|
|
305
|
+
if (audit.textOverlaps.length) await withEvidence({ ruleId: 'text-overlap', page: landedKey, view: tag, items: audit.textOverlaps, selectors: audit.targets.textOverlaps }, audit.targets.textOverlaps);
|
|
306
|
+
if (audit.cutOff.length) await withEvidence({ ruleId: 'cut-off-at-edge', page: landedKey, view: tag, items: audit.cutOff, selectors: audit.targets.cutOff }, audit.targets.cutOff);
|
|
307
|
+
if (audit.covered.length) await withEvidence({ ruleId: 'control-covered', page: landedKey, view: tag, items: audit.covered, selectors: audit.targets.covered }, audit.targets.covered);
|
|
308
|
+
if (audit.placeholders.length) await withEvidence({ ruleId: 'placeholder-text', page: landedKey, view: tag, items: audit.placeholders, selectors: audit.targets.placeholders }, audit.targets.placeholders);
|
|
309
|
+
if (audit.distorted.length) await withEvidence({ ruleId: 'distorted-image', page: landedKey, view: tag, items: audit.distorted, selectors: audit.targets.distorted }, audit.targets.distorted);
|
|
310
|
+
if (audit.loading.length) {
|
|
311
|
+
// Give slow data a little more time before calling it stuck.
|
|
312
|
+
await page.waitForTimeout(4000);
|
|
313
|
+
const again = await page.evaluate(inPageAudit, [Boolean(vp.isMobile) && vp.viewport.width < 600, vp.viewport.width]).catch(() => null);
|
|
314
|
+
if (again?.loading.length) await withEvidence({ ruleId: 'stuck-loading', page: landedKey, view: tag, items: again.loading, selectors: again.targets.loading }, again.targets.loading);
|
|
315
|
+
}
|
|
295
316
|
if (audit.overlaps.length) await withEvidence({ ruleId: 'overlapping-elements', page: landedKey, view: tag, items: audit.overlaps, selectors: audit.targets.overlaps }, audit.targets.overlaps);
|
|
296
317
|
if (audit.small.length) await withEvidence({ ruleId: 'small-text', page: landedKey, view: tag, items: audit.small, selectors: audit.targets.small }, audit.targets.small);
|
|
297
318
|
for (const [i, src] of audit.brokenImages.entries()) await withEvidence({ ruleId: 'broken-image', page: landedKey, view: tag, detail: src, selectors: [audit.targets.brokenImages[i]] }, [audit.targets.brokenImages[i]]);
|
package/src/checks/evidence.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { mkdirSync } from 'node:fs';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
9
9
|
/** Runs in the page: draws the outlines. Returns how many elements were found. */
|
|
10
|
-
function drawOutlines([selectors, label, screenEdge]) {
|
|
10
|
+
function drawOutlines([selectors, label, screenEdge, colour = '#e01e1e']) {
|
|
11
11
|
document.getElementById('__glc_evidence')?.remove();
|
|
12
12
|
const layer = document.createElement('div');
|
|
13
13
|
layer.id = '__glc_evidence';
|
|
@@ -23,34 +23,52 @@ function drawOutlines([selectors, label, screenEdge]) {
|
|
|
23
23
|
}
|
|
24
24
|
if (el && !found.includes(el)) found.push(el);
|
|
25
25
|
}
|
|
26
|
+
// The element's box plus wherever its content is drawn (text can run outside the box).
|
|
27
|
+
const extent = (el) => {
|
|
28
|
+
const b = el.getBoundingClientRect();
|
|
29
|
+
const range = document.createRange();
|
|
30
|
+
range.selectNodeContents(el);
|
|
31
|
+
const c = range.getBoundingClientRect();
|
|
32
|
+
if (!c.width || !c.height) return b;
|
|
33
|
+
const left = Math.min(b.left, c.left);
|
|
34
|
+
const top = Math.min(b.top, c.top);
|
|
35
|
+
const right = Math.max(b.right, c.right);
|
|
36
|
+
const bottom = Math.max(b.bottom, c.bottom);
|
|
37
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
38
|
+
};
|
|
26
39
|
found.forEach((el, i) => {
|
|
27
|
-
const r = el
|
|
40
|
+
const r = extent(el);
|
|
28
41
|
const box = document.createElement('div');
|
|
29
|
-
box.style.cssText = `position:absolute;left:${r.left + scrollX - 3}px;top:${r.top + scrollY - 3}px;width:${Math.max(r.width, 4) + 6}px;height:${Math.max(r.height, 4) + 6}px;border:3px solid
|
|
42
|
+
box.style.cssText = `position:absolute;left:${r.left + scrollX - 3}px;top:${r.top + scrollY - 3}px;width:${Math.max(r.width, 4) + 6}px;height:${Math.max(r.height, 4) + 6}px;border:3px solid ${colour};border-radius:4px;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 0 0 9999px rgba(0,0,0,.08);box-sizing:border-box`;
|
|
30
43
|
const tag = document.createElement('div');
|
|
31
|
-
tag.textContent = found.length > 1 ? String(i + 1) : '!';
|
|
32
|
-
tag.style.cssText = `position:absolute;left:-3px;top:${r.top + scrollY < 26 ? 'calc(100% + 2px)' : '-24px'};background
|
|
44
|
+
tag.textContent = colour === '#e01e1e' ? (found.length > 1 ? String(i + 1) : '!') : '✓';
|
|
45
|
+
tag.style.cssText = `position:absolute;left:-3px;top:${r.top + scrollY < 26 ? 'calc(100% + 2px)' : '-24px'};background:${colour};color:#fff;font:700 13px/20px system-ui,sans-serif;padding:0 7px;border-radius:4px`;
|
|
33
46
|
box.appendChild(tag);
|
|
34
47
|
layer.appendChild(box);
|
|
35
48
|
});
|
|
36
49
|
if (screenEdge) {
|
|
37
50
|
const edge = document.createElement('div');
|
|
38
|
-
edge.style.cssText = `position:absolute;left:${screenEdge}px;top:0;width:0;height:${document.documentElement.scrollHeight}px;border-left:3px dashed
|
|
51
|
+
edge.style.cssText = `position:absolute;left:${screenEdge}px;top:0;width:0;height:${document.documentElement.scrollHeight}px;border-left:3px dashed ${colour}`;
|
|
39
52
|
const text = document.createElement('div');
|
|
40
53
|
text.textContent = 'screen edge';
|
|
41
|
-
text.style.cssText =
|
|
54
|
+
text.style.cssText = `position:absolute;left:6px;top:8px;background:${colour};color:#fff;font:700 12px/18px system-ui,sans-serif;padding:0 6px;border-radius:4px;white-space:nowrap`;
|
|
42
55
|
edge.appendChild(text);
|
|
43
56
|
layer.appendChild(edge);
|
|
44
57
|
}
|
|
45
58
|
if (label) {
|
|
46
59
|
const caption = document.createElement('div');
|
|
47
60
|
caption.textContent = label;
|
|
48
|
-
caption.style.cssText = `position:fixed;left:8px;bottom:8px;max-width:calc(100% - 16px);background
|
|
61
|
+
caption.style.cssText = `position:fixed;left:8px;bottom:8px;max-width:calc(100% - 16px);background:${colour};color:#fff;font:600 13px/1.35 system-ui,sans-serif;padding:6px 10px;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,.3)`;
|
|
49
62
|
layer.appendChild(caption);
|
|
50
63
|
}
|
|
51
64
|
// Show the first problem element in the middle of the screen.
|
|
52
65
|
if (found[0] && !screenEdge) found[0].scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
53
|
-
|
|
66
|
+
// Where the outlined elements are on screen now (for the close-up picture).
|
|
67
|
+
const rects = found.map(extent);
|
|
68
|
+
const box = rects.length
|
|
69
|
+
? { left: Math.min(...rects.map((r) => r.left)), top: Math.min(...rects.map((r) => r.top)), right: Math.max(...rects.map((r) => r.right)), bottom: Math.max(...rects.map((r) => r.bottom)) }
|
|
70
|
+
: null;
|
|
71
|
+
return { count: found.length, box };
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
let counter = 0;
|
|
@@ -59,24 +77,144 @@ let counter = 0;
|
|
|
59
77
|
* Takes the evidence picture for one finding. Returns the report-relative path, or '' when the
|
|
60
78
|
* elements could not be found again.
|
|
61
79
|
*/
|
|
62
|
-
export async function captureEvidence(page, reportDir, { selectors = [], label = '', screenEdge = 0, limit = 80 }) {
|
|
63
|
-
if (counter >= limit) return '';
|
|
80
|
+
export async function captureEvidence(page, reportDir, { selectors = [], label = '', screenEdge = 0, limit = 80, colour = '#e01e1e', file: fixedFile = '' }) {
|
|
81
|
+
if (!fixedFile && counter >= limit) return '';
|
|
64
82
|
const dir = join(reportDir, 'evidence');
|
|
65
83
|
mkdirSync(dir, { recursive: true });
|
|
66
|
-
const
|
|
84
|
+
const drawn = await page.evaluate(drawOutlines, [selectors.slice(0, 6), label, screenEdge, colour]).catch(() => ({ count: 0, box: null }));
|
|
85
|
+
const found = drawn.count;
|
|
67
86
|
if (!found && !screenEdge && selectors.length) {
|
|
68
87
|
await page.evaluate(() => document.getElementById('__glc_evidence')?.remove()).catch(() => {});
|
|
69
88
|
return '';
|
|
70
89
|
}
|
|
71
|
-
counter += 1;
|
|
72
|
-
const file = `evidence/${String(counter).padStart(3, '0')}.jpg`;
|
|
90
|
+
if (!fixedFile) counter += 1;
|
|
91
|
+
const file = fixedFile || `evidence/${String(counter).padStart(3, '0')}.jpg`;
|
|
73
92
|
await page.waitForTimeout(80);
|
|
74
93
|
const ok = await page.screenshot({ path: join(reportDir, file), type: 'jpeg', quality: 72, animations: 'disabled', fullPage: Boolean(screenEdge) }).then(() => true).catch(() => false);
|
|
94
|
+
// A close-up around the problem, so small elements (a menu link, an icon) can be seen clearly.
|
|
95
|
+
let zoom = '';
|
|
96
|
+
const vp = page.viewportSize();
|
|
97
|
+
if (ok && drawn.box && !screenEdge && vp) {
|
|
98
|
+
const b = drawn.box;
|
|
99
|
+
const width = Math.min(vp.width, Math.max(520, b.right - b.left + 160));
|
|
100
|
+
const height = Math.min(vp.height, Math.max(260, b.bottom - b.top + 120));
|
|
101
|
+
const x = Math.max(0, Math.min(vp.width - width, (b.left + b.right) / 2 - width / 2));
|
|
102
|
+
const y = Math.max(0, Math.min(vp.height - height, (b.top + b.bottom) / 2 - height / 2));
|
|
103
|
+
if (width < vp.width * 0.85 || height < vp.height * 0.85) {
|
|
104
|
+
zoom = file.replace(/\.jpg$/, '-zoom.jpg');
|
|
105
|
+
await page.screenshot({ path: join(reportDir, zoom), type: 'jpeg', quality: 80, clip: { x, y, width, height } }).catch(() => {
|
|
106
|
+
zoom = '';
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
75
110
|
await page.evaluate(() => {
|
|
76
111
|
document.getElementById('__glc_evidence')?.remove();
|
|
77
112
|
scrollTo(0, 0);
|
|
78
113
|
}).catch(() => {});
|
|
79
|
-
return ok ? file : '';
|
|
114
|
+
return ok ? { file, zoom } : '';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The "Expected" picture: the same spot with the problem corrected in the browser for this one
|
|
119
|
+
* picture (outlined in green), then the page is put back exactly as it was.
|
|
120
|
+
* Only for problems a picture can show; others get the Expected sentence only.
|
|
121
|
+
*/
|
|
122
|
+
export const EXPECTED_VISUAL = new Set(['horizontal-overflow', 'clipped-text', 'text-outside-box', 'cut-off-at-edge', 'small-text', 'focus-not-visible', 'mouse-only-control', 'rtl-missing', 'color-contrast', 'target-size']);
|
|
123
|
+
|
|
124
|
+
/** Runs in the page: corrects the problem on these elements; remembers how to undo it. */
|
|
125
|
+
function applyExpected([kind, selectors]) {
|
|
126
|
+
const undo = [];
|
|
127
|
+
const set = (el, prop, value) => {
|
|
128
|
+
undo.push([el, prop, el.style.getPropertyValue(prop), el.style.getPropertyPriority(prop)]);
|
|
129
|
+
el.style.setProperty(prop, value, 'important');
|
|
130
|
+
};
|
|
131
|
+
const els = selectors.map((sel) => {
|
|
132
|
+
try {
|
|
133
|
+
return document.querySelector(sel);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}).filter(Boolean);
|
|
138
|
+
const luminance = (rgb) => {
|
|
139
|
+
const [r, g, b] = (rgb.match(/[\d.]+/g) ?? [255, 255, 255]).slice(0, 3).map(Number).map((v) => {
|
|
140
|
+
const c = v / 255;
|
|
141
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
142
|
+
});
|
|
143
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
144
|
+
};
|
|
145
|
+
const background = (el) => {
|
|
146
|
+
for (let n = el; n; n = n.parentElement) {
|
|
147
|
+
const bg = getComputedStyle(n).backgroundColor;
|
|
148
|
+
if (bg && !/rgba\(0, 0, 0, 0\)|transparent/.test(bg)) return bg;
|
|
149
|
+
}
|
|
150
|
+
return 'rgb(255, 255, 255)';
|
|
151
|
+
};
|
|
152
|
+
if (kind === 'rtl-missing') {
|
|
153
|
+
const html = document.documentElement;
|
|
154
|
+
const before = html.getAttribute('dir');
|
|
155
|
+
html.setAttribute('dir', 'rtl');
|
|
156
|
+
window.__glcUndo = () => (before === null ? html.removeAttribute('dir') : html.setAttribute('dir', before));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
for (const el of els) {
|
|
160
|
+
if (kind === 'horizontal-overflow') {
|
|
161
|
+
set(el, 'max-width', '100%');
|
|
162
|
+
set(el, 'box-sizing', 'border-box');
|
|
163
|
+
set(el, 'overflow-x', 'auto');
|
|
164
|
+
if (el.tagName === 'TABLE' || el.tagName === 'PRE') set(el, 'display', 'block');
|
|
165
|
+
for (const img of el.querySelectorAll('img, video, iframe')) set(img, 'max-width', '100%');
|
|
166
|
+
} else if (kind === 'clipped-text') {
|
|
167
|
+
set(el, 'overflow', 'visible');
|
|
168
|
+
set(el, 'height', 'auto');
|
|
169
|
+
set(el, 'max-height', 'none');
|
|
170
|
+
set(el, 'white-space', 'normal');
|
|
171
|
+
} else if (kind === 'text-outside-box') {
|
|
172
|
+
for (const prop of ['width', 'height']) set(el, prop, 'auto');
|
|
173
|
+
set(el, 'max-width', '100%');
|
|
174
|
+
set(el, 'white-space', 'normal');
|
|
175
|
+
set(el, 'overflow-wrap', 'anywhere');
|
|
176
|
+
if (getComputedStyle(el).display === 'inline') set(el, 'display', 'inline-block');
|
|
177
|
+
} else if (kind === 'cut-off-at-edge') {
|
|
178
|
+
for (const prop of ['left', 'right', 'margin-left', 'margin-right', 'text-indent', 'transform']) if (getComputedStyle(el)[prop.replace(/-(.)/g, (_, c) => c.toUpperCase())] !== 'auto') set(el, prop, prop === 'transform' ? 'none' : prop === 'left' || prop === 'right' ? 'auto' : '0');
|
|
179
|
+
set(el, 'max-width', '100%');
|
|
180
|
+
set(el, 'overflow-wrap', 'anywhere');
|
|
181
|
+
set(el, 'white-space', 'normal');
|
|
182
|
+
} else if (kind === 'small-text') {
|
|
183
|
+
set(el, 'font-size', '14px');
|
|
184
|
+
} else if (kind === 'color-contrast') {
|
|
185
|
+
set(el, 'color', luminance(background(el)) > 0.4 ? '#1b1f24' : '#ffffff');
|
|
186
|
+
} else if (kind === 'target-size') {
|
|
187
|
+
set(el, 'min-width', '24px');
|
|
188
|
+
set(el, 'min-height', '24px');
|
|
189
|
+
set(el, 'display', 'inline-block');
|
|
190
|
+
} else if (kind === 'focus-not-visible' || kind === 'mouse-only-control') {
|
|
191
|
+
set(el, 'outline', '3px solid #1f4fbf');
|
|
192
|
+
set(el, 'outline-offset', '2px');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if ((kind === 'focus-not-visible' || kind === 'mouse-only-control') && els[0]) els[0].focus?.({ preventScroll: true });
|
|
196
|
+
window.__glcUndo = () => {
|
|
197
|
+
for (const [el, prop, value, priority] of undo.reverse()) {
|
|
198
|
+
if (value) el.style.setProperty(prop, value, priority);
|
|
199
|
+
else el.style.removeProperty(prop);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
return els.length > 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function captureExpected(page, reportDir, actualFile, { kind, selectors = [], label = '', screenEdge = 0 }) {
|
|
206
|
+
if (!actualFile || !EXPECTED_VISUAL.has(kind)) return null;
|
|
207
|
+
const applied = await page.evaluate(applyExpected, [kind, selectors.slice(0, 6)]).catch(() => false);
|
|
208
|
+
if (!applied) return null;
|
|
209
|
+
await page.waitForTimeout(120);
|
|
210
|
+
const file = actualFile.replace(/\.jpg$/, '-expected.jpg');
|
|
211
|
+
const shot = await captureEvidence(page, reportDir, { selectors: kind === 'rtl-missing' ? [] : selectors, label, screenEdge: kind === 'horizontal-overflow' ? screenEdge : 0, colour: '#16a34a', file });
|
|
212
|
+
await page.evaluate(() => {
|
|
213
|
+
window.__glcUndo?.();
|
|
214
|
+
delete window.__glcUndo;
|
|
215
|
+
document.activeElement?.blur?.();
|
|
216
|
+
}).catch(() => {});
|
|
217
|
+
return shot || null;
|
|
80
218
|
}
|
|
81
219
|
|
|
82
220
|
/** A plain picture of the page as it is now (no outlines), e.g. a page that crashed. */
|
package/src/checks/in-page.mjs
CHANGED
|
@@ -73,6 +73,178 @@ export function inPageAudit([phone, screenWidth]) {
|
|
|
73
73
|
if (el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 2) { clipped.push(describe(el)); targets.clipped.push(cssPath(el)); }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Where an element's own text is drawn (it can be outside the element's box).
|
|
77
|
+
const range = document.createRange();
|
|
78
|
+
const textRect = (el, lines = []) => {
|
|
79
|
+
let box = null;
|
|
80
|
+
for (const n of el.childNodes) {
|
|
81
|
+
if (n.nodeType !== 3 || !n.textContent.trim()) continue;
|
|
82
|
+
range.selectNodeContents(n);
|
|
83
|
+
for (const r of range.getClientRects()) {
|
|
84
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
85
|
+
if (lines.length < 20) lines.push({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
|
|
86
|
+
box = box ? { left: Math.min(box.left, r.left), top: Math.min(box.top, r.top), right: Math.max(box.right, r.right), bottom: Math.max(box.bottom, r.bottom) } : { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return box;
|
|
90
|
+
};
|
|
91
|
+
const transparent = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(c);
|
|
92
|
+
const srOnly = (el, cs, r) => r.width <= 2 || r.height <= 2 || (cs.clip && cs.clip !== 'auto') || (cs.clipPath && cs.clipPath !== 'none');
|
|
93
|
+
const inDialog = (el) => Boolean(el.closest('dialog, [role=dialog], [role=alertdialog], [aria-modal=true], [aria-hidden=true]'));
|
|
94
|
+
const texts = [];
|
|
95
|
+
for (const el of all) {
|
|
96
|
+
if (texts.length >= 600) break;
|
|
97
|
+
if (!visible(el) || !el.childNodes.length || inDialog(el)) continue;
|
|
98
|
+
const cs = getComputedStyle(el);
|
|
99
|
+
const r = el.getBoundingClientRect();
|
|
100
|
+
if (srOnly(el, cs, r) || transparent(cs.color)) continue;
|
|
101
|
+
const lines = [];
|
|
102
|
+
const t = textRect(el, lines);
|
|
103
|
+
if (t) texts.push({ el, cs, r, t, lines });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Text that spills out of a box with a visible shape (a button, a badge, a card): the box is
|
|
107
|
+
// too small for its text, and the text runs over whatever is next to it.
|
|
108
|
+
const outside = [];
|
|
109
|
+
targets.outside = [];
|
|
110
|
+
for (const { el, cs, r, t } of texts) {
|
|
111
|
+
if (outside.length >= 5) break;
|
|
112
|
+
if (el === document.body || el === document.documentElement) continue;
|
|
113
|
+
if (/(hidden|clip)/.test(`${cs.overflowX} ${cs.overflowY}`)) continue; // that is clipped-text
|
|
114
|
+
const shaped = !transparent(cs.backgroundColor) || parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) + parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) > 0 || el.matches('button, [role=button], input, select, textarea');
|
|
115
|
+
if (!shaped) continue;
|
|
116
|
+
const slackY = Math.max(4, parseFloat(cs.fontSize) * 0.3);
|
|
117
|
+
const past = { right: t.right - r.right, left: r.left - t.left, bottom: t.bottom - r.bottom - slackY, top: r.top - t.top - slackY };
|
|
118
|
+
const worst = Object.entries(past).sort((a, b) => b[1] - a[1])[0];
|
|
119
|
+
if (worst[1] > 3) {
|
|
120
|
+
outside.push(`${describe(el)}: the text goes ${Math.round(worst[1] + (worst[0] === 'top' || worst[0] === 'bottom' ? slackY : 0))}px past the ${worst[0]} edge of its ${Math.round(r.width)} × ${Math.round(r.height)}px box`);
|
|
121
|
+
targets.outside.push(cssPath(el));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Text drawn on top of other text (absolute positioning, negative margins, text spilling out).
|
|
126
|
+
const textOverlaps = [];
|
|
127
|
+
targets.textOverlaps = [];
|
|
128
|
+
const hit = (p, q) => {
|
|
129
|
+
const w = Math.min(p.right, q.right) - Math.max(p.left, q.left);
|
|
130
|
+
const h = Math.min(p.bottom, q.bottom) - Math.max(p.top, q.top);
|
|
131
|
+
if (w < 4 || h < 4) return null;
|
|
132
|
+
const smaller = Math.min((p.right - p.left) * (p.bottom - p.top), (q.right - q.left) * (q.bottom - q.top));
|
|
133
|
+
if (w * h < 0.15 * smaller) return null;
|
|
134
|
+
return { x: (Math.max(p.left, q.left) + Math.min(p.right, q.right)) / 2, y: (Math.max(p.top, q.top) + Math.min(p.bottom, q.bottom)) / 2 };
|
|
135
|
+
};
|
|
136
|
+
const reported = new Set();
|
|
137
|
+
for (let i = 0; i < texts.length && textOverlaps.length < 5; i++) {
|
|
138
|
+
const a = texts[i];
|
|
139
|
+
for (let j = i + 1; j < texts.length; j++) {
|
|
140
|
+
const b = texts[j];
|
|
141
|
+
if (!hit(a.t, b.t) || reported.has(b.el)) continue;
|
|
142
|
+
let point = null;
|
|
143
|
+
for (const p of a.lines) {
|
|
144
|
+
for (const q of b.lines) if ((point = hit(p, q))) break;
|
|
145
|
+
if (point) break;
|
|
146
|
+
}
|
|
147
|
+
if (!point) continue;
|
|
148
|
+
// Only when both texts can really be seen there (not one hidden behind a solid layer).
|
|
149
|
+
const top = document.elementFromPoint(point.x, point.y);
|
|
150
|
+
if (top && !(a.el.contains(top) || b.el.contains(top) || top.contains(a.el) || top.contains(b.el))) continue;
|
|
151
|
+
reported.add(a.el);
|
|
152
|
+
reported.add(b.el);
|
|
153
|
+
textOverlaps.push(`${describe(a.el).replace(/ ".*/, '')} "${[...a.el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join(' ').replace(/\s+/g, ' ').trim().slice(0, 30)}" and ${describe(b.el)}`);
|
|
154
|
+
targets.textOverlaps.push(cssPath(a.el), cssPath(b.el));
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Content cut off at the edge of the screen where the user cannot scroll to it (pushed off to
|
|
160
|
+
// the left, or past the right edge when the page hides sideways scrolling).
|
|
161
|
+
const cutOff = [];
|
|
162
|
+
targets.cutOff = [];
|
|
163
|
+
for (const { el, t } of texts) {
|
|
164
|
+
if (cutOff.length >= 5) break;
|
|
165
|
+
if (insideFixedOrScroller(el) || overflow.some((o) => o.el.contains(el))) continue;
|
|
166
|
+
const left = t.left < -2 && t.right > 0;
|
|
167
|
+
const right = t.right > width + 2 && t.left < width && docWidth <= width + 1;
|
|
168
|
+
if (!left && !right) continue;
|
|
169
|
+
if (cutOff.length && targets.cutOff.some((sel) => document.querySelector(sel)?.contains(el))) continue;
|
|
170
|
+
cutOff.push(`${describe(el)}: ${left ? `starts ${Math.round(-t.left)}px before the left edge` : `goes ${Math.round(t.right - width)}px past the right edge`} of the screen`);
|
|
171
|
+
targets.cutOff.push(cssPath(el));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Buttons and links covered by something else, so a click lands on the wrong thing.
|
|
175
|
+
const covered = [];
|
|
176
|
+
targets.covered = [];
|
|
177
|
+
for (const el of document.querySelectorAll('a[href], button, input:not([type=hidden]), select, textarea, [role=button]')) {
|
|
178
|
+
if (covered.length >= 5) break;
|
|
179
|
+
if (!visible(el) || inDialog(el) || el.disabled) continue;
|
|
180
|
+
const r = el.getBoundingClientRect();
|
|
181
|
+
if (r.width < 8 || r.height < 8 || r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue;
|
|
182
|
+
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
|
183
|
+
if (!top || el.contains(top) || top.contains(el)) continue;
|
|
184
|
+
const label = top.closest('label');
|
|
185
|
+
if (label && (label.control === el || label.contains(el))) continue;
|
|
186
|
+
if (top.closest('dialog, [role=dialog], [role=alertdialog], [aria-modal=true]') || /cookie|consent|gdpr/i.test(`${top.closest('[id],[class]')?.id ?? ''} ${top.closest('[class]')?.className ?? ''}`)) continue;
|
|
187
|
+
covered.push(`${describe(el)} is under ${describe(top)}`);
|
|
188
|
+
targets.covered.push(cssPath(el), cssPath(top));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Text that was never filled in: undefined, NaN, [object Object], template braces, lorem ipsum,
|
|
192
|
+
// TODO, translation keys shown instead of the translation.
|
|
193
|
+
const placeholders = [];
|
|
194
|
+
targets.placeholders = [];
|
|
195
|
+
const leftover = [
|
|
196
|
+
[/\bundefined\b/, 'undefined'], [/\bNaN\b/, 'NaN'], [/\[object Object\]/, '[object Object]'], [/Invalid Date/, 'Invalid Date'],
|
|
197
|
+
[/(^null$|:\s*null\b)/, 'null'], [/\{\{[^}]*\}\}|\$\{[^}]*\}/, 'template braces'], [/lorem ipsum/i, 'lorem ipsum'], [/\b(TODO|FIXME|TBD)\b/, 'TODO'],
|
|
198
|
+
];
|
|
199
|
+
const key = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9_]*){2,}$|^[a-z][a-zA-Z0-9]*\.[a-z]+[A-Z][a-zA-Z0-9]*$|^[A-Z][A-Z0-9]*(_[A-Z0-9]+){2,}$/;
|
|
200
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
201
|
+
for (let n = walker.nextNode(); n && placeholders.length < 5; n = walker.nextNode()) {
|
|
202
|
+
const text = n.textContent.replace(/\s+/g, ' ').trim();
|
|
203
|
+
const el = n.parentElement;
|
|
204
|
+
if (!text || !el || el.closest('script, style, noscript, template, code, pre, samp, kbd, textarea, [contenteditable]') || !visible(el)) continue;
|
|
205
|
+
const found = leftover.find(([re]) => re.test(text))?.[1] ?? (key.test(text) ? 'translation key' : '');
|
|
206
|
+
if (!found || targets.placeholders.includes(cssPath(el))) continue;
|
|
207
|
+
placeholders.push(`${describe(el)} shows "${text.slice(0, 60)}" (${found})`);
|
|
208
|
+
targets.placeholders.push(cssPath(el));
|
|
209
|
+
}
|
|
210
|
+
for (const input of document.querySelectorAll('input[placeholder], input[value]')) {
|
|
211
|
+
if (placeholders.length >= 5 || !visible(input)) continue;
|
|
212
|
+
const text = `${input.value} ${input.placeholder}`;
|
|
213
|
+
const found = leftover.slice(0, 6).find(([re]) => re.test(text))?.[1];
|
|
214
|
+
if (found) {
|
|
215
|
+
placeholders.push(`${describe(input)} shows "${text.trim().slice(0, 60)}" (${found})`);
|
|
216
|
+
targets.placeholders.push(cssPath(input));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Images stretched or squashed (shown at a different shape than the picture).
|
|
221
|
+
const distorted = [];
|
|
222
|
+
targets.distorted = [];
|
|
223
|
+
for (const img of document.images) {
|
|
224
|
+
if (distorted.length >= 5 || !visible(img) || !img.naturalWidth || !img.naturalHeight || /\.svg(\?|#|$)/i.test(img.currentSrc || img.src)) continue;
|
|
225
|
+
const cs = getComputedStyle(img);
|
|
226
|
+
if (cs.objectFit !== 'fill') continue;
|
|
227
|
+
const r = img.getBoundingClientRect();
|
|
228
|
+
const box = { w: r.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), h: r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom) };
|
|
229
|
+
if (box.w < 16 || box.h < 16) continue;
|
|
230
|
+
const ratio = (box.w / box.h) / (img.naturalWidth / img.naturalHeight);
|
|
231
|
+
if (Math.abs(ratio - 1) > 0.1) {
|
|
232
|
+
distorted.push(`${(img.currentSrc || img.src).startsWith('data:') ? 'an embedded image' : (img.currentSrc || img.src).split('/').pop().slice(0, 50)} is ${img.naturalWidth} × ${img.naturalHeight} but shown at ${Math.round(box.w)} × ${Math.round(box.h)} (${ratio > 1 ? 'stretched wide' : 'squashed'})`);
|
|
233
|
+
targets.distorted.push(cssPath(img));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A loading indicator still showing after the page finished loading.
|
|
238
|
+
const loading = [];
|
|
239
|
+
targets.loading = [];
|
|
240
|
+
for (const el of document.querySelectorAll('[aria-busy=true], [role=progressbar]:not([aria-valuenow]), [class*=spinner i], [class*=loader i], [class*=loading i], [class*=skeleton i]')) {
|
|
241
|
+
if (loading.length >= 3 || !visible(el) || inDialog(el) || targets.loading.some((sel) => document.querySelector(sel)?.contains(el))) continue;
|
|
242
|
+
const r = el.getBoundingClientRect();
|
|
243
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
244
|
+
loading.push(describe(el));
|
|
245
|
+
targets.loading.push(cssPath(el));
|
|
246
|
+
}
|
|
247
|
+
|
|
76
248
|
// Small text on phones.
|
|
77
249
|
const small = [];
|
|
78
250
|
if (phone) {
|
|
@@ -219,6 +391,13 @@ export function inPageAudit([phone, screenWidth]) {
|
|
|
219
391
|
overflow: overflow.map((o) => `${describe(o.el)} reaches ${o.right}px`),
|
|
220
392
|
targets: { ...targets, overflow: overflow.map((o) => cssPath(o.el)), brokenImages: [...document.images].filter((img) => img.complete && img.naturalWidth === 0 && (img.currentSrc || img.src)).slice(0, 10).map(cssPath) },
|
|
221
393
|
clipped,
|
|
394
|
+
outside,
|
|
395
|
+
textOverlaps,
|
|
396
|
+
cutOff,
|
|
397
|
+
covered,
|
|
398
|
+
placeholders,
|
|
399
|
+
distorted,
|
|
400
|
+
loading,
|
|
222
401
|
small,
|
|
223
402
|
overlaps,
|
|
224
403
|
brokenImages,
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-language explanations for every finding, written for the developer who has to fix it:
|
|
3
|
+
* what what is wrong, in one or two sentences anyone on the team understands
|
|
4
|
+
* who who is affected and how (why it matters)
|
|
5
|
+
* expected how it should be instead, in one sentence (shown next to the Expected picture)
|
|
6
|
+
* ref the standard or guideline behind the check
|
|
7
|
+
* Accessibility findings from axe-core are explained per axe rule (AXE below).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const WHO = {
|
|
11
|
+
functionality: 'Every user of the page: something does not work as it should.',
|
|
12
|
+
responsive: 'Users on the screen sizes listed: the page looks broken or is hard to use.',
|
|
13
|
+
accessibility: 'People with disabilities (screen-reader, keyboard-only, low-vision users) and anyone in a hurry or on a small screen.',
|
|
14
|
+
performance: 'Every user, most of all on phones and slow connections: the page feels slow.',
|
|
15
|
+
auth: 'Everyone with an account: their data and access depend on it.',
|
|
16
|
+
api: 'Everyone using the app: the server lets wrong or unsafe requests through, or fails.',
|
|
17
|
+
security: 'Your users and your organisation: attackers can use this.',
|
|
18
|
+
seo: 'People finding or sharing the site: search results and shared links look wrong.',
|
|
19
|
+
code: 'The team: the code is harder to maintain or ships mistakes.',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const EXPLAIN = {
|
|
23
|
+
// ---------- functionality ----------
|
|
24
|
+
'build-failed': { what: 'The production build stops with an error, so this version cannot be deployed.', who: 'Nobody can use this version.' },
|
|
25
|
+
'start-failed': { what: 'The app does not start after the build, so nothing could be checked.', who: 'Nobody can use this version.' },
|
|
26
|
+
'page-error': { what: 'A JavaScript error stopped part of the page from running. Parts of the page may be missing or buttons may do nothing.', who: 'Every user who opens this page.', code: '// Before\nconst email = profile.contact.email;\n// After: guard data that may be missing\nconst email = profile?.contact?.email ?? \'\';' },
|
|
27
|
+
'console-error': { what: 'The page writes an error in the browser console. Often a sign of a bug, a failed request or a framework warning.', who: 'Usually invisible to users, but it often hides a real problem.' },
|
|
28
|
+
'failed-request': { what: 'The page asked the server for something (data, a script, a style) and got an error or no answer.', who: 'Users of this page: data or styling may be missing.' },
|
|
29
|
+
'broken-image': { what: 'An image on the page does not load and shows as broken or empty.', who: 'Every user of this page.' },
|
|
30
|
+
'broken-link': { what: 'A link leads to a page that does not exist ("page not found").', who: 'Users who click the link.' },
|
|
31
|
+
'broken-anchor': { what: 'A link that should jump to a section of the page (#…) points to a section that does not exist.', who: 'Users who click it: nothing happens.' },
|
|
32
|
+
'blank-page': { what: 'The page shows nothing at all.', who: 'Every user who opens this page.' },
|
|
33
|
+
'crash-when-api-fails': { what: 'When the server has a short problem, this page throws an error instead of telling the user.', who: 'Users during any server hiccup: they see a broken page.', code: '// Before\napi(\'/me\').then(setMe);\n// After\napi(\'/me\').then(setMe).catch(() => setError(\'Your profile could not be loaded. Try again.\'));' },
|
|
34
|
+
'blank-when-api-fails': { what: 'When the server has a short problem, this page goes blank.', who: 'Users during any server hiccup.' },
|
|
35
|
+
'silent-when-api-fails': { what: 'When the server has a short problem, the page shows no message: users wait for data that never comes.', who: 'Users during any server hiccup.', code: '{error && <p role="alert">Your applications could not be loaded. Please try again.</p>}' },
|
|
36
|
+
'no-not-found-page': { what: 'An address that does not exist shows a blank or normal page instead of a clear "page not found" page.', who: 'Users with an old bookmark or a mistyped address.' },
|
|
37
|
+
'login-failed': { what: 'The tool could not sign in with the test account, so the signed-in pages were not checked.', who: 'This run: most of the app was not tested.' },
|
|
38
|
+
|
|
39
|
+
// ---------- design and layout ----------
|
|
40
|
+
'missing-viewport-meta': { what: 'The page does not tell phones how wide it is, so phones show it zoomed out and tiny.', who: 'Phone users.', code: '<meta name="viewport" content="width=device-width, initial-scale=1">', ref: 'WCAG 1.4.10 Reflow' },
|
|
41
|
+
'horizontal-overflow': { what: 'Something on the page is wider than the screen, so the whole page scrolls sideways (the dashed line in the picture is the edge of the screen).', who: 'Phone and tablet users: they have to scroll left and right to read the page.', code: '/* Wrap wide tables so only the table scrolls */\n.table-wrap { overflow-x: auto; }\n/* and let other content shrink */\nimg, video { max-width: 100%; }', ref: 'WCAG 1.4.10 Reflow' },
|
|
42
|
+
'clipped-text': { what: 'Text is cut off inside its box, so part of it cannot be read.', who: 'Users on the screen sizes listed.', code: '/* Let the box grow, or show an ellipsis on purpose */\n.label { overflow: visible; }\n/* or */ .label { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }' },
|
|
43
|
+
'text-outside-box': { what: 'The box (for example a button) is smaller than its text, so the text runs out of it and over the page around it. Text outside the box is often hard or impossible to read.', who: 'Every user on the screen sizes listed.' },
|
|
44
|
+
'text-overlap': { what: 'Two pieces of text are drawn on top of each other, so neither can be read properly.', who: 'Every user on the screen sizes listed.' },
|
|
45
|
+
'cut-off-at-edge': { what: 'Part of the content is outside the screen and the page does not let the user scroll to it, so it cannot be seen at all.', who: 'Users on the screen sizes listed.' },
|
|
46
|
+
'control-covered': { what: 'Something lies on top of a button or link, so clicking it hits the covering element instead and nothing (or the wrong thing) happens.', who: 'Every user on the screen sizes listed.' },
|
|
47
|
+
'placeholder-text': { what: 'The page shows text that was never filled in properly: a value the code did not have (undefined, NaN, null), a translation key instead of the words, template braces or sample text.', who: 'Every user of this page; it looks broken and unprofessional.' },
|
|
48
|
+
'distorted-image': { what: 'An image is shown at a different shape than the picture itself, so it looks stretched or squashed.', who: 'Every user on the screen sizes listed.' },
|
|
49
|
+
'stuck-loading': { what: 'A loading indicator (spinner, skeleton, busy state) is still showing after the page and all its requests finished, so the user waits for content that never comes.', who: 'Every user of this page.' },
|
|
50
|
+
'overlapping-elements': { what: 'Buttons or links lie on top of each other, so users may press the wrong one.', who: 'Users on the screen sizes listed, especially on touch screens.' },
|
|
51
|
+
'small-text': { what: 'Text is smaller than 12 px on a phone and is hard to read.', who: 'Phone users, especially older users.', code: 'body { font-size: 16px; } /* and use rem for smaller text, never below 0.75rem */' },
|
|
52
|
+
'rtl-missing': { what: 'The page is in a right-to-left language (Arabic…) but is laid out left to right.', who: 'Arabic readers: the text and layout run in the wrong direction.', code: '<html lang="ar" dir="rtl">' },
|
|
53
|
+
'font-failed': { what: 'A web font did not load, so the page falls back to another font and looks different from the design.', who: 'Every user of this page.' },
|
|
54
|
+
'inconsistent-typography': { what: 'The page uses many different fonts or text sizes, so it looks inconsistent.', who: 'Every user: the design looks less polished.' },
|
|
55
|
+
'visual-change': { what: 'The page looks different from the saved baseline picture (changed areas in red). If you did not mean to change it, it is a design regression.', who: 'Every user of this page.' },
|
|
56
|
+
|
|
57
|
+
// ---------- accessibility and keyboard ----------
|
|
58
|
+
'focus-not-visible': { what: 'When you press Tab, these items get the keyboard focus, but nothing on screen shows it. Keyboard users cannot see where they are on the page.', who: 'Keyboard-only and screen-reader users, and anyone using Tab to fill in forms.', code: '/* Before */\nnav a:focus { outline: none; }\n/* After */\nnav a:focus-visible { outline: 2px solid #1f4fbf; outline-offset: 2px; }', ref: 'WCAG 2.4.7 Focus Visible (AA)' },
|
|
59
|
+
'keyboard-trap': { what: 'Pressing Tab keeps the focus on the same element: keyboard users are stuck and cannot leave it.', who: 'Keyboard-only and screen-reader users: they cannot use the rest of the page.', ref: 'WCAG 2.1.2 No Keyboard Trap (A)' },
|
|
60
|
+
'mouse-only-control': { what: 'This element reacts to a mouse click but cannot be reached or used with the keyboard (it is a clickable <div> or <span>, not a button or link).', who: 'Keyboard-only and screen-reader users: they cannot open or use it.', code: '<!-- Before -->\n<div class="card" onClick={toggle}>Opening hours</div>\n<!-- After -->\n<button type="button" class="card" onClick={toggle} aria-expanded={open}>Opening hours</button>', ref: 'WCAG 2.1.1 Keyboard (A)' },
|
|
61
|
+
'form-field-type': { what: 'Form fields use a general text type where a specific one exists (email, phone), or a password field cannot be filled by password managers.', who: 'Phone users get the wrong keyboard; password-manager users must type passwords by hand.', code: '<input type="email" autocomplete="email">\n<input type="tel" autocomplete="tel">\n<input type="password" autocomplete="current-password">', ref: 'WCAG 1.3.5 Identify Input Purpose (AA)' },
|
|
62
|
+
|
|
63
|
+
// ---------- performance ----------
|
|
64
|
+
'heavy-page': { what: 'The page downloads more data than the budget, so it loads slowly on phones.', who: 'Phone users and anyone on a slow or paid connection.' },
|
|
65
|
+
'heavy-javascript': { what: 'The page downloads more JavaScript than the budget. The browser must download and run all of it before the page responds.', who: 'Phone users: the page loads and reacts slowly.', code: '// Load pages only when they are opened (React)\nconst Reports = lazy(() => import(\'./pages/Reports\'));' },
|
|
66
|
+
'too-many-requests': { what: 'The page makes more requests than the budget; each one adds waiting time.', who: 'Users on slow connections.' },
|
|
67
|
+
'slow-page': { what: 'The page took longer than the budget to load, even on this fast computer.', who: 'Every user; on phones it will be slower still.' },
|
|
68
|
+
'page-layout-shift': { what: 'Content jumps around while the page loads, so users may click the wrong thing.', who: 'Every user, most of all on phones.', code: '<img src="banner.jpg" width="1200" height="400" alt="…">', ref: 'Core Web Vitals: CLS' },
|
|
69
|
+
'main-thread-blocked': { what: 'Scripts keep the browser busy while the page loads, so clicks and typing lag.', who: 'Every user, most of all on mid-range phones.', ref: 'Core Web Vitals: INP' },
|
|
70
|
+
'oversized-images': { what: 'Images are downloaded much larger than they are shown, wasting data and time.', who: 'Phone users and slow connections.', code: '<img src="photo-800.webp" srcset="photo-400.webp 400w, photo-800.webp 800w" sizes="(max-width: 600px) 100vw, 800px" alt="…">' },
|
|
71
|
+
'images-without-size': { what: 'Images have no width and height, so the page jumps when they load.', who: 'Every user.', code: '<img src="logo.png" width="160" height="40" alt="Service Portal">' },
|
|
72
|
+
'slow-api-call': { what: 'An API call the page makes is slower than the budget, so users wait for the data.', who: 'Every user of the pages that make this call.' },
|
|
73
|
+
'large-api-response': { what: 'An API call returns a very large response, which is slow to download and process.', who: 'Phone users and slow connections.' },
|
|
74
|
+
'repeated-api-call': { what: 'The page sends the same request several times while it loads. The extra requests only add waiting time and server load.', who: 'Every user, and the server.', code: '// React: one request, shared by every component\nconst { data } = useQuery({ queryKey: [\'health\'], queryFn: getHealth });' },
|
|
75
|
+
'too-many-api-calls': { what: 'The page makes many API calls while it loads.', who: 'Users on slow connections, and the server.' },
|
|
76
|
+
'uncompressed-response': { what: 'Text files and responses are sent without compression, so they are several times larger than needed.', who: 'Every user.', code: '# Nginx\ngzip on;\ngzip_types text/css application/javascript application/json;' },
|
|
77
|
+
'assets-not-cached': { what: 'Built files are downloaded again on every visit instead of coming from the browser cache.', who: 'Returning users.', code: '# Nginx: files with a hash in the name never change\nlocation /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }' },
|
|
78
|
+
'slow-under-load': { what: 'With several users at the same time, requests become slow.', who: 'Every user at busy times.' },
|
|
79
|
+
'errors-under-load': { what: 'With several users at the same time, some requests fail.', who: 'Every user at busy times.' },
|
|
80
|
+
'perf-score-low': { what: 'Lighthouse rates the page speed as poor.', who: 'Every user, most of all on phones.' },
|
|
81
|
+
'perf-score-medium': { what: 'Lighthouse rates the page speed as needing improvement.', who: 'Phone users.' },
|
|
82
|
+
'slow-lcp': { what: 'The main content of the page appears late (Largest Contentful Paint).', who: 'Every user, most of all on phones.', ref: 'Core Web Vitals: LCP' },
|
|
83
|
+
'layout-shift': { what: 'Content jumps around while the page loads (Cumulative Layout Shift).', who: 'Every user.', ref: 'Core Web Vitals: CLS' },
|
|
84
|
+
'blocking-time': { what: 'Scripts block the page so it cannot respond to clicks for a while (Total Blocking Time).', who: 'Every user, most of all on phones.' },
|
|
85
|
+
'large-javascript': { what: 'The page loads a lot of JavaScript.', who: 'Phone users.' },
|
|
86
|
+
|
|
87
|
+
// ---------- sign-in and access ----------
|
|
88
|
+
'wrong-password-accepted': { what: 'The sign-in let the test user in with a WRONG password (or an empty form).', who: 'Every account: anyone could sign in as anyone.', ref: 'OWASP ASVS V2 Authentication' },
|
|
89
|
+
'login-no-error-message': { what: 'After a wrong password, the sign-in page shows no message.', who: 'Users who mistype their password: they do not know what happened.' },
|
|
90
|
+
'empty-login-no-feedback': { what: 'Submitting the empty sign-in form shows nothing about what is missing.', who: 'Every user signing in.' },
|
|
91
|
+
'user-enumeration': { what: 'A wrong password and an unknown user get different messages, so anyone can find out which accounts exist.', who: 'Every account holder: attackers can build a list of valid accounts.', code: '// Same message for both cases\nreturn res.status(401).json({ message: \'The user name or password is not correct.\' });', ref: 'OWASP ASVS 2.2' },
|
|
92
|
+
'page-without-sign-in': { what: 'A page that is shown to signed-in users also opens without signing in.', who: 'Account holders: their information may be visible to anyone.' },
|
|
93
|
+
'api-without-sign-in': { what: 'An API the app uses returns data without signing in.', who: 'Account holders: their data is readable by anyone who knows the address.', ref: 'OWASP API Security Top 10: API2' },
|
|
94
|
+
'sensitive-field-in-api': { what: 'An API response contains a field such as a password hash, secret or ID number.', who: 'Account holders: anyone who can see the response sees their sensitive data.', ref: 'OWASP API Security Top 10: API3' },
|
|
95
|
+
'token-in-web-storage': { what: 'The sign-in token is kept in localStorage or sessionStorage, where any script on the page can read it.', who: 'Account holders: if any script is compromised, their session can be stolen.', ref: 'OWASP Session Management' },
|
|
96
|
+
'form-server-error': { what: 'Submitting a form made the server fail (error 500).', who: 'Users of this form.' },
|
|
97
|
+
'form-accepts-empty': { what: 'An empty form was sent to the server and accepted.', who: 'Users and data quality: empty records are created.' },
|
|
98
|
+
'form-no-feedback': { what: 'Submitting the form empty gives no message about what is missing.', who: 'Users of this form.', code: '<input id="email" required aria-describedby="email-error">\n<p id="email-error" role="alert">Enter your email.</p>' },
|
|
99
|
+
'sso-not-https': { what: 'A sign-in with Google, UAE PASS or another provider starts over an unencrypted connection.', who: 'Every user signing in this way.' },
|
|
100
|
+
'sso-no-state': { what: 'The sign-in with an outside provider sends no "state" value. Without it, an attacker can trick a user into signing in to the attacker\'s account (login CSRF).', who: 'Every user signing in this way.', code: '// Use a library that adds state and PKCE (Auth.js, MSAL, oidc-client-ts),\n// or add a random state, keep it in the session, and compare it on return.', ref: 'OAuth 2.0 Security Best Current Practice (RFC 9700)' },
|
|
101
|
+
'sso-implicit-flow': { what: 'The sign-in uses the old token flow, which puts tokens in the address bar.', who: 'Every user signing in this way.', ref: 'RFC 9700' },
|
|
102
|
+
'sso-no-pkce': { what: 'The sign-in uses the code flow without PKCE.', who: 'Every user signing in this way (only a risk for apps without a server-side secret).', ref: 'RFC 9700' },
|
|
103
|
+
'sso-insecure-redirect': { what: 'The provider sends users back to an unencrypted or incomplete address.', who: 'Every user signing in this way.' },
|
|
104
|
+
'sso-button-broken': { what: 'A sign-in button (Google, UAE PASS…) does nothing when clicked.', who: 'Everyone who signs in this way: they cannot sign in.' },
|
|
105
|
+
'sso-cancel-crash': { what: 'When a user cancels at Google or UAE PASS, the page they come back to fails.', who: 'Users who change their mind or close the provider page.' },
|
|
106
|
+
'sso-cancel-blank': { what: 'When a user cancels at Google or UAE PASS, the page they come back to is blank.', who: 'Users who change their mind or close the provider page.' },
|
|
107
|
+
'sso-cancel-silent': { what: 'When a user cancels at Google or UAE PASS, the page they come back to does not say what happened.', who: 'Users who change their mind: they are stuck on "Signing you in…".', code: 'const error = new URLSearchParams(location.search).get(\'error\');\nif (error) return <p role="alert">Sign-in was cancelled. <a href="/login">Try again</a></p>;' },
|
|
108
|
+
'session-expired': { what: 'The saved sign-in (session or token) no longer works, so the signed-in pages were not really tested.', who: 'This run.' },
|
|
109
|
+
'signed-in-not-tested': { what: 'The tool was not told how to sign in, so only the public pages were tested.', who: 'This run: the signed-in part of the app was not checked.' },
|
|
110
|
+
|
|
111
|
+
// ---------- API tests ----------
|
|
112
|
+
'api-other-users-data': { what: 'A signed-in user could open a record that belongs to ANOTHER user by changing the id in the address.', who: 'Every account holder: anyone signed in can read other people\'s data.', code: '// Before\nconst record = await db.find(id);\n// After\nconst record = await db.find(id);\nif (!record || record.ownerId !== user.id) return res.status(404).end();', ref: 'OWASP API Security Top 10: API1 (Broken Object Level Authorization)' },
|
|
113
|
+
'api-no-token-accepted': { what: 'An endpoint that needs sign-in answered without any sign-in.', who: 'Account holders: their data is open to anyone.', ref: 'OWASP API2' },
|
|
114
|
+
'api-bad-token-accepted': { what: 'An endpoint accepted a tampered sign-in token.', who: 'Every account: tokens are not really checked.', ref: 'OWASP API2' },
|
|
115
|
+
'api-wrong-role-allowed': { what: 'A user without the needed role (for example not an admin) could use an endpoint meant for that role.', who: 'Your organisation: normal users can do admin actions.', ref: 'OWASP API5 (Broken Function Level Authorization)' },
|
|
116
|
+
'api-accepts-invalid-input': { what: 'The server accepted data that breaks its own rules (wrong type, too long, missing field).', who: 'Data quality and security: bad data gets stored.', ref: 'OWASP API Security' },
|
|
117
|
+
'api-mass-assignment': { what: 'The server let the client set a field it should control itself (for example status, role or owner).', who: 'Your organisation: users can change things they must not.', ref: 'OWASP API3 (Broken Object Property Level Authorization)' },
|
|
118
|
+
'api-server-error': { what: 'A request made the server fail (error 500).', who: 'Users of this feature.' },
|
|
119
|
+
'api-error-leaks-details': { what: 'An error response shows internal details (stack traces, SQL, file paths).', who: 'Your organisation: attackers learn how the system is built.' },
|
|
120
|
+
'api-slow': { what: 'Some API endpoints answer slower than the budget.', who: 'Every user of those features.' },
|
|
121
|
+
|
|
122
|
+
// ---------- security ----------
|
|
123
|
+
'exposed-file': { what: 'A private file (for example .env or .git) can be downloaded from the site.', who: 'Your organisation: passwords and keys inside it are public.' },
|
|
124
|
+
'secret-in-bundle': { what: 'A secret (API key, password, private key) is inside the JavaScript sent to every browser.', who: 'Your organisation: anyone can read and use it.' },
|
|
125
|
+
'secret-in-source': { what: 'A secret is written in the source code.', who: 'Your organisation: everyone with the code (and Git history) has it.' },
|
|
126
|
+
'source-maps-public': { what: 'Source maps can be downloaded, so anyone can read your original source code.', who: 'Your organisation.' },
|
|
127
|
+
'missing-csp': { what: 'The site sends no Content-Security-Policy, which limits the damage of injected scripts.', who: 'Every user, if a script injection bug exists.', code: "Content-Security-Policy: default-src 'self'; frame-ancestors 'none'" },
|
|
128
|
+
'missing-frame-protection': { what: 'Another site can show this site inside a frame (clickjacking).', who: 'Every user.', code: "Content-Security-Policy: frame-ancestors 'none'" },
|
|
129
|
+
'insecure-cookie': { what: 'A cookie is missing protective flags (HttpOnly, Secure or SameSite).', who: 'Every signed-in user.', code: 'Set-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/' },
|
|
130
|
+
'vulnerable-dependency': { what: 'A package the app ships has a known vulnerability.', who: 'Every user, depending on the vulnerability.' },
|
|
131
|
+
'vulnerable-dependency-critical': { what: 'A package the app ships has a known CRITICAL vulnerability.', who: 'Every user.' },
|
|
132
|
+
'sensitive-in-url': { what: 'A password, token or ID number is sent in an address, where it is kept in logs and browser history.', who: 'The users whose values these are.' },
|
|
133
|
+
'mixed-content': { what: 'An HTTPS page loads something over plain HTTP.', who: 'Every user: the browser may block it or show a warning.' },
|
|
134
|
+
|
|
135
|
+
// ---------- SEO ----------
|
|
136
|
+
'missing-title': { what: 'The page has no title (the text shown in browser tabs and search results).', who: 'Everyone with several tabs open, and search engines.' },
|
|
137
|
+
'duplicate-title': { what: 'Several pages have the same title, so browser tabs, history and search results cannot be told apart.', who: 'Every user with several tabs open, and search engines.', code: "document.title = 'My applications · Service Portal';" },
|
|
138
|
+
'missing-description': { what: 'The page has no description for search results and shared links.', who: 'People finding the site in search.' },
|
|
139
|
+
'missing-h1': { what: 'The page has no main heading (h1).', who: 'Screen-reader users and search engines.' },
|
|
140
|
+
'missing-lang': { what: 'The page does not say its language.', who: 'Screen-reader users (wrong pronunciation) and translation tools.', code: '<html lang="en">', ref: 'WCAG 3.1.1 (A)' },
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** axe-core rules: the id is the start of the finding's detail ("color-contrast: …"). */
|
|
144
|
+
export const AXE = {
|
|
145
|
+
'color-contrast': { what: 'Text is too light against its background to be read easily.', who: 'Low-vision users, older users, and anyone reading in sunlight.', code: '/* Before */ .faint { color: #b9bec6; }\n/* After: at least 4.5:1 against the background */ .faint { color: #5d6672; }', ref: 'WCAG 1.4.3 Contrast (AA)' },
|
|
146
|
+
'image-alt': { what: 'An image has no text alternative (alt), so screen readers cannot say what it shows.', who: 'Blind and screen-reader users.', code: '<img src="hero.svg" alt="People applying for services online">\n<!-- decorative images: alt="" -->', ref: 'WCAG 1.1.1 (A)' },
|
|
147
|
+
label: { what: 'A form field has no label, so screen readers cannot say what to type.', who: 'Screen-reader users, and voice-control users.', code: '<label for="email">Email</label>\n<input id="email" type="email">', ref: 'WCAG 1.3.1, 4.1.2 (A)' },
|
|
148
|
+
'button-name': { what: 'A button has no name (for example an icon without text), so screen readers say only "button".', who: 'Screen-reader users.', code: '<button aria-label="Close"><svg …/></button>', ref: 'WCAG 4.1.2 (A)' },
|
|
149
|
+
'link-name': { what: 'A link has no name (for example an icon without text).', who: 'Screen-reader users.', code: '<a href="/profile" aria-label="My profile"><svg …/></a>', ref: 'WCAG 2.4.4, 4.1.2 (A)' },
|
|
150
|
+
'target-size': { what: 'A button or link is too small or too close to others to tap reliably.', who: 'Touch-screen users and people with limited hand control.', code: '.icon-button { min-width: 24px; min-height: 24px; }', ref: 'WCAG 2.5.8 Target Size (AA)' },
|
|
151
|
+
'html-has-lang': { what: 'The page does not say its language.', who: 'Screen-reader users.', code: '<html lang="en">', ref: 'WCAG 3.1.1 (A)' },
|
|
152
|
+
'document-title': { what: 'The page has no title.', who: 'Screen-reader users and everyone with several tabs.', ref: 'WCAG 2.4.2 (A)' },
|
|
153
|
+
'select-name': { what: 'A dropdown has no label.', who: 'Screen-reader users.', ref: 'WCAG 4.1.2 (A)' },
|
|
154
|
+
'aria-required-attr': { what: 'An element with an ARIA role is missing an attribute that role needs.', who: 'Screen-reader users.', ref: 'WCAG 4.1.2 (A)' },
|
|
155
|
+
'list': { what: 'A list is built incorrectly, so screen readers cannot announce it as a list.', who: 'Screen-reader users.', ref: 'WCAG 1.3.1 (A)' },
|
|
156
|
+
'listitem': { what: 'A list item is not inside a list.', who: 'Screen-reader users.', ref: 'WCAG 1.3.1 (A)' },
|
|
157
|
+
'meta-viewport': { what: 'The page stops users from zooming in.', who: 'Low-vision users.', code: '<meta name="viewport" content="width=device-width, initial-scale=1">', ref: 'WCAG 1.4.4 (AA)' },
|
|
158
|
+
'frame-title': { what: 'A frame has no title.', who: 'Screen-reader users.', ref: 'WCAG 4.1.2 (A)' },
|
|
159
|
+
'duplicate-id-aria': { what: 'Two elements share an id used by ARIA, so labels point to the wrong element.', who: 'Screen-reader users.', ref: 'WCAG 4.1.2 (A)' },
|
|
160
|
+
'nested-interactive': { what: 'A button or link is inside another button or link.', who: 'Keyboard and screen-reader users.', ref: 'WCAG 4.1.2 (A)' },
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
/** How it should be, in one sentence, for the "Expected" side of each finding. */
|
|
165
|
+
export const EXPECTED = {
|
|
166
|
+
'page-error': 'The page runs without JavaScript errors, and every part of it shows.',
|
|
167
|
+
'console-error': 'The browser console shows no errors on this page.',
|
|
168
|
+
'failed-request': 'Every request the page makes succeeds.',
|
|
169
|
+
'broken-image': 'The image loads and shows.',
|
|
170
|
+
'broken-link': 'The link opens an existing page.',
|
|
171
|
+
'broken-anchor': 'The link jumps to the section it names.',
|
|
172
|
+
'blank-page': 'The page shows its content.',
|
|
173
|
+
'crash-when-api-fails': 'The page stays on screen and shows a message such as "Could not load your profile. Try again."',
|
|
174
|
+
'blank-when-api-fails': 'The page stays on screen and shows a message with a way to try again.',
|
|
175
|
+
'silent-when-api-fails': 'A clear message says the data could not be loaded, with a way to try again.',
|
|
176
|
+
'no-not-found-page': 'An unknown address shows a "Page not found" page with a link home.',
|
|
177
|
+
'missing-viewport-meta': 'Phones show the page at its normal size, not zoomed out.',
|
|
178
|
+
'horizontal-overflow': 'The page fits the screen: no sideways scrolling. Wide content (such as a table) scrolls inside its own box.',
|
|
179
|
+
'clipped-text': 'All the text is visible inside its box.',
|
|
180
|
+
'text-outside-box': 'The box grows to fit its text: all the text sits inside it and nothing runs over the page.',
|
|
181
|
+
'text-overlap': 'Each piece of text has its own space and can be read on its own.',
|
|
182
|
+
'cut-off-at-edge': 'All the content fits inside the screen.',
|
|
183
|
+
'control-covered': 'Nothing covers the button or link: a click on it works.',
|
|
184
|
+
'placeholder-text': 'Every value shows real text: a name, number, date or a clear "not available".',
|
|
185
|
+
'distorted-image': 'The image keeps its own shape: it fits or is cropped, never stretched.',
|
|
186
|
+
'stuck-loading': 'When loading ends the indicator goes away and the content (or an error message) is shown.',
|
|
187
|
+
'overlapping-elements': 'Buttons and links sit apart, each easy to press on its own.',
|
|
188
|
+
'small-text': 'Text on phones is at least 12 px (16 px for body text) and easy to read.',
|
|
189
|
+
'rtl-missing': 'The Arabic page reads right to left: text, menus and layout are mirrored.',
|
|
190
|
+
'font-failed': 'The page uses the font from the design.',
|
|
191
|
+
'inconsistent-typography': 'The page uses one or two fonts and a small set of text sizes.',
|
|
192
|
+
'visual-change': 'The page looks like the approved baseline picture.',
|
|
193
|
+
'focus-not-visible': 'When you press Tab, a clear ring shows which item has the focus.',
|
|
194
|
+
'keyboard-trap': 'Pressing Tab always moves on to the next item.',
|
|
195
|
+
'mouse-only-control': 'The item can be reached with Tab and opened with Enter or Space, like a real button.',
|
|
196
|
+
'form-field-type': 'Email and phone fields bring up the right keyboard, and password managers can fill the password.',
|
|
197
|
+
'heavy-page': 'The page downloads less than the budget.',
|
|
198
|
+
'heavy-javascript': 'The page loads only the JavaScript it needs.',
|
|
199
|
+
'too-many-requests': 'The page makes fewer requests than the budget.',
|
|
200
|
+
'slow-page': 'The page loads within the budget.',
|
|
201
|
+
'page-layout-shift': 'Content stays in place while the page loads.',
|
|
202
|
+
'main-thread-blocked': 'The page reacts to clicks and typing right away while it loads.',
|
|
203
|
+
'oversized-images': 'Images are downloaded at the size they are shown.',
|
|
204
|
+
'images-without-size': 'Images have a width and height, so nothing jumps when they load.',
|
|
205
|
+
'slow-api-call': 'The API call answers within the budget.',
|
|
206
|
+
'large-api-response': 'The API returns only the data the page shows.',
|
|
207
|
+
'repeated-api-call': 'Each request is sent once per page load.',
|
|
208
|
+
'too-many-api-calls': 'The page needs only a few API calls to load.',
|
|
209
|
+
'uncompressed-response': 'Text files and responses are sent compressed (gzip or brotli).',
|
|
210
|
+
'assets-not-cached': 'Built files come from the browser cache on later visits.',
|
|
211
|
+
'slow-under-load': 'Requests stay fast with several users at the same time.',
|
|
212
|
+
'errors-under-load': 'No requests fail with several users at the same time.',
|
|
213
|
+
'wrong-password-accepted': 'A wrong password or an empty form is refused.',
|
|
214
|
+
'login-no-error-message': 'A wrong password shows a clear message.',
|
|
215
|
+
'empty-login-no-feedback': 'An empty sign-in shows which fields are needed.',
|
|
216
|
+
'user-enumeration': 'A wrong password and an unknown user get the same message.',
|
|
217
|
+
'page-without-sign-in': 'Opening the page without signing in sends the user to the sign-in page.',
|
|
218
|
+
'api-without-sign-in': 'The API answers 401 without signing in.',
|
|
219
|
+
'sensitive-field-in-api': 'API responses never contain passwords, secrets or ID numbers.',
|
|
220
|
+
'token-in-web-storage': 'The sign-in token is kept in an HttpOnly cookie that scripts cannot read.',
|
|
221
|
+
'form-server-error': 'The server answers bad input with a clear 400 message, never a 500.',
|
|
222
|
+
'form-accepts-empty': 'The form is not sent until the required fields are filled in.',
|
|
223
|
+
'form-no-feedback': 'Each missing field shows a message next to it.',
|
|
224
|
+
'sso-no-state': 'Every sign-in request carries a random "state" value that is checked on return.',
|
|
225
|
+
'sso-not-https': 'The sign-in starts on an https:// address.',
|
|
226
|
+
'sso-implicit-flow': 'The sign-in uses response_type=code with PKCE.',
|
|
227
|
+
'sso-no-pkce': 'The sign-in sends a code_challenge (PKCE).',
|
|
228
|
+
'sso-insecure-redirect': 'The provider returns the user to an https:// address of your own.',
|
|
229
|
+
'sso-button-broken': 'Clicking the button opens the provider\'s sign-in.',
|
|
230
|
+
'sso-cancel-crash': 'After a cancelled sign-in, the page says "Sign-in was cancelled" with a way to try again.',
|
|
231
|
+
'sso-cancel-blank': 'After a cancelled sign-in, the page says "Sign-in was cancelled" with a way to try again.',
|
|
232
|
+
'sso-cancel-silent': 'After a cancelled sign-in, the page says "Sign-in was cancelled" with a way to try again.',
|
|
233
|
+
'session-expired': 'The saved sign-in works, so every signed-in page is tested.',
|
|
234
|
+
'signed-in-not-tested': 'The tool signs in, so every signed-in page is tested.',
|
|
235
|
+
'api-other-users-data': 'Asking for another user\'s record answers 404 (or 403); users only see their own records.',
|
|
236
|
+
'api-no-token-accepted': 'Without signing in, the endpoint answers 401.',
|
|
237
|
+
'api-bad-token-accepted': 'A tampered token is refused with 401.',
|
|
238
|
+
'api-wrong-role-allowed': 'Users without the role get 403.',
|
|
239
|
+
'api-accepts-invalid-input': 'Invalid data is refused with 400 and a clear message.',
|
|
240
|
+
'api-mass-assignment': 'Fields the server controls (status, role, owner) are ignored or refused.',
|
|
241
|
+
'api-server-error': 'The server never answers 500 to a request.',
|
|
242
|
+
'api-error-leaks-details': 'Error responses show a short message, no internal details.',
|
|
243
|
+
'api-slow': 'Every endpoint answers within the budget.',
|
|
244
|
+
'exposed-file': 'Private files answer 404.',
|
|
245
|
+
'secret-in-bundle': 'No secrets in the JavaScript sent to browsers.',
|
|
246
|
+
'secret-in-source': 'No secrets in the source code; they come from the server environment.',
|
|
247
|
+
'source-maps-public': 'Source maps are not served in production.',
|
|
248
|
+
'missing-csp': 'The page is sent with a Content-Security-Policy header.',
|
|
249
|
+
'missing-frame-protection': 'The page refuses to be shown inside other sites.',
|
|
250
|
+
'insecure-cookie': 'Session cookies have HttpOnly, Secure and SameSite.',
|
|
251
|
+
'vulnerable-dependency': 'No dependency with a known vulnerability ships to production.',
|
|
252
|
+
'vulnerable-dependency-critical': 'No dependency with a known vulnerability ships to production.',
|
|
253
|
+
'sensitive-in-url': 'Passwords, tokens and ID numbers travel in headers or the request body, never in the address.',
|
|
254
|
+
'missing-title': 'The page has a title that names it.',
|
|
255
|
+
'duplicate-title': 'Each page has its own title, for example "My applications · Service Portal".',
|
|
256
|
+
'missing-description': 'The page has a short description.',
|
|
257
|
+
'missing-h1': 'The page has one main heading (h1).',
|
|
258
|
+
'missing-lang': 'The page says its language, for example <html lang="en">.',
|
|
259
|
+
};
|
|
260
|
+
export const AXE_EXPECTED = {
|
|
261
|
+
'color-contrast': 'The text is dark enough to read easily against its background (contrast at least 4.5:1).',
|
|
262
|
+
'image-alt': 'The image has a text alternative (alt) that says what it shows, or alt="" if it is decoration.',
|
|
263
|
+
label: 'Every form field has a visible label that screen readers read out.',
|
|
264
|
+
'button-name': 'Every button has a name that screen readers read out.',
|
|
265
|
+
'link-name': 'Every link has a name that screen readers read out.',
|
|
266
|
+
'target-size': 'Buttons and links are at least 24 × 24 px with space around them.',
|
|
267
|
+
'html-has-lang': 'The page says its language.',
|
|
268
|
+
'meta-viewport': 'Users can zoom in.',
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The explanation for a finding: rule- or axe-specific, with sensible fallbacks. */
|
|
272
|
+
export function explain(finding) {
|
|
273
|
+
let entry = EXPLAIN[finding.ruleId];
|
|
274
|
+
if (/^a11y-/.test(finding.ruleId)) {
|
|
275
|
+
const axeId = String(finding.detail ?? '').split(':')[0].trim();
|
|
276
|
+
entry = AXE[axeId] ?? { what: String(finding.detail ?? '').split(':').slice(1).join(':').trim() || finding.title, who: WHO.accessibility, ref: 'WCAG 2.2 AA' };
|
|
277
|
+
}
|
|
278
|
+
const axeId = /^a11y-/.test(finding.ruleId) ? String(finding.detail ?? '').split(':')[0].trim() : '';
|
|
279
|
+
return {
|
|
280
|
+
what: entry?.what ?? finding.title,
|
|
281
|
+
who: entry?.who ?? WHO[finding.area] ?? '',
|
|
282
|
+
expected: (axeId ? AXE_EXPECTED[axeId] : EXPECTED[finding.ruleId]) ?? '',
|
|
283
|
+
ref: entry?.ref ?? '',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** "a#nav.link \"About\"" → the link "About" (for people, next to the CSS selector). */
|
|
288
|
+
export function humanElement(item) {
|
|
289
|
+
const match = String(item).match(/^([a-z0-9]+)[^\s"]*\s*(?:"([^"]*)")?/i);
|
|
290
|
+
if (!match) return String(item);
|
|
291
|
+
const names = { a: 'link', button: 'button', input: 'field', select: 'dropdown', textarea: 'text box', img: 'image', table: 'table', div: 'box', span: 'text', p: 'paragraph', h1: 'heading', h2: 'heading', h3: 'heading', label: 'label', nav: 'menu', li: 'list item', td: 'table cell', th: 'table heading', svg: 'icon', form: 'form', section: 'section', header: 'header', footer: 'footer' };
|
|
292
|
+
const kind = names[match[1].toLowerCase()] ?? match[1].toLowerCase();
|
|
293
|
+
return match[2] ? `the ${kind} "${match[2]}"` : `a ${kind}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The elements of a finding for people: the link "About" next to its CSS selector. */
|
|
297
|
+
export function elementsOf(f) {
|
|
298
|
+
const selectors = f.selectors ?? [];
|
|
299
|
+
const items = f.items ?? [];
|
|
300
|
+
// Only short element descriptions ("a \"About\"") become names; longer items stay under Details.
|
|
301
|
+
const describes = (item) => /^[a-z][a-z0-9]*[#.\w-]*(\s+"[^"]*")?$/i.test(String(item).trim());
|
|
302
|
+
return selectors.slice(0, 6).map((selector, i) => ({ name: items[i] && describes(items[i]) ? humanElement(items[i]) : '', selector }));
|
|
303
|
+
}
|
package/src/core/rules.mjs
CHANGED
|
@@ -69,6 +69,34 @@ export const RULES = {
|
|
|
69
69
|
area: 'responsive', severity: 'minor', title: 'Text is cut off inside its box',
|
|
70
70
|
fix: 'Let the box grow (remove fixed width/height), allow wrapping, or use text-overflow: ellipsis with a title so the full text can still be read.',
|
|
71
71
|
},
|
|
72
|
+
'text-outside-box': {
|
|
73
|
+
area: 'responsive', severity: 'major', title: 'Text spills out of its box',
|
|
74
|
+
fix: 'Let the box fit its text: remove the fixed width/height (use min-width/min-height), allow wrapping, or shorten the text.',
|
|
75
|
+
},
|
|
76
|
+
'text-overlap': {
|
|
77
|
+
area: 'responsive', severity: 'major', title: 'Text lies on top of other text',
|
|
78
|
+
fix: 'Give each piece of text its own space at this screen size: remove the absolute position or negative margin, or let the container grow.',
|
|
79
|
+
},
|
|
80
|
+
'cut-off-at-edge': {
|
|
81
|
+
area: 'responsive', severity: 'major', title: 'Content is cut off at the edge of the screen',
|
|
82
|
+
fix: 'Keep the content inside the screen: max-width: 100%, no fixed widths or negative offsets; let long words wrap.',
|
|
83
|
+
},
|
|
84
|
+
'control-covered': {
|
|
85
|
+
area: 'functionality', severity: 'major', title: 'A button or link is covered by something else',
|
|
86
|
+
fix: 'Move the covering element away, or fix its position/z-index so the button or link can be clicked. Decorative layers should use pointer-events: none.',
|
|
87
|
+
},
|
|
88
|
+
'placeholder-text': {
|
|
89
|
+
area: 'functionality', severity: 'major', title: 'Unfinished text on the page (undefined, NaN, a translation key…)',
|
|
90
|
+
fix: 'Show real values: handle missing data (show a dash or a message), format dates and numbers, add the missing translations, and remove sample text.',
|
|
91
|
+
},
|
|
92
|
+
'distorted-image': {
|
|
93
|
+
area: 'responsive', severity: 'minor', title: 'An image is stretched or squashed',
|
|
94
|
+
fix: 'Keep the picture\'s shape: set only the width (height: auto), or use object-fit: cover / contain.',
|
|
95
|
+
},
|
|
96
|
+
'stuck-loading': {
|
|
97
|
+
area: 'functionality', severity: 'major', title: 'The page is still loading after everything has loaded',
|
|
98
|
+
fix: 'Hide the loading indicator when the data arrives and when a request fails; show the content or an error message.',
|
|
99
|
+
},
|
|
72
100
|
'overlapping-elements': {
|
|
73
101
|
area: 'responsive', severity: 'minor', title: 'Buttons or links overlap each other',
|
|
74
102
|
fix: 'Give the items room at this screen size: wrap them, stack them, or reduce their size. Overlapping controls are hard to tap.',
|
package/src/export/docx.mjs
CHANGED
|
@@ -51,7 +51,7 @@ function imageSize(buffer, png) {
|
|
|
51
51
|
const run = (text, options = {}) => new TextRun({ text: String(text ?? ''), font: FONT, ...options });
|
|
52
52
|
const para = (text, options = {}, runOptions = {}) => new Paragraph({ spacing: { after: 120 }, ...options, children: [run(text, runOptions)] });
|
|
53
53
|
|
|
54
|
-
function cell(text, width, { fill, bold, color, align, size = 19, keepNext = false } = {}) {
|
|
54
|
+
function cell(text, width, { fill, bold, color, align, size = 19, keepNext = false, mono = false } = {}) {
|
|
55
55
|
return new TableCell({
|
|
56
56
|
width: { size: width, type: WidthType.DXA },
|
|
57
57
|
borders,
|
|
@@ -60,7 +60,7 @@ function cell(text, width, { fill, bold, color, align, size = 19, keepNext = fal
|
|
|
60
60
|
shading: fill ? { type: ShadingType.CLEAR, color: 'auto', fill } : undefined,
|
|
61
61
|
children: String(text ?? '')
|
|
62
62
|
.split('\n')
|
|
63
|
-
.map((line) => new Paragraph({ alignment: align, keepNext, children: [run(line, { bold, color, size })] })),
|
|
63
|
+
.map((line) => new Paragraph({ alignment: align, keepNext, children: [run(line, { bold, color, size: mono ? 17 : size, ...(mono ? { font: 'Consolas' } : {}) })] })),
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -99,7 +99,7 @@ function kv(rows, keepWithNext = false) {
|
|
|
99
99
|
return new Table({
|
|
100
100
|
width: { size: PAGE_WIDTH, type: WidthType.DXA },
|
|
101
101
|
columnWidths: widths,
|
|
102
|
-
rows: rows.map(([k, v], i) => new TableRow({ cantSplit: true, children: [cell(k, widths[0], { fill: C.head, bold: true, keepNext: keepWithNext && i === rows.length - 1 }), cell(v, widths[1], { keepNext: keepWithNext && i === rows.length - 1 })] })),
|
|
102
|
+
rows: rows.map(([k, v], i) => new TableRow({ cantSplit: true, children: [cell(k, widths[0], { fill: C.head, bold: true, keepNext: keepWithNext && i === rows.length - 1 }), cell(v, widths[1], { keepNext: keepWithNext && i === rows.length - 1, mono: k === 'Elements' || k === 'Details', fill: undefined })] })),
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -158,23 +158,34 @@ export async function writeDocx(doc, reportDir, outFile) {
|
|
|
158
158
|
children: [run(`${block.id} `, { color: C.muted }), run(block.title), run(` ${block.severity.toUpperCase()}`, { color: SEV[block.severity], size: 20 })],
|
|
159
159
|
}),
|
|
160
160
|
);
|
|
161
|
+
children.push(new Paragraph({ keepNext: true, spacing: { after: 120 }, children: [run(block.what, { size: 21 })] }));
|
|
162
|
+
// Actual (red) next to Expected (green): pictures where there are any, and one sentence each.
|
|
163
|
+
const half = Math.floor(PAGE_WIDTH / 2);
|
|
164
|
+
const side = (part, label, colour, png) => {
|
|
165
|
+
const parts = [new Paragraph({ spacing: { after: 80 }, children: [run(`${label} `, { bold: true, color: colour, size: 18 }), run(label === 'ACTUAL' ? 'how it is now' : 'how it should be', { color: C.muted, size: 17 })] })];
|
|
166
|
+
if (part.src && existsSync(join(reportDir, part.src))) {
|
|
167
|
+
const data = readFileSync(join(reportDir, part.src));
|
|
168
|
+
const size = imageSize(data, png);
|
|
169
|
+
const scale = Math.min(205 / size.width, 200 / size.height);
|
|
170
|
+
parts.push(new Paragraph({ alignment: AlignmentType.CENTER, children: [new ImageRun({ type: png ? 'png' : 'jpg', data, transformation: { width: Math.round(size.width * scale * 1.333), height: Math.round(size.height * scale * 1.333) }, altText: { title: `${label}: ${block.title}`, description: part.text, name: `${block.id}-${label}` } })] }));
|
|
171
|
+
} else if (label === 'EXPECTED' && block.actual.src) {
|
|
172
|
+
parts.push(new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 200, after: 200 }, children: [run('✓', { size: 56, color: C.good })] }));
|
|
173
|
+
}
|
|
174
|
+
parts.push(new Paragraph({ spacing: { before: 80 }, children: [run(part.text, { size: 18 })] }));
|
|
175
|
+
return new TableCell({
|
|
176
|
+
width: { size: half, type: WidthType.DXA },
|
|
177
|
+
margins: { top: 100, bottom: 100, left: 120, right: 120 },
|
|
178
|
+
shading: { type: ShadingType.CLEAR, color: 'auto', fill: 'F7F8FA' },
|
|
179
|
+
borders: { top: { style: BorderStyle.SINGLE, size: 24, color: colour }, bottom: border, left: border, right: border },
|
|
180
|
+
children: parts,
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
children.push(new Table({ width: { size: half * 2, type: WidthType.DXA }, columnWidths: [half, half], rows: [new TableRow({ cantSplit: true, children: [side(block.actual, 'ACTUAL', C.blocker, block.actual.png), side(block.expected, 'EXPECTED', C.good, false)] })] }));
|
|
184
|
+
children.push(new Paragraph({ spacing: { after: 80 }, children: [] }));
|
|
161
185
|
const rows = [...block.rows];
|
|
162
|
-
if (block.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
rows.push(['How to fix', block.fix]);
|
|
166
|
-
rows.push(['Check the fix', block.verify]);
|
|
167
|
-
if (block.link) rows.push(['Reference', block.link]);
|
|
168
|
-
const pic0 = block.picture && existsSync(join(reportDir, block.picture.src));
|
|
169
|
-
children.push(kv(rows, pic0));
|
|
170
|
-
const pic = block.picture && existsSync(join(reportDir, block.picture.src)) ? block.picture : null;
|
|
171
|
-
if (pic) {
|
|
172
|
-
const data = readFileSync(join(reportDir, pic.src));
|
|
173
|
-
const size = imageSize(data, pic.png);
|
|
174
|
-
const scale = Math.min(450 / size.width, 300 / size.height);
|
|
175
|
-
children.push(new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 120 }, keepNext: true, children: [new ImageRun({ type: pic.png ? 'png' : 'jpg', data, transformation: { width: Math.round(size.width * scale), height: Math.round(size.height * scale) }, altText: { title: pic.caption, description: pic.caption, name: block.id } })] }));
|
|
176
|
-
children.push(new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 200 }, children: [run(`${block.id} · ${pic.caption}`, { size: 17, color: C.muted, italics: true })] }));
|
|
177
|
-
} else children.push(spacer());
|
|
186
|
+
if (block.link) rows.push(['Learn more', block.link]);
|
|
187
|
+
children.push(kv(rows));
|
|
188
|
+
children.push(spacer());
|
|
178
189
|
} else if (block.t === 'images') {
|
|
179
190
|
const items = block.items.filter((i) => existsSync(join(reportDir, i.src)));
|
|
180
191
|
if (!items.length) continue;
|
package/src/export/model.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* { t: 'table', head, rows, widths, tone? } · { t: 'kv', rows } · { t: 'finding', ... }
|
|
13
13
|
* { t: 'images', items: [{ src, caption, width, height }] } · { t: 'break' }
|
|
14
14
|
*/
|
|
15
|
+
import { elementsOf, explain } from '../core/explain.mjs';
|
|
15
16
|
import { AREAS, SEVERITY_ORDER } from '../core/rules.mjs';
|
|
16
17
|
|
|
17
18
|
export const SEVERITY_TEXT = {
|
|
@@ -31,13 +32,15 @@ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
|
31
32
|
const date = (ms) => new Date(ms).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
32
33
|
const time = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
33
34
|
|
|
34
|
-
function stepsFor(f, pages) {
|
|
35
|
+
export function stepsFor(f, pages) {
|
|
35
36
|
const page = String(f.page);
|
|
36
37
|
const signedIn = pages.find((p) => p.key === page)?.signedIn;
|
|
37
38
|
const screen = [f.evidenceView, ...(f.views ?? [])].map((v) => String(v ?? '').match(/^([^(,·]+?)\s*\((\d+) px\)/)).find(Boolean);
|
|
38
39
|
if (page.startsWith('/')) {
|
|
39
40
|
const steps = [`${signedIn ? 'Sign in as the test user, then open' : 'Open'} ${page}${screen ? ` on a ${screen[1].trim().toLowerCase()} screen (${screen[2]} px wide)` : ''}.`];
|
|
40
41
|
if (f.ruleId === 'focus-not-visible' || f.ruleId === 'keyboard-trap') steps.push('Press Tab to move through the page.');
|
|
42
|
+
if (/^sso-cancel/.test(f.ruleId)) steps.push('This is the page the provider returns to. Add ?error=access_denied to its address (what Google or UAE PASS send when the user presses Cancel) and open it.');
|
|
43
|
+
else if (/^sso-|^uaepass-/.test(f.ruleId)) steps.push('Click the sign-in button named under Actual, and look at the address it opens (the browser address bar, or DevTools > Network).');
|
|
41
44
|
if (/when-api-fails/.test(f.ruleId)) steps.push('In the browser developer tools, block the API requests (Network > right-click > Block request URL), then reload.');
|
|
42
45
|
if (f.ruleId === 'horizontal-overflow') steps.push('Scroll sideways: the page moves left and right.');
|
|
43
46
|
if (/^a11y-/.test(f.ruleId)) steps.push('Run an accessibility checker on the page (axe DevTools or Lighthouse > Accessibility) to see the same result.');
|
|
@@ -177,23 +180,26 @@ export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
|
177
180
|
b.push({ t: 'h2', text: `${severity[0].toUpperCase()}${severity.slice(1)} (${group.length})` });
|
|
178
181
|
b.push({ t: 'p', text: SEVERITY_TEXT[severity], muted: true });
|
|
179
182
|
for (const f of group) {
|
|
183
|
+
const x = explain(f);
|
|
184
|
+
const elements = elementsOf(f);
|
|
185
|
+
const actualSrc = f.evidenceZoom ?? f.evidence;
|
|
186
|
+
const expectedSrc = f.expectedZoom ?? f.expected;
|
|
180
187
|
b.push({
|
|
181
188
|
t: 'finding',
|
|
182
189
|
id: id(findings.indexOf(f)),
|
|
183
190
|
title: f.title,
|
|
184
191
|
severity: f.severity,
|
|
192
|
+
what: x.what,
|
|
193
|
+
actual: { src: actualSrc ?? (f.ruleId === 'visual-change' && f.link ? f.link : ''), png: !actualSrc && f.ruleId === 'visual-change', text: f.detail && !/^a11y-/.test(f.ruleId) ? f.detail : x.what },
|
|
194
|
+
expected: { src: expectedSrc ?? '', text: x.expected || 'This problem no longer appears when the check runs again.' },
|
|
185
195
|
rows: [
|
|
186
|
-
['
|
|
187
|
-
['
|
|
188
|
-
...(
|
|
189
|
-
...(f.
|
|
196
|
+
['Where', `${f.page}${f.views?.length ? ` · ${f.views.join(', ')}` : ''}`],
|
|
197
|
+
...(elements.length ? [['Elements', elements.map((e) => `${e.name ? `${e.name} ` : ''}${e.selector}`).join('\n')]] : []),
|
|
198
|
+
...(x.who ? [['Affects', x.who]] : []),
|
|
199
|
+
...(!(elements.length && (f.items ?? []).length <= elements.length && !/^a11y-/.test(f.ruleId)) && (f.items ?? []).length ? [['Details', f.items.slice(0, 8).join('\n')]] : []),
|
|
200
|
+
['See it', stepsFor(f, pages).map((step, i) => `${i + 1}. ${step}`).join('\n')],
|
|
201
|
+
...(x.ref ? [['Reference', x.ref]] : []),
|
|
190
202
|
],
|
|
191
|
-
evidence: (f.items ?? []).slice(0, 8),
|
|
192
|
-
picture: f.evidence ? { src: f.evidence, caption: `Evidence${f.evidenceView ? ` · ${f.evidenceView}` : ''}${f.selectors?.length ? ': the problem is outlined in red' : ''}` } : f.ruleId === 'visual-change' && f.link ? { src: f.link, caption: 'Changed areas in red', png: true } : null,
|
|
193
|
-
selectors: (f.selectors ?? []).slice(0, 6),
|
|
194
|
-
steps: stepsFor(f, pages),
|
|
195
|
-
fix: f.fix,
|
|
196
|
-
verify: 'Run the check again (npm run go-live): this finding must be gone.',
|
|
197
203
|
link: /^https?:/.test(f.link ?? '') ? f.link : '',
|
|
198
204
|
});
|
|
199
205
|
}
|
package/src/export/pdf.mjs
CHANGED
|
@@ -60,13 +60,16 @@ export function documentHtml(doc, reportDir) {
|
|
|
60
60
|
);
|
|
61
61
|
} else if (block.t === 'finding') {
|
|
62
62
|
const row = (k, v) => `<tr><th>${esc(k)}</th><td>${v}</td></tr>`;
|
|
63
|
-
const pic =
|
|
63
|
+
const pic = (part, png) => (part.src ? dataUri(reportDir, part.src, png) : '');
|
|
64
|
+
const actualPic = pic(block.actual, block.actual.png);
|
|
65
|
+
const expectedPic = pic(block.expected, false);
|
|
66
|
+
const fmt = (k, v) => (k === 'See it' ? `<ol>${v.split('\n').map((x) => `<li>${esc(x.replace(/^\d+\.\s*/, ''))}</li>`).join('')}</ol>` : k === 'Elements' || k === 'Details' ? v.split('\n').map((x) => `<code>${esc(x)}</code>`).join('<br>') : esc(v));
|
|
64
67
|
body.push(
|
|
65
|
-
`<section class="finding" style="--c:${SEV[block.severity]}"><h3><span class="fid">${esc(block.id)}</span> ${esc(block.title)} <span class="chip">${esc(block.severity)}</span></h3
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}</section>`,
|
|
68
|
+
`<section class="finding" style="--c:${SEV[block.severity]}"><h3><span class="fid">${esc(block.id)}</span> ${esc(block.title)} <span class="chip">${esc(block.severity)}</span></h3>
|
|
69
|
+
<p class="what">${esc(block.what)}</p>
|
|
70
|
+
<div class="compare"><div class="side actual"><div class="lab"><b>ACTUAL</b> how it is now</div>${actualPic ? `<img src="${actualPic}" alt="Actual">` : ''}<p>${esc(block.actual.text)}</p></div>
|
|
71
|
+
<div class="side expected"><div class="lab"><b>EXPECTED</b> how it should be</div>${expectedPic ? `<img src="${expectedPic}" alt="Expected">` : actualPic ? '<div class="tick">✓</div>' : ''}<p>${esc(block.expected.text)}</p></div></div>
|
|
72
|
+
<table class="kv">${block.rows.map(([k, v]) => row(k, fmt(k, v))).join('')}${block.link ? row('Learn more', esc(block.link)) : ''}</table></section>`,
|
|
70
73
|
);
|
|
71
74
|
} else if (block.t === 'images') {
|
|
72
75
|
const figures = block.items
|
|
@@ -104,10 +107,13 @@ tr{break-inside:avoid}thead{display:table-header-group}
|
|
|
104
107
|
.good{color:#1a7f45;font-weight:700}.warn{color:#a15c00;font-weight:700}.bad{color:#b3261e;font-weight:700}.sev{font-weight:700;text-transform:capitalize}
|
|
105
108
|
.count{font-size:20pt;font-weight:700;text-align:center}
|
|
106
109
|
.verdict{border-left:5pt solid var(--v);background:#f4f6f9;padding:8pt 10pt;margin:6pt 0 10pt}.verdict strong{color:var(--v);font-size:14pt;margin-right:6pt}
|
|
107
|
-
.finding{margin-bottom:
|
|
110
|
+
.finding{margin-bottom:12pt}.what{margin:2pt 0 6pt}
|
|
111
|
+
.compare{display:flex;gap:8pt;margin-bottom:6pt;break-inside:avoid}.side{flex:1;border:0.6pt solid #d9dde3;border-radius:4pt;padding:5pt;background:#f7f8fa}.side.actual{border-top:3pt solid #b3261e}.side.expected{border-top:3pt solid #1a7f45}
|
|
112
|
+
.side .lab{font-size:8pt;color:#5d6672;margin-bottom:4pt}.side.actual b{color:#b3261e}.side.expected b{color:#1a7f45}.side img{display:block;max-width:100%;max-height:190pt;margin:0 auto;border:0.6pt solid #d9dde3;background:#fff}.side p{margin:4pt 0 0;font-size:9pt}.tick{text-align:center;font-size:30pt;color:#1a7f45;padding:20pt 0}.finding table{break-inside:avoid}.finding ol{margin:0;padding-left:14pt}
|
|
108
113
|
figure.evidence{margin:4pt 0 0;break-inside:avoid}figure.evidence img{max-width:100%;max-height:300pt;border:0.6pt solid #d9dde3}.finding h3{border-left:3pt solid var(--c);padding-left:6pt}.fid{color:#5d6672;font-weight:400}
|
|
109
114
|
.chip{font-size:8pt;color:#fff;background:var(--c);padding:1pt 5pt;border-radius:8pt;text-transform:uppercase;vertical-align:middle}
|
|
110
115
|
code{font:8.5pt/1.35 Consolas,"DejaVu Sans Mono",monospace;word-break:break-word}
|
|
116
|
+
pre{margin:0;font:8pt/1.4 Consolas,"DejaVu Sans Mono",monospace;white-space:pre-wrap;background:#f4f6f9;padding:4pt 6pt;border-radius:3pt}
|
|
111
117
|
.figs{display:flex;gap:10pt;align-items:flex-start;break-inside:avoid;margin-bottom:8pt}
|
|
112
118
|
figure{margin:0;flex:1;text-align:center}figure img{max-width:100%;max-height:230pt;border:0.6pt solid #d9dde3}figcaption{font-size:8.5pt;color:#5d6672}
|
|
113
119
|
.break{break-after:page}
|
package/src/report/report.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { writeFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { elementsOf, explain } from '../core/explain.mjs';
|
|
9
10
|
import { AREAS, SEVERITY_ORDER, rule } from '../core/rules.mjs';
|
|
11
|
+
import { stepsFor } from '../export/model.mjs';
|
|
10
12
|
|
|
11
13
|
const WEIGHT = { blocker: 40, major: 15, minor: 5, info: 0 };
|
|
12
14
|
|
|
@@ -20,7 +22,7 @@ export function mergeFindings(raw) {
|
|
|
20
22
|
const existing = merged.get(key);
|
|
21
23
|
if (existing) {
|
|
22
24
|
if (f.view && !existing.views.includes(f.view)) existing.views.push(f.view);
|
|
23
|
-
if (!existing.evidence && f.evidence) Object.assign(existing, { evidence: f.evidence, evidenceView: f.evidenceView, selectors: f.selectors ?? existing.selectors });
|
|
25
|
+
if (!existing.evidence && f.evidence) Object.assign(existing, { evidence: f.evidence, evidenceZoom: f.evidenceZoom, expected: f.expected, expectedZoom: f.expectedZoom, evidenceView: f.evidenceView, selectors: f.selectors ?? existing.selectors });
|
|
24
26
|
if (SEVERITY_ORDER.indexOf(severity) < SEVERITY_ORDER.indexOf(existing.severity)) existing.severity = severity;
|
|
25
27
|
} else {
|
|
26
28
|
merged.set(key, { ...f, area: r.area, title: r.title, fix: r.fix, severity, views: f.view ? [f.view] : [] });
|
|
@@ -51,16 +53,41 @@ const VERDICT_TEXT = {
|
|
|
51
53
|
'NOT READY': 'Not ready: fix the blocking problems below first.',
|
|
52
54
|
};
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
/**
|
|
57
|
+
* One finding, shown so a developer understands it at a glance: one sentence on what is wrong,
|
|
58
|
+
* the Actual picture (problem in red) next to the Expected picture (corrected, in green), where it
|
|
59
|
+
* is, who it affects, and how to see it yourself.
|
|
60
|
+
*/
|
|
61
|
+
function findingHtml(f, pages = []) {
|
|
62
|
+
const x = explain(f);
|
|
63
|
+
const elements = elementsOf(f);
|
|
64
|
+
const steps = stepsFor(f, pages);
|
|
65
|
+
const details = elements.length && (f.items ?? []).length <= elements.length && !/^a11y-/.test(f.ruleId) ? [] : f.items ?? [];
|
|
66
|
+
const actualSrc = f.evidenceZoom ?? f.evidence;
|
|
67
|
+
const expectedSrc = f.expectedZoom ?? f.expected;
|
|
68
|
+
const picture = (src, full, alt) => `<a href="${esc(full ?? src)}" target="_blank"><img loading="lazy" src="${esc(src)}" alt="${esc(alt)}"></a>`;
|
|
69
|
+
const visualChange = f.ruleId === 'visual-change' && f.link ? f.link : '';
|
|
70
|
+
const compare = actualSrc || visualChange
|
|
71
|
+
? `<div class="compare">
|
|
72
|
+
<figure class="actual"><figcaption><span class="tag bad">Actual</span> how it is now</figcaption>${actualSrc ? picture(actualSrc, f.evidence, `Actual: ${f.title}`) : picture(visualChange, visualChange, 'Changed areas in red')}<p>${esc(f.detail && !/^a11y-/.test(f.ruleId) ? f.detail : x.what)}</p></figure>
|
|
73
|
+
<figure class="expected"><figcaption><span class="tag good">Expected</span> how it should be</figcaption>${expectedSrc ? picture(expectedSrc, f.expected, `Expected: ${f.title}`) : '<div class="nopic">✓</div>'}<p>${esc(x.expected || 'This problem no longer appears when the check runs again.')}</p></figure>
|
|
74
|
+
</div>`
|
|
75
|
+
: `<div class="compare text">
|
|
76
|
+
<div class="actual"><span class="tag bad">Actual</span><p>${esc(f.detail || x.what)}</p>${details.length ? `<ul class="items">${details.map((i) => `<li><code>${esc(i)}</code></li>`).join('')}</ul>` : ''}</div>
|
|
77
|
+
<div class="expected"><span class="tag good">Expected</span><p>${esc(x.expected || 'This problem no longer appears when the check runs again.')}</p></div>
|
|
78
|
+
</div>`;
|
|
55
79
|
return `<details class="finding sev-${f.severity}" data-sev="${f.severity}" data-area="${f.area}" ${f.severity === 'blocker' ? 'open' : ''}>
|
|
56
80
|
<summary><span class="chip ${f.severity}">${f.severity}</span><span class="ftitle">${esc(f.title)}</span><span class="where">${esc(f.page)}</span></summary>
|
|
57
81
|
<div class="fbody">
|
|
58
|
-
|
|
59
|
-
${
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
82
|
+
<p class="what">${esc(x.what)}</p>
|
|
83
|
+
${compare}
|
|
84
|
+
<dl class="facts">
|
|
85
|
+
<dt>Where</dt><dd><strong>${esc(f.page)}</strong>${f.views.length ? ` · ${f.views.map(esc).join(', ')}` : ''}${elements.length ? `<ul class="elements">${elements.map((e) => `<li>${e.name ? `${esc(e.name)} ` : ''}<code>${esc(e.selector)}</code></li>`).join('')}</ul>` : ''}</dd>
|
|
86
|
+
${x.who ? `<dt>Affects</dt><dd>${esc(x.who)}</dd>` : ''}
|
|
87
|
+
${details.length && (actualSrc || visualChange) ? `<dt>Details</dt><dd><ul class="items">${details.map((i) => `<li><code>${esc(i)}</code></li>`).join('')}</ul></dd>` : ''}
|
|
88
|
+
<dt>See it</dt><dd><ol class="steps">${steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol></dd>
|
|
89
|
+
${x.ref || f.link ? `<dt>Reference</dt><dd>${x.ref ? esc(x.ref) : ''}${f.link && /^https?:/.test(f.link) ? `${x.ref ? ' · ' : ''}<a href="${esc(f.link)}" target="_blank" rel="noopener">Learn more</a>` : ''}</dd>` : ''}
|
|
90
|
+
</dl>
|
|
64
91
|
</div>
|
|
65
92
|
</details>`;
|
|
66
93
|
}
|
|
@@ -210,7 +237,17 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:36px 0 12px}h3{font-si
|
|
|
210
237
|
.finding{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--c);border-radius:10px;margin:8px 0}
|
|
211
238
|
.sev-blocker{--c:var(--blocker)}.sev-major{--c:var(--major)}.sev-minor{--c:var(--minor)}.sev-info{--c:var(--info)}
|
|
212
239
|
.finding summary{cursor:pointer;padding:10px 14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}.ftitle{font-weight:600}.where{color:var(--muted);font-size:13px;margin-left:auto;word-break:break-all}
|
|
213
|
-
.fbody{padding:
|
|
240
|
+
.fbody{padding:2px 16px 16px}
|
|
241
|
+
.what{margin:2px 0 12px;font-size:15px}
|
|
242
|
+
.compare{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px}@media(max-width:820px){.compare{grid-template-columns:1fr}}
|
|
243
|
+
.compare figure,.compare>div{min-width:0;margin:0;border:1px solid var(--line);border-radius:10px;padding:10px;background:var(--bg)}
|
|
244
|
+
.compare .actual{border-top:4px solid var(--bad)}.compare .expected{border-top:4px solid var(--good)}
|
|
245
|
+
.compare figcaption{font-size:13px;color:var(--muted);margin-bottom:8px}.compare img{display:block;width:100%;max-height:420px;object-fit:contain;object-position:top center;border-radius:6px;background:#fff;border:1px solid var(--line)}
|
|
246
|
+
.compare p{margin:8px 0 0;font-size:14px}.compare .nopic{display:grid;place-items:center;height:120px;font-size:42px;color:var(--good)}
|
|
247
|
+
.tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 8px;border-radius:999px;color:#fff;margin-right:6px}.tag.bad{background:var(--bad)}.tag.good{background:var(--good)}
|
|
248
|
+
.compare.text p{margin-top:6px}.compare code,.facts code{word-break:break-all;white-space:pre-wrap}
|
|
249
|
+
.facts{display:grid;grid-template-columns:110px 1fr;gap:6px 14px;margin:0;font-size:14px}.facts dt{color:var(--muted);font-weight:600}.facts dd{margin:0}
|
|
250
|
+
.elements,.steps{margin:4px 0 0;padding-left:20px}.elements code{font-size:12.5px;word-break:break-all}
|
|
214
251
|
a{color:var(--accent)}
|
|
215
252
|
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;font-size:14px}th,td{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{font-size:12px;color:var(--muted);text-transform:uppercase}
|
|
216
253
|
td.good{color:var(--good);font-weight:700}td.warn{color:var(--warn);font-weight:700}td.bad{color:var(--bad);font-weight:700}
|
|
@@ -244,7 +281,7 @@ ul.plain{padding-left:18px}.empty{color:var(--muted)}
|
|
|
244
281
|
<div class="filters" role="group" aria-label="Filter by severity">
|
|
245
282
|
<button data-filter-sev="all" class="active">All</button>${SEVERITY_ORDER.map((s) => `<button data-filter-sev="${s}">${s} (${summary.counts[s]})</button>`).join('')}
|
|
246
283
|
</div>
|
|
247
|
-
<div id="findings">${findings.length ? findings.map(findingHtml).join('') : '<p class="empty">No problems found.</p>'}</div>
|
|
284
|
+
<div id="findings">${findings.length ? findings.map((f) => findingHtml(f, pages)).join('') : '<p class="empty">No problems found.</p>'}</div>
|
|
248
285
|
|
|
249
286
|
${apiHtml}
|
|
250
287
|
|