frontend-go-live-check 1.3.0 → 1.4.1
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 +50 -6
- package/package.json +1 -1
- package/src/checks/browser.mjs +12 -0
- package/src/checks/evidence.mjs +27 -3
- package/src/checks/in-page.mjs +179 -0
- package/src/core/explain.mjs +16 -0
- package/src/core/rules.mjs +28 -0
- package/src/export/docx.mjs +1 -1
- package/src/export/model.mjs +46 -12
- package/src/export/pdf.mjs +1 -1
- package/src/report/report.mjs +58 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.1
|
|
4
|
+
|
|
5
|
+
- Problems are grouped **by type** (Functionality, Design, Accessibility, Performance, Sign-in, APIs, Security, SEO, Code) in the HTML report, the PDF and the Word file, most serious first in each group.
|
|
6
|
+
- Every problem has **Steps to reproduce** and **Test again**: the shortest command that checks only that problem after the fix.
|
|
7
|
+
- README: new *How it works* and *Built with* sections.
|
|
8
|
+
|
|
9
|
+
## 1.4.0
|
|
10
|
+
|
|
11
|
+
- New layout checks on every page at every screen size, each with Actual and Expected pictures where possible:
|
|
12
|
+
- text that spills out of its box (for example a button with a fixed size that is too small for its label);
|
|
13
|
+
- text drawn on top of other text;
|
|
14
|
+
- content cut off at the edge of the screen where the user cannot scroll to it.
|
|
15
|
+
- New content checks:
|
|
16
|
+
- buttons and links covered by another element;
|
|
17
|
+
- unfinished text: `undefined`, `NaN`, `null`, `[object Object]`, `Invalid Date`, `{{ }}`, translation keys, lorem ipsum, TODO;
|
|
18
|
+
- stretched or squashed images;
|
|
19
|
+
- loading indicators still showing after the page finished loading.
|
|
20
|
+
- Evidence outlines now include text that runs outside its element.
|
|
21
|
+
|
|
3
22
|
## 1.3.0
|
|
4
23
|
|
|
5
24
|
- 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.
|
package/README.md
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
<a href="#quick-start">Quick start</a> ·
|
|
21
21
|
<a href="#sign-in-email-google-microsoft-uae-pass">Sign-in</a> ·
|
|
22
22
|
<a href="#what-it-checks">What it checks</a> ·
|
|
23
|
+
<a href="#how-it-works">How it works</a> ·
|
|
23
24
|
<a href="#the-report">The report</a> ·
|
|
24
25
|
<a href="#pipelines-approve-or-reject-the-merge">Pipelines</a> ·
|
|
25
26
|
<a href="#settings">Settings</a> ·
|
|
27
|
+
<a href="#built-with">Built with</a> ·
|
|
26
28
|
<a href="#faq">FAQ</a>
|
|
27
29
|
</p>
|
|
28
30
|
|
|
@@ -142,9 +144,9 @@ The report lists every sign-in method found (*email and password · Google · UA
|
|
|
142
144
|
|
|
143
145
|
| | Area | What is checked |
|
|
144
146
|
| :-: | --- | --- |
|
|
145
|
-
| 🧭 | **Functionality** | JavaScript errors, console errors, failed requests, broken links, images and anchors, blank pages, the production build, the "page not found" page |
|
|
147
|
+
| 🧭 | **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
148
|
| 🛟 | **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
|
|
149
|
+
| 📱 | **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
150
|
| 🖼️ | **Visual changes** | A pixel comparison of every page with the saved baseline, with the changed areas in red |
|
|
149
151
|
| ♿ | **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
152
|
| ⚡ | **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 |
|
|
@@ -160,6 +162,24 @@ The report's **What was checked** table says exactly what ran on your project, a
|
|
|
160
162
|
|
|
161
163
|
---
|
|
162
164
|
|
|
165
|
+
## How it works
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
① Detect ──▶ ② Build & start ──▶ ③ Sign in ──▶ ④ Find pages ──▶ ⑤ Test ──▶ ⑥ Report ──▶ ⑦ Verdict
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Step | What happens |
|
|
172
|
+
| :-: | --- |
|
|
173
|
+
| **1. Detect** | Reads `package.json`: the framework, the package manager, the sign-in libraries, and your API file in `api/` |
|
|
174
|
+
| **2. Build & start** | Builds the app and starts it **the way it runs in production**, plus any services you listed (a backend or a mock API) |
|
|
175
|
+
| **3. Sign in** | With the test account, the saved session (`login`) or a test token, so the signed-in pages are tested too |
|
|
176
|
+
| **4. Find pages** | Follows every link, reads your routes in the source code and your `sitemap.xml` |
|
|
177
|
+
| **5. Test** | Opens every page at every screen size in a real browser and runs every check in [What it checks](#what-it-checks); then calls every API endpoint from your Postman or OpenAPI file |
|
|
178
|
+
| **6. Report** | Writes the HTML report with Actual and Expected pictures, plus the PDF, the Word file and the zip |
|
|
179
|
+
| **7. Verdict** | **READY**, **NEEDS ATTENTION** or **NOT READY**; in a pipeline, the run fails on blockers and the merge is rejected |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
163
183
|
## Works with
|
|
164
184
|
|
|
165
185
|
**Next.js** · **React** (Vite, Create React App) · **Vue** · **Nuxt** · **Angular** · **Svelte / SvelteKit** · **Astro** · **Remix / React Router** · **Gatsby** · **Solid** · **Preact** · **Qwik** · **plain HTML**. Anything else, in any language, works with a start command or `--url`.
|
|
@@ -195,9 +215,9 @@ The framework and the package manager (npm, pnpm, yarn, bun) are detected. The a
|
|
|
195
215
|
The report has:
|
|
196
216
|
|
|
197
217
|
- the **verdict** and a **score per area** (click an area to filter);
|
|
198
|
-
-
|
|
218
|
+
- **problems by type**: Functionality, Design, Accessibility, Performance, Sign-in, APIs, Security, SEO, Code, each with its score, worst problems first;
|
|
199
219
|
- the API results, the speed of every page, and the load test;
|
|
200
|
-
- screenshots of every page at every size.
|
|
220
|
+
- screenshots of every page at every size, for the design review.
|
|
201
221
|
|
|
202
222
|
### Every problem: Actual next to Expected
|
|
203
223
|
|
|
@@ -206,7 +226,9 @@ Each problem is shown so a developer understands it at a glance, without knowing
|
|
|
206
226
|
- **one sentence** on what is wrong, in plain words;
|
|
207
227
|
- **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
228
|
- **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)
|
|
229
|
+
- **where** (page, screen size, the element by name and CSS selector) and **who it affects**;
|
|
230
|
+
- **steps to reproduce** it yourself;
|
|
231
|
+
- **test again**: the shortest command that checks only this problem after the fix (copy button included), for example `npx frontend-go-live-check --only pages --pages /login --max-pages 1`.
|
|
210
232
|
|
|
211
233
|
<p align="center">
|
|
212
234
|
<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">
|
|
@@ -325,7 +347,7 @@ Everything is optional. `init` writes `go-live-check.config.json`, and your edit
|
|
|
325
347
|
}
|
|
326
348
|
```
|
|
327
349
|
|
|
328
|
-
API tests need no settings when your files are in `api/` (
|
|
350
|
+
API tests need no settings when your files are in `api/` (see *API tests from Postman or OpenAPI* below). Passwords are written as `"$NAME"`. Put their values in `go-live-check.env` (Git ignores it) or in your pipeline's secrets. They are replaced with `‹secret›` in every report.
|
|
329
351
|
|
|
330
352
|
<details>
|
|
331
353
|
<summary><b>🔐 Sign-in forms that need help</b></summary>
|
|
@@ -451,6 +473,28 @@ console.log(summary.verdict); // READY · NEEDS ATTENTION · NOT READY
|
|
|
451
473
|
|
|
452
474
|
---
|
|
453
475
|
|
|
476
|
+
## Built with
|
|
477
|
+
|
|
478
|
+
Trusted open-source tools, used by thousands of teams, do the heavy lifting. The checks, the sign-in handling, the layout and content checks and the reports are this package's own code on top of them.
|
|
479
|
+
|
|
480
|
+
| Job | Tool | What it does here |
|
|
481
|
+
| --- | --- | --- |
|
|
482
|
+
| 🌐 **Real browser** | [Playwright](https://playwright.dev) | Opens every page in Chromium (Firefox and WebKit with `--all-browsers`) at every screen size; clicks, types, uses the keyboard, signs in, takes the screenshots and prints the PDF |
|
|
483
|
+
| ♿ **Accessibility** | [axe-core](https://github.com/dequelabs/axe-core) (`@axe-core/playwright`) | The accessibility rules (WCAG 2.0 / 2.1 / 2.2, A and AA) used by most accessibility tools |
|
|
484
|
+
| ⚡ **Speed and quality** | [Lighthouse](https://developer.chrome.com/docs/lighthouse) | Google's performance, best-practice and SEO scores, as a phone and as a desktop |
|
|
485
|
+
| | [chrome-launcher](https://github.com/GoogleChrome/chrome-launcher) · [puppeteer-core](https://pptr.dev) | Start and control Chrome for Lighthouse, including signed-in pages |
|
|
486
|
+
| 🖼️ **Visual changes** | [pixelmatch](https://github.com/mapbox/pixelmatch) · [pngjs](https://github.com/pngjs/pngjs) | Compare every screenshot with the saved baseline, pixel by pixel, and mark the changes in red |
|
|
487
|
+
| 🧪 **API tests** | [Ajv](https://ajv.js.org) · [ajv-formats](https://github.com/ajv-validator/ajv-formats) | Check every API answer against your OpenAPI or Postman schema, including formats (email, date, URL, ID) |
|
|
488
|
+
| | [Faker](https://fakerjs.dev) | Realistic test data (names, emails, numbers) for the requests that create records |
|
|
489
|
+
| | [yaml](https://eemeli.org/yaml) | Reads OpenAPI files written in YAML |
|
|
490
|
+
| 📄 **Documents** | [docx](https://docx.js.org) | Writes the Word report |
|
|
491
|
+
| | [JSZip](https://stuk.github.io/jszip) | Packs the full report, screenshots, PDF and Word file into one zip |
|
|
492
|
+
| 🛡️ **Dependencies** | `npm audit` (comes with npm) | Known security problems in your project's libraries |
|
|
493
|
+
|
|
494
|
+
No other services: nothing is uploaded, and no account is needed.
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
454
498
|
## FAQ
|
|
455
499
|
|
|
456
500
|
<details>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frontend-go-live-check",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
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
|
@@ -301,6 +301,18 @@ export async function runBrowserChecks({ base, config, reportDir, log, starts =
|
|
|
301
301
|
if (!audit.viewportMeta) add({ ruleId: 'missing-viewport-meta', page: landedKey, view: tag });
|
|
302
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 });
|
|
303
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
|
+
}
|
|
304
316
|
if (audit.overlaps.length) await withEvidence({ ruleId: 'overlapping-elements', page: landedKey, view: tag, items: audit.overlaps, selectors: audit.targets.overlaps }, audit.targets.overlaps);
|
|
305
317
|
if (audit.small.length) await withEvidence({ ruleId: 'small-text', page: landedKey, view: tag, items: audit.small, selectors: audit.targets.small }, audit.targets.small);
|
|
306
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
|
@@ -23,8 +23,21 @@ function drawOutlines([selectors, label, screenEdge, colour = '#e01e1e']) {
|
|
|
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
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');
|
|
@@ -51,7 +64,7 @@ function drawOutlines([selectors, label, screenEdge, colour = '#e01e1e']) {
|
|
|
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).
|
|
54
|
-
const rects = found.map(
|
|
67
|
+
const rects = found.map(extent);
|
|
55
68
|
const box = rects.length
|
|
56
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)) }
|
|
57
70
|
: null;
|
|
@@ -106,7 +119,7 @@ export async function captureEvidence(page, reportDir, { selectors = [], label =
|
|
|
106
119
|
* picture (outlined in green), then the page is put back exactly as it was.
|
|
107
120
|
* Only for problems a picture can show; others get the Expected sentence only.
|
|
108
121
|
*/
|
|
109
|
-
export const EXPECTED_VISUAL = new Set(['horizontal-overflow', 'clipped-text', 'small-text', 'focus-not-visible', 'mouse-only-control', 'rtl-missing', 'color-contrast', 'target-size']);
|
|
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']);
|
|
110
123
|
|
|
111
124
|
/** Runs in the page: corrects the problem on these elements; remembers how to undo it. */
|
|
112
125
|
function applyExpected([kind, selectors]) {
|
|
@@ -155,6 +168,17 @@ function applyExpected([kind, selectors]) {
|
|
|
155
168
|
set(el, 'height', 'auto');
|
|
156
169
|
set(el, 'max-height', 'none');
|
|
157
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');
|
|
158
182
|
} else if (kind === 'small-text') {
|
|
159
183
|
set(el, 'font-size', '14px');
|
|
160
184
|
} else if (kind === 'color-contrast') {
|
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,
|
package/src/core/explain.mjs
CHANGED
|
@@ -40,6 +40,13 @@ export const EXPLAIN = {
|
|
|
40
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
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
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.' },
|
|
43
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.' },
|
|
44
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 */' },
|
|
45
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">' },
|
|
@@ -108,6 +115,7 @@ export const EXPLAIN = {
|
|
|
108
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)' },
|
|
109
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' },
|
|
110
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-login-failed': { what: 'The API tests could not sign in with a test user, so the tests that need a signed-in user did not run.', who: 'Every user of the app if the sign-in API itself is broken; otherwise check the test user settings.' },
|
|
111
119
|
'api-server-error': { what: 'A request made the server fail (error 500).', who: 'Users of this feature.' },
|
|
112
120
|
'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.' },
|
|
113
121
|
'api-slow': { what: 'Some API endpoints answer slower than the budget.', who: 'Every user of those features.' },
|
|
@@ -170,6 +178,13 @@ export const EXPECTED = {
|
|
|
170
178
|
'missing-viewport-meta': 'Phones show the page at its normal size, not zoomed out.',
|
|
171
179
|
'horizontal-overflow': 'The page fits the screen: no sideways scrolling. Wide content (such as a table) scrolls inside its own box.',
|
|
172
180
|
'clipped-text': 'All the text is visible inside its box.',
|
|
181
|
+
'text-outside-box': 'The box grows to fit its text: all the text sits inside it and nothing runs over the page.',
|
|
182
|
+
'text-overlap': 'Each piece of text has its own space and can be read on its own.',
|
|
183
|
+
'cut-off-at-edge': 'All the content fits inside the screen.',
|
|
184
|
+
'control-covered': 'Nothing covers the button or link: a click on it works.',
|
|
185
|
+
'placeholder-text': 'Every value shows real text: a name, number, date or a clear "not available".',
|
|
186
|
+
'distorted-image': 'The image keeps its own shape: it fits or is cropped, never stretched.',
|
|
187
|
+
'stuck-loading': 'When loading ends the indicator goes away and the content (or an error message) is shown.',
|
|
173
188
|
'overlapping-elements': 'Buttons and links sit apart, each easy to press on its own.',
|
|
174
189
|
'small-text': 'Text on phones is at least 12 px (16 px for body text) and easy to read.',
|
|
175
190
|
'rtl-missing': 'The Arabic page reads right to left: text, menus and layout are mirrored.',
|
|
@@ -224,6 +239,7 @@ export const EXPECTED = {
|
|
|
224
239
|
'api-wrong-role-allowed': 'Users without the role get 403.',
|
|
225
240
|
'api-accepts-invalid-input': 'Invalid data is refused with 400 and a clear message.',
|
|
226
241
|
'api-mass-assignment': 'Fields the server controls (status, role, owner) are ignored or refused.',
|
|
242
|
+
'api-login-failed': 'The test user signs in to the API and gets a token, so every signed-in API test can run.',
|
|
227
243
|
'api-server-error': 'The server never answers 500 to a request.',
|
|
228
244
|
'api-error-leaks-details': 'Error responses show a short message, no internal details.',
|
|
229
245
|
'api-slow': 'Every endpoint answers within the budget.',
|
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
|
@@ -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, mono: k === 'Elements' || k === 'Details', fill: undefined })] })),
|
|
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' || k === 'Evidence' || k === 'Test again after the fix', fill: undefined })] })),
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
105
|
|
package/src/export/model.mjs
CHANGED
|
@@ -36,6 +36,10 @@ export function stepsFor(f, pages) {
|
|
|
36
36
|
const page = String(f.page);
|
|
37
37
|
const signedIn = pages.find((p) => p.key === page)?.signedIn;
|
|
38
38
|
const screen = [f.evidenceView, ...(f.views ?? [])].map((v) => String(v ?? '').match(/^([^(,·]+?)\s*\((\d+) px\)/)).find(Boolean);
|
|
39
|
+
if (/^api-/.test(f.ruleId ?? '') || /^\/api\b/.test(page)) {
|
|
40
|
+
if (f.ruleId === 'api-login-failed') return ['Send the sign-in request of the API (for example POST /api/login) with the test user named above and its password from go-live-check.env.', 'Look at the answer: it should be 200 with a token.'];
|
|
41
|
+
return ['Send the request in the curl command shown above, with a token of the test user named there.', 'Compare the status and the answer with what is Expected.'];
|
|
42
|
+
}
|
|
39
43
|
if (page.startsWith('/')) {
|
|
40
44
|
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)` : ''}.`];
|
|
41
45
|
if (f.ruleId === 'focus-not-visible' || f.ruleId === 'keyboard-trap') steps.push('Press Tab to move through the page.');
|
|
@@ -47,12 +51,40 @@ export function stepsFor(f, pages) {
|
|
|
47
51
|
if (f.selectors?.length) steps.push('Find the outlined element(s) with the selectors under "Elements" (DevTools > Elements > Ctrl/Cmd+F).');
|
|
48
52
|
return steps;
|
|
49
53
|
}
|
|
50
|
-
if (/^(GET|POST|PUT|PATCH|DELETE) /.test(page)) return ['Send the request
|
|
51
|
-
return ['See the list
|
|
54
|
+
if (/^(GET|POST|PUT|PATCH|DELETE) /.test(page)) return ['Send the request in the curl command shown above, with a token of the test user named there.'];
|
|
55
|
+
return ['See the list shown above.'];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The shortest run that checks this one problem again after the fix (only its kind of check,
|
|
60
|
+
* only its page where possible).
|
|
61
|
+
*/
|
|
62
|
+
export function retestFor(f) {
|
|
63
|
+
const cmd = 'npx frontend-go-live-check';
|
|
64
|
+
const page = String(f.page ?? '');
|
|
65
|
+
const onPage = page.startsWith('/') && !/^\/api\b/.test(page) ? ` --pages ${page} --max-pages 1` : '';
|
|
66
|
+
const id = f.ruleId ?? '';
|
|
67
|
+
if (f.area === 'api' || /^api-/.test(id)) return `${cmd} --api-only`;
|
|
68
|
+
if (f.area === 'code') return `${cmd} --only code`;
|
|
69
|
+
if (f.area === 'security') return `${cmd} --only pages,security${onPage}`;
|
|
70
|
+
if (/when-api-fails$/.test(id)) return `${cmd} --only pages,resilience${onPage}`;
|
|
71
|
+
if (f.area === 'auth') return `${cmd} --only pages,auth`;
|
|
72
|
+
if (/^(perf-score|slow-lcp|layout-shift|blocking-time|large-javascript)/.test(id)) return `${cmd} --only pages,lighthouse${onPage}`;
|
|
73
|
+
if (/under-load$/.test(id)) return `${cmd} --only pages,load`;
|
|
74
|
+
if (id === 'visual-change') return `${cmd} --only pages${onPage} (if the change is intended: ${cmd} --update-baseline)`;
|
|
75
|
+
return `${cmd} --only pages${onPage}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Findings grouped by type of problem (design, accessibility, APIs, security…), worst first in each. */
|
|
79
|
+
export function byArea(findings) {
|
|
80
|
+
const order = Object.keys(AREAS);
|
|
81
|
+
const rank = (f) => SEVERITY_ORDER.indexOf(f.severity);
|
|
82
|
+
return [...findings].sort((a, b) => order.indexOf(a.area) - order.indexOf(b.area) || rank(a) - rank(b));
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
55
|
-
const { meta, summary,
|
|
86
|
+
const { meta, summary, pages = [], lighthouse = { results: [] }, api, coverage = [], visual, requests, load } = report;
|
|
87
|
+
const findings = byArea(report.findings ?? []);
|
|
56
88
|
const id = (i) => `GLC-${String(i + 1).padStart(3, '0')}`;
|
|
57
89
|
const counts = summary.counts;
|
|
58
90
|
const actionable = findings.filter((f) => f.severity !== 'info');
|
|
@@ -99,7 +131,7 @@ export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
|
99
131
|
text: `${meta.project} (${meta.framework}) was tested on ${date(meta.finishedAt)}${meta.apiOnly ? '' : `: ${plural(pages.length, 'page')} at ${meta.viewports.length} screen sizes (${meta.viewports.join(', ')})`}${api ? `${meta.apiOnly ? ':' : ', and'} ${plural(api.endpoints.length, 'API endpoint')} with ${api.cases} test cases` : ''}. The test found ${plural(counts.blocker, 'blocking problem')}, ${plural(counts.major, 'major problem')} and ${plural(counts.minor, 'minor problem')}${counts.info ? `, plus ${counts.info} item${counts.info === 1 ? '' : 's'} for information` : ''}.`,
|
|
100
132
|
});
|
|
101
133
|
b.push({ t: 'table', head: ['Blocker', 'Major', 'Minor', 'Info'], widths: [25, 25, 25, 25], rows: [[counts.blocker, counts.major, counts.minor, counts.info].map(String)], tone: 'counts' });
|
|
102
|
-
const top = findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').slice(0, 10);
|
|
134
|
+
const top = findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').sort((a, c) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(c.severity)).slice(0, 10);
|
|
103
135
|
if (top.length) {
|
|
104
136
|
b.push({ t: 'h3', text: 'Most important problems' });
|
|
105
137
|
b.push({ t: 'bullets', items: top.map((f) => `${id(findings.indexOf(f))} · ${f.severity.toUpperCase()} · ${f.title} (${f.page})`) });
|
|
@@ -159,7 +191,7 @@ export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
|
159
191
|
b.push({ t: 'h1', text: 'Findings' });
|
|
160
192
|
if (!findings.length) b.push({ t: 'p', text: 'No problems were found.' });
|
|
161
193
|
else {
|
|
162
|
-
b.push({ t: 'p', text: `${plural(findings.length, 'finding')}, most serious first. The same problem seen at several screen sizes is listed once.` });
|
|
194
|
+
b.push({ t: 'p', text: `${plural(findings.length, 'finding')}, grouped by type of problem (design, accessibility, APIs, security…), most serious first in each group. The same problem seen at several screen sizes is listed once.` });
|
|
163
195
|
b.push({ t: 'table', head: ['ID', 'Severity', 'Problem', 'Where'], widths: [12, 13, 47, 28], rows: findings.map((f, i) => [id(i), cap(f.severity), f.title, String(f.page)]), tone: 'severity' });
|
|
164
196
|
const byPage = new Map();
|
|
165
197
|
actionable.forEach((f) => {
|
|
@@ -174,11 +206,12 @@ export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
|
174
206
|
b.push({ t: 'p', text: 'Use this to share the work: each row lists the findings to fix in one place.', muted: true });
|
|
175
207
|
b.push({ t: 'table', head: ['Page or endpoint', 'Blocker', 'Major', 'Minor', 'Findings'], widths: [34, 11, 11, 11, 33], rows: [...byPage].sort((a, b) => b[1].blocker - a[1].blocker || b[1].major - a[1].major).map(([k, r]) => [k, String(r.blocker), String(r.major), String(r.minor), r.ids.join(', ')]) });
|
|
176
208
|
}
|
|
177
|
-
for (const
|
|
178
|
-
const group = findings.filter((f) => f.
|
|
209
|
+
for (const [areaId, areaName] of Object.entries(AREAS)) {
|
|
210
|
+
const group = findings.filter((f) => f.area === areaId);
|
|
179
211
|
if (!group.length) continue;
|
|
180
|
-
|
|
181
|
-
b.push({ t: '
|
|
212
|
+
const parts = SEVERITY_ORDER.map((sev) => [sev, group.filter((f) => f.severity === sev).length]).filter(([, n]) => n).map(([sev, n]) => `${n} ${sev}`);
|
|
213
|
+
b.push({ t: 'h2', text: `${areaName} (${group.length})` });
|
|
214
|
+
b.push({ t: 'p', text: parts.join(' · '), muted: true });
|
|
182
215
|
for (const f of group) {
|
|
183
216
|
const x = explain(f);
|
|
184
217
|
const elements = elementsOf(f);
|
|
@@ -190,14 +223,15 @@ export function buildDocument(report, { version = '', settings = {} } = {}) {
|
|
|
190
223
|
title: f.title,
|
|
191
224
|
severity: f.severity,
|
|
192
225
|
what: x.what,
|
|
193
|
-
actual: { src: actualSrc ?? (f.ruleId === 'visual-change' && f.link ? f.link : ''), png: !actualSrc && f.ruleId === 'visual-change', text:
|
|
226
|
+
actual: { src: actualSrc ?? (f.ruleId === 'visual-change' && f.link ? f.link : ''), png: !actualSrc && f.ruleId === 'visual-change', text: (!/^a11y-/.test(f.ruleId) && (f.detail || ((f.items ?? []).length === 1 ? f.items[0] : ''))) || x.what },
|
|
194
227
|
expected: { src: expectedSrc ?? '', text: x.expected || 'This problem no longer appears when the check runs again.' },
|
|
195
228
|
rows: [
|
|
196
229
|
['Where', `${f.page}${f.views?.length ? ` · ${f.views.join(', ')}` : ''}`],
|
|
197
230
|
...(elements.length ? [['Elements', elements.map((e) => `${e.name ? `${e.name} ` : ''}${e.selector}`).join('\n')]] : []),
|
|
198
231
|
...(x.who ? [['Affects', x.who]] : []),
|
|
199
|
-
...(!(elements.length && (f.items ?? []).length <= elements.length && !/^a11y-/.test(f.ruleId)) && (f.items ?? []).length ? [['
|
|
200
|
-
['
|
|
232
|
+
...(!(elements.length && (f.items ?? []).length <= elements.length && !/^a11y-/.test(f.ruleId)) && (f.items ?? []).length ? [['Evidence', f.items.slice(0, 8).join('\n')]] : []),
|
|
233
|
+
['Steps to reproduce', stepsFor(f, pages).map((step, i) => `${i + 1}. ${step}`).join('\n')],
|
|
234
|
+
['Test again after the fix', retestFor(f)],
|
|
201
235
|
...(x.ref ? [['Reference', x.ref]] : []),
|
|
202
236
|
],
|
|
203
237
|
link: /^https?:/.test(f.link ?? '') ? f.link : '',
|
package/src/export/pdf.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function documentHtml(doc, reportDir) {
|
|
|
63
63
|
const pic = (part, png) => (part.src ? dataUri(reportDir, part.src, png) : '');
|
|
64
64
|
const actualPic = pic(block.actual, block.actual.png);
|
|
65
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));
|
|
66
|
+
const fmt = (k, v) => (k === 'See it' || k === 'Steps to reproduce' ? `<ol>${v.split('\n').map((x) => `<li>${esc(x.replace(/^\d+\.\s*/, ''))}</li>`).join('')}</ol>` : k === 'Elements' || k === 'Details' || k === 'Evidence' ? v.split('\n').map((x) => `<code>${esc(x)}</code>`).join('<br>') : k === 'Test again after the fix' ? `<code class="cmd">${esc(v)}</code>` : esc(v));
|
|
67
67
|
body.push(
|
|
68
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
69
|
<p class="what">${esc(block.what)}</p>
|
package/src/report/report.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { elementsOf, explain } from '../core/explain.mjs';
|
|
10
10
|
import { AREAS, SEVERITY_ORDER, rule } from '../core/rules.mjs';
|
|
11
|
-
import { stepsFor } from '../export/model.mjs';
|
|
11
|
+
import { byArea, retestFor, stepsFor } from '../export/model.mjs';
|
|
12
12
|
|
|
13
13
|
const WEIGHT = { blocker: 40, major: 15, minor: 5, info: 0 };
|
|
14
14
|
|
|
@@ -69,7 +69,7 @@ function findingHtml(f, pages = []) {
|
|
|
69
69
|
const visualChange = f.ruleId === 'visual-change' && f.link ? f.link : '';
|
|
70
70
|
const compare = actualSrc || visualChange
|
|
71
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(
|
|
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((!/^a11y-/.test(f.ruleId) && (f.detail || ((f.items ?? []).length === 1 ? f.items[0] : ''))) || x.what)}</p></figure>
|
|
73
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
74
|
</div>`
|
|
75
75
|
: `<div class="compare text">
|
|
@@ -82,10 +82,11 @@ function findingHtml(f, pages = []) {
|
|
|
82
82
|
<p class="what">${esc(x.what)}</p>
|
|
83
83
|
${compare}
|
|
84
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>
|
|
85
|
+
<dt>Where</dt><dd><strong>${pages.some((p) => p.key === f.page) ? `<a href="#page-${esc(String(f.page).replace(/[^\w-]/g, '_'))}" onclick="document.getElementById(this.hash.slice(1)).open=true">${esc(f.page)}</a>` : 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
86
|
${x.who ? `<dt>Affects</dt><dd>${esc(x.who)}</dd>` : ''}
|
|
87
|
-
${details.length && (actualSrc || visualChange) ? `<dt>
|
|
88
|
-
<dt>
|
|
87
|
+
${details.length && (actualSrc || visualChange) ? `<dt>Evidence</dt><dd><ul class="items">${details.map((i) => `<li><code>${esc(i)}</code></li>`).join('')}</ul></dd>` : ''}
|
|
88
|
+
<dt>Steps to reproduce</dt><dd><ol class="steps">${steps.map((step) => `<li>${esc(step)}</li>`).join('')}</ol></dd>
|
|
89
|
+
<dt>Test again</dt><dd><div class="retest"><code>${esc(retestFor(f))}</code><button type="button" class="copy" onclick="navigator.clipboard?.writeText(this.previousElementSibling.textContent.split(' (')[0]);this.textContent='Copied'">Copy</button></div><small class="muted">After the fix, run this: the problem must be gone.</small></dd>
|
|
89
90
|
${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
91
|
</dl>
|
|
91
92
|
</div>
|
|
@@ -123,13 +124,30 @@ export function writeReports({ reportDir, meta, findings, summary, pages, lighth
|
|
|
123
124
|
)
|
|
124
125
|
.join('');
|
|
125
126
|
|
|
127
|
+
// Problems grouped by type (design, accessibility, APIs, security…), worst first in each.
|
|
128
|
+
const ICONS = { functionality: '🧭', responsive: '📱', accessibility: '♿', performance: '⚡', auth: '🔐', api: '🧪', security: '🛡️', seo: '🔎', code: '🧹' };
|
|
129
|
+
const scoreOf = new Map(summary.areas.map((a) => [a.id, a.score]));
|
|
130
|
+
const ordered = byArea(findings);
|
|
131
|
+
const groups = Object.entries(AREAS)
|
|
132
|
+
.map(([areaId, name]) => [areaId, name, ordered.filter((f) => f.area === areaId)])
|
|
133
|
+
.filter(([, , list]) => list.length)
|
|
134
|
+
.map(([areaId, name, list]) => {
|
|
135
|
+
const score = scoreOf.get(areaId) ?? 100;
|
|
136
|
+
const band = score >= 90 ? 'good' : score >= 60 ? 'warn' : 'bad';
|
|
137
|
+
return `<section class="agroup" data-group id="area-${areaId}">
|
|
138
|
+
<header class="ahead"><span class="aicon" aria-hidden="true">${ICONS[areaId] ?? '•'}</span><h3>${esc(name)}</h3><span class="ascore ${band}">${score}</span><span class="pchips">${SEVERITY_ORDER.map((sev) => {
|
|
139
|
+
const n = list.filter((f) => f.severity === sev).length;
|
|
140
|
+
return n ? `<span class="chip ${sev}">${n} ${sev}</span>` : '';
|
|
141
|
+
}).join('')}</span></header>
|
|
142
|
+
<div class="plist">${list.map((f) => findingHtml(f, pages)).join('')}</div>
|
|
143
|
+
</section>`;
|
|
144
|
+
})
|
|
145
|
+
.join('');
|
|
126
146
|
const gallery = pages
|
|
127
147
|
.map((p) => {
|
|
128
|
-
const shots = Object.entries(p.shots)
|
|
129
|
-
.map(([size, src]) => `<figure><a href="${esc(src)}" target="_blank"><img loading="lazy" src="${esc(src)}" alt="${esc(`${p.key} at ${size}`)}"></a><figcaption>${esc(size)}</figcaption></figure>`)
|
|
130
|
-
.join('');
|
|
148
|
+
const shots = Object.entries(p.shots ?? {}).map(([size, src]) => `<figure><a href="${esc(src)}" target="_blank"><img loading="lazy" src="${esc(src)}" alt="${esc(`${p.key} at ${size}`)}"></a><figcaption>${esc(size)}</figcaption></figure>`).join('');
|
|
131
149
|
const problems = findings.filter((f) => f.page === p.key && f.severity !== 'info').length;
|
|
132
|
-
return `<
|
|
150
|
+
return `<details class="page" id="page-${esc(p.key.replace(/[^\w-]/g, '_'))}"><summary><strong>${esc(p.title || p.key)}</strong> <code>${esc(p.key)}</code> ${problems ? `<span class="pcount">${problems} problem${problems === 1 ? '' : 's'}</span>` : '<span class="pok">no problems</span>'}</summary><div class="shots">${shots}</div></details>`;
|
|
133
151
|
})
|
|
134
152
|
.join('');
|
|
135
153
|
|
|
@@ -162,7 +180,7 @@ export function writeReports({ reportDir, meta, findings, summary, pages, lighth
|
|
|
162
180
|
if (visual) {
|
|
163
181
|
const changed = visual.results.filter((r) => r.status === 'changed');
|
|
164
182
|
const fresh = visual.results.filter((r) => r.status === 'new' || r.status === 'updated');
|
|
165
|
-
visualHtml = `<h2>Visual changes</h2>
|
|
183
|
+
visualHtml = `<h2 id="visual">Visual changes</h2>
|
|
166
184
|
<p class="meta">${visual.updated ? `The baseline was updated with ${fresh.length} picture(s).` : fresh.length === visual.results.length ? `First run: ${fresh.length} picture(s) saved as the baseline in <code>${esc(visual.baselineDir)}</code>. Commit that folder; the next runs show what changed.` : `${visual.results.length - changed.length - fresh.length} picture(s) look the same as the baseline, ${changed.length} changed${fresh.length ? `, ${fresh.length} new` : ''}.`}</p>
|
|
167
185
|
${changed.length ? `<div class="shots">${changed.map((r) => `<figure><a href="visual/diff/${esc(r.name)}" target="_blank"><img loading="lazy" src="visual/diff/${esc(r.name)}" alt="${esc(`Changes on ${r.page}`)}"></a><figcaption>${esc(r.page)} · ${esc(r.view)} · ${(r.ratio * 100).toFixed(1)}% · <a href="visual/now/${esc(r.name)}" target="_blank">now</a></figcaption></figure>`).join('')}</div>` : ''}`;
|
|
168
186
|
}
|
|
@@ -183,7 +201,7 @@ ${changed.length ? `<div class="shots">${changed.map((r) => `<figure><a href="vi
|
|
|
183
201
|
return `<td>${rows.map((r) => `<span class="${r.ok ? 'pass' : 'fail'}" title="${esc(`${r.kind}: ${r.label}`)}">${r.status}${r.kind === 'other-user' ? '*' : ''} ${r.ok ? '✓' : '✗'}</span>`).join('<br>')}</td>`;
|
|
184
202
|
};
|
|
185
203
|
const notes = (key) => [...new Set(api.results.filter((r) => r.endpoint === key && r.ok === null).map((r) => r.note))].join('; ');
|
|
186
|
-
apiHtml = `<h2>API tests</h2>
|
|
204
|
+
apiHtml = `<h2 id="api">API tests</h2>
|
|
187
205
|
<div class="apistats">
|
|
188
206
|
<div><strong>${tested}/${api.endpoints.length}</strong><span>endpoints tested</span></div>
|
|
189
207
|
<div><strong>${api.cases}</strong><span>cases</span></div>
|
|
@@ -246,12 +264,26 @@ h1{font-size:26px;margin:0 0 4px}h2{font-size:19px;margin:36px 0 12px}h3{font-si
|
|
|
246
264
|
.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
265
|
.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
266
|
.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:
|
|
267
|
+
.facts{display:grid;grid-template-columns:150px 1fr;gap:6px 14px;margin:0;font-size:14px}.facts dt{color:var(--muted);font-weight:600}.facts dd{margin:0}
|
|
250
268
|
.elements,.steps{margin:4px 0 0;padding-left:20px}.elements code{font-size:12.5px;word-break:break-all}
|
|
251
269
|
a{color:var(--accent)}
|
|
252
270
|
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}
|
|
253
271
|
td.good{color:var(--good);font-weight:700}td.warn{color:var(--warn);font-weight:700}td.bad{color:var(--bad);font-weight:700}
|
|
254
272
|
.tablewrap{overflow-x:auto}
|
|
273
|
+
.agroup{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:14px 0}
|
|
274
|
+
.ahead{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.ahead h3{margin:0;font-size:17px}.aicon{font-size:20px}
|
|
275
|
+
.ascore{font-weight:700;font-size:14px;border-radius:8px;padding:1px 8px;border:1px solid currentColor}.ascore.good{color:var(--good)}.ascore.warn{color:var(--warn)}.ascore.bad{color:var(--bad)}
|
|
276
|
+
.retest{display:flex;gap:8px;align-items:flex-start}.retest code{flex:1;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12.5px}
|
|
277
|
+
.copy{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:6px;padding:5px 10px;cursor:pointer;font:inherit;font-size:12px}
|
|
278
|
+
details.page summary{cursor:pointer;display:flex;gap:10px;align-items:center;flex-wrap:wrap}details.page .shots{margin-top:10px}
|
|
279
|
+
.toc{display:flex;gap:6px 18px;flex-wrap:wrap;margin:14px 0 0;font-size:14px}.toc a{text-decoration:none;font-weight:600}
|
|
280
|
+
.pgroup{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin:14px 0}
|
|
281
|
+
.phead{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.phead h3{margin:0}.phead code{font-size:15px}
|
|
282
|
+
.pkind{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:1px 6px}
|
|
283
|
+
.pchips{display:flex;gap:6px;flex-wrap:wrap;margin-left:auto}
|
|
284
|
+
.pshots{margin:10px 0 4px}.pshots summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--accent)}.pshots .shots{margin-top:8px}.pshots .shots img{max-height:220px;max-width:200px}
|
|
285
|
+
.plist .finding{background:var(--bg)}.plist{margin-top:8px}
|
|
286
|
+
.pgroup.clean summary{cursor:pointer;display:flex;gap:10px;align-items:center}.cleanpage{margin-top:14px}.cleanpage h3{font-size:14px}
|
|
255
287
|
.page{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin:10px 0}.pcount{font-size:12px;color:var(--major);font-weight:600}.pok{font-size:12px;color:var(--good)}
|
|
256
288
|
.shots{display:flex;gap:12px;overflow-x:auto;align-items:flex-start}.shots figure{margin:0;flex:0 0 auto}.shots img{display:block;max-height:340px;max-width:260px;border:1px solid var(--line);border-radius:6px;object-fit:cover;object-position:top}
|
|
257
289
|
figcaption{font-size:12px;color:var(--muted);margin-top:4px}
|
|
@@ -274,23 +306,26 @@ ul.plain{padding-left:18px}.empty{color:var(--muted)}
|
|
|
274
306
|
<div class="counts">${SEVERITY_ORDER.map((s) => `<span class="chip ${s}">${summary.counts[s]} ${s}</span>`).join('')}</div>
|
|
275
307
|
</div>
|
|
276
308
|
|
|
309
|
+
<nav class="toc" aria-label="Contents"><a href="#problems">Problems by type</a>${pages.length ? '<a href="#pages">Screenshots</a>' : ''}${api ? '<a href="#api">API tests</a>' : ''}${meta.apiOnly ? '' : '<a href="#speed">Speed</a>'}${visual ? '<a href="#visual">Visual changes</a>' : ''}<a href="#checked">What was checked</a></nav>
|
|
310
|
+
|
|
277
311
|
<h2>Areas</h2>
|
|
278
312
|
<div class="areas">${areaCards}</div>
|
|
279
313
|
|
|
280
|
-
<h2>
|
|
314
|
+
<h2 id="problems">Problems by type</h2>
|
|
315
|
+
<p class="meta">Grouped by type of problem, most serious first in each group. Open a problem to see the <b>evidence</b> (Actual next to Expected), the <b>steps to reproduce</b> it, and the command to <b>test it again</b> after the fix.</p>
|
|
281
316
|
<div class="filters" role="group" aria-label="Filter by severity">
|
|
282
317
|
<button data-filter-sev="all" class="active">All</button>${SEVERITY_ORDER.map((s) => `<button data-filter-sev="${s}">${s} (${summary.counts[s]})</button>`).join('')}
|
|
283
318
|
</div>
|
|
284
|
-
<div id="findings">${findings.length ?
|
|
319
|
+
<div id="findings">${findings.length ? groups : '<p class="empty">No problems found.</p>'}</div>
|
|
285
320
|
|
|
286
321
|
${apiHtml}
|
|
287
322
|
|
|
288
|
-
${meta.apiOnly ? '' : `<h2>
|
|
289
|
-
|
|
323
|
+
${meta.apiOnly || !pages.length ? '' : `<h2 id="pages">Screenshots of every page</h2>
|
|
324
|
+
<p class="meta">Every page at every screen size, as the browser drew it: for the design review. Open a page to see its pictures; click a picture for full size.</p>
|
|
325
|
+
${gallery}`}
|
|
290
326
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
${gallery || '<p class="empty">No pages were opened.</p>'}`}
|
|
327
|
+
${meta.apiOnly ? '' : `<h2 id="speed">Speed and quality (Lighthouse)</h2>
|
|
328
|
+
${lighthouse.results.length ? `<div class="tablewrap"><table><thead><tr><th>Page</th><th>Device</th><th>Perf.</th><th>Access.</th><th>Best pract.</th><th>SEO</th><th>LCP</th><th>CLS</th><th>TBT</th><th>JS</th><th></th></tr></thead><tbody>${lhRows}</tbody></table></div><p class="meta">Phone = slow 4G and a mid-range phone CPU, as Lighthouse simulates it. Good: LCP under 2.5 s, CLS under 0.1, TBT under 200 ms.</p>` : '<p class="empty">Lighthouse did not run.</p>'}`}
|
|
294
329
|
|
|
295
330
|
${requestsHtml}
|
|
296
331
|
|
|
@@ -302,15 +337,15 @@ ${perfRows ? `<h2>Every page: size and speed</h2>
|
|
|
302
337
|
<div class="tablewrap"><table><thead><tr><th>Page</th><th>Downloaded</th><th>JavaScript</th><th>Images</th><th>Requests</th><th>Load</th><th>LCP</th><th>CLS</th></tr></thead><tbody>${perfRows}</tbody></table></div>
|
|
303
338
|
<p class="meta">Measured in the test browser on this computer (no network slow-down); red = over the budget in the settings.</p>` : ''}
|
|
304
339
|
|
|
305
|
-
<h2>What was checked</h2>
|
|
340
|
+
<h2 id="checked">What was checked</h2>
|
|
306
341
|
${coverageHtml}
|
|
307
|
-
<p class="meta">Not covered by an automatic check: ${meta.apiOnly ? '' : 'whether the design matches what was intended (use the screenshots
|
|
342
|
+
<p class="meta">Not covered by an automatic check: ${meta.apiOnly ? '' : 'whether the design matches what was intended (use the screenshots of each page), '}business rules (what each ${meta.apiOnly ? 'endpoint' : 'page'} should do beyond its documented shape), load under many users, and an in-depth penetration test. ${esc(meta.steps.join(' · '))}</p>
|
|
308
343
|
</main>
|
|
309
344
|
<script>
|
|
310
345
|
const sevButtons=[...document.querySelectorAll('[data-filter-sev]')],areaButtons=[...document.querySelectorAll('[data-filter-area]')];let sev='all',area=null;
|
|
311
|
-
function apply(){document.querySelectorAll('.finding').forEach(f=>{f.hidden=!((sev==='all'||f.dataset.sev===sev)&&(!area||f.dataset.area===area))});sevButtons.forEach(b=>b.classList.toggle('active',b.dataset.filterSev===sev));areaButtons.forEach(b=>b.classList.toggle('active',b.dataset.filterArea===area))}
|
|
346
|
+
function apply(){document.querySelectorAll('.finding').forEach(f=>{f.hidden=!((sev==='all'||f.dataset.sev===sev)&&(!area||f.dataset.area===area))});document.querySelectorAll('[data-group]').forEach(g=>{g.hidden=![...g.querySelectorAll('.finding')].some(f=>!f.hidden)});sevButtons.forEach(b=>b.classList.toggle('active',b.dataset.filterSev===sev));areaButtons.forEach(b=>b.classList.toggle('active',b.dataset.filterArea===area))}
|
|
312
347
|
sevButtons.forEach(b=>b.addEventListener('click',()=>{sev=b.dataset.filterSev;apply()}));
|
|
313
|
-
areaButtons.forEach(b=>b.addEventListener('click',()=>{area=area===b.dataset.filterArea?null:b.dataset.filterArea;apply();document.getElementById('findings').scrollIntoView({behavior:'smooth'})}));
|
|
348
|
+
areaButtons.forEach(b=>b.addEventListener('click',()=>{area=area===b.dataset.filterArea?null:b.dataset.filterArea;apply();(document.getElementById('area-'+b.dataset.filterArea)||document.getElementById('findings')).scrollIntoView({behavior:'smooth'})}));
|
|
314
349
|
</script>
|
|
315
350
|
</body>
|
|
316
351
|
</html>`;
|