edgepress 2026.10.12 → 2026.10.13

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.
Files changed (57) hide show
  1. package/README.md +12 -2
  2. package/config.yml +171 -171
  3. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
  4. package/content/assets/edgepress-markdown-guide.svg +21 -21
  5. package/content/guides/markdown-media.md +38 -0
  6. package/content/guides/product-demos.md +30 -30
  7. package/content/pages/deployment/en.json +1 -1
  8. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  9. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  10. package/languages/base/en.json +98 -98
  11. package/languages/packs/zh-CN.json +98 -98
  12. package/package.json +90 -90
  13. package/src/assets.js +187 -186
  14. package/src/browser-audit.js +158 -158
  15. package/src/build.js +293 -291
  16. package/src/cli.js +364 -364
  17. package/src/content.js +233 -233
  18. package/src/data-saver.js +3 -3
  19. package/src/generators.js +414 -420
  20. package/src/i18n.js +86 -86
  21. package/src/markdown.js +195 -160
  22. package/src/page-check.js +1 -1
  23. package/src/plugin-api.js +77 -77
  24. package/src/report-visuals.js +642 -642
  25. package/src/revision-cards.js +30 -30
  26. package/src/rss.js +100 -0
  27. package/src/theme.js +4 -4
  28. package/src/video-metadata.js +36 -0
  29. package/static/edgepress/fonts/faces.css +2217 -2217
  30. package/static/edgepress/media-viewer.js +67 -10
  31. package/static/edgepress/project-demo.js +83 -83
  32. package/static/edgepress/search.js +84 -84
  33. package/tests/assets-runtime.test.mjs +26 -26
  34. package/tests/audit.test.mjs +142 -142
  35. package/tests/brand-regression.test.mjs +110 -110
  36. package/tests/document-package.test.mjs +2 -2
  37. package/tests/embed-syntax.test.mjs +68 -68
  38. package/tests/image-viewer.test.mjs +36 -36
  39. package/tests/markdown-media.test.mjs +119 -0
  40. package/tests/oembed-layout.test.mjs +6 -1
  41. package/tests/project-demo.test.mjs +73 -73
  42. package/tests/release-version.test.mjs +28 -0
  43. package/tests/rss.test.mjs +54 -0
  44. package/tests/video-metadata.test.mjs +28 -0
  45. package/tests/writing.test.mjs +83 -83
  46. package/themes/atelier/assets/style.css +340 -340
  47. package/themes/default/assets/style.css +371 -371
  48. package/themes/default/layouts/partials/head.html +1 -1
  49. package/themes/folio/assets/style.css +515 -515
  50. package/themes/folio/theme.json +5 -5
  51. package/themes/signal/assets/style.css +332 -332
  52. package/tools/README.md +10 -10
  53. package/tools/assets/generate-brand-assets.py +160 -160
  54. package/tools/assets/video-preview.mjs +15 -0
  55. package/tools/recordings/demo-workbench.mjs +53 -53
  56. package/tools/recordings/desktop-recording.mjs +70 -70
  57. package/tools/recordings/vm-desktop.mjs +59 -59
