openvibers/openvibe.shared browser-harness.js
browser-harness.js
| 1 | ; |
| 2 | /** |
| 3 | * openvibe-shared/browser-harness — real-Chrome checks for any OpenVibe site (roadmap WS-Q task 3, WS-T |
| 4 | * tasks 3 and 4), generalised from Live's test/browser/smoke.js. No dependency: Chrome is driven over the |
| 5 | * DevTools protocol with Node's own WebSocket (Node 22+), one browser context per site (in-memory cache). |
| 6 | * |
| 7 | * const harness = require('openvibe-shared/browser-harness'); |
| 8 | * const report = await harness.run({ base: 'https://openvibe.wiki', routes: ['/', '/p/intro', { path: '/nope', status: 404 }] }); |
| 9 | * console.log(harness.format(report)); |
| 10 | * process.exit(report.ok ? 0 : 1); |
| 11 | * |
| 12 | * For every route, at every width (default 390, 768, 1280), in a fresh tab: |
| 13 | * status the main document's HTTP status is the expected one (200 unless the route says `status`) |
| 14 | * errors no uncaught exception, console.error or browser error log entry (failed loads, CSP) |
| 15 | * overflow the page cannot be scrolled sideways; the widest offending elements are named |
| 16 | * scripts no script URL (origin + path) is requested twice |
| 17 | * once per route, with JavaScript disabled (the initial HTML, what a crawler reads): |
| 18 | * nojs at least `minText` (200) characters of visible text |
| 19 | * canonical exactly one <link rel=canonical>, an absolute URL; another origin or a different URL with |
| 20 | * JavaScript on is a warning; a noindex page (meta robots or X-Robots-Tag) may have none |
| 21 | * jsonld every application/ld+json block parses, and the headline (else name) of each top-level entity |
| 22 | * (of an ItemList: 80 % of its item names) is in the visible text: without JavaScript passes, only |
| 23 | * with JavaScript warns, nowhere fails |
| 24 | * once per route, at the widest width: |
| 25 | * axe axe-core, WCAG 2.0/2.1 A and AA rules: serious and critical violations fail, moderate and |
| 26 | * minor are reported |
| 27 | * text the visible text has no "[object Object]", bare undefined/NaN, {{ }}, markup or an entity shown as |
| 28 | * text, or a space before a full stop (TEXT_SMELLS); warns, never fails |
| 29 | * once per site (`navigation`): |
| 30 | * growth A → B → A, `laps` times (default 5; a link on the page is clicked when there is one, so a |
| 31 | * single-page app navigates in place). After a forced GC at the end of each lap the JS heap, |
| 32 | * DOM nodes, event listeners, documents (Performance.getMetrics), live intervals, pending |
| 33 | * timeouts and open WebSockets (counted by a probe installed before any page script) are |
| 34 | * sampled. It fails when a measure grows from the end of lap 2 to the last lap by more than |
| 35 | * its budget (BUDGETS) and was still growing over the last half of the laps. |
| 36 | * idle then, after the page settles, `idleMs` (5 s) of doing nothing: CPU busy % (TaskDuration), |
| 37 | * requests and bytes. Reported, never failed. |
| 38 | * |
| 39 | * Options (run): base (required), routes (paths or { path, status, widths, checks: { nojs: false, … } }), |
| 40 | * widths, checks ({ axe: false, navigation: false, … }), navigation ({ from, to, laps, budgets }), |
| 41 | * minText, settleMs (quiet time after load, 600), maxSettleMs (15000), idleMs, ignoreErrors (RegExps on the |
| 42 | * message or URL, or { label, text, url } where every RegExp given must match; matches are counted per label |
| 43 | * in `ignored`, never dropped silently), |
| 44 | * axe ({ source } | { path } | { url, sha384 }), browser (a running one from launch()), chrome ({ bin, |
| 45 | * browserURL, tmpDir, args }), log (a function, progress lines). |
| 46 | * Report: { base, startedAt, finishedAt, chrome, routes: [RouteResult], navigation, summary, ok }. |
| 47 | * |
| 48 | * axe-core is not vendored (580 KB in every site's node_modules): an installed `axe-core` package is used |
| 49 | * when one resolves, else AXE.version is fetched once from jsDelivr, checked against AXE.sha384 (the same |
| 50 | * bytes as the npm tarball), and cached in $XDG_CACHE_HOME/openvibe-shared (or ~/.cache/openvibe-shared). |
| 51 | * Chrome: CHROME_BIN or the usual paths; `--no-sandbox` unless chrome.sandbox is true. |
| 52 | */ |
| 53 | const { spawn } = require('child_process'); |
| 54 | const crypto = require('crypto'); |
| 55 | const fs = require('fs'); |
| 56 | const os = require('os'); |
| 57 | const path = require('path'); |
| 58 | |
| 59 | const WIDTHS = [390, 768, 1280]; |
| 60 | const CHECKS = ['status', 'errors', 'overflow', 'scripts', 'nojs', 'canonical', 'jsonld', 'axe', 'text']; |
| 61 | |
| 62 | // Words a template, a serializer or a double escape leaves in a page (11-02 found each on a live site: a status page's |
| 63 | // "released undefined", /kit's "[object Object]", a changelog's "<ul>", a summary's "covers ."). Text a |
| 64 | // developer page shows in its code samples can match, so the check only warns. |
| 65 | const TEXT_SMELLS = [ |
| 66 | ['[object Object]', /\[object Object\]/], |
| 67 | ['undefined or NaN', /(?:^|[\s(:])(?:undefined|NaN)(?=[\s.,;)]|$)/], |
| 68 | ['template braces', /\{\{|\}\}/], |
| 69 | ['escaped twice', /&(?:amp|lt|gt|quot|#\d+);|<\/?(?:ul|li|p|div|span|strong|em|code|a)(?:\s[^>]*)?>/i], |
| 70 | ['a space before a full stop', /[A-Za-z] [.,;](?=\s|$)/], |
| 71 | ]; |
| 72 | function textSmells(text) { |
| 73 | const out = []; |
| 74 | for (const [name, re] of TEXT_SMELLS) { |
| 75 | const m = re.exec(text); |
| 76 | if (m) out.push({ smell: name, sample: text.slice(Math.max(0, m.index - 40), m.index + m[0].length + 30).trim() }); |
| 77 | } |
| 78 | return out; |
| 79 | } |
| 80 | /** Growth allowed from the end of lap 2 to the end of the last lap (so over 3 laps with the default 5). */ |
| 81 | const BUDGETS = Object.freeze({ heapKB: 3072, nodes: 300, listeners: 30, documents: 1, intervals: 1, timeouts: 10, sockets: 1 }); |
| 82 | const AXE = Object.freeze({ |
| 83 | version: '4.13.0', |
| 84 | url: 'https://cdn.jsdelivr.net/npm/[email protected]/axe.min.js', |
| 85 | sha384: 'jzJDdyy7z7+/I7TeoAg0Gc8k9hD8b1xRN0W18hMptWJ0cdoiebywhPpCyP9eBOgn', |
| 86 | tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'], |
| 87 | }); |
| 88 | // Entities whose name is not a visible fact of the page (a breadcrumb trail, a search box action, a logo). |
| 89 | const JSONLD_SKIP_TYPES = new Set(['BreadcrumbList', 'ListItem', 'SearchAction', 'EntryPoint', 'ImageObject', 'ContactPoint', 'ReadAction', 'WatchAction']); |
| 90 | const USER_AGENT_SUFFIX = ' OpenVibe-BrowserCheck/1'; |
| 91 | |
| 92 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 93 | |
| 94 | // ─── Chrome and the DevTools protocol ──────────────────────────────────────────────────────────── |
| 95 | |
| 96 | function findChrome(bin) { |
| 97 | return [bin, process.env.CHROME_BIN, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', |
| 98 | '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((c) => c && fs.existsSync(c)) || null; |
| 99 | } |
| 100 | |
| 101 | /** One WebSocket to the browser; pages are flattened sessions on it. */ |
| 102 | function connect(wsUrl, onClose) { |
| 103 | if (typeof WebSocket !== 'function') throw new Error('browser-harness needs Node 22 or later (global WebSocket)'); |
| 104 | const ws = new WebSocket(wsUrl); |
| 105 | let nextId = 0, closed = false; |
| 106 | const pending = new Map(); |
| 107 | const listeners = new Map(); // `${sessionId}|${method}` → [fn] |
| 108 | ws.onmessage = (e) => { |
| 109 | const msg = JSON.parse(e.data); |
| 110 | if (msg.id) { |
| 111 | const p = pending.get(msg.id); |
| 112 | if (!p) return; |
| 113 | pending.delete(msg.id); clearTimeout(p.timer); |
| 114 | if (msg.error) p.reject(new Error(`${p.method}: ${msg.error.message}`)); else p.resolve(msg.result || {}); |
| 115 | return; |
| 116 | } |
| 117 | for (const fn of listeners.get(`${msg.sessionId || ''}|${msg.method}`) || []) { |
| 118 | try { fn(msg.params || {}); } catch { /* a listener never breaks the connection */ } |
| 119 | } |
| 120 | }; |
| 121 | ws.onclose = () => { |
| 122 | closed = true; |
| 123 | for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('Chrome connection closed')); } |
| 124 | pending.clear(); |
| 125 | if (onClose) onClose(); |
| 126 | }; |
| 127 | const send = (method, params = {}, sessionId, timeoutMs = 60000) => new Promise((resolve, reject) => { |
| 128 | if (closed) { reject(new Error('Chrome connection closed')); return; } |
| 129 | const id = ++nextId; |
| 130 | const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method}: no answer in ${timeoutMs} ms`)); }, timeoutMs); |
| 131 | pending.set(id, { resolve, reject, timer, method }); |
| 132 | ws.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); |
| 133 | }); |
| 134 | const on = (sessionId, method, fn) => { |
| 135 | const k = `${sessionId || ''}|${method}`; |
| 136 | if (!listeners.has(k)) listeners.set(k, []); |
| 137 | listeners.get(k).push(fn); |
| 138 | }; |
| 139 | const off = (sessionId) => { for (const k of [...listeners.keys()]) if (k.startsWith(`${sessionId}|`)) listeners.delete(k); }; |
| 140 | const opened = new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error(`cannot connect to ${wsUrl}`)); }); |
| 141 | return { opened, send, on, off, close: () => { try { ws.close(); } catch { /* closed */ } }, get closed() { return closed; } }; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Starts headless Chrome (or attaches to one at chrome.browserURL, e.g. through an ssh tunnel) and answers |
| 146 | * { version, userAgent, send, newContext(), close() }. close() kills only the process it started and resolves once |
| 147 | * its temporary profile is removed. |
| 148 | */ |
| 149 | async function launch({ bin, browserURL, tmpDir, args = [], sandbox = false, headless = true, _again = false } = {}) { |
| 150 | let proc = null, profile = null, wsUrl; |
| 151 | if (browserURL) { |
| 152 | const v = await (await fetch(`${browserURL.replace(/\/$/, '')}/json/version`)).json(); |
| 153 | wsUrl = v.webSocketDebuggerUrl; |
| 154 | } else { |
| 155 | const exe = findChrome(bin); |
| 156 | if (!exe) throw new Error('Chrome not found (set CHROME_BIN)'); |
| 157 | profile = fs.mkdtempSync(path.join(tmpDir || os.tmpdir(), 'ov-harness-')); |
| 158 | proc = spawn(exe, [ |
| 159 | ...(headless ? ['--headless=new'] : []), '--remote-debugging-port=0', `--user-data-dir=${profile}`, |
| 160 | ...(sandbox ? [] : ['--no-sandbox']), '--no-first-run', '--no-default-browser-check', '--disable-extensions', |
| 161 | '--disable-background-networking', '--disable-component-update', '--disable-sync', '--disable-default-apps', |
| 162 | // Every page draws and runs timers as if in front: on macOS a new page counted as an occluded window, painted |
| 163 | // nothing, and the Core Web Vitals test saw no paint and no shift (2026-10-27, the first macOS CI run). |
| 164 | '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', |
| 165 | // No back/forward cache: kept pages would count in the navigation check's heap, node and document samples. |
| 166 | '--mute-audio', '--disk-cache-size=16777216', '--window-size=1280,900', '--disable-features=BackForwardCache', ...args, 'about:blank', |
| 167 | ], { stdio: 'ignore', detached: true }); // its own process group: close() stops Chrome and its helpers together |
| 168 | const portFile = path.join(profile, 'DevToolsActivePort'); |
| 169 | // Up to 45 s: several Chromes starting at once on a two-core CI runner took longer than 20 s (2026-10-25). |
| 170 | for (let i = 0; i < 450 && !wsUrl; i++) { |
| 171 | if (proc.exitCode != null) break; |
| 172 | try { |
| 173 | const [port, p] = fs.readFileSync(portFile, 'utf8').split('\n'); |
| 174 | if (port && p) wsUrl = `ws://127.0.0.1:${port.trim()}${p.trim()}`; |
| 175 | } catch { /* not yet */ } |
| 176 | if (!wsUrl) await sleep(100); |
| 177 | } |
| 178 | if (!wsUrl) { |
| 179 | proc.kill('SIGKILL'); fs.rmSync(profile, { recursive: true, force: true }); |
| 180 | // Once more before giving up: on a loaded runner the first Chrome of a batch can take longer than the |
| 181 | // wait, and the second, with the binary warm, comes up in a second or two (macOS CI, 2026-11-10). |
| 182 | if (!_again) return launch({ bin, browserURL, tmpDir, args, sandbox, headless, _again: true }); |
| 183 | throw new Error('Chrome did not start'); |
| 184 | } |
| 185 | } |
| 186 | const conn = connect(wsUrl); |
| 187 | await conn.opened; |
| 188 | const version = await conn.send('Browser.getVersion'); |
| 189 | // Only the Chrome this launch started: its process group (the browser and its renderer/GPU helpers, which |
| 190 | // otherwise outlive a killed browser for a moment and keep writing to the profile). |
| 191 | const killChrome = () => { if (!proc) return; try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch { /* gone */ } } }; |
| 192 | const removeProfile = () => { if (profile) { try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { /* retried by close() */ } } }; |
| 193 | // A leftover profile per run fills a small disk: if this process ends first, Chrome and the profile go too. |
| 194 | const onExit = () => { killChrome(); removeProfile(); }; |
| 195 | if (proc) process.once('exit', onExit); |
| 196 | /** Closes the connection, stops only the Chrome this launch started, and resolves once its profile is gone. */ |
| 197 | const close = () => new Promise((resolve) => { |
| 198 | conn.close(); |
| 199 | if (!proc) { resolve(); return; } |
| 200 | const done = async () => { |
| 201 | for (let i = 0; i < 30 && profile && fs.existsSync(profile); i++) { removeProfile(); if (fs.existsSync(profile)) await sleep(100); } |
| 202 | process.removeListener('exit', onExit); |
| 203 | resolve(); |
| 204 | }; |
| 205 | if (proc.exitCode != null || proc.signalCode != null) { killChrome(); done(); return; } |
| 206 | proc.once('exit', () => { killChrome(); done(); }); |
| 207 | killChrome(); |
| 208 | }); |
| 209 | return { |
| 210 | version: version.product, userAgent: version.userAgent, pid: proc ? proc.pid : null, |
| 211 | send: conn.send, on: conn.on, off: conn.off, get closed() { return conn.closed; }, close, |
| 212 | newContext: async () => { |
| 213 | const { browserContextId } = await conn.send('Target.createBrowserContext', { disposeOnDetach: false }); |
| 214 | return { id: browserContextId, dispose: () => conn.send('Target.disposeBrowserContext', { browserContextId }).catch(() => {}) }; |
| 215 | }, |
| 216 | }; |
| 217 | } |
| 218 | |
| 219 | // Installed before any page script (navigation check only): live intervals, pending timeouts, open sockets |
| 220 | // and window/document listeners as the browser keeps them (re-adding the same listener is a no-op; `once` ones are |
| 221 | // not counted; one added with an AbortSignal is forgotten when the signal aborts, as the browser forgets it: holding it |
| 222 | // would keep alive whatever it closes over, and the check would measure its own probe). |
| 223 | const PROBE = `(() => { |
| 224 | if (window.__ovProbe) return; |
| 225 | const w = window; const intervals = new Set(), timeouts = new Set(), sockets = new Set(); |
| 226 | const si = w.setInterval, ci = w.clearInterval, st = w.setTimeout, ct = w.clearTimeout; |
| 227 | w.setInterval = function (...a) { const id = si.apply(this, a); intervals.add(id); return id; }; |
| 228 | w.clearInterval = function (id) { intervals.delete(id); timeouts.delete(id); return ci.call(this, id); }; |
| 229 | w.setTimeout = function (fn, ...a) { |
| 230 | if (typeof fn !== 'function') return st.call(this, fn, ...a); |
| 231 | let id; id = st.call(this, function (...b) { timeouts.delete(id); return fn.apply(this, b); }, ...a); timeouts.add(id); return id; |
| 232 | }; |
| 233 | w.clearTimeout = function (id) { timeouts.delete(id); intervals.delete(id); return ct.call(this, id); }; |
| 234 | const WS = w.WebSocket; |
| 235 | if (WS) { |
| 236 | w.WebSocket = function (...a) { const s = new WS(...a); sockets.add(s); s.addEventListener('close', () => sockets.delete(s)); return s; }; |
| 237 | w.WebSocket.prototype = WS.prototype; Object.assign(w.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); |
| 238 | } |
| 239 | const counts = {}; |
| 240 | for (const target of [w, document]) { |
| 241 | const add = target.addEventListener, rem = target.removeEventListener; const key = target === w ? 'window' : 'document'; |
| 242 | const reg = new Map(); counts[key] = () => [...reg.values()].reduce((n, m) => n + [...m.values()].reduce((k, s) => k + s.size, 0), 0); |
| 243 | const cap = (o) => !!(o === true || (o && o.capture)); |
| 244 | const forget = (t, fn, c) => { const m = reg.get(t); if (m && m.has(fn)) { m.get(fn).delete(c); if (!m.get(fn).size) m.delete(fn); } }; |
| 245 | target.addEventListener = function (t, fn, o) { |
| 246 | const sig = o && typeof o === 'object' ? o.signal : null; |
| 247 | if (fn && !(o && o.once) && !(sig && sig.aborted)) { |
| 248 | if (!reg.has(t)) reg.set(t, new Map()); const m = reg.get(t); if (!m.has(fn)) m.set(fn, new Set()); |
| 249 | const c = cap(o); if (!m.get(fn).has(c)) { m.get(fn).add(c); if (sig) sig.addEventListener('abort', () => forget(t, fn, c), { once: true }); } |
| 250 | } |
| 251 | return add.call(this, t, fn, o); |
| 252 | }; |
| 253 | target.removeEventListener = function (t, fn, o) { forget(t, fn, cap(o)); return rem.call(this, t, fn, o); }; |
| 254 | } |
| 255 | Object.defineProperty(w, '__ovProbe', { value: () => ({ intervals: intervals.size, timeouts: timeouts.size, |
| 256 | sockets: [...sockets].filter((s) => s.readyState < 2).length, windowListeners: counts.window(), documentListeners: counts.document(), |
| 257 | href: location.pathname + location.search, timeOrigin: performance.timeOrigin }) }); |
| 258 | })();`; |
| 259 | |
| 260 | /** A tab in `context`, with its errors, script requests, document status and network activity recorded. */ |
| 261 | async function openPage(browser, context, { width = 1280, height = 900, js = true, probe = false, block = null } = {}) { |
| 262 | const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank', browserContextId: context.id }); |
| 263 | const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true }); |
| 264 | const send = (m, p, t) => browser.send(m, p, sessionId, t); |
| 265 | const on = (m, fn) => browser.on(sessionId, m, fn); |
| 266 | const st = { errors: [], scripts: new Map(), doc: null, redirects: [], inflight: new Map(), lastActivity: Date.now(), loading: false, requests: 0, bytes: 0, mainFrame: null, urls: [] }; |
| 267 | const activity = () => { st.lastActivity = Date.now(); }; |
| 268 | on('Runtime.exceptionThrown', (p) => { |
| 269 | const d = p.exceptionDetails || {}; |
| 270 | st.errors.push({ source: 'exception', text: firstLine((d.exception && d.exception.description) || d.text), url: d.url || '' }); |
| 271 | }); |
| 272 | on('Runtime.consoleAPICalled', (p) => { |
| 273 | if (p.type !== 'error' && p.type !== 'assert') return; |
| 274 | const text = (p.args || []).map((a) => (a.value !== undefined ? String(a.value) : a.description || a.type)).join(' '); |
| 275 | const frame = p.stackTrace && p.stackTrace.callFrames && p.stackTrace.callFrames[0]; |
| 276 | st.errors.push({ source: 'console', text: firstLine(text), url: frame ? frame.url : '' }); |
| 277 | }); |
| 278 | on('Log.entryAdded', (p) => { |
| 279 | const e = p.entry || {}; |
| 280 | if (e.level === 'error') st.errors.push({ source: e.source || 'log', text: firstLine(e.text), url: e.url || '' }); |
| 281 | }); |
| 282 | on('Network.requestWillBeSent', (p) => { |
| 283 | activity(); st.requests++; |
| 284 | if (st.urls.length < 500) st.urls.push(p.request.url); |
| 285 | if (p.type === 'Script') { |
| 286 | const key = scriptKey(p.request.url); |
| 287 | if (key) { if (!st.scripts.has(key)) st.scripts.set(key, new Set()); st.scripts.get(key).add(p.requestId); } |
| 288 | } |
| 289 | if (p.type === 'Document' && p.frameId === st.mainFrame) { |
| 290 | if (p.redirectResponse) st.redirects.push({ status: p.redirectResponse.status, url: p.redirectResponse.url }); |
| 291 | else st.redirects = []; |
| 292 | } |
| 293 | if (p.type !== 'EventSource') st.inflight.set(p.requestId, p.type); |
| 294 | }); |
| 295 | on('Network.responseReceived', (p) => { |
| 296 | activity(); |
| 297 | if (p.type === 'Document' && p.frameId === st.mainFrame) { |
| 298 | const hdr = Object.fromEntries(Object.entries(p.response.headers || {}).map(([k, v]) => [k.toLowerCase(), v])); |
| 299 | st.doc = { status: p.response.status, url: p.response.url, mimeType: p.response.mimeType, xRobots: hdr['x-robots-tag'] || '' }; |
| 300 | } |
| 301 | }); |
| 302 | on('Network.dataReceived', (p) => { st.bytes += p.encodedDataLength || 0; }); |
| 303 | on('Network.loadingFinished', (p) => { activity(); st.inflight.delete(p.requestId); }); |
| 304 | on('Network.loadingFailed', (p) => { |
| 305 | activity(); st.inflight.delete(p.requestId); |
| 306 | if (p.type === 'Document' && !st.doc && !p.canceled) st.doc = { status: null, error: p.errorText }; |
| 307 | }); |
| 308 | on('Network.webSocketFrameReceived', activity); |
| 309 | on('Page.frameStartedLoading', (p) => { if (p.frameId === st.mainFrame) { st.loading = true; activity(); } }); |
| 310 | on('Page.frameStoppedLoading', (p) => { if (p.frameId === st.mainFrame) { st.loading = false; activity(); } }); |
| 311 | await Promise.all([send('Page.enable'), send('Runtime.enable'), send('Network.enable'), send('Log.enable'), send('Performance.enable')]); |
| 312 | st.mainFrame = (await send('Page.getFrameTree')).frameTree.frame.id; |
| 313 | await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 700 }); |
| 314 | if (width < 700) await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); |
| 315 | // A phone width gets a phone user agent too (sites that choose markup by user agent), marked as this harness. |
| 316 | const ua = width < 700 ? browser.userAgent.replace(/\([^)]*\)/, '(Linux; Android 10; K)').replace(/ Safari\//, ' Mobile Safari/') : browser.userAgent; |
| 317 | await send('Network.setUserAgentOverride', { userAgent: ua + USER_AGENT_SUFFIX }); |
| 318 | if (!js) await send('Emulation.setScriptExecutionDisabled', { value: true }); |
| 319 | // Requests matching these patterns fail as if the host were down (checkUnreachable). |
| 320 | if (Array.isArray(block) && block.length) await send('Network.setBlockedURLs', { urls: block }); |
| 321 | if (probe) await send('Page.addScriptToEvaluateOnNewDocument', { source: PROBE }); |
| 322 | |
| 323 | const evaluate = async (expression, timeoutMs) => { |
| 324 | const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs); |
| 325 | if (r.exceptionDetails) { |
| 326 | const d = r.exceptionDetails; |
| 327 | throw new Error(firstLine((d.exception && d.exception.description) || d.text)); |
| 328 | } |
| 329 | return r.result ? r.result.value : undefined; |
| 330 | }; |
| 331 | /** Waits for the main frame to stop loading and the network to go quiet (EventSource/WebSocket excluded). */ |
| 332 | const settle = async ({ quietMs = 600, maxMs = 15000, minMs = 300 } = {}) => { |
| 333 | const start = Date.now(); |
| 334 | await sleep(minMs); |
| 335 | while (Date.now() - start < maxMs) { |
| 336 | const idleFor = Date.now() - st.lastActivity; |
| 337 | // A request that never finishes (long poll) cannot hold the page forever: 3× quiet with no event. |
| 338 | if (!st.loading && ((st.inflight.size === 0 && idleFor >= quietMs) || idleFor >= quietMs * 3)) return true; |
| 339 | await sleep(100); |
| 340 | } |
| 341 | return false; |
| 342 | }; |
| 343 | const goto = async (url, opts) => { |
| 344 | st.doc = null; st.loading = true; activity(); |
| 345 | const r = await send('Page.navigate', { url }); |
| 346 | if (r.errorText) { st.loading = false; if (!st.doc) st.doc = { status: null, error: r.errorText }; } |
| 347 | return settle(opts); |
| 348 | }; |
| 349 | const metrics = async () => { |
| 350 | const { metrics: list } = await send('Performance.getMetrics'); |
| 351 | return Object.fromEntries(list.map((m) => [m.name, m.value])); |
| 352 | }; |
| 353 | const close = async () => { |
| 354 | browser.off(sessionId); |
| 355 | await browser.send('Target.closeTarget', { targetId }).catch(() => {}); |
| 356 | }; |
| 357 | return { send, on, evaluate, settle, goto, metrics, close, state: st }; |
| 358 | } |
| 359 | |
| 360 | const firstLine = (s) => String(s || '').split('\n')[0].slice(0, 300); |
| 361 | function scriptKey(u) { |
| 362 | try { const x = new URL(u); return /^https?:$/.test(x.protocol) ? x.origin + x.pathname : null; } catch { return null; } |
| 363 | } |
| 364 | |
| 365 | // ─── Page reads (evaluated in the page) ─────────────────────────────────────────────────────────── |
| 366 | |
| 367 | const READ_PAGE = `(() => { |
| 368 | const de = document.documentElement, body = document.body; |
| 369 | const vw = de.clientWidth; |
| 370 | const scrollWidth = Math.max(de.scrollWidth, body ? body.scrollWidth : 0); |
| 371 | const x0 = window.scrollX; window.scrollTo(100000, window.scrollY); const scrollX = window.scrollX; window.scrollTo(x0, window.scrollY); |
| 372 | const describe = (e) => { |
| 373 | let s = e.tagName.toLowerCase(); |
| 374 | if (e.id) s += '#' + e.id; |
| 375 | else if (typeof e.className === 'string' && e.className.trim()) s += '.' + e.className.trim().split(/\\s+/).slice(0, 2).join('.'); |
| 376 | return s.slice(0, 80); |
| 377 | }; |
| 378 | const offenders = []; |
| 379 | if (scrollX > 0 || scrollWidth > vw + 1) { |
| 380 | for (const e of body ? body.getElementsByTagName('*') : []) { |
| 381 | if (offenders.length >= 5) break; |
| 382 | const r = e.getBoundingClientRect(); |
| 383 | if (!r.width || r.right <= vw + 1) continue; |
| 384 | if (offenders.some((o) => o.el.contains(e))) continue; |
| 385 | const cs = getComputedStyle(e); |
| 386 | if (cs.position === 'fixed' || cs.visibility === 'hidden') continue; |
| 387 | offenders.push({ el: e, desc: describe(e) + ' (right ' + Math.round(r.right) + 'px)' }); |
| 388 | } |
| 389 | } |
| 390 | // A counted fold's contents (a closed <details>: one tap, with or without JavaScript, and read by search engines) |
| 391 | // count as the page's text: the network's rule is to fold, never to hide (the owner, 2026-10-31). |
| 392 | const folded = [...document.querySelectorAll('details:not([open])')].map((d) => [...d.children].filter((c) => c.tagName !== 'SUMMARY').map((c) => c.textContent).join(' ')).join(' '); |
| 393 | const text = ((body ? body.innerText : '') + ' ' + folded).replace(/\\s+/g, ' ').trim(); |
| 394 | return { |
| 395 | url: location.href, title: document.title, vw, scrollWidth, scrollX, |
| 396 | offenders: offenders.map((o) => o.desc), |
| 397 | canonical: [...document.querySelectorAll('link[rel~="canonical" i]')].map((l) => l.getAttribute('href')), |
| 398 | robots: [...document.querySelectorAll('meta[name="robots" i]')].map((m) => m.getAttribute('content') || '').join(', '), |
| 399 | jsonld: [...document.querySelectorAll('script[type="application/ld+json" i]')].map((s) => s.textContent), |
| 400 | h1: document.querySelectorAll('h1').length, links: document.querySelectorAll('a[href]').length, |
| 401 | textChars: text.length, text: text.slice(0, 300000), |
| 402 | }; |
| 403 | })()`; |
| 404 | |
| 405 | // ─── Pure helpers (tested without a browser) ────────────────────────────────────────────────────── |
| 406 | |
| 407 | /** Routes as objects: { path, status, widths, checks }. */ |
| 408 | function normalizeRoutes(routes) { |
| 409 | const list = (routes && routes.length ? routes : ['/']).map((r) => (typeof r === 'string' ? { path: r } : { ...r })); |
| 410 | for (const r of list) { |
| 411 | if (!r.path || !String(r.path).startsWith('/')) throw new Error(`browser-harness: route path must start with / (${JSON.stringify(r.path)})`); |
| 412 | r.status = r.status == null ? 200 : Number(r.status); |
| 413 | r.checks = { ...(r.checks || {}) }; |
| 414 | // A route answered with an error page is checked for its status, errors and layout, not for SEO. |
| 415 | if (r.status !== 200) for (const c of ['nojs', 'canonical', 'jsonld']) if (r.checks[c] === undefined) r.checks[c] = false; |
| 416 | } |
| 417 | return list; |
| 418 | } |
| 419 | |
| 420 | const normText = (s) => String(s || '').normalize('NFKC').toLowerCase() |
| 421 | .replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"').replace(/[–—]/g, '-') |
| 422 | .replace(/\s+/g, ' ').trim(); |
| 423 | |
| 424 | /** Where `value` appears in `text`: 'full', 'prefix' (a long value's first 60 characters) or null. */ |
| 425 | function findText(text, value) { |
| 426 | const t = normText(text), v = normText(value).replace(/(\.\.\.|…)$/, '').trim(); |
| 427 | if (!v) return null; |
| 428 | if (t.includes(v)) return 'full'; |
| 429 | if (v.length > 80 && t.includes(v.slice(0, 60))) return 'prefix'; |
| 430 | return null; |
| 431 | } |
| 432 | |
| 433 | const WEBPAGE_TYPES = /(^|,)(WebPage|CollectionPage|AboutPage|ContactPage|ProfilePage|SearchResultsPage|ItemPage|FAQPage|QAPage|CheckoutPage|MedicalWebPage|RealEstateListing)(,|$)/; |
| 434 | /** |
| 435 | * Where a JSON-LD entity's headline/name (a list: 80 % of its item names) is visible: 'html' (without JavaScript), 'js' (only with it) or null. |
| 436 | * A title-style name ("Content — OpenVibe.Live") also matches by its leading part, and a web page's name by |
| 437 | * the document title. |
| 438 | */ |
| 439 | function visibleIn(entity, nojs, jsText) { |
| 440 | if (entity.items) { |
| 441 | // At least 80 % of a list's item names (a stale or truncated one may be missing). |
| 442 | const share = (t) => entity.items.filter((x) => findText(t, x)).length / entity.items.length; |
| 443 | return share(nojs.text) >= 0.8 ? 'html' : share(jsText) >= 0.8 ? 'js' : null; |
| 444 | } |
| 445 | const parts = entity.value.split(/\s+[—–|·-]\s+/); |
| 446 | const candidates = [entity.value, ...(parts.length > 1 && parts[0].length >= 3 ? [parts[0]] : [])]; |
| 447 | const inText = (t) => candidates.some((c) => findText(t, c)); |
| 448 | if (inText(nojs.text) || (WEBPAGE_TYPES.test(entity.type) && nojs.title && findText(nojs.title, entity.value))) return 'html'; |
| 449 | return inText(jsText) ? 'js' : null; |
| 450 | } |
| 451 | |
| 452 | /** Parses JSON-LD blocks: { parseErrors: [msg], entities: [{ type, field, value }] } (top level and @graph). */ |
| 453 | function jsonLdEntities(blocks) { |
| 454 | const parseErrors = [], entities = []; |
| 455 | (blocks || []).forEach((raw, i) => { |
| 456 | let data; |
| 457 | try { data = JSON.parse(raw); } catch (e) { parseErrors.push(`block ${i + 1}: ${e.message}`); return; } |
| 458 | const nodes = []; |
| 459 | for (const d of Array.isArray(data) ? data : [data]) { |
| 460 | if (!d || typeof d !== 'object') continue; |
| 461 | if (Array.isArray(d['@graph'])) nodes.push(...d['@graph']); else nodes.push(d); |
| 462 | } |
| 463 | for (const n of nodes) { |
| 464 | if (!n || typeof n !== 'object') continue; |
| 465 | const types = [].concat(n['@type'] || []).map(String); |
| 466 | if (types.some((t) => JSONLD_SKIP_TYPES.has(t))) continue; |
| 467 | // A list's facts are its items (its own name is a label, often an aria-label): their names are checked. |
| 468 | if (types.includes('ItemList') && Array.isArray(n.itemListElement)) { |
| 469 | const names = n.itemListElement.map((x) => x && (typeof x.name === 'string' ? x.name : x.item && typeof x.item.name === 'string' ? x.item.name : null)) |
| 470 | .filter((x) => x && x.trim()).map((x) => x.trim()); |
| 471 | if (names.length) { entities.push({ type: types.join(','), field: 'items', value: names.length === 1 ? names[0] : `${names.length} items`, items: names, name: typeof n.name === 'string' ? n.name : undefined }); continue; } |
| 472 | } |
| 473 | const field = typeof n.headline === 'string' && n.headline.trim() ? 'headline' : typeof n.name === 'string' && n.name.trim() ? 'name' : null; |
| 474 | if (field) entities.push({ type: types.join(',') || '?', field, value: n[field].trim() }); |
| 475 | } |
| 476 | }); |
| 477 | return { parseErrors, entities }; |
| 478 | } |
| 479 | |
| 480 | /** Growth from sample `from` (index) to the last; fails a measure over budget that still grew over the last half. */ |
| 481 | function growth(samples, budgets = BUDGETS, from = 1) { |
| 482 | const s = samples.filter(Boolean); |
| 483 | if (s.length < from + 2) return { measured: false, deltas: {}, over: [] }; |
| 484 | const base = s[from], last = s[s.length - 1], mid = s[Math.floor((from + s.length - 1) / 2)]; |
| 485 | const deltas = {}, over = []; |
| 486 | for (const k of Object.keys(budgets)) { |
| 487 | if (typeof base[k] !== 'number' || typeof last[k] !== 'number') continue; |
| 488 | const d = last[k] - base[k]; |
| 489 | deltas[k] = Math.round(d * 10) / 10; |
| 490 | if (d > budgets[k] && last[k] > mid[k]) over.push({ name: k, from: base[k], to: last[k], delta: deltas[k], budget: budgets[k] }); |
| 491 | } |
| 492 | return { measured: true, laps: s.length, from: from + 1, deltas, over }; |
| 493 | } |
| 494 | |
| 495 | /** Same-origin paths from a sitemap (or the <loc>s of a sitemap index), one per first path segment (all /@… count as one), capped. */ |
| 496 | function sitemapPaths(xml, base, cap = 4) { |
| 497 | const origin = new URL(base).origin; |
| 498 | const seen = new Set(), out = []; |
| 499 | for (const m of String(xml).matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) { |
| 500 | let u; |
| 501 | try { u = new URL(m[1].replace(/&/g, '&')); } catch { continue; } |
| 502 | if (u.origin !== origin) continue; |
| 503 | const p = u.pathname + u.search; |
| 504 | // One per section: /p/…, /tool/…; every /@channel is one section. |
| 505 | const first = u.pathname.split('/')[1] || ''; |
| 506 | const section = first.startsWith('@') ? '@' : first; |
| 507 | if (p === '/' || seen.has(section)) continue; |
| 508 | seen.add(section); out.push(p); |
| 509 | if (out.length >= cap) break; |
| 510 | } |
| 511 | return out; |
| 512 | } |
| 513 | |
| 514 | function checkRoute(route, r, { minText, ignoreErrors }) { |
| 515 | const checks = {}; |
| 516 | // A document that is not HTML (an API's JSON 404) is not a page: only its status is judged. |
| 517 | const types = r.widths.map((w) => w.contentType).filter(Boolean); |
| 518 | if (types.length && types.every((t) => !/html/i.test(t))) { |
| 519 | r.contentType = types[0]; |
| 520 | route = { ...route, checks: Object.fromEntries(CHECKS.filter((c) => c !== 'status').map((c) => [c, false])) }; |
| 521 | r.axe = r.axe && { skipped: true }; |
| 522 | } |
| 523 | const want = (c) => route.checks[c] !== false; |
| 524 | // status: every width and the no-JS load answered the expected status. |
| 525 | checks.status = statusesOf(r).every((s) => s === route.status) ? 'pass' : 'fail'; |
| 526 | const { kept, ignored } = splitErrors(r.widths.flatMap((w) => w.errors), ignoreErrors); |
| 527 | r.errors = dedupe(kept.map((e) => `${e.source}: ${e.text}${e.url && !e.text.includes(e.url) ? ` (${e.url})` : ''}`)); |
| 528 | if (Object.keys(ignored).length) r.ignored = ignored; |
| 529 | checks.errors = want('errors') ? (r.errors.length ? 'fail' : 'pass') : 'skip'; |
| 530 | checks.overflow = want('overflow') ? (r.widths.some((w) => w.overflow) ? 'fail' : 'pass') : 'skip'; |
| 531 | checks.scripts = want('scripts') ? (r.widths.some((w) => w.duplicateScripts.length) ? 'fail' : 'pass') : 'skip'; |
| 532 | if (r.nojs && !r.nojs.error) { |
| 533 | const n = r.nojs; |
| 534 | checks.nojs = !want('nojs') ? 'skip' : n.textChars >= minText ? 'pass' : 'fail'; |
| 535 | if (!want('canonical')) checks.canonical = 'skip'; |
| 536 | // A page that asks not to be indexed needs no canonical URL (one it has is still checked). |
| 537 | else if (!n.canonical.length && /noindex/i.test(n.robots || '')) { checks.canonical = 'skip'; r.canonical = { found: 0, note: 'noindex page: no canonical needed' }; } |
| 538 | else if (n.canonical.length !== 1) { checks.canonical = 'fail'; r.canonical = { found: n.canonical.length, note: n.canonical.length ? 'more than one canonical link' : 'no canonical link' }; } |
| 539 | else { |
| 540 | const href = n.canonical[0]; |
| 541 | let abs = null; |
| 542 | try { abs = /^https?:\/\//i.test(href) ? new URL(href) : null; } catch { abs = null; } |
| 543 | const jsHref = (r.widths.find((w) => w.canonical) || {}).canonical || []; |
| 544 | r.canonical = { href }; |
| 545 | if (!abs) { checks.canonical = 'fail'; r.canonical.note = 'not an absolute URL'; } |
| 546 | else { |
| 547 | const notes = []; |
| 548 | if (abs.origin !== new URL(r.url).origin) notes.push(`points to ${abs.origin}`); |
| 549 | if (jsHref.length === 1 && jsHref[0] !== href) notes.push(`JavaScript changes it to ${jsHref[0]}`); |
| 550 | if (jsHref.length > 1) notes.push(`${jsHref.length} canonical links with JavaScript`); |
| 551 | checks.canonical = notes.length ? 'warn' : 'pass'; |
| 552 | if (notes.length) r.canonical.note = notes.join('; '); |
| 553 | } |
| 554 | } |
| 555 | if (!want('jsonld')) checks.jsonld = 'skip'; |
| 556 | else { |
| 557 | const { parseErrors, entities } = jsonLdEntities(n.jsonld); |
| 558 | const jsText = (r.widths.find((w) => w.width === Math.max(...r.widths.map((x) => x.width))) || {}).text || ''; |
| 559 | const found = entities.map((e) => ({ ...e, found: visibleIn(e, n, jsText) })); |
| 560 | r.jsonld = { blocks: n.jsonld.length, parseErrors, entities: found }; |
| 561 | checks.jsonld = !n.jsonld.length ? 'skip' : parseErrors.length || found.some((e) => !e.found) ? 'fail' : found.some((e) => e.found === 'js') ? 'warn' : 'pass'; |
| 562 | } |
| 563 | } else { |
| 564 | for (const c of ['nojs', 'canonical', 'jsonld']) checks[c] = want(c) && r.nojs && r.nojs.error ? 'fail' : 'skip'; |
| 565 | } |
| 566 | // text: what the page says reads whole (TEXT_SMELLS); warns, never fails. |
| 567 | const pageText = (r.widths.find((w) => w.text) || {}).text || ''; |
| 568 | if (!want('text') || !pageText) checks.text = 'skip'; |
| 569 | else { const smells = textSmells(pageText); if (smells.length) r.textSmells = smells; checks.text = smells.length ? 'warn' : 'pass'; } |
| 570 | if (!r.axe || r.axe.skipped) checks.axe = 'skip'; |
| 571 | else if (r.axe.error) checks.axe = 'fail'; |
| 572 | else checks.axe = r.axe.serious + r.axe.critical ? 'fail' : r.axe.moderate ? 'warn' : 'pass'; |
| 573 | r.checks = checks; |
| 574 | r.ok = !Object.values(checks).includes('fail'); |
| 575 | return r; |
| 576 | } |
| 577 | |
| 578 | const dedupe = (a) => [...new Set(a)]; |
| 579 | |
| 580 | /** Errors not matched by an ignore rule, and a count per rule label of those that were. */ |
| 581 | function splitErrors(errors, rules = []) { |
| 582 | const kept = [], ignored = {}; |
| 583 | for (const e of errors) { |
| 584 | const rule = rules.find((x) => (x instanceof RegExp ? x.test(e.text) || x.test(e.url || '') |
| 585 | : (!x.text || x.text.test(e.text)) && (!x.url || x.url.test(e.url || '')))); |
| 586 | if (!rule) { kept.push(e); continue; } |
| 587 | const label = rule instanceof RegExp ? String(rule) : rule.label || String(rule.text || rule.url); |
| 588 | ignored[label] = (ignored[label] || 0) + 1; |
| 589 | } |
| 590 | return { kept, ignored }; |
| 591 | } |
| 592 | /** The main document's status at every width and without JavaScript (null: no response). */ |
| 593 | const statusesOf = (r) => [...r.widths.map((w) => w.status), ...(r.nojs && !r.nojs.error ? [r.nojs.status] : [])]; |
| 594 | |
| 595 | /** Per check: { pass, warn, fail, skip }; axe impact totals; growth and idle; ok. */ |
| 596 | function summarize(report) { |
| 597 | const checks = {}; |
| 598 | for (const c of CHECKS) checks[c] = { pass: 0, warn: 0, fail: 0, skip: 0 }; |
| 599 | const axe = { critical: 0, serious: 0, moderate: 0, minor: 0 }; |
| 600 | for (const r of report.routes) { |
| 601 | for (const c of CHECKS) checks[c][(r.checks && r.checks[c]) || 'skip']++; |
| 602 | if (r.axe && !r.axe.skipped && !r.axe.error) for (const k of Object.keys(axe)) axe[k] += r.axe[k] || 0; |
| 603 | } |
| 604 | const nav = report.navigation; |
| 605 | checks.navigation = { pass: 0, warn: 0, fail: 0, skip: 0 }; |
| 606 | checks.navigation[!nav || nav.skipped ? 'skip' : nav.error || !nav.ok ? 'fail' : 'pass']++; |
| 607 | const ok = report.routes.every((r) => r.ok) && checks.navigation.fail === 0 && !report.error; |
| 608 | return { routes: report.routes.length, checks, axe, ok }; |
| 609 | } |
| 610 | |
| 611 | /** Plain text (default) or Markdown: the failures and warnings, route by route, then the navigation check. */ |
| 612 | function format(report, { markdown = false } = {}) { |
| 613 | const L = []; |
| 614 | const b = (s) => (markdown ? `**${s}**` : s); |
| 615 | const code = (s) => (markdown ? `\`${String(s).replace(/`/g, "'")}\`` : String(s)); |
| 616 | L.push(`${b(report.base)}: ${report.ok ? 'pass' : 'FAIL'} (${report.routes.length} route(s), ${report.chrome || 'Chrome'})`); |
| 617 | if (report.error) L.push(` error: ${report.error}`); |
| 618 | for (const r of report.routes) { |
| 619 | const bad = Object.entries(r.checks || {}).filter(([, v]) => v === 'fail' || v === 'warn'); |
| 620 | if (r.error) { L.push(`- ${code(r.path)}: error: ${r.error}`); continue; } |
| 621 | if (!bad.length) { L.push(`- ${code(r.path)}: pass${r.contentType ? ` (${r.contentType}: only the status is checked)` : ''}`); continue; } |
| 622 | L.push(`- ${code(r.path)}: ${bad.map(([k, v]) => `${k} ${v}`).join(', ')}`); |
| 623 | const d = (s) => L.push(` - ${s}`); |
| 624 | if (r.contentType) d(`answered ${r.contentType}, not HTML: only the status is checked`); |
| 625 | if (r.checks.status === 'fail') d(`status: expected ${r.status}, got ${dedupe(statusesOf(r).map(String)).join('/')}${r.widths.find((w) => w.navError) ? ` (${r.widths.find((w) => w.navError).navError})` : ''}`); |
| 626 | for (const e of (r.errors || []).slice(0, 6)) d(`error: ${e}`); |
| 627 | for (const [label, n] of Object.entries(r.ignored || {})) d(`ignored ×${n}: ${label}`); |
| 628 | if ((r.errors || []).length > 6) d(`… ${r.errors.length - 6} more error(s)`); |
| 629 | for (const w of r.widths.filter((x) => x.overflow)) d(`overflow at ${w.width}px: page ${w.scrollWidth}px wide; ${w.offenders.join(', ') || 'no single element found'}`); |
| 630 | for (const w of r.widths.filter((x) => x.duplicateScripts.length)) d(`scripts requested twice at ${w.width}px: ${w.duplicateScripts.map((s) => `${s.url} ×${s.count}`).join(', ')}`); |
| 631 | if (r.checks.nojs === 'fail') d(r.nojs && r.nojs.error ? `no-JS load failed: ${r.nojs.error}` : `no-JS: only ${r.nojs.textChars} characters of visible text`); |
| 632 | if (r.canonical && r.canonical.note) d(`canonical: ${r.canonical.note}${r.canonical.href ? ` (${r.canonical.href})` : ''}`); |
| 633 | if (r.jsonld) { |
| 634 | for (const e of r.jsonld.parseErrors) d(`JSON-LD does not parse: ${e}`); |
| 635 | for (const e of r.jsonld.entities.filter((x) => x.found !== 'html')) { |
| 636 | d(e.items ? `JSON-LD ${e.type}${e.name ? ` ${JSON.stringify(e.name.slice(0, 60))}` : ''}: its ${e.items.length} item names are ${e.found === 'js' ? 'only visible with JavaScript' : 'not in the visible text (80 % needed)'}` |
| 637 | : `JSON-LD ${e.type} ${e.field} ${JSON.stringify(e.value.slice(0, 90))} ${e.found === 'js' ? 'is only visible with JavaScript' : 'is not in the visible text'}`); |
| 638 | } |
| 639 | } |
| 640 | for (const t of r.textSmells || []) d(`text: ${t.smell}: …${JSON.stringify(t.sample)}…`); |
| 641 | if (r.axe && r.axe.error) d(`axe: ${r.axe.error}`); |
| 642 | for (const v of (r.axe && r.axe.violations) || []) { |
| 643 | if (v.impact === 'minor') continue; |
| 644 | d(`axe ${v.impact}: ${v.id} (${v.nodes} node${v.nodes === 1 ? '' : 's'}) ${v.help}: ${v.targets.map(code).join(', ')}`); |
| 645 | } |
| 646 | } |
| 647 | const n = report.navigation; |
| 648 | if (n && !n.skipped) { |
| 649 | if (n.error) L.push(`- navigation: error: ${n.error}`); |
| 650 | else { |
| 651 | const g = n.growth; |
| 652 | L.push(`- navigation ${code(n.from)} ↔ ${code(n.to)} ×${n.laps} (${n.mode}): ${n.ok ? 'pass' : 'FAIL'}; growth lap ${g.from}→${g.laps}: ${fmtDeltas(g.deltas)}`); |
| 653 | for (const o of g.over) L.push(` - ${o.name} grew ${o.from} → ${o.to} (+${o.delta}, budget ${o.budget})`); |
| 654 | if (n.idle) { |
| 655 | const i = n.idle, a = i.animations; |
| 656 | L.push(` - idle ${i.seconds}s after settle: CPU ${i.cpuPct}% (script ${i.scriptMs ?? '?'} ms, style ${i.styleMs ?? '?'} ms, layout ${i.layoutMs ?? '?'} ms), ${i.requests} request(s), ${i.kb} KB${i.urls && i.urls.length ? ` (${i.urls.map(code).join(', ')})` : ''}${a && a.running ? `; ${a.running} running animation(s), ${a.infinite} infinite: ${a.list.map(code).join(', ')}` : ''}`); |
| 657 | } |
| 658 | } |
| 659 | for (const e of (n.errors || []).slice(0, 4)) L.push(` - error during navigation: ${e}`); |
| 660 | } |
| 661 | return L.join('\n'); |
| 662 | } |
| 663 | |
| 664 | function fmtDeltas(d) { |
| 665 | const parts = []; |
| 666 | if (d.heapKB !== undefined) parts.push(`heap ${sign(Math.round(d.heapKB))} KB`); |
| 667 | for (const k of ['nodes', 'listeners', 'documents', 'intervals', 'timeouts', 'sockets']) if (d[k] !== undefined) parts.push(`${k} ${sign(d[k])}`); |
| 668 | return parts.join(', ') || 'not measured'; |
| 669 | } |
| 670 | const sign = (n) => (n > 0 ? `+${n}` : String(n)); |
| 671 | |
| 672 | // ─── axe-core ───────────────────────────────────────────────────────────────────────────────────── |
| 673 | |
| 674 | const axeCache = new Map(); |
| 675 | async function axeSource(opt = {}) { |
| 676 | if (opt.source) return opt.source; |
| 677 | if (opt.path) return fs.readFileSync(opt.path, 'utf8'); |
| 678 | const url = opt.url || AXE.url, sha = opt.sha384 || AXE.sha384; |
| 679 | if (axeCache.has(url)) return axeCache.get(url); |
| 680 | if (!opt.url) { |
| 681 | try { |
| 682 | const src = fs.readFileSync(require.resolve('axe-core/axe.min.js', { paths: [process.cwd(), __dirname] }), 'utf8'); |
| 683 | axeCache.set(url, src); |
| 684 | return src; |
| 685 | } catch { /* not installed */ } |
| 686 | } |
| 687 | const dir = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'openvibe-shared'); |
| 688 | const file = path.join(dir, `axe-core-${crypto.createHash('sha256').update(url).digest('hex').slice(0, 12)}.min.js`); |
| 689 | const verify = (buf) => crypto.createHash('sha384').update(buf).digest('base64') === sha; |
| 690 | try { const b = fs.readFileSync(file); if (verify(b)) { axeCache.set(url, b.toString('utf8')); return axeCache.get(url); } } catch { /* not cached */ } |
| 691 | const res = await fetch(url, { signal: AbortSignal.timeout(30000) }); |
| 692 | if (!res.ok) throw new Error(`axe-core download: ${url} answered ${res.status}`); |
| 693 | const buf = Buffer.from(await res.arrayBuffer()); |
| 694 | if (!verify(buf)) throw new Error(`axe-core download: ${url} does not match its pinned sha384`); |
| 695 | try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, buf); } catch { /* cache is best effort */ } |
| 696 | axeCache.set(url, buf.toString('utf8')); |
| 697 | return axeCache.get(url); |
| 698 | } |
| 699 | |
| 700 | async function runAxe(page, source, tags = AXE.tags) { |
| 701 | await page.evaluate(`${source};0`, 60000); |
| 702 | const json = await page.evaluate(`axe.run(document, { runOnly: { type: 'tag', values: ${JSON.stringify(tags)} }, resultTypes: ['violations'] }) |
| 703 | .then((r) => JSON.stringify(r.violations.map((v) => ({ id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.length, |
| 704 | targets: v.nodes.slice(0, 3).map((n) => n.target.join(' ').slice(0, 120)) }))))`, 120000); |
| 705 | const violations = JSON.parse(json || '[]'); |
| 706 | const out = { violations, critical: 0, serious: 0, moderate: 0, minor: 0 }; |
| 707 | for (const v of violations) if (out[v.impact] !== undefined) out[v.impact]++; |
| 708 | return out; |
| 709 | } |
| 710 | |
| 711 | // ─── Runs ───────────────────────────────────────────────────────────────────────────────────────── |
| 712 | |
| 713 | async function checkOneRoute(browser, context, route, o) { |
| 714 | const url = new URL(route.path, o.base).href; |
| 715 | const r = { path: route.path, url, status: route.status, widths: [], nojs: null, axe: null }; |
| 716 | const widths = route.widths || o.widths; |
| 717 | const axeWidth = Math.max(...widths); |
| 718 | for (const width of widths) { |
| 719 | const page = await openPage(browser, context, { width }); |
| 720 | try { |
| 721 | const settled = await page.goto(url, { quietMs: o.settleMs, maxMs: o.maxSettleMs }); |
| 722 | const w = { width, status: page.state.doc ? page.state.doc.status : null, settled, errors: [], duplicateScripts: [] }; |
| 723 | if (page.state.doc && page.state.doc.mimeType) w.contentType = page.state.doc.mimeType; |
| 724 | if (page.state.doc && page.state.doc.error) w.navError = page.state.doc.error; |
| 725 | if (page.state.redirects.length) w.redirects = page.state.redirects; |
| 726 | const s = await page.evaluate(READ_PAGE); |
| 727 | Object.assign(w, { title: s.title, finalUrl: s.url, scrollWidth: s.scrollWidth, overflow: s.scrollX > 0, offenders: s.offenders, canonical: s.canonical }); |
| 728 | if (width === axeWidth) { w.text = s.text; r.title = s.title; } |
| 729 | // The browser logs the document's own error status; the status check already judges it. |
| 730 | const docUrl = page.state.doc && page.state.doc.url; |
| 731 | w.errors = page.state.errors.filter((e) => !(e.source === 'network' && docUrl && e.url === docUrl)); |
| 732 | w.duplicateScripts = [...page.state.scripts].filter(([, ids]) => ids.size > 1).map(([u, ids]) => ({ url: u, count: ids.size })); |
| 733 | if (width === axeWidth && o.checks.axe !== false && route.checks.axe !== false) { |
| 734 | try { r.axe = await runAxe(page, await axeSource(o.axe)); } catch (e) { r.axe = { error: e.message }; } |
| 735 | } else if (width === axeWidth) r.axe = { skipped: true }; |
| 736 | r.widths.push(w); |
| 737 | } finally { await page.close(); } |
| 738 | } |
| 739 | const wantNoJs = ['nojs', 'canonical', 'jsonld'].some((c) => o.checks[c] !== false && route.checks[c] !== false); |
| 740 | if (wantNoJs) { |
| 741 | const page = await openPage(browser, context, { width: axeWidth, js: false }); |
| 742 | try { |
| 743 | await page.goto(url, { quietMs: o.settleMs, maxMs: o.maxSettleMs }); |
| 744 | const s = await page.evaluate(READ_PAGE); |
| 745 | r.nojs = { status: page.state.doc ? page.state.doc.status : null, textChars: s.textChars, h1: s.h1, links: s.links, canonical: s.canonical, jsonld: s.jsonld, text: s.text, title: s.title, |
| 746 | robots: [s.robots, page.state.doc && page.state.doc.xRobots].filter(Boolean).join(', ') }; |
| 747 | if (page.state.doc && page.state.doc.error) r.nojs.error = page.state.doc.error; |
| 748 | } catch (e) { r.nojs = { error: e.message }; } finally { await page.close(); } |
| 749 | } |
| 750 | for (const c of CHECKS) if (o.checks[c] === false) route.checks[c] = false; |
| 751 | checkRoute(route, r, o); |
| 752 | // Keep the report small: the page text was only needed for the JSON-LD comparison. |
| 753 | for (const w of r.widths) delete w.text; |
| 754 | if (r.nojs) { delete r.nojs.text; delete r.nojs.jsonld; } |
| 755 | return r; |
| 756 | } |
| 757 | |
| 758 | /** |
| 759 | * ADR-024 (WS-E task 1): with a host unreachable (by default openvibe.network, where the theme, the frame |
| 760 | * and sign-in live), does a page still paint, with the default theme? Loads `url` once per width with the |
| 761 | * patterns in `block` failing, then reads the page: visible text, the --accent token, the body's |
| 762 | * background, and errors other than the blocked requests themselves. |
| 763 | * → { url, ok, blocked, widths: [{ width, status, settled, textChars, accent, background, errors, ok }] } |
| 764 | */ |
| 765 | async function checkUnreachable(url, { block = ['*://openvibe.network/*'], widths = [390, 1280], minText = 200, maxMs = 20000, browser: given = null, chrome = {} } = {}) { |
| 766 | const browser = given || await launch(chrome); |
| 767 | const out = { url, blocked: block, widths: [] }; |
| 768 | try { |
| 769 | for (const width of widths) { |
| 770 | const context = await browser.newContext(); |
| 771 | const page = await openPage(browser, context, { width, block }); |
| 772 | try { |
| 773 | const settled = await page.goto(url, { maxMs }); |
| 774 | const s = await page.evaluate(`(() => { |
| 775 | const cs = getComputedStyle(document.documentElement); |
| 776 | const clear = (c) => !c || /^rgba\\(0, 0, 0, 0\\)$|^transparent$/.test(c); |
| 777 | const body = document.body ? getComputedStyle(document.body).backgroundColor : ''; |
| 778 | return { text: (document.body && document.body.innerText || '').trim().length, accent: cs.getPropertyValue('--accent').trim(), |
| 779 | background: clear(body) ? cs.backgroundColor : body, theme: document.documentElement.getAttribute('data-theme') || '' }; |
| 780 | })()`); |
| 781 | const errors = page.state.errors.filter((e) => !/ERR_BLOCKED_BY_CLIENT|blocked/i.test(e.text || '')); |
| 782 | const w = { width, status: page.state.doc ? page.state.doc.status : null, settled, textChars: s.text, accent: s.accent, background: s.background, theme: s.theme, errors }; |
| 783 | w.ok = w.status === 200 && settled && s.text >= minText && !!s.accent && !/^rgba\(0, 0, 0, 0\)$|^transparent$/.test(s.background); |
| 784 | out.widths.push(w); |
| 785 | } finally { await page.close(); await context.dispose(); } |
| 786 | } |
| 787 | } finally { if (!given) await browser.close(); } |
| 788 | out.ok = out.widths.every((w) => w.ok); |
| 789 | return out; |
| 790 | } |
| 791 | |
| 792 | async function sample(page) { |
| 793 | await page.send('HeapProfiler.collectGarbage').catch(() => {}); |
| 794 | await sleep(200); |
| 795 | const m = await page.metrics(); |
| 796 | const p = await page.evaluate('window.__ovProbe ? window.__ovProbe() : null').catch(() => null); |
| 797 | return { |
| 798 | heapKB: Math.round((m.JSHeapUsedSize || 0) / 1024), nodes: m.Nodes, listeners: m.JSEventListeners, documents: m.Documents, |
| 799 | intervals: p ? p.intervals : undefined, timeouts: p ? p.timeouts : undefined, sockets: p ? p.sockets : undefined, |
| 800 | href: p ? p.href : undefined, timeOrigin: p ? p.timeOrigin : undefined, |
| 801 | }; |
| 802 | } |
| 803 | |
| 804 | async function navigationCheck(browser, context, o) { |
| 805 | const n = o.navigation; |
| 806 | const from = n.from, to = n.to; |
| 807 | const out = { from, to, laps: n.laps, samples: [] }; |
| 808 | const page = await openPage(browser, context, { width: 1280, probe: true }); |
| 809 | try { |
| 810 | await page.goto(new URL(from, o.base).href, { quietMs: o.settleMs, maxMs: o.maxSettleMs }); |
| 811 | let modes = new Set(); |
| 812 | const go = async (target) => { |
| 813 | const before = await page.evaluate('performance.timeOrigin'); |
| 814 | const clicked = await page.evaluate(`(() => { |
| 815 | const want = ${JSON.stringify(target)}; |
| 816 | const a = [...document.querySelectorAll('a[href]')].find((x) => { try { const u = new URL(x.href, location.href); |
| 817 | return u.origin === location.origin && (u.pathname + u.search) === want && !x.target && !x.hasAttribute('download'); } catch (e) { return false; } }); |
| 818 | if (!a) return false; a.click(); return true; })()`); |
| 819 | if (!clicked) { page.state.loading = true; await page.send('Page.navigate', { url: new URL(target, o.base).href }); } |
| 820 | await page.settle({ quietMs: o.settleMs, maxMs: o.maxSettleMs, minMs: 500 }); |
| 821 | const after = await page.evaluate('performance.timeOrigin').catch(() => null); |
| 822 | modes.add(clicked ? (after === before ? 'in-page' : 'link') : 'load'); |
| 823 | }; |
| 824 | for (let i = 0; i < n.laps; i++) { |
| 825 | if (to !== from) await go(to); |
| 826 | await go(from); |
| 827 | if (to === from) await go(from); |
| 828 | out.samples.push(await sample(page)); |
| 829 | } |
| 830 | out.mode = [...modes].join('+'); |
| 831 | out.growth = growth(out.samples, n.budgets, 1); |
| 832 | // Idle: nothing happens for idleMs; what does the page still do? |
| 833 | await page.settle({ quietMs: o.settleMs, maxMs: o.maxSettleMs }); |
| 834 | const m0 = await page.metrics(); |
| 835 | const r0 = page.state.requests, b0 = page.state.bytes, u0 = page.state.urls.length; |
| 836 | await sleep(o.idleMs); |
| 837 | const m1 = await page.metrics(); |
| 838 | const seconds = o.idleMs / 1000; |
| 839 | const ms = (k) => Math.round(((m1[k] || 0) - (m0[k] || 0)) * 1000); |
| 840 | out.idle = { |
| 841 | seconds, cpuPct: Math.round(((m1.TaskDuration - m0.TaskDuration) / seconds) * 1000) / 10, |
| 842 | scriptMs: ms('ScriptDuration'), styleMs: ms('RecalcStyleDuration'), layoutMs: ms('LayoutDuration'), |
| 843 | requests: page.state.requests - r0, kb: Math.round((page.state.bytes - b0) / 102.4) / 10, |
| 844 | urls: dedupe(page.state.urls.slice(u0).map((u) => { try { const x = new URL(u); return x.origin + x.pathname; } catch { return u; } })).slice(0, 5), |
| 845 | // Running animations keep the page painting while nothing happens (infinite ones never stop). |
| 846 | animations: await page.evaluate(`(() => { const d = (e) => !e ? '?' : e.tagName.toLowerCase() + (e.id ? '#' + e.id : typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\\s+/)[0] : ''); |
| 847 | const run = document.getAnimations().filter((a) => a.playState === 'running'); |
| 848 | return { running: run.length, infinite: run.filter((a) => a.effect && a.effect.getTiming().iterations === Infinity).length, |
| 849 | list: [...new Set(run.map((a) => (a.animationName || a.transitionProperty || a.constructor.name) + ' on ' + d(a.effect && a.effect.target)))].slice(0, 4) }; })()`).catch(() => null), |
| 850 | }; |
| 851 | const { kept, ignored } = splitErrors(page.state.errors, o.ignoreErrors); |
| 852 | out.errors = dedupe(kept.map((e) => `${e.source}: ${e.text}`)); |
| 853 | if (Object.keys(ignored).length) out.ignored = ignored; |
| 854 | out.ok = out.growth.over.length === 0; |
| 855 | } finally { await page.close(); } |
| 856 | return out; |
| 857 | } |
| 858 | |
| 859 | /** Checks one site. Launches Chrome unless `browser` is given; every page runs in a fresh browser context. */ |
| 860 | async function run(opts = {}) { |
| 861 | if (!opts.base) throw new Error('browser-harness: base is required'); |
| 862 | const base = new URL(opts.base).origin + new URL(opts.base).pathname.replace(/\/$/, ''); |
| 863 | const routes = normalizeRoutes(opts.routes); |
| 864 | const o = { |
| 865 | base, widths: opts.widths || WIDTHS, checks: { ...(opts.checks || {}) }, minText: opts.minText ?? 200, |
| 866 | settleMs: opts.settleMs ?? 600, maxSettleMs: opts.maxSettleMs ?? 15000, idleMs: opts.idleMs ?? 5000, |
| 867 | ignoreErrors: (opts.ignoreErrors || []).map((x) => (typeof x === 'string' ? new RegExp(x) : x)), axe: opts.axe || {}, |
| 868 | }; |
| 869 | const navRoutes = routes.filter((r) => r.status === 200); |
| 870 | const nav = opts.navigation === false || o.checks.navigation === false || !navRoutes.length ? null : { |
| 871 | from: (opts.navigation && opts.navigation.from) || navRoutes[0].path, |
| 872 | to: (opts.navigation && opts.navigation.to) || (navRoutes[1] || navRoutes[0]).path, |
| 873 | laps: Math.max(3, (opts.navigation && opts.navigation.laps) || 5), |
| 874 | budgets: { ...BUDGETS, ...((opts.navigation && opts.navigation.budgets) || {}) }, |
| 875 | }; |
| 876 | o.navigation = nav; |
| 877 | const log = opts.log || (() => {}); |
| 878 | const report = { base, startedAt: new Date().toISOString(), widths: o.widths, routes: [], navigation: null }; |
| 879 | const own = !opts.browser; |
| 880 | const browser = opts.browser || await launch(opts.chrome || {}); |
| 881 | report.chrome = browser.version; |
| 882 | try { |
| 883 | for (const route of routes) { |
| 884 | const context = await browser.newContext(); |
| 885 | const t0 = Date.now(); |
| 886 | try { report.routes.push(await checkOneRoute(browser, context, route, o)); } |
| 887 | catch (e) { report.routes.push({ path: route.path, url: new URL(route.path, base).href, status: route.status, widths: [], error: e.message, checks: Object.fromEntries(CHECKS.map((c) => [c, c === 'status' ? 'fail' : 'skip'])), ok: false }); } |
| 888 | finally { await context.dispose(); } |
| 889 | const last = report.routes[report.routes.length - 1]; |
| 890 | log(`${base}${route.path} ${last.ok ? 'pass' : 'FAIL'} (${Date.now() - t0} ms)`); |
| 891 | if (browser.closed) throw new Error('Chrome exited'); |
| 892 | } |
| 893 | if (nav) { |
| 894 | const context = await browser.newContext(); |
| 895 | try { report.navigation = await navigationCheck(browser, context, o); } |
| 896 | catch (e) { report.navigation = { from: nav.from, to: nav.to, error: e.message, ok: false }; } |
| 897 | finally { await context.dispose(); } |
| 898 | log(`${base} navigation ${report.navigation.ok ? 'pass' : 'FAIL'}`); |
| 899 | } else report.navigation = { skipped: true }; |
| 900 | } catch (e) { |
| 901 | report.error = e.message; |
| 902 | } finally { |
| 903 | if (own) await browser.close(); |
| 904 | } |
| 905 | report.finishedAt = new Date().toISOString(); |
| 906 | report.summary = summarize(report); |
| 907 | report.ok = report.summary.ok; |
| 908 | return report; |
| 909 | } |
| 910 | |
| 911 | /** |
| 912 | * Retry an assertion until it passes (the browser tests' answer to a fixed wait before a check, 2026-11-11: the macOS |
| 913 | * runners are slow enough that a 140 ms debounce sometimes outlasted a 180 ms sleep, and Shared's releases kept failing |
| 914 | * on menu-places-chrome). check is an async function that throws while the page is not there yet; the last error is |
| 915 | * thrown after timeout ms. Waits are a ceiling, never a guess: a check that is already true returns at once. |
| 916 | */ |
| 917 | async function eventually(check, { timeout = 5000, every = 50 } = {}) { |
| 918 | const end = Date.now() + timeout; |
| 919 | let last; |
| 920 | for (;;) { |
| 921 | try { return await check(); } catch (e) { last = e; } |
| 922 | if (Date.now() >= end) throw last; |
| 923 | await new Promise((r) => setTimeout(r, every)); |
| 924 | } |
| 925 | } |
| 926 | |
| 927 | module.exports = { textSmells, eventually, |
| 928 | run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable, |
| 929 | // pure helpers, exported for tests and for the Host CLI |
| 930 | normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors, |
| 931 | WIDTHS, CHECKS, BUDGETS, AXE, PROBE, |
| 932 | }; |
| 933 |