openvibers/openvibe.shared Commits

browser-harness: an ItemList's JSON-LD is judged by its items (80 % of the item names visible), not by its own name, which is a label (often a nav's aria-label: Tools' "Tool families"); a list without item names falls back to its name

OP OpenVibers committed 26 Sept 2026 · 9b8eac3001 · 3 files, +27 −4


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ca015ef..91b7213 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,7 +6,7 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## 1.16.0 — 2026-09-26
--**`openvibe-shared/browser-harness`** (roadmap WS-Q task 3, WS-T tasks 3 and 4): Live's browser smoke suite, generalised for any site. It drives headless Chrome over the DevTools protocol with Node's own WebSocket (Node 22+, no dependency), one browser context per route. `run({ base, routes, widths, checks, navigation, … })` checks every route at 390, 768 and 1280 px: the HTTP status (a route can expect 404), console errors (exceptions, `console.error`, failed loads, CSP), horizontal overflow (naming the elements) and scripts requested twice. With JavaScript off it checks the visible text (200 characters), exactly one absolute canonical link, and that JSON-LD parses and its headline or name is in the visible text. axe-core runs at the widest width: serious and critical violations fail, moderate is reported. Once per site, A → B → A navigation runs five times, clicking a link when there is one so a single-page app navigates in place. After a forced GC it samples heap, DOM nodes, listeners and documents (`Performance.getMetrics`), plus live intervals, pending timeouts and open WebSockets (a probe installed before page scripts). A measure fails when it grows past its budget from lap 2 to the last lap and is still growing (`BUDGETS`). CPU, requests and bytes over 5 idle seconds are reported, not failed. `ignoreErrors` rules are counted per label, never dropped silently. `format()` gives text or Markdown, and `summarize()` gives per-check counts. axe-core is not vendored: an installed `axe-core` is used, else 4.13.0 comes from jsDelivr, checked against a pinned sha384 (the same bytes as the npm tarball) and cached. `test/browser-harness-chrome.test.js` runs it in real Chrome against a fixture and skips without Chrome. Additive.
++**`openvibe-shared/browser-harness`** (roadmap WS-Q task 3, WS-T tasks 3 and 4): Live's browser smoke suite, generalised for any site. It drives headless Chrome over the DevTools protocol with Node's own WebSocket (Node 22+, no dependency), one browser context per route. `run({ base, routes, widths, checks, navigation, … })` checks every route at 390, 768 and 1280 px: the HTTP status (a route can expect 404), console errors (exceptions, `console.error`, failed loads, CSP), horizontal overflow (naming the elements) and scripts requested twice. With JavaScript off it checks the visible text (200 characters), exactly one absolute canonical link, and that JSON-LD parses and its headline or name is in the visible text. For an ItemList, at least 80% of the item names must be visible. axe-core runs at the widest width: serious and critical violations fail, moderate is reported. Once per site, A → B → A navigation runs five times, clicking a link when there is one so a single-page app navigates in place. After a forced GC it samples heap, DOM nodes, listeners and documents (`Performance.getMetrics`), plus live intervals, pending timeouts and open WebSockets (a probe installed before page scripts). A measure fails when it grows past its budget from lap 2 to the last lap and is still growing (`BUDGETS`). CPU, requests and bytes over 5 idle seconds are reported, not failed. `ignoreErrors` rules are counted per label, never dropped silently. `format()` gives text or Markdown, and `summarize()` gives per-check counts. axe-core is not vendored: an installed `axe-core` is used, else 4.13.0 comes from jsDelivr, checked against a pinned sha384 (the same bytes as the npm tarball) and cached. `test/browser-harness-chrome.test.js` runs it in real Chrome against a fixture and skips without Chrome. Additive.
Fix: the selected filter chip of the shared update log (`shipped.js`, every site's `/updates`) was white on `--accent` (3.67:1). It now uses `--accent-strong` / `--on-accent-strong` (4.5:1 or better in every theme), like the navbar's Sign In button. The harness found it.
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 25a208e..2d51028 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -19,7 +19,8 @@
* canonical exactly one <link rel=canonical>, an absolute URL; another origin or a different URL with
* JavaScript on is a warning
* jsonld every application/ld+json block parses, and the headline (else name) of each top-level entity
-- * is in the visible text: without JavaScript passes, only with JavaScript warns, nowhere fails
++ * (of an ItemList: 80 % of its item names) is in the visible text: without JavaScript passes, only
++ * with JavaScript warns, nowhere fails
* once per route, at the widest width:
* axe axe-core, WCAG 2.0/2.1 A and AA rules: serious and critical violations fail, moderate and
* minor are reported
@@@ -368,11 +369,16 @@ function findText(text, value) {
const WEBPAGE_TYPES = /(^|,)(WebPage|CollectionPage|AboutPage|ContactPage|ProfilePage|SearchResultsPage|ItemPage|FAQPage|QAPage|CheckoutPage|MedicalWebPage|RealEstateListing)(,|$)/;
/**
-- * Where a JSON-LD entity's headline/name is visible: 'html' (without JavaScript), 'js' (only with it) or null.
++ * 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.
* A title-style name ("Content — OpenVibe.Live") also matches by its leading part, and a web page's name by
* the document title.
*/
function visibleIn(entity, nojs, jsText) {
++ if (entity.items) {
++ // At least 80 % of a list's item names (a stale or truncated one may be missing).
++ const share = (t) => entity.items.filter((x) => findText(t, x)).length / entity.items.length;
++ return share(nojs.text) >= 0.8 ? 'html' : share(jsText) >= 0.8 ? 'js' : null;
++ }
const parts = entity.value.split(/\s+[—–|·-]\s+/);
const candidates = [entity.value, ...(parts.length > 1 && parts[0].length >= 3 ? [parts[0]] : [])];
const inText = (t) => candidates.some((c) => findText(t, c));
@@@ -395,6 +401,12 @@ function jsonLdEntities(blocks) {
if (!n || typeof n !== 'object') continue;
const types = [].concat(n['@type'] || []).map(String);
if (types.some((t) => JSONLD_SKIP_TYPES.has(t))) continue;
++ // A list's facts are its items (its own name is a label, often an aria-label): their names are checked.
++ if (types.includes('ItemList') && Array.isArray(n.itemListElement)) {
++ const names = n.itemListElement.map((x) => x && (typeof x.name === 'string' ? x.name : x.item && typeof x.item.name === 'string' ? x.item.name : null))
++ .filter((x) => x && x.trim()).map((x) => x.trim());
++ 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; }
++ }
const field = typeof n.headline === 'string' && n.headline.trim() ? 'headline' : typeof n.name === 'string' && n.name.trim() ? 'name' : null;
if (field) entities.push({ type: types.join(',') || '?', field, value: n[field].trim() });
}
@@@ -551,7 +563,10 @@ function format(report, { markdown = false } = {}) {
if (r.canonical && r.canonical.note) d(`canonical: ${r.canonical.note}${r.canonical.href ? ` (${r.canonical.href})` : ''}`);
if (r.jsonld) {
for (const e of r.jsonld.parseErrors) d(`JSON-LD does not parse: ${e}`);
-- for (const e of r.jsonld.entities.filter((x) => x.found !== 'html')) d(`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'}`);
++ for (const e of r.jsonld.entities.filter((x) => x.found !== 'html')) {
++ 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)'}`
++ : `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'}`);
++ }
}
if (r.axe && r.axe.error) d(`axe: ${r.axe.error}`);
for (const v of (r.axe && r.axe.violations) || []) {
ddiff --git a/test/browser-harness.test.js b/test/browser-harness.test.js
iindex c4d8c86..8549fec 100644
---- a/test/browser-harness.test.js
++++ b/test/browser-harness.test.js
@@@ -46,6 +46,14 @@ const h = require('../browser-harness');
assert.strictEqual(h.visibleIn({ type: 'ItemList', value: 'Live & recent' }, html, 'LIVE & Recent'), 'js');
assert.strictEqual(h.visibleIn({ type: 'Thing', value: 'A - B' }, { text: 'a', title: '' }, ''), null, 'a leading part under 3 characters is not enough');
++ // An ItemList is judged by its items (80 % visible), not by its label.
++ const list = h.jsonLdEntities([JSON.stringify({ '@type': 'ItemList', name: 'Tool families', itemListElement: ['Image', 'Audio', 'Video', 'Text', 'Network'].map((name, i) => ({ '@type': 'ListItem', position: i + 1, name })) })]).entities[0];
++ assert.deepStrictEqual([list.field, list.value, list.name, list.items.length], ['items', '5 items', 'Tool families', 5]);
++ assert.strictEqual(h.visibleIn(list, { text: 'image audio video text', title: '' }, ''), 'html', '4 of 5 is enough');
++ assert.strictEqual(h.visibleIn(list, { text: 'image audio', title: '' }, 'image audio video text network'), 'js');
++ assert.strictEqual(h.visibleIn(list, { text: 'image', title: '' }, 'audio'), null);
++ assert.deepStrictEqual(h.jsonLdEntities([JSON.stringify({ '@type': 'ItemList', name: 'Empty list', itemListElement: [] })]).entities, [{ type: 'ItemList', field: 'name', value: 'Empty list' }], 'a list without item names falls back to its name');
++
// Ignore rules: a RegExp on the message or URL, or { label, text, url } with every given RegExp matching; counted.
const split = h.splitErrors([
{ source: 'security', text: "Loading the script 'https://static.cloudflareinsights.com/beacon.min.js' violates CSP", url: 'https://a.example/' },