@@ -1,642 +1,642 @@
1
- import {reportDirectory} from './report-path.js';
2
- import { createHash } from 'node:crypto';
3
- import { access, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
4
- import { createServer } from 'node:http';
5
- import { extname, relative, resolve, sep } from 'node:path';
6
- import { tmpdir } from 'node:os';
7
- import { spawn } from 'node:child_process';
8
- import { auditBrowser, deviceProfiles } from './browser-audit.js';
9
-
10
- function escapeHtml(value) {
11
- return String(value ?? '').replace(/[&<>"']/g, (char) => ({
12
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
13
- })[char]);
14
- }
15
-
16
- function inside(root, target) {
17
- const rel = relative(root, target);
18
- return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolutePath(rel);
19
- }
20
-
21
- function isAbsolutePath(value) {
22
- return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith(sep) || value.startsWith('/');
23
- }
24
-
25
- async function browserPath() {
26
- const candidates = [];
27
- if (process.env.EDGEPRESS_BROWSER) candidates.push(process.env.EDGEPRESS_BROWSER);
28
- const programFiles = process.env['PROGRAMFILES'];
29
- const programFilesX86 = process.env['PROGRAMFILES(X86)'];
30
- const localAppData = process.env['LOCALAPPDATA'];
31
- if (programFilesX86) candidates.push(resolve(programFilesX86, 'Microsoft/Edge/Application/msedge.exe'));
32
- if (programFiles) {
33
- candidates.push(resolve(programFiles, 'Microsoft/Edge/Application/msedge.exe'));
34
- candidates.push(resolve(programFiles, 'Google/Chrome/Application/chrome.exe'));
35
- }
36
- if (localAppData) candidates.push(resolve(localAppData, 'Microsoft/Edge/Application/msedge.exe'));
37
- candidates.push('/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable', '/usr/bin/chromium',
38
- '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
39
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
40
- for (const candidate of candidates) {
41
- if (!candidate) continue;
42
- try { await access(candidate); return candidate; } catch { /* Try the next installed browser. */ }
43
- }
44
- return null;
45
- }
46
-
47
- async function freePort() {
48
- const server = createServer();
49
- await new Promise((resolveListen, reject) => {
50
- server.once('error', reject);
51
- server.listen(0, '127.0.0.1', resolveListen);
52
- });
53
- const port = server.address().port;
54
- await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()));
55
- return port;
56
- }
57
-
58
- function withTimeout(promise, timeoutMs, message) {
59
- let timer;
60
- return Promise.race([
61
- promise,
62
- new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })
63
- ]).finally(() => clearTimeout(timer));
64
- }
65
-
66
- async function startHeadlessBrowser(executable, profile) {
67
- const port = await freePort();
68
- const child = spawn(executable, [
69
- '--headless=new', '--mute-audio', '--disable-notifications',
70
- '--disable-sync', '--disable-features=msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion',
71
- '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-gpu', '--no-sandbox', '--no-first-run', '--no-default-browser-check',
72
- '--disable-background-networking', '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1',
73
- '--remote-debugging-port=' + port, '--remote-allow-origins=*', '--user-data-dir=' + profile, 'about:blank'
74
- ], { stdio: 'ignore', windowsHide: true });
75
- try {
76
- let targets = [];
77
- for (let attempt = 0; attempt < 80; attempt += 1) {
78
- try {
79
- targets = await (await fetch('http://127.0.0.1:' + port + '/json/list')).json();
80
- if (targets.some((target) => target.type === 'page')) break;
81
- } catch { /* Wait for the headless browser endpoint. */ }
82
- if (child.exitCode !== null) throw new Error('Headless browser exited during startup');
83
- await new Promise((resolveWait) => setTimeout(resolveWait, 250));
84
- }
85
- const page = targets.find((target) => target.type === 'page');
86
- if (!page) throw new Error('Headless browser did not expose a page target');
87
- const socket = new WebSocket(page.webSocketDebuggerUrl);
88
- await new Promise((resolveOpen, rejectOpen) => {
89
- socket.addEventListener('open', resolveOpen, { once: true });
90
- socket.addEventListener('error', rejectOpen, { once: true });
91
- });
92
- let messageId = 0;
93
- const pending = new Map();
94
- const eventWaiters = new Map();
95
- socket.addEventListener('message', (event) => {
96
- const message = JSON.parse(event.data);
97
- if (message.id && pending.has(message.id)) {
98
- const callbacks = pending.get(message.id);
99
- pending.delete(message.id);
100
- if (message.error) callbacks.reject(new Error(message.error.message));
101
- else callbacks.resolve(message.result);
102
- }
103
- if (message.method && eventWaiters.has(message.method)) {
104
- const waiters = eventWaiters.get(message.method);
105
- eventWaiters.delete(message.method);
106
- for (const resolveEvent of waiters) resolveEvent(message.params);
107
- }
108
- });
109
- const send = (method, params = {}) => new Promise((resolveMessage, rejectMessage) => {
110
- const id = ++messageId;
111
- pending.set(id, { resolve: resolveMessage, reject: rejectMessage });
112
- socket.send(JSON.stringify({ id, method, params }));
113
- });
114
- const waitFor = (method) => new Promise((resolveEvent) => {
115
- const waiters = eventWaiters.get(method) || [];
116
- waiters.push(resolveEvent);
117
- eventWaiters.set(method, waiters);
118
- });
119
- await send('Page.enable');
120
- await send('Runtime.enable');
121
- await send('Accessibility.enable');
122
- const setViewport = async (viewport) => {
123
- await send('Emulation.setDeviceMetricsOverride', {
124
- width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor || 1,
125
- mobile: viewport.mobile ?? viewport.name === 'mobile',
126
- screenWidth: viewport.width, screenHeight: viewport.height
127
- });
128
- await send('Emulation.setTouchEmulationEnabled', { enabled: viewport.touch ?? viewport.name === 'mobile', maxTouchPoints: 5 });
129
- await send('Emulation.setEmulatedMedia', { features: viewport.colorScheme
130
- ? [{ name: 'prefers-color-scheme', value: viewport.colorScheme }] : [] });
131
- };
132
- const navigate = async (url) => {
133
- await send('Page.bringToFront');
134
- const loaded = waitFor('Page.loadEventFired');
135
- const result = await send('Page.navigate', { url });
136
- if (result.errorText) throw new Error('Could not load screenshot page: ' + result.errorText);
137
- try { await withTimeout(loaded, 20000, 'Screenshot page load timed out: ' + url); }
138
- catch (error) {
139
- // Chromium can omit loadEventFired after navigation to an already loaded URL.
140
- const state=await send('Runtime.evaluate',{expression:'JSON.stringify({url:location.href,state:document.readyState})',returnByValue:true});
141
- const document=JSON.parse(state.result.value||'{}');
142
- if(document.url!==url||document.state!=='complete')throw error;
143
- }
144
- await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
145
- };
146
- const screenshot = async ({ hideConsent = false, clipSelector = '' } = {}) => {
147
- let previousDisplay;
148
- if (hideConsent) {
149
- const hidden = await send('Runtime.evaluate', {
150
- expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (!node) return null; const old = node.style.display; node.style.display = "none"; return old; })()',
151
- returnByValue: true
152
- });
153
- previousDisplay = hidden.result.value;
154
- }
155
- try {
156
- let clip;
157
- if (clipSelector) {
158
- const measured = await send('Runtime.evaluate', {
159
- expression: '(() => { const node = document.querySelector("' + clipSelector + '"); if (!node) return null; const rect = node.getBoundingClientRect(); return JSON.stringify({x:Math.max(0,rect.left),y:Math.max(0,rect.top),width:Math.min(window.innerWidth,rect.right)-Math.max(0,rect.left),height:Math.min(window.innerHeight,rect.bottom)-Math.max(0,rect.top)}); })()',
160
- returnByValue: true
161
- });
162
- if (!measured.result.value) return null;
163
- const rect = JSON.parse(measured.result.value);
164
- if (rect.width <= 0 || rect.height <= 0) return null;
165
- clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
166
- }
167
- const result = await send('Page.captureScreenshot', {
168
- format: 'png', fromSurface: true, captureBeyondViewport: false, ...(clip ? { clip } : {})
169
- });
170
- return Buffer.from(result.data, 'base64');
171
- } finally {
172
- if (hideConsent && previousDisplay !== undefined && previousDisplay !== null) {
173
- await send('Runtime.evaluate', {
174
- expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (node) node.style.display = ' + JSON.stringify(previousDisplay) + '; })()'
175
- });
176
- }
177
- }
178
- };
179
- const metrics = async () => {
180
- const result = await send('Runtime.evaluate', {
181
- expression: '(() => { const manager = document.querySelector("[data-consent-ui]"); const oldDisplay = manager?.style.display; if (manager) manager.style.display = "none"; const pageDocumentWidth = Math.max(document.documentElement.scrollWidth,document.body?.scrollWidth || 0); if (manager) manager.style.display = oldDisplay; const panel = manager?.querySelector(".privacy-panel"); const rect = manager?.getBoundingClientRect(); const panelRect = panel?.getBoundingClientRect(); const buttons = [...(panel?.querySelectorAll("button") || [])]; const buttonsFit = !panelRect || buttons.every(button => { const r = button.getBoundingClientRect(); return r.left >= panelRect.left - 1 && r.right <= panelRect.right + 1; }); const consentInnerNoOverflow = !panel || (panel.scrollWidth <= panel.clientWidth + 1 && buttonsFit); const consentWithinViewport = !rect || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1); const accept = panel?.querySelector(".privacy-accept"); const reject = panel?.querySelector(".privacy-reject"); const ar = accept?.getBoundingClientRect(); const rr = reject?.getBoundingClientRect(); const consentActionsBalanced = Boolean(accept && reject && Math.abs(ar.width-rr.width)<1 && Math.abs(ar.height-rr.height)<1 && getComputedStyle(accept).backgroundColor===getComputedStyle(reject).backgroundColor); const switches = [...(panel?.querySelectorAll(".privacy-service-toggle input") || [])]; const consentOptInDefault = switches.every(input => !input.checked); const serviceCards = [...(panel?.querySelectorAll(".privacy-service-setting") || [])]; const consentServiceDisclosures = serviceCards.length===0 || serviceCards.every(card => card.querySelectorAll(".privacy-service-disclosures dt").length>=4); const consentDetails = Boolean(panel?.querySelector(".privacy-details>summary")); return JSON.stringify({viewportWidth:innerWidth,documentWidth:document.documentElement.scrollWidth,pageDocumentWidth,consentExists:Boolean(manager),consentWithinViewport,consentInnerNoOverflow,consentActionsBalanced,consentOptInDefault,consentServiceDisclosures,consentDetails,consentWidth:rect?.width || 0,consentHeight:rect?.height || 0}); })()',
182
- returnByValue: true
183
- });
184
- return JSON.parse(result.result.value);
185
- };
186
- const printToPdf = async () => {
187
- let result;
188
- try {
189
- result = await send('Page.printToPDF', {
190
- printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false,
191
- generateTaggedPDF: true, generateDocumentOutline: true
192
- });
193
- } catch {
194
- result = await send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false });
195
- }
196
- return Buffer.from(result.data, 'base64');
197
- };
198
- const close = async () => {
199
- try { socket.close(); } catch { /* Browser is shutting down. */ }
200
- if (child.exitCode === null) {
201
- child.kill();
202
- await new Promise((resolveExit) => child.once('exit', resolveExit));
203
- }
204
- };
205
- return { setViewport, navigate, screenshot, metrics, printToPdf, close, audit: profile => auditBrowser(send, profile) };
206
- } catch (error) {
207
- if (child.exitCode === null) {
208
- child.kill();
209
- await new Promise((resolveExit) => child.once('exit', resolveExit));
210
- }
211
- throw error;
212
- }
213
- }
214
-
215
- const mimeTypes = {
216
- '.html': 'text/html; charset=utf-8',
217
- '.css': 'text/css; charset=utf-8',
218
- '.js': 'text/javascript; charset=utf-8',
219
- '.json': 'application/json; charset=utf-8',
220
- '.svg': 'image/svg+xml',
221
- '.png': 'image/png',
222
- '.jpg': 'image/jpeg',
223
- '.jpeg': 'image/jpeg',
224
- '.webp': 'image/webp',
225
- '.ico': 'image/x-icon',
226
- '.woff': 'font/woff',
227
- '.woff2': 'font/woff2',
228
- '.mp4': 'video/mp4',
229
- '.vtt': 'text/vtt; charset=utf-8',
230
- '.xml': 'application/xml; charset=utf-8',
231
- '.txt': 'text/plain; charset=utf-8'
232
- };
233
-
234
- function reportHtml(report, screenshots) {
235
- const documents = report.documents || [];
236
- const reportReading = report.visualEvidence?.reportAccessibility || { status: 'pending', checks: 0, passed: 0 };
237
- const consentChecks = report.consentUi?.checks || 0;
238
- const consentPassed = report.consentUi?.passed || 0;
239
- const consentErrors = Math.max(0, consentChecks - consentPassed);
240
- const consentScore = consentChecks ? Math.floor(consentPassed / consentChecks * 100) + '%' : '—';
241
- const browserChecks = report.browserChecks || { status: 'not run', results: [] };
242
- const browserRows = [['Device emulation', ['device']], ['Browser structure and accessibility tree', ['structure', 'semantics']], ['Keyboard sample', ['keyboard']]]
243
- .map(([label, groups]) => {
244
- const checks = browserChecks.results.flatMap(result => result.checks).filter(check => groups.includes(check.group));
245
- const errors = checks.filter(check => !check.passed).length;
246
- const status = ['unavailable', 'incomplete'].includes(browserChecks.status) ? browserChecks.status : checks.length ? errors ? 'fail' : 'pass' : 'not run';
247
- return '<tr><th scope="row">' + label + '</th><td>' + escapeHtml(status) + '</td><td>—</td><td>' + checks.length + '</td><td>' + errors + '</td><td>0</td></tr>';
248
- }).join('');
249
- const browserDetails = '<p>Device profiles run in an isolated headless Chromium browser. Accessibility-tree checks inspect what assistive technology can receive; no physical device or screen reader was operated. Keyboard checks use real Tab, Shift+Tab and Enter events, sampling at most 12 focus stops per page/profile.</p>' +
250
- '<p>Status: ' + escapeHtml(browserChecks.status) + (browserChecks.reason ? ' · ' + escapeHtml(browserChecks.reason) : '') + '</p>' +
251
- (browserChecks.results.length ? '<table><caption>Browser simulation results</caption><thead><tr><th scope="col">Page</th><th scope="col">Profile</th><th scope="col">Status</th><th scope="col">Checks</th><th scope="col">Failures</th></tr></thead><tbody>' +
252
- browserChecks.results.map(result => '<tr><th scope="row">' + escapeHtml(result.page) + '</th><td>' + escapeHtml(result.profile + ' · ' + result.colorScheme) +
253
- '</td><td>' + escapeHtml(result.status) + '</td><td>' + result.checks.length + '</td><td>' +
254
- escapeHtml(result.checks.filter(check => !check.passed).map(check => check.group + ': ' + check.name + (check.detail ? ' (' + check.detail + ')' : '')).join('; ')) + '</td></tr>').join('') + '</tbody></table>' : '');
255
- const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes', consent: 'Consent interface' })[type] || type;
256
- const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
257
- '<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
258
- '<tr><th scope="row">Accessibility (HTML/CSS structure)</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
259
- '%</td><td>' + report.accessibility.checks + '</td><td>' + report.accessibility.errors + '</td><td>' + report.accessibility.warnings + '</td></tr>' +
260
- '<tr><th scope="row">Agent friendliness</th><td>' + escapeHtml(report.agentFriendliness.status) + '</td><td>' + report.agentFriendliness.score +
261
- '%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
262
- '<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
263
- (report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
264
- browserRows + '<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
265
- '</td><td>' + consentChecks + '</td><td>' + consentErrors + '</td><td>0</td></tr>' +
266
- '<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
267
- Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
268
- '</td><td>' + (reportReading.checks - reportReading.passed) + '</td><td>0</td></tr>' +
269
- '</tbody></table>';
270
- const summary = '<table><caption>Reviewed content</caption><thead><tr><th scope="col">Content type</th><th scope="col">Documents</th></tr></thead><tbody>' +
271
- ['post', 'page', 'homepage', 'system'].map((type) => '<tr><th scope="row">' + typeLabel(type) + '</th><td>' +
272
- documents.filter((item) => item.type === type).length + '</td></tr>').join('') + '</tbody></table>';
273
- const documentSections = ['post', 'page', 'homepage'].map((type) => {
274
- const items = documents.filter((item) => item.type === type);
275
- if (!items.length) return '';
276
- return '<section><h3>' + typeLabel(type) + ' reviewed</h3><table><caption>' + typeLabel(type) + ' accessibility and agent-friendliness status</caption>' +
277
- '<thead><tr><th scope="col">Title</th><th scope="col">Generated route</th><th scope="col">Status</th></tr></thead><tbody>' +
278
- items.map((item) => '<tr><th scope="row">' + escapeHtml(item.title) + '</th><td><code>' + escapeHtml(item.path) + '</code></td><td>' +
279
- escapeHtml(item.status) + '</td></tr>').join('') + '</tbody></table></section>';
280
- }).join('');
281
- const findings = report.issues.length
282
- ? '<ul>' + report.issues.map((item) => '<li><strong>' + escapeHtml(item.severity + ' · ' + item.category) +
283
- '</strong> ' + escapeHtml(item.page) + ' — ' + escapeHtml(item.message) + '</li>').join('') + '</ul>'
284
- : '<p>No findings.</p>';
285
- const screenshotGroups = ['post', 'page', 'homepage', 'system', 'consent'].map((type) => {
286
- const items = screenshots.filter((item) => item.type === type);
287
- if (!items.length) return '';
288
- return '<section><h3>' + typeLabel(type) + ' screenshots</h3>' + items.map((item) => {
289
- const dimensions = item.capture === 'consent'
290
- ? 'Consent component ' + Math.round(item.consentWidth || 0) + ' by ' + Math.round(item.consentHeight || 0) + ' CSS pixels; within viewport: ' + Boolean(item.consentWithinViewport)
291
- : item.viewportWidth + ' CSS pixels wide; page content width ' + item.documentWidth + ' CSS pixels with consent hidden';
292
- const alt = 'Screenshot of ' + (item.title || item.page) + ' at the ' + item.viewport + ' viewport, ' +
293
- item.width + ' by ' + item.height + ' pixels; ' + dimensions + '.';
294
- return '<h4>' + escapeHtml(item.title || item.page) + ' · ' + escapeHtml(item.viewport) + '</h4><figure><img src="./page-check-screenshots/' +
295
- escapeHtml(item.file) + '" alt="' + escapeHtml(alt) + '"><figcaption><strong>' + escapeHtml(item.title || item.page) +
296
- '</strong><br><code>' + escapeHtml(item.page) + '</code> · ' + escapeHtml(item.viewport) + ' viewport · ' +
297
- escapeHtml(dimensions) + '</figcaption></figure>';
298
- }).join('') + '</section>';
299
- }).join('');
300
- const markdownDetails = (report.markdownRendering?.documents || []).map((document) => '<section><h3>' + escapeHtml(document.path) +
301
- '</h4><ul>' + document.checks.map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.feature) + '</li>').join('') + '</ul></section>').join('');
302
- const consentDetails = [...(report.consentUi?.results || []), ...(report.consentUi?.visualChecks || [])]
303
- .map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.name ||
304
- (item.page + ' at ' + item.viewport + ' viewport')) + '</li>').join('');
305
- return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
306
- '<title>EdgePress accessibility and content report</title><style>@page{size:A4;margin:15mm}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font:12px/1.55 Arial,sans-serif;color:#202520;background:#fff;margin:0 auto;padding:16px;max-width:1100px}header,main,footer{display:block}h1{font-size:25px;line-height:1.2}h2{font-size:19px;margin:26px 0 8px}h3{font-size:15px;margin:20px 0 8px}h4{font-size:12px;margin:16px 0 6px}p{margin:6px 0}a{color:#174c35;text-decoration:underline;text-underline-offset:2px}a:focus-visible{outline:3px solid #9a481e;outline-offset:3px}.skip-link{position:absolute;left:-10000px;top:auto}.skip-link:focus{left:16px;top:16px;padding:8px;background:#fff;border:2px solid #174c35;z-index:2}.report-nav ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}table{width:100%;border-collapse:collapse;margin:10px 0 20px;table-layout:fixed}caption{text-align:left;font-weight:bold;padding:0 0 6px}th,td{padding:7px;border:1px solid #879188;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:#e9eee9}tbody th{background:#f5f6f3}li{margin:4px 0;overflow-wrap:anywhere}code{overflow-wrap:anywhere}.status{font-weight:bold;text-transform:uppercase}.appendix{break-before:page}figure{margin:0 0 20px;break-inside:avoid}figure img{display:block;max-width:100%;max-height:220mm;width:auto;height:auto;margin:0 auto;border:1px solid #879188;object-fit:contain}figcaption{font-size:10px;color:#39443b;margin-top:5px}section{break-inside:auto}footer{border-top:1px solid #879188;margin-top:24px;padding-top:10px}@media(max-width:600px){body{padding:12px}.report-nav ul{display:block}table{font-size:11px}th,td{padding:5px}}@media print{body{max-width:none;padding:0}.report-nav{display:none}a{color:#202520}.appendix{break-before:page}h2,h3,h4{break-after:avoid}figure{break-inside:avoid}}</style></head>' +
307
- '<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
308
- escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
309
- '<nav class="report-nav" aria-label="Report sections"><ul><li><a href="#summary">Summary</a></li><li><a href="#content">Content reviewed</a></li>' +
310
- '<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#browser">Browser simulations</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
311
- '<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
312
- ' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
313
- ' · Script references: ' + report.metrics.scripts + '.</p></section>' +
314
- '<section id="content"><h2>Content reviewed</h2><p>Every generated post and customizable page is listed with its generated route and audit status. Posts are checked alongside pages.</p>' +
315
- documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
316
- '<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
317
- escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed using an in-memory renderer fixture. No tutorial article needs to be published.</p>' + markdownDetails + '</section>' +
318
- '<section id="browser"><h2>Device, accessibility-tree and keyboard simulations</h2>' + browserDetails + '</section>' +
319
- '<section id="consent"><h2>Consent interface checks</h2><p>Consent component checks run separately from page-content layout checks.</p>' +
320
- (consentDetails ? '<ul>' + consentDetails + '</ul>' : '<p>No consent UI checks were run.</p>') + '</section>' +
321
- '<section id="evidence" class="appendix"><h2>Responsive and consent screenshots</h2><p>Page screenshots use a 390 by 844 CSS pixel mobile viewport and hide the consent component while measuring page content. Separate consent screenshots crop the component at desktop and mobile sizes.</p>' +
322
- screenshotGroups + '</section><section id="scope"><h2>Scope and limits</h2><ul>' + report.scope.map((item) => '<li>' + escapeHtml(item) + '</li>').join('') +
323
- '</ul></section></main><footer><p>EdgePress local PDF report. Screenshot details are included as evidence and may be small when printed.</p></footer></body></html>';
324
- }
325
-
326
- function checkReportReadingStructure(html) {
327
- const checks = [];
328
- const check = (name, passed) => checks.push({ name, passed });
329
- check('document language', /<html\b[^>]*\blang="[^"]+"/i.test(html));
330
- check('document title', /<title>[^<]+<\/title>/i.test(html));
331
- check('skip link and main landmark', /class="skip-link"[^>]*href="#main"/i.test(html) && /<main\b[^>]*id="main"/i.test(html));
332
- check('one report title', [...html.matchAll(/<h1\b/gi)].length === 1);
333
- check('labeled report navigation', /<nav\b[^>]*aria-label="[^"]+"/i.test(html));
334
- const headings = [...html.matchAll(/<h([1-6])\b/gi)].map((match) => Number(match[1]));
335
- let previous = 0;
336
- let skipped = false;
337
- for (const level of headings) {
338
- if (previous && level > previous + 1) skipped = true;
339
- previous = level;
340
- }
341
- check('sequential heading levels', !skipped);
342
- const images = [...html.matchAll(/<img\b([^>]*)>/gi)];
343
- check('described screenshot images', images.length > 0 && images.every((item) => /\balt="[^"]+"/i.test(item[1])));
344
- const links = [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)];
345
- check('named report links', links.every((item) => /\baria-label="[^"]+"/i.test(item[1]) || item[2].replace(/<[^>]*>/g, '').trim()));
346
- const tables = [...html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)];
347
- check('captioned tables with scoped headers', tables.length > 0 && tables.every((item) =>
348
- /<caption>[^<]+<\/caption>/i.test(item[1]) && /<thead\b/i.test(item[1]) &&
349
- [...item[1].matchAll(/<th\b([^>]*)>/gi)].length > 0 && [...item[1].matchAll(/<th\b([^>]*)>/gi)]
350
- .every((header) => /\bscope="(?:col|row)"/i.test(header[1]))));
351
- const ids = [...html.matchAll(/\bid="([^"]+)"/gi)].map((match) => match[1]);
352
- check('unique in-page section targets', new Set(ids).size === ids.length);
353
- check('visible keyboard focus', /:focus-visible\s*\{[^}]*outline/i.test(html));
354
- return { status: checks.every((item) => item.passed) ? 'pass' : 'fail', checks: checks.length,
355
- passed: checks.filter((item) => item.passed).length, results: checks };
356
- }
357
-
358
- async function startReportServer(output, evidenceDirectory, htmlPath) {
359
- const outputRoot = await realpath(output);
360
- const evidenceRoot = await realpath(evidenceDirectory);
361
- const server = createServer(async (request, response) => {
362
- try {
363
- const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
364
- if (pathname === '/report.html') {
365
- response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
366
- response.end(await readFile(htmlPath));
367
- return;
368
- }
369
- let base;
370
- let relativePath;
371
- if (pathname.startsWith('/evidence/') || pathname.startsWith('/page-check-screenshots/')) {
372
- base = evidenceRoot;
373
- relativePath = pathname.startsWith('/evidence/')
374
- ? pathname.slice('/evidence/'.length)
375
- : pathname.slice('/page-check-screenshots/'.length);
376
- } else {
377
- base = outputRoot;
378
- relativePath = pathname.startsWith('/site/') ? pathname.slice('/site/'.length) : pathname.replace(/^\/+/, '');
379
- }
380
- let target = resolve(base, relativePath);
381
- if (!inside(base, target)) {
382
- response.writeHead(400);
383
- response.end();
384
- return;
385
- }
386
- let info;
387
- try { info = await lstat(target); }
388
- catch (error) {
389
- if (error.code !== 'ENOENT') throw error;
390
- if (!extname(target)) {
391
- const fileCandidate = target + '.html';
392
- try { info = await lstat(fileCandidate); target = fileCandidate; }
393
- catch { target = resolve(target, 'index.html'); info = await lstat(target); }
394
- } else throw error;
395
- }
396
- if (info.isDirectory() && !info.isSymbolicLink()) {
397
- target = resolve(target, 'index.html');
398
- info = await lstat(target);
399
- }
400
- if (info.isSymbolicLink() || !info.isFile()) {
401
- response.writeHead(404);
402
- response.end();
403
- return;
404
- }
405
- const realTarget = await realpath(target);
406
- if (!inside(base, realTarget)) {
407
- response.writeHead(404);
408
- response.end();
409
- return;
410
- }
411
- response.writeHead(200, { 'content-type': mimeTypes[extname(target).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
412
- response.end(await readFile(realTarget));
413
- } catch {
414
- response.writeHead(404);
415
- response.end();
416
- }
417
- });
418
- await new Promise((resolveListen, reject) => {
419
- server.once('error', reject);
420
- server.listen(0, '127.0.0.1', resolveListen);
421
- });
422
- return { server, port: server.address().port };
423
- }
424
-
425
- function siteAddress(relativeFile) {
426
- return '/site/' + relativeFile.split('/').map(encodeURIComponent).join('/');
427
- }
428
-
429
- export async function addVisualEvidence(config, report) {
430
- const root = await reportDirectory(config.root);
431
- const browser = await browserPath();
432
- report.browserChecks = { status: 'unavailable', physicalDevices: 'not tested', screenReaders: 'not tested',
433
- method: 'Headless Chromium device emulation, accessibility tree and CDP keyboard input', keyboardSampleLimit: 12, results: [] };
434
- if (!browser) {
435
- report.browserChecks.reason = 'No installed Chromium browser was found.';
436
- return { status: 'unavailable', reason: 'Install Microsoft Edge, Chrome, or Chromium to capture page screenshots and PDF evidence.' };
437
- }
438
-
439
- const stamp = createHash('sha256').update(report.generatedAt).digest('hex').slice(0, 8);
440
- const profile = await mkdtemp(resolve(tmpdir(), 'edgepress-report-' + process.pid + '-' + stamp + '-'));
441
- const evidenceDirectory = resolve(profile, 'page-check-screenshots');
442
- const htmlPath = resolve(profile, 'report.html');
443
- const pdfPath = resolve(root, 'page-check.pdf');
444
- let reportServer;
445
- let browserSession;
446
- const browserSessions=[];
447
- const runInBrowsers=async(items,task)=>Promise.all(browserSessions.map(async(session,worker)=>{
448
- for(let index=worker;index<items.length;index+=browserSessions.length)await task(session,items[index],index);
449
- }));
450
- try {
451
- await mkdir(evidenceDirectory, { recursive: true });
452
- const evidenceInfo = await lstat(evidenceDirectory);
453
- if (evidenceInfo.isSymbolicLink() || !evidenceInfo.isDirectory()) throw new Error('Temporary screenshot directory must be a real directory');
454
- const documents = new Map((report.documents || []).map((item) => [item.path, item]));
455
- const candidates = new Set((report.documents || [])
456
- .filter((item) => ['post', 'page', 'homepage'].includes(item.type))
457
- .map((item) => item.path));
458
- for (const locale of config.i18n.locales) {
459
- const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
460
- candidates.add(prefix + 'index.html');
461
- candidates.add(prefix + 'archives/index.html');
462
- candidates.add(prefix + 'search/index.html');
463
- candidates.add(prefix + 'privacy-policy/index.html');
464
- }
465
- for (const issue of report.issues) {
466
- if (issue.page && issue.page.toLowerCase().endsWith('.html')) candidates.add(issue.page);
467
- }
468
-
469
- const available = [];
470
- for (const relativeFile of candidates) {
471
- const target = resolve(config.resolvedPaths.output, relativeFile);
472
- try {
473
- const info = await lstat(target);
474
- if (info.isFile() && !info.isSymbolicLink()) available.push(relativeFile);
475
- } catch (error) {
476
- if (error.code !== 'ENOENT') throw error;
477
- }
478
- }
479
-
480
- const screenshots = [];
481
- const desktop = { name: 'desktop', width: 1440, height: 1100 };
482
- const mobile = { name: 'mobile', width: 390, height: 844 };
483
- const homePaths = new Set(config.i18n.locales.map((locale) => locale === config.i18n.defaultLocale ? 'index.html' : locale + '/index.html'));
484
- const privacyPaths = new Set(config.i18n.locales.map((locale) =>
485
- (locale === config.i18n.defaultLocale ? '' : locale + '/') + 'privacy-policy/index.html'));
486
- for (const relativeFile of available) {
487
- const document = documents.get(relativeFile) || { type: 'system', title: relativeFile };
488
- const viewports = homePaths.has(relativeFile) || privacyPaths.has(relativeFile) || document.type === 'post'
489
- ? [desktop, mobile] : [mobile];
490
- for (const viewport of viewports) {
491
- const digest = createHash('sha256').update(relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
492
- const file = 'page-' + digest + '.png';
493
- const target = resolve(evidenceDirectory, file);
494
- screenshots.push({ page: relativeFile, type: document.type, capture: 'page', title: document.title, viewport: viewport.name, width: viewport.width,
495
- height: viewport.height, file, target,
496
- label: relativeFile + ' · ' + viewport.name + ' (' + viewport.width + '×' + viewport.height + ')' });
497
- }
498
- }
499
- if (config.browserPlugins.consent.enabled) {
500
- const availableSet = new Set(available);
501
- for (const relativeFile of new Set([...homePaths, ...privacyPaths])) {
502
- if (!availableSet.has(relativeFile)) continue;
503
- for (const viewport of [desktop, mobile]) {
504
- const digest = createHash('sha256').update('consent:' + relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
505
- const file = 'consent-' + digest + '.png';
506
- screenshots.push({ page: relativeFile, type: 'consent', capture: 'consent', title: 'Privacy choices',
507
- viewport: viewport.name, width: viewport.width, height: viewport.height, file,
508
- target: resolve(evidenceDirectory, file), label: 'Privacy choices · ' + relativeFile + ' · ' + viewport.name +
509
- ' (' + viewport.width + '×' + viewport.height + ')' });
510
- }
511
- }
512
- }
513
- reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
514
- for(let worker=0;worker<Math.min(3,available.length||1);worker++){
515
- const browserProfile=resolve(profile,'browser-'+worker);await mkdir(browserProfile);
516
- browserSessions.push(await startHeadlessBrowser(browser,browserProfile));
517
- }
518
- browserSession=browserSessions[0];
519
- let reviewed=0;
520
- const pageResults=new Array(available.length);
521
- await runInBrowsers(available,async(browserSession,relativeFile,index)=>{
522
- const results=[];
523
- for (const device of deviceProfiles) for (const colorScheme of ['light', 'dark']) {
524
- const emulation = { ...device, colorScheme };
525
- await browserSession.setViewport(emulation);
526
- await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(relativeFile));
527
- const result = { page: relativeFile, profile: device.name, colorScheme, ...await browserSession.audit(emulation) };
528
- results.push(result);
529
- }
530
- pageResults[index]=results;
531
- report.browserChecks.results=pageResults.filter(Boolean).flat();
532
- reviewed+=1;if(reviewed%25===0||reviewed===available.length)console.log('Browser checks: '+reviewed+'/'+available.length+' pages');
533
- });
534
- report.browserChecks.results=pageResults.flat();
535
- for(const result of report.browserChecks.results)for(const check of result.checks.filter(item=>!item.passed)){
536
- report.issues.push({severity:'error',category:'browser-'+check.group,page:result.page,
537
- message:result.profile+'/'+result.colorScheme+': '+check.name+(check.detail?' ('+check.detail+')':'')+'.'});
538
- }
539
- report.browserChecks.status = report.browserChecks.results.every(result => result.status === 'pass') ? 'pass' : 'fail';
540
- if (!report.browserChecks.results.length) {
541
- report.browserChecks.status = 'unavailable';
542
- report.browserChecks.reason = 'No generated pages were available for browser checks.';
543
- }
544
- let captured=0;
545
- await runInBrowsers(screenshots,async(browserSession,screenshot)=>{
546
- const viewport = screenshot.viewport === 'mobile' ? mobile : desktop;
547
- await browserSession.setViewport(viewport);
548
- await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
549
- const dimensions = await browserSession.metrics();
550
- if (screenshot.capture === 'consent') {
551
- report.consentUi.visualChecks ||= [];
552
- const checks = [
553
- ['component stays inside the viewport', dimensions.consentExists && dimensions.consentWithinViewport],
554
- ['component content stays inside its own box', dimensions.consentExists && dimensions.consentInnerNoOverflow],
555
- ['accept and reject actions have equal visual weight', dimensions.consentExists && dimensions.consentActionsBalanced],
556
- ['optional services begin unchecked', dimensions.consentExists && dimensions.consentOptInDefault],
557
- ['expanded service details disclose all configured fields', dimensions.consentExists && dimensions.consentServiceDisclosures && dimensions.consentDetails]
558
- ];
559
- report.consentUi.visualChecks.push(...checks.map(([name, passed]) => ({ page: screenshot.page, viewport: viewport.name,
560
- name, passed, consentWidth: dimensions.consentWidth, consentHeight: dimensions.consentHeight })));
561
- const withinViewport = dimensions.consentExists && dimensions.consentWithinViewport;
562
- const noInnerOverflow = dimensions.consentExists && dimensions.consentInnerNoOverflow;
563
- if (!dimensions.consentExists) {
564
- report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
565
- message: 'The consent manager did not appear in the browser.' });
566
- } else if (!withinViewport || !noInnerOverflow) {
567
- report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
568
- message: 'Consent UI exceeded its viewport or its own content box at the ' + viewport.name + ' size.' });
569
- }
570
- }
571
- const image = screenshot.capture === 'consent'
572
- ? await browserSession.screenshot({ clipSelector: '[data-consent-ui]' })
573
- : await browserSession.screenshot({ hideConsent: config.browserPlugins.consent.enabled });
574
- if (!image) {
575
- report.issues.push({ severity: 'error', category: screenshot.capture === 'consent' ? 'consent-ui' : 'visual-evidence',
576
- page: screenshot.page, message: 'The requested screenshot area was not present in the browser.' });
577
- return;
578
- }
579
- await writeFile(screenshot.target, image);
580
- const imageInfo = await lstat(screenshot.target);
581
- if (!imageInfo.isFile() || imageInfo.size < 1000) throw new Error('Headless browser produced an empty screenshot');
582
- screenshot.viewportWidth = dimensions.viewportWidth;
583
- screenshot.documentWidth = dimensions.pageDocumentWidth;
584
- screenshot.consentWidth = dimensions.consentWidth;
585
- screenshot.consentHeight = dimensions.consentHeight;
586
- screenshot.consentWithinViewport = dimensions.consentWithinViewport;
587
- if (viewport.name === 'mobile' && dimensions.viewportWidth !== viewport.width) {
588
- report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
589
- message: 'Mobile viewport measured ' + dimensions.viewportWidth + ' CSS pixels instead of ' + viewport.width + '.' });
590
- } else if (screenshot.capture === 'page' && viewport.name === 'mobile' && dimensions.pageDocumentWidth > dimensions.viewportWidth) {
591
- report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
592
- message: 'Page content width is ' + dimensions.pageDocumentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport; consent UI is measured separately.' });
593
- }
594
- captured+=1;if(captured%50===0||captured===screenshots.length)console.log('Report screenshots: '+captured+'/'+screenshots.length);
595
- });
596
- if (report.consentUi?.visualChecks) {
597
- const visualChecks = report.consentUi.visualChecks;
598
- report.consentUi.checks += visualChecks.length;
599
- report.consentUi.passed += visualChecks.filter((item) => item.passed).length;
600
- report.consentUi.status = report.consentUi.passed === report.consentUi.checks ? 'pass' : 'fail';
601
- }
602
- report.errors = report.issues.filter((item) => item.severity === 'error').length;
603
- report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
604
- report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
605
- for (const document of report.documents || []) {
606
- const findings = report.issues.filter((item) => item.page === document.path);
607
- if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
608
- else if (findings.length) document.status = 'warning';
609
- }
610
- report.visualEvidence = { status: 'complete', pdf: 'tools/reports/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
611
- page: item.page, type: item.type, capture: item.capture, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
612
- viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
613
- consentWidth: item.consentWidth, consentHeight: item.consentHeight, consentWithinViewport: item.consentWithinViewport,
614
- label: item.label
615
- })) };
616
- const initialHtml = reportHtml(report, screenshots);
617
- const reportAccessibility = checkReportReadingStructure(initialHtml);
618
- report.visualEvidence.reportAccessibility = reportAccessibility;
619
- for (const check of reportAccessibility.results.filter((item) => !item.passed)) {
620
- report.issues.push({ severity: 'error', category: 'report-accessibility', page: 'tools/reports/page-check.pdf',
621
- message: 'Report reading requirement failed: ' + check.name + '.' });
622
- }
623
- report.errors = report.issues.filter((item) => item.severity === 'error').length;
624
- report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
625
- report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
626
- const finalHtml = reportHtml(report, screenshots);
627
- const finalReading = checkReportReadingStructure(finalHtml);
628
- report.visualEvidence.reportAccessibility = finalReading;
629
- if (finalReading.status !== 'pass') throw new Error('The generated accessibility report failed its reading-order checks.');
630
- await writeFile(htmlPath, finalHtml, 'utf8');
631
- await browserSession.setViewport({ name: 'desktop', width: 1200, height: 900 });
632
- await browserSession.navigate('http://127.0.0.1:' + reportServer.port + '/report.html');
633
- const pdf = await browserSession.printToPdf();
634
- if (pdf.length < 1000) throw new Error('Headless browser produced an empty PDF');
635
- await writeFile(pdfPath, pdf);
636
- return report.visualEvidence;
637
- } finally {
638
- await Promise.allSettled(browserSessions.map(session=>session.close()));
639
- if (reportServer) await new Promise((resolveClose) => reportServer.server.close(resolveClose));
640
- await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 });
641
- }
642
- }
1
+ import {reportDirectory} from './report-path.js';
2
+ import { createHash } from 'node:crypto';
3
+ import { access, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { extname, relative, resolve, sep } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { spawn } from 'node:child_process';
8
+ import { auditBrowser, deviceProfiles } from './browser-audit.js';
9
+
10
+ function escapeHtml(value) {
11
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
12
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
13
+ })[char]);
14
+ }
15
+
16
+ function inside(root, target) {
17
+ const rel = relative(root, target);
18
+ return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolutePath(rel);
19
+ }
20
+
21
+ function isAbsolutePath(value) {
22
+ return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith(sep) || value.startsWith('/');
23
+ }
24
+
25
+ async function browserPath() {
26
+ const candidates = [];
27
+ if (process.env.EDGEPRESS_BROWSER) candidates.push(process.env.EDGEPRESS_BROWSER);
28
+ const programFiles = process.env['PROGRAMFILES'];
29
+ const programFilesX86 = process.env['PROGRAMFILES(X86)'];
30
+ const localAppData = process.env['LOCALAPPDATA'];
31
+ if (programFilesX86) candidates.push(resolve(programFilesX86, 'Microsoft/Edge/Application/msedge.exe'));
32
+ if (programFiles) {
33
+ candidates.push(resolve(programFiles, 'Microsoft/Edge/Application/msedge.exe'));
34
+ candidates.push(resolve(programFiles, 'Google/Chrome/Application/chrome.exe'));
35
+ }
36
+ if (localAppData) candidates.push(resolve(localAppData, 'Microsoft/Edge/Application/msedge.exe'));
37
+ candidates.push('/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable', '/usr/bin/chromium',
38
+ '/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
39
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
40
+ for (const candidate of candidates) {
41
+ if (!candidate) continue;
42
+ try { await access(candidate); return candidate; } catch { /* Try the next installed browser. */ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ async function freePort() {
48
+ const server = createServer();
49
+ await new Promise((resolveListen, reject) => {
50
+ server.once('error', reject);
51
+ server.listen(0, '127.0.0.1', resolveListen);
52
+ });
53
+ const port = server.address().port;
54
+ await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()));
55
+ return port;
56
+ }
57
+
58
+ function withTimeout(promise, timeoutMs, message) {
59
+ let timer;
60
+ return Promise.race([
61
+ promise,
62
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })
63
+ ]).finally(() => clearTimeout(timer));
64
+ }
65
+
66
+ async function startHeadlessBrowser(executable, profile) {
67
+ const port = await freePort();
68
+ const child = spawn(executable, [
69
+ '--headless=new', '--mute-audio', '--disable-notifications',
70
+ '--disable-sync', '--disable-features=msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion',
71
+ '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-gpu', '--no-sandbox', '--no-first-run', '--no-default-browser-check',
72
+ '--disable-background-networking', '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1',
73
+ '--remote-debugging-port=' + port, '--remote-allow-origins=*', '--user-data-dir=' + profile, 'about:blank'
74
+ ], { stdio: 'ignore', windowsHide: true });
75
+ try {
76
+ let targets = [];
77
+ for (let attempt = 0; attempt < 80; attempt += 1) {
78
+ try {
79
+ targets = await (await fetch('http://127.0.0.1:' + port + '/json/list')).json();
80
+ if (targets.some((target) => target.type === 'page')) break;
81
+ } catch { /* Wait for the headless browser endpoint. */ }
82
+ if (child.exitCode !== null) throw new Error('Headless browser exited during startup');
83
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
84
+ }
85
+ const page = targets.find((target) => target.type === 'page');
86
+ if (!page) throw new Error('Headless browser did not expose a page target');
87
+ const socket = new WebSocket(page.webSocketDebuggerUrl);
88
+ await new Promise((resolveOpen, rejectOpen) => {
89
+ socket.addEventListener('open', resolveOpen, { once: true });
90
+ socket.addEventListener('error', rejectOpen, { once: true });
91
+ });
92
+ let messageId = 0;
93
+ const pending = new Map();
94
+ const eventWaiters = new Map();
95
+ socket.addEventListener('message', (event) => {
96
+ const message = JSON.parse(event.data);
97
+ if (message.id && pending.has(message.id)) {
98
+ const callbacks = pending.get(message.id);
99
+ pending.delete(message.id);
100
+ if (message.error) callbacks.reject(new Error(message.error.message));
101
+ else callbacks.resolve(message.result);
102
+ }
103
+ if (message.method && eventWaiters.has(message.method)) {
104
+ const waiters = eventWaiters.get(message.method);
105
+ eventWaiters.delete(message.method);
106
+ for (const resolveEvent of waiters) resolveEvent(message.params);
107
+ }
108
+ });
109
+ const send = (method, params = {}) => new Promise((resolveMessage, rejectMessage) => {
110
+ const id = ++messageId;
111
+ pending.set(id, { resolve: resolveMessage, reject: rejectMessage });
112
+ socket.send(JSON.stringify({ id, method, params }));
113
+ });
114
+ const waitFor = (method) => new Promise((resolveEvent) => {
115
+ const waiters = eventWaiters.get(method) || [];
116
+ waiters.push(resolveEvent);
117
+ eventWaiters.set(method, waiters);
118
+ });
119
+ await send('Page.enable');
120
+ await send('Runtime.enable');
121
+ await send('Accessibility.enable');
122
+ const setViewport = async (viewport) => {
123
+ await send('Emulation.setDeviceMetricsOverride', {
124
+ width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor || 1,
125
+ mobile: viewport.mobile ?? viewport.name === 'mobile',
126
+ screenWidth: viewport.width, screenHeight: viewport.height
127
+ });
128
+ await send('Emulation.setTouchEmulationEnabled', { enabled: viewport.touch ?? viewport.name === 'mobile', maxTouchPoints: 5 });
129
+ await send('Emulation.setEmulatedMedia', { features: viewport.colorScheme
130
+ ? [{ name: 'prefers-color-scheme', value: viewport.colorScheme }] : [] });
131
+ };
132
+ const navigate = async (url) => {
133
+ await send('Page.bringToFront');
134
+ const loaded = waitFor('Page.loadEventFired');
135
+ const result = await send('Page.navigate', { url });
136
+ if (result.errorText) throw new Error('Could not load screenshot page: ' + result.errorText);
137
+ try { await withTimeout(loaded, 20000, 'Screenshot page load timed out: ' + url); }
138
+ catch (error) {
139
+ // Chromium can omit loadEventFired after navigation to an already loaded URL.
140
+ const state=await send('Runtime.evaluate',{expression:'JSON.stringify({url:location.href,state:document.readyState})',returnByValue:true});
141
+ const document=JSON.parse(state.result.value||'{}');
142
+ if(document.url!==url||document.state!=='complete')throw error;
143
+ }
144
+ await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
145
+ };
146
+ const screenshot = async ({ hideConsent = false, clipSelector = '' } = {}) => {
147
+ let previousDisplay;
148
+ if (hideConsent) {
149
+ const hidden = await send('Runtime.evaluate', {
150
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (!node) return null; const old = node.style.display; node.style.display = "none"; return old; })()',
151
+ returnByValue: true
152
+ });
153
+ previousDisplay = hidden.result.value;
154
+ }
155
+ try {
156
+ let clip;
157
+ if (clipSelector) {
158
+ const measured = await send('Runtime.evaluate', {
159
+ expression: '(() => { const node = document.querySelector("' + clipSelector + '"); if (!node) return null; const rect = node.getBoundingClientRect(); return JSON.stringify({x:Math.max(0,rect.left),y:Math.max(0,rect.top),width:Math.min(window.innerWidth,rect.right)-Math.max(0,rect.left),height:Math.min(window.innerHeight,rect.bottom)-Math.max(0,rect.top)}); })()',
160
+ returnByValue: true
161
+ });
162
+ if (!measured.result.value) return null;
163
+ const rect = JSON.parse(measured.result.value);
164
+ if (rect.width <= 0 || rect.height <= 0) return null;
165
+ clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
166
+ }
167
+ const result = await send('Page.captureScreenshot', {
168
+ format: 'png', fromSurface: true, captureBeyondViewport: false, ...(clip ? { clip } : {})
169
+ });
170
+ return Buffer.from(result.data, 'base64');
171
+ } finally {
172
+ if (hideConsent && previousDisplay !== undefined && previousDisplay !== null) {
173
+ await send('Runtime.evaluate', {
174
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (node) node.style.display = ' + JSON.stringify(previousDisplay) + '; })()'
175
+ });
176
+ }
177
+ }
178
+ };
179
+ const metrics = async () => {
180
+ const result = await send('Runtime.evaluate', {
181
+ expression: '(() => { const manager = document.querySelector("[data-consent-ui]"); const oldDisplay = manager?.style.display; if (manager) manager.style.display = "none"; const pageDocumentWidth = Math.max(document.documentElement.scrollWidth,document.body?.scrollWidth || 0); if (manager) manager.style.display = oldDisplay; const panel = manager?.querySelector(".privacy-panel"); const rect = manager?.getBoundingClientRect(); const panelRect = panel?.getBoundingClientRect(); const buttons = [...(panel?.querySelectorAll("button") || [])]; const buttonsFit = !panelRect || buttons.every(button => { const r = button.getBoundingClientRect(); return r.left >= panelRect.left - 1 && r.right <= panelRect.right + 1; }); const consentInnerNoOverflow = !panel || (panel.scrollWidth <= panel.clientWidth + 1 && buttonsFit); const consentWithinViewport = !rect || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1); const accept = panel?.querySelector(".privacy-accept"); const reject = panel?.querySelector(".privacy-reject"); const ar = accept?.getBoundingClientRect(); const rr = reject?.getBoundingClientRect(); const consentActionsBalanced = Boolean(accept && reject && Math.abs(ar.width-rr.width)<1 && Math.abs(ar.height-rr.height)<1 && getComputedStyle(accept).backgroundColor===getComputedStyle(reject).backgroundColor); const switches = [...(panel?.querySelectorAll(".privacy-service-toggle input") || [])]; const consentOptInDefault = switches.every(input => !input.checked); const serviceCards = [...(panel?.querySelectorAll(".privacy-service-setting") || [])]; const consentServiceDisclosures = serviceCards.length===0 || serviceCards.every(card => card.querySelectorAll(".privacy-service-disclosures dt").length>=4); const consentDetails = Boolean(panel?.querySelector(".privacy-details>summary")); return JSON.stringify({viewportWidth:innerWidth,documentWidth:document.documentElement.scrollWidth,pageDocumentWidth,consentExists:Boolean(manager),consentWithinViewport,consentInnerNoOverflow,consentActionsBalanced,consentOptInDefault,consentServiceDisclosures,consentDetails,consentWidth:rect?.width || 0,consentHeight:rect?.height || 0}); })()',
182
+ returnByValue: true
183
+ });
184
+ return JSON.parse(result.result.value);
185
+ };
186
+ const printToPdf = async () => {
187
+ let result;
188
+ try {
189
+ result = await send('Page.printToPDF', {
190
+ printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false,
191
+ generateTaggedPDF: true, generateDocumentOutline: true
192
+ });
193
+ } catch {
194
+ result = await send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false });
195
+ }
196
+ return Buffer.from(result.data, 'base64');
197
+ };
198
+ const close = async () => {
199
+ try { socket.close(); } catch { /* Browser is shutting down. */ }
200
+ if (child.exitCode === null) {
201
+ child.kill();
202
+ await new Promise((resolveExit) => child.once('exit', resolveExit));
203
+ }
204
+ };
205
+ return { setViewport, navigate, screenshot, metrics, printToPdf, close, audit: profile => auditBrowser(send, profile) };
206
+ } catch (error) {
207
+ if (child.exitCode === null) {
208
+ child.kill();
209
+ await new Promise((resolveExit) => child.once('exit', resolveExit));
210
+ }
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ const mimeTypes = {
216
+ '.html': 'text/html; charset=utf-8',
217
+ '.css': 'text/css; charset=utf-8',
218
+ '.js': 'text/javascript; charset=utf-8',
219
+ '.json': 'application/json; charset=utf-8',
220
+ '.svg': 'image/svg+xml',
221
+ '.png': 'image/png',
222
+ '.jpg': 'image/jpeg',
223
+ '.jpeg': 'image/jpeg',
224
+ '.webp': 'image/webp',
225
+ '.ico': 'image/x-icon',
226
+ '.woff': 'font/woff',
227
+ '.woff2': 'font/woff2',
228
+ '.mp4': 'video/mp4',
229
+ '.vtt': 'text/vtt; charset=utf-8',
230
+ '.xml': 'application/xml; charset=utf-8',
231
+ '.txt': 'text/plain; charset=utf-8'
232
+ };
233
+
234
+ function reportHtml(report, screenshots) {
235
+ const documents = report.documents || [];
236
+ const reportReading = report.visualEvidence?.reportAccessibility || { status: 'pending', checks: 0, passed: 0 };
237
+ const consentChecks = report.consentUi?.checks || 0;
238
+ const consentPassed = report.consentUi?.passed || 0;
239
+ const consentErrors = Math.max(0, consentChecks - consentPassed);
240
+ const consentScore = consentChecks ? Math.floor(consentPassed / consentChecks * 100) + '%' : '—';
241
+ const browserChecks = report.browserChecks || { status: 'not run', results: [] };
242
+ const browserRows = [['Device emulation', ['device']], ['Browser structure and accessibility tree', ['structure', 'semantics']], ['Keyboard sample', ['keyboard']]]
243
+ .map(([label, groups]) => {
244
+ const checks = browserChecks.results.flatMap(result => result.checks).filter(check => groups.includes(check.group));
245
+ const errors = checks.filter(check => !check.passed).length;
246
+ const status = ['unavailable', 'incomplete'].includes(browserChecks.status) ? browserChecks.status : checks.length ? errors ? 'fail' : 'pass' : 'not run';
247
+ return '<tr><th scope="row">' + label + '</th><td>' + escapeHtml(status) + '</td><td>—</td><td>' + checks.length + '</td><td>' + errors + '</td><td>0</td></tr>';
248
+ }).join('');
249
+ const browserDetails = '<p>Device profiles run in an isolated headless Chromium browser. Accessibility-tree checks inspect what assistive technology can receive; no physical device or screen reader was operated. Keyboard checks use real Tab, Shift+Tab and Enter events, sampling at most 12 focus stops per page/profile.</p>' +
250
+ '<p>Status: ' + escapeHtml(browserChecks.status) + (browserChecks.reason ? ' · ' + escapeHtml(browserChecks.reason) : '') + '</p>' +
251
+ (browserChecks.results.length ? '<table><caption>Browser simulation results</caption><thead><tr><th scope="col">Page</th><th scope="col">Profile</th><th scope="col">Status</th><th scope="col">Checks</th><th scope="col">Failures</th></tr></thead><tbody>' +
252
+ browserChecks.results.map(result => '<tr><th scope="row">' + escapeHtml(result.page) + '</th><td>' + escapeHtml(result.profile + ' · ' + result.colorScheme) +
253
+ '</td><td>' + escapeHtml(result.status) + '</td><td>' + result.checks.length + '</td><td>' +
254
+ escapeHtml(result.checks.filter(check => !check.passed).map(check => check.group + ': ' + check.name + (check.detail ? ' (' + check.detail + ')' : '')).join('; ')) + '</td></tr>').join('') + '</tbody></table>' : '');
255
+ const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes', consent: 'Consent interface' })[type] || type;
256
+ const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
257
+ '<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
258
+ '<tr><th scope="row">Accessibility (HTML/CSS structure)</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
259
+ '%</td><td>' + report.accessibility.checks + '</td><td>' + report.accessibility.errors + '</td><td>' + report.accessibility.warnings + '</td></tr>' +
260
+ '<tr><th scope="row">Agent friendliness</th><td>' + escapeHtml(report.agentFriendliness.status) + '</td><td>' + report.agentFriendliness.score +
261
+ '%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
262
+ '<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
263
+ (report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
264
+ browserRows + '<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
265
+ '</td><td>' + consentChecks + '</td><td>' + consentErrors + '</td><td>0</td></tr>' +
266
+ '<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
267
+ Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
268
+ '</td><td>' + (reportReading.checks - reportReading.passed) + '</td><td>0</td></tr>' +
269
+ '</tbody></table>';
270
+ const summary = '<table><caption>Reviewed content</caption><thead><tr><th scope="col">Content type</th><th scope="col">Documents</th></tr></thead><tbody>' +
271
+ ['post', 'page', 'homepage', 'system'].map((type) => '<tr><th scope="row">' + typeLabel(type) + '</th><td>' +
272
+ documents.filter((item) => item.type === type).length + '</td></tr>').join('') + '</tbody></table>';
273
+ const documentSections = ['post', 'page', 'homepage'].map((type) => {
274
+ const items = documents.filter((item) => item.type === type);
275
+ if (!items.length) return '';
276
+ return '<section><h3>' + typeLabel(type) + ' reviewed</h3><table><caption>' + typeLabel(type) + ' accessibility and agent-friendliness status</caption>' +
277
+ '<thead><tr><th scope="col">Title</th><th scope="col">Generated route</th><th scope="col">Status</th></tr></thead><tbody>' +
278
+ items.map((item) => '<tr><th scope="row">' + escapeHtml(item.title) + '</th><td><code>' + escapeHtml(item.path) + '</code></td><td>' +
279
+ escapeHtml(item.status) + '</td></tr>').join('') + '</tbody></table></section>';
280
+ }).join('');
281
+ const findings = report.issues.length
282
+ ? '<ul>' + report.issues.map((item) => '<li><strong>' + escapeHtml(item.severity + ' · ' + item.category) +
283
+ '</strong> ' + escapeHtml(item.page) + ' — ' + escapeHtml(item.message) + '</li>').join('') + '</ul>'
284
+ : '<p>No findings.</p>';
285
+ const screenshotGroups = ['post', 'page', 'homepage', 'system', 'consent'].map((type) => {
286
+ const items = screenshots.filter((item) => item.type === type);
287
+ if (!items.length) return '';
288
+ return '<section><h3>' + typeLabel(type) + ' screenshots</h3>' + items.map((item) => {
289
+ const dimensions = item.capture === 'consent'
290
+ ? 'Consent component ' + Math.round(item.consentWidth || 0) + ' by ' + Math.round(item.consentHeight || 0) + ' CSS pixels; within viewport: ' + Boolean(item.consentWithinViewport)
291
+ : item.viewportWidth + ' CSS pixels wide; page content width ' + item.documentWidth + ' CSS pixels with consent hidden';
292
+ const alt = 'Screenshot of ' + (item.title || item.page) + ' at the ' + item.viewport + ' viewport, ' +
293
+ item.width + ' by ' + item.height + ' pixels; ' + dimensions + '.';
294
+ return '<h4>' + escapeHtml(item.title || item.page) + ' · ' + escapeHtml(item.viewport) + '</h4><figure><img src="./page-check-screenshots/' +
295
+ escapeHtml(item.file) + '" alt="' + escapeHtml(alt) + '"><figcaption><strong>' + escapeHtml(item.title || item.page) +
296
+ '</strong><br><code>' + escapeHtml(item.page) + '</code> · ' + escapeHtml(item.viewport) + ' viewport · ' +
297
+ escapeHtml(dimensions) + '</figcaption></figure>';
298
+ }).join('') + '</section>';
299
+ }).join('');
300
+ const markdownDetails = (report.markdownRendering?.documents || []).map((document) => '<section><h3>' + escapeHtml(document.path) +
301
+ '</h4><ul>' + document.checks.map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.feature) + '</li>').join('') + '</ul></section>').join('');
302
+ const consentDetails = [...(report.consentUi?.results || []), ...(report.consentUi?.visualChecks || [])]
303
+ .map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.name ||
304
+ (item.page + ' at ' + item.viewport + ' viewport')) + '</li>').join('');
305
+ return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
306
+ '<title>EdgePress accessibility and content report</title><style>@page{size:A4;margin:15mm}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font:12px/1.55 Arial,sans-serif;color:#202520;background:#fff;margin:0 auto;padding:16px;max-width:1100px}header,main,footer{display:block}h1{font-size:25px;line-height:1.2}h2{font-size:19px;margin:26px 0 8px}h3{font-size:15px;margin:20px 0 8px}h4{font-size:12px;margin:16px 0 6px}p{margin:6px 0}a{color:#174c35;text-decoration:underline;text-underline-offset:2px}a:focus-visible{outline:3px solid #9a481e;outline-offset:3px}.skip-link{position:absolute;left:-10000px;top:auto}.skip-link:focus{left:16px;top:16px;padding:8px;background:#fff;border:2px solid #174c35;z-index:2}.report-nav ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}table{width:100%;border-collapse:collapse;margin:10px 0 20px;table-layout:fixed}caption{text-align:left;font-weight:bold;padding:0 0 6px}th,td{padding:7px;border:1px solid #879188;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:#e9eee9}tbody th{background:#f5f6f3}li{margin:4px 0;overflow-wrap:anywhere}code{overflow-wrap:anywhere}.status{font-weight:bold;text-transform:uppercase}.appendix{break-before:page}figure{margin:0 0 20px;break-inside:avoid}figure img{display:block;max-width:100%;max-height:220mm;width:auto;height:auto;margin:0 auto;border:1px solid #879188;object-fit:contain}figcaption{font-size:10px;color:#39443b;margin-top:5px}section{break-inside:auto}footer{border-top:1px solid #879188;margin-top:24px;padding-top:10px}@media(max-width:600px){body{padding:12px}.report-nav ul{display:block}table{font-size:11px}th,td{padding:5px}}@media print{body{max-width:none;padding:0}.report-nav{display:none}a{color:#202520}.appendix{break-before:page}h2,h3,h4{break-after:avoid}figure{break-inside:avoid}}</style></head>' +
307
+ '<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
308
+ escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
309
+ '<nav class="report-nav" aria-label="Report sections"><ul><li><a href="#summary">Summary</a></li><li><a href="#content">Content reviewed</a></li>' +
310
+ '<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#browser">Browser simulations</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
311
+ '<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
312
+ ' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
313
+ ' · Script references: ' + report.metrics.scripts + '.</p></section>' +
314
+ '<section id="content"><h2>Content reviewed</h2><p>Every generated post and customizable page is listed with its generated route and audit status. Posts are checked alongside pages.</p>' +
315
+ documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
316
+ '<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
317
+ escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed using an in-memory renderer fixture. No tutorial article needs to be published.</p>' + markdownDetails + '</section>' +
318
+ '<section id="browser"><h2>Device, accessibility-tree and keyboard simulations</h2>' + browserDetails + '</section>' +
319
+ '<section id="consent"><h2>Consent interface checks</h2><p>Consent component checks run separately from page-content layout checks.</p>' +
320
+ (consentDetails ? '<ul>' + consentDetails + '</ul>' : '<p>No consent UI checks were run.</p>') + '</section>' +
321
+ '<section id="evidence" class="appendix"><h2>Responsive and consent screenshots</h2><p>Page screenshots use a 390 by 844 CSS pixel mobile viewport and hide the consent component while measuring page content. Separate consent screenshots crop the component at desktop and mobile sizes.</p>' +
322
+ screenshotGroups + '</section><section id="scope"><h2>Scope and limits</h2><ul>' + report.scope.map((item) => '<li>' + escapeHtml(item) + '</li>').join('') +
323
+ '</ul></section></main><footer><p>EdgePress local PDF report. Screenshot details are included as evidence and may be small when printed.</p></footer></body></html>';
324
+ }
325
+
326
+ function checkReportReadingStructure(html) {
327
+ const checks = [];
328
+ const check = (name, passed) => checks.push({ name, passed });
329
+ check('document language', /<html\b[^>]*\blang="[^"]+"/i.test(html));
330
+ check('document title', /<title>[^<]+<\/title>/i.test(html));
331
+ check('skip link and main landmark', /class="skip-link"[^>]*href="#main"/i.test(html) && /<main\b[^>]*id="main"/i.test(html));
332
+ check('one report title', [...html.matchAll(/<h1\b/gi)].length === 1);
333
+ check('labeled report navigation', /<nav\b[^>]*aria-label="[^"]+"/i.test(html));
334
+ const headings = [...html.matchAll(/<h([1-6])\b/gi)].map((match) => Number(match[1]));
335
+ let previous = 0;
336
+ let skipped = false;
337
+ for (const level of headings) {
338
+ if (previous && level > previous + 1) skipped = true;
339
+ previous = level;
340
+ }
341
+ check('sequential heading levels', !skipped);
342
+ const images = [...html.matchAll(/<img\b([^>]*)>/gi)];
343
+ check('described screenshot images', images.length > 0 && images.every((item) => /\balt="[^"]+"/i.test(item[1])));
344
+ const links = [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)];
345
+ check('named report links', links.every((item) => /\baria-label="[^"]+"/i.test(item[1]) || item[2].replace(/<[^>]*>/g, '').trim()));
346
+ const tables = [...html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)];
347
+ check('captioned tables with scoped headers', tables.length > 0 && tables.every((item) =>
348
+ /<caption>[^<]+<\/caption>/i.test(item[1]) && /<thead\b/i.test(item[1]) &&
349
+ [...item[1].matchAll(/<th\b([^>]*)>/gi)].length > 0 && [...item[1].matchAll(/<th\b([^>]*)>/gi)]
350
+ .every((header) => /\bscope="(?:col|row)"/i.test(header[1]))));
351
+ const ids = [...html.matchAll(/\bid="([^"]+)"/gi)].map((match) => match[1]);
352
+ check('unique in-page section targets', new Set(ids).size === ids.length);
353
+ check('visible keyboard focus', /:focus-visible\s*\{[^}]*outline/i.test(html));
354
+ return { status: checks.every((item) => item.passed) ? 'pass' : 'fail', checks: checks.length,
355
+ passed: checks.filter((item) => item.passed).length, results: checks };
356
+ }
357
+
358
+ async function startReportServer(output, evidenceDirectory, htmlPath) {
359
+ const outputRoot = await realpath(output);
360
+ const evidenceRoot = await realpath(evidenceDirectory);
361
+ const server = createServer(async (request, response) => {
362
+ try {
363
+ const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
364
+ if (pathname === '/report.html') {
365
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
366
+ response.end(await readFile(htmlPath));
367
+ return;
368
+ }
369
+ let base;
370
+ let relativePath;
371
+ if (pathname.startsWith('/evidence/') || pathname.startsWith('/page-check-screenshots/')) {
372
+ base = evidenceRoot;
373
+ relativePath = pathname.startsWith('/evidence/')
374
+ ? pathname.slice('/evidence/'.length)
375
+ : pathname.slice('/page-check-screenshots/'.length);
376
+ } else {
377
+ base = outputRoot;
378
+ relativePath = pathname.startsWith('/site/') ? pathname.slice('/site/'.length) : pathname.replace(/^\/+/, '');
379
+ }
380
+ let target = resolve(base, relativePath);
381
+ if (!inside(base, target)) {
382
+ response.writeHead(400);
383
+ response.end();
384
+ return;
385
+ }
386
+ let info;
387
+ try { info = await lstat(target); }
388
+ catch (error) {
389
+ if (error.code !== 'ENOENT') throw error;
390
+ if (!extname(target)) {
391
+ const fileCandidate = target + '.html';
392
+ try { info = await lstat(fileCandidate); target = fileCandidate; }
393
+ catch { target = resolve(target, 'index.html'); info = await lstat(target); }
394
+ } else throw error;
395
+ }
396
+ if (info.isDirectory() && !info.isSymbolicLink()) {
397
+ target = resolve(target, 'index.html');
398
+ info = await lstat(target);
399
+ }
400
+ if (info.isSymbolicLink() || !info.isFile()) {
401
+ response.writeHead(404);
402
+ response.end();
403
+ return;
404
+ }
405
+ const realTarget = await realpath(target);
406
+ if (!inside(base, realTarget)) {
407
+ response.writeHead(404);
408
+ response.end();
409
+ return;
410
+ }
411
+ response.writeHead(200, { 'content-type': mimeTypes[extname(target).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
412
+ response.end(await readFile(realTarget));
413
+ } catch {
414
+ response.writeHead(404);
415
+ response.end();
416
+ }
417
+ });
418
+ await new Promise((resolveListen, reject) => {
419
+ server.once('error', reject);
420
+ server.listen(0, '127.0.0.1', resolveListen);
421
+ });
422
+ return { server, port: server.address().port };
423
+ }
424
+
425
+ function siteAddress(relativeFile) {
426
+ return '/site/' + relativeFile.split('/').map(encodeURIComponent).join('/');
427
+ }
428
+
429
+ export async function addVisualEvidence(config, report) {
430
+ const root = await reportDirectory(config.root);
431
+ const browser = await browserPath();
432
+ report.browserChecks = { status: 'unavailable', physicalDevices: 'not tested', screenReaders: 'not tested',
433
+ method: 'Headless Chromium device emulation, accessibility tree and CDP keyboard input', keyboardSampleLimit: 12, results: [] };
434
+ if (!browser) {
435
+ report.browserChecks.reason = 'No installed Chromium browser was found.';
436
+ return { status: 'unavailable', reason: 'Install Microsoft Edge, Chrome, or Chromium to capture page screenshots and PDF evidence.' };
437
+ }
438
+
439
+ const stamp = createHash('sha256').update(report.generatedAt).digest('hex').slice(0, 8);
440
+ const profile = await mkdtemp(resolve(tmpdir(), 'edgepress-report-' + process.pid + '-' + stamp + '-'));
441
+ const evidenceDirectory = resolve(profile, 'page-check-screenshots');
442
+ const htmlPath = resolve(profile, 'report.html');
443
+ const pdfPath = resolve(root, 'page-check.pdf');
444
+ let reportServer;
445
+ let browserSession;
446
+ const browserSessions=[];
447
+ const runInBrowsers=async(items,task)=>Promise.all(browserSessions.map(async(session,worker)=>{
448
+ for(let index=worker;index<items.length;index+=browserSessions.length)await task(session,items[index],index);
449
+ }));
450
+ try {
451
+ await mkdir(evidenceDirectory, { recursive: true });
452
+ const evidenceInfo = await lstat(evidenceDirectory);
453
+ if (evidenceInfo.isSymbolicLink() || !evidenceInfo.isDirectory()) throw new Error('Temporary screenshot directory must be a real directory');
454
+ const documents = new Map((report.documents || []).map((item) => [item.path, item]));
455
+ const candidates = new Set((report.documents || [])
456
+ .filter((item) => ['post', 'page', 'homepage'].includes(item.type))
457
+ .map((item) => item.path));
458
+ for (const locale of config.i18n.locales) {
459
+ const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
460
+ candidates.add(prefix + 'index.html');
461
+ candidates.add(prefix + 'archives/index.html');
462
+ candidates.add(prefix + 'search/index.html');
463
+ candidates.add(prefix + 'privacy-policy/index.html');
464
+ }
465
+ for (const issue of report.issues) {
466
+ if (issue.page && issue.page.toLowerCase().endsWith('.html')) candidates.add(issue.page);
467
+ }
468
+
469
+ const available = [];
470
+ for (const relativeFile of candidates) {
471
+ const target = resolve(config.resolvedPaths.output, relativeFile);
472
+ try {
473
+ const info = await lstat(target);
474
+ if (info.isFile() && !info.isSymbolicLink()) available.push(relativeFile);
475
+ } catch (error) {
476
+ if (error.code !== 'ENOENT') throw error;
477
+ }
478
+ }
479
+
480
+ const screenshots = [];
481
+ const desktop = { name: 'desktop', width: 1440, height: 1100 };
482
+ const mobile = { name: 'mobile', width: 390, height: 844 };
483
+ const homePaths = new Set(config.i18n.locales.map((locale) => locale === config.i18n.defaultLocale ? 'index.html' : locale + '/index.html'));
484
+ const privacyPaths = new Set(config.i18n.locales.map((locale) =>
485
+ (locale === config.i18n.defaultLocale ? '' : locale + '/') + 'privacy-policy/index.html'));
486
+ for (const relativeFile of available) {
487
+ const document = documents.get(relativeFile) || { type: 'system', title: relativeFile };
488
+ const viewports = homePaths.has(relativeFile) || privacyPaths.has(relativeFile) || document.type === 'post'
489
+ ? [desktop, mobile] : [mobile];
490
+ for (const viewport of viewports) {
491
+ const digest = createHash('sha256').update(relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
492
+ const file = 'page-' + digest + '.png';
493
+ const target = resolve(evidenceDirectory, file);
494
+ screenshots.push({ page: relativeFile, type: document.type, capture: 'page', title: document.title, viewport: viewport.name, width: viewport.width,
495
+ height: viewport.height, file, target,
496
+ label: relativeFile + ' · ' + viewport.name + ' (' + viewport.width + '×' + viewport.height + ')' });
497
+ }
498
+ }
499
+ if (config.browserPlugins.consent.enabled) {
500
+ const availableSet = new Set(available);
501
+ for (const relativeFile of new Set([...homePaths, ...privacyPaths])) {
502
+ if (!availableSet.has(relativeFile)) continue;
503
+ for (const viewport of [desktop, mobile]) {
504
+ const digest = createHash('sha256').update('consent:' + relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
505
+ const file = 'consent-' + digest + '.png';
506
+ screenshots.push({ page: relativeFile, type: 'consent', capture: 'consent', title: 'Privacy choices',
507
+ viewport: viewport.name, width: viewport.width, height: viewport.height, file,
508
+ target: resolve(evidenceDirectory, file), label: 'Privacy choices · ' + relativeFile + ' · ' + viewport.name +
509
+ ' (' + viewport.width + '×' + viewport.height + ')' });
510
+ }
511
+ }
512
+ }
513
+ reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
514
+ for(let worker=0;worker<Math.min(3,available.length||1);worker++){
515
+ const browserProfile=resolve(profile,'browser-'+worker);await mkdir(browserProfile);
516
+ browserSessions.push(await startHeadlessBrowser(browser,browserProfile));
517
+ }
518
+ browserSession=browserSessions[0];
519
+ let reviewed=0;
520
+ const pageResults=new Array(available.length);
521
+ await runInBrowsers(available,async(browserSession,relativeFile,index)=>{
522
+ const results=[];
523
+ for (const device of deviceProfiles) for (const colorScheme of ['light', 'dark']) {
524
+ const emulation = { ...device, colorScheme };
525
+ await browserSession.setViewport(emulation);
526
+ await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(relativeFile));
527
+ const result = { page: relativeFile, profile: device.name, colorScheme, ...await browserSession.audit(emulation) };
528
+ results.push(result);
529
+ }
530
+ pageResults[index]=results;
531
+ report.browserChecks.results=pageResults.filter(Boolean).flat();
532
+ reviewed+=1;if(reviewed%25===0||reviewed===available.length)console.log('Browser checks: '+reviewed+'/'+available.length+' pages');
533
+ });
534
+ report.browserChecks.results=pageResults.flat();
535
+ for(const result of report.browserChecks.results)for(const check of result.checks.filter(item=>!item.passed)){
536
+ report.issues.push({severity:'error',category:'browser-'+check.group,page:result.page,
537
+ message:result.profile+'/'+result.colorScheme+': '+check.name+(check.detail?' ('+check.detail+')':'')+'.'});
538
+ }
539
+ report.browserChecks.status = report.browserChecks.results.every(result => result.status === 'pass') ? 'pass' : 'fail';
540
+ if (!report.browserChecks.results.length) {
541
+ report.browserChecks.status = 'unavailable';
542
+ report.browserChecks.reason = 'No generated pages were available for browser checks.';
543
+ }
544
+ let captured=0;
545
+ await runInBrowsers(screenshots,async(browserSession,screenshot)=>{
546
+ const viewport = screenshot.viewport === 'mobile' ? mobile : desktop;
547
+ await browserSession.setViewport(viewport);
548
+ await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
549
+ const dimensions = await browserSession.metrics();
550
+ if (screenshot.capture === 'consent') {
551
+ report.consentUi.visualChecks ||= [];
552
+ const checks = [
553
+ ['component stays inside the viewport', dimensions.consentExists && dimensions.consentWithinViewport],
554
+ ['component content stays inside its own box', dimensions.consentExists && dimensions.consentInnerNoOverflow],
555
+ ['accept and reject actions have equal visual weight', dimensions.consentExists && dimensions.consentActionsBalanced],
556
+ ['optional services begin unchecked', dimensions.consentExists && dimensions.consentOptInDefault],
557
+ ['expanded service details disclose all configured fields', dimensions.consentExists && dimensions.consentServiceDisclosures && dimensions.consentDetails]
558
+ ];
559
+ report.consentUi.visualChecks.push(...checks.map(([name, passed]) => ({ page: screenshot.page, viewport: viewport.name,
560
+ name, passed, consentWidth: dimensions.consentWidth, consentHeight: dimensions.consentHeight })));
561
+ const withinViewport = dimensions.consentExists && dimensions.consentWithinViewport;
562
+ const noInnerOverflow = dimensions.consentExists && dimensions.consentInnerNoOverflow;
563
+ if (!dimensions.consentExists) {
564
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
565
+ message: 'The consent manager did not appear in the browser.' });
566
+ } else if (!withinViewport || !noInnerOverflow) {
567
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
568
+ message: 'Consent UI exceeded its viewport or its own content box at the ' + viewport.name + ' size.' });
569
+ }
570
+ }
571
+ const image = screenshot.capture === 'consent'
572
+ ? await browserSession.screenshot({ clipSelector: '[data-consent-ui]' })
573
+ : await browserSession.screenshot({ hideConsent: config.browserPlugins.consent.enabled });
574
+ if (!image) {
575
+ report.issues.push({ severity: 'error', category: screenshot.capture === 'consent' ? 'consent-ui' : 'visual-evidence',
576
+ page: screenshot.page, message: 'The requested screenshot area was not present in the browser.' });
577
+ return;
578
+ }
579
+ await writeFile(screenshot.target, image);
580
+ const imageInfo = await lstat(screenshot.target);
581
+ if (!imageInfo.isFile() || imageInfo.size < 1000) throw new Error('Headless browser produced an empty screenshot');
582
+ screenshot.viewportWidth = dimensions.viewportWidth;
583
+ screenshot.documentWidth = dimensions.pageDocumentWidth;
584
+ screenshot.consentWidth = dimensions.consentWidth;
585
+ screenshot.consentHeight = dimensions.consentHeight;
586
+ screenshot.consentWithinViewport = dimensions.consentWithinViewport;
587
+ if (viewport.name === 'mobile' && dimensions.viewportWidth !== viewport.width) {
588
+ report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
589
+ message: 'Mobile viewport measured ' + dimensions.viewportWidth + ' CSS pixels instead of ' + viewport.width + '.' });
590
+ } else if (screenshot.capture === 'page' && viewport.name === 'mobile' && dimensions.pageDocumentWidth > dimensions.viewportWidth) {
591
+ report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
592
+ message: 'Page content width is ' + dimensions.pageDocumentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport; consent UI is measured separately.' });
593
+ }
594
+ captured+=1;if(captured%50===0||captured===screenshots.length)console.log('Report screenshots: '+captured+'/'+screenshots.length);
595
+ });
596
+ if (report.consentUi?.visualChecks) {
597
+ const visualChecks = report.consentUi.visualChecks;
598
+ report.consentUi.checks += visualChecks.length;
599
+ report.consentUi.passed += visualChecks.filter((item) => item.passed).length;
600
+ report.consentUi.status = report.consentUi.passed === report.consentUi.checks ? 'pass' : 'fail';
601
+ }
602
+ report.errors = report.issues.filter((item) => item.severity === 'error').length;
603
+ report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
604
+ report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
605
+ for (const document of report.documents || []) {
606
+ const findings = report.issues.filter((item) => item.page === document.path);
607
+ if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
608
+ else if (findings.length) document.status = 'warning';
609
+ }
610
+ report.visualEvidence = { status: 'complete', pdf: 'tools/reports/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
611
+ page: item.page, type: item.type, capture: item.capture, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
612
+ viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
613
+ consentWidth: item.consentWidth, consentHeight: item.consentHeight, consentWithinViewport: item.consentWithinViewport,
614
+ label: item.label
615
+ })) };
616
+ const initialHtml = reportHtml(report, screenshots);
617
+ const reportAccessibility = checkReportReadingStructure(initialHtml);
618
+ report.visualEvidence.reportAccessibility = reportAccessibility;
619
+ for (const check of reportAccessibility.results.filter((item) => !item.passed)) {
620
+ report.issues.push({ severity: 'error', category: 'report-accessibility', page: 'tools/reports/page-check.pdf',
621
+ message: 'Report reading requirement failed: ' + check.name + '.' });
622
+ }
623
+ report.errors = report.issues.filter((item) => item.severity === 'error').length;
624
+ report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
625
+ report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
626
+ const finalHtml = reportHtml(report, screenshots);
627
+ const finalReading = checkReportReadingStructure(finalHtml);
628
+ report.visualEvidence.reportAccessibility = finalReading;
629
+ if (finalReading.status !== 'pass') throw new Error('The generated accessibility report failed its reading-order checks.');
630
+ await writeFile(htmlPath, finalHtml, 'utf8');
631
+ await browserSession.setViewport({ name: 'desktop', width: 1200, height: 900 });
632
+ await browserSession.navigate('http://127.0.0.1:' + reportServer.port + '/report.html');
633
+ const pdf = await browserSession.printToPdf();
634
+ if (pdf.length < 1000) throw new Error('Headless browser produced an empty PDF');
635
+ await writeFile(pdfPath, pdf);
636
+ return report.visualEvidence;
637
+ } finally {
638
+ await Promise.allSettled(browserSessions.map(session=>session.close()));
639
+ if (reportServer) await new Promise((resolveClose) => reportServer.server.close(resolveClose));
640
+ await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 });
641
+ }
642
+ }