frontend-go-live-check 1.4.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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 1.4.0
4
10
 
5
11
  - New layout checks on every page at every screen size, each with Actual and Expected pictures where possible:
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
 
@@ -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
- - every problem, with where it is and at which screen sizes;
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), **who it affects**, and **how to see it yourself**.
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/` ([see API tests](#settings)). 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.
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.4.0",
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": {
@@ -115,6 +115,7 @@ export const EXPLAIN = {
115
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
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
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.' },
118
119
  'api-server-error': { what: 'A request made the server fail (error 500).', who: 'Users of this feature.' },
119
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.' },
120
121
  'api-slow': { what: 'Some API endpoints answer slower than the budget.', who: 'Every user of those features.' },
@@ -238,6 +239,7 @@ export const EXPECTED = {
238
239
  'api-wrong-role-allowed': 'Users without the role get 403.',
239
240
  'api-accepts-invalid-input': 'Invalid data is refused with 400 and a clear message.',
240
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.',
241
243
  'api-server-error': 'The server never answers 500 to a request.',
242
244
  'api-error-leaks-details': 'Error responses show a short message, no internal details.',
243
245
  'api-slow': 'Every endpoint answers within the budget.',
@@ -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
 
@@ -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 shown under Evidence (curl), with a token of the test user named there.'];
51
- return ['See the list under Evidence.'];
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, findings = [], pages = [], lighthouse = { results: [] }, api, coverage = [], visual, requests, load } = report;
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 severity of SEVERITY_ORDER) {
178
- const group = findings.filter((f) => f.severity === severity);
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
- b.push({ t: 'h2', text: `${severity[0].toUpperCase()}${severity.slice(1)} (${group.length})` });
181
- b.push({ t: 'p', text: SEVERITY_TEXT[severity], muted: true });
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: f.detail && !/^a11y-/.test(f.ruleId) ? f.detail : x.what },
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 ? [['Details', f.items.slice(0, 8).join('\n')]] : []),
200
- ['See it', stepsFor(f, pages).map((step, i) => `${i + 1}. ${step}`).join('\n')],
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 : '',
@@ -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>
@@ -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(f.detail && !/^a11y-/.test(f.ruleId) ? f.detail : x.what)}</p></figure>
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>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>
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 `<section class="page"><h3>${esc(p.title || p.key)} <code>${esc(p.key)}</code>${problems ? `<span class="pcount">${problems} problem${problems === 1 ? '' : 's'}</span>` : '<span class="pok">no problems</span>'}</h3><div class="shots">${shots}</div></section>`;
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:110px 1fr;gap:6px 14px;margin:0;font-size:14px}.facts dt{color:var(--muted);font-weight:600}.facts dd{margin:0}
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>Findings</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 ? findings.map((f) => findingHtml(f, pages)).join('') : '<p class="empty">No problems found.</p>'}</div>
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>Speed and quality (Lighthouse)</h2>
289
- ${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>'}
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
- <h2>Pages and screenshots (design review)</h2>
292
- <p class="meta">Every page at every screen size, as the browser drew it. Look through them for anything that looks wrong; click a picture for full size.</p>
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 above), '}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>
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>`;