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
Changes
@@@ -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.@@@ -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) || []) {@@@ -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/' },
@@ -6,7 +6,7 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -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
## 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.
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.
@@ -19,7 +19,8 @@
@@ -19,7 +19,8 @@
* canonical exactly one <link rel=canonical>, an absolute URL; another origin or a different URL with
* canonical exactly one <link rel=canonical>, an absolute URL; another origin or a different URL with
* JavaScript on is a warning
* JavaScript on is a warning
* jsonld every application/ld+json block parses, and the headline (else name) of each top-level entity
* 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:
* 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
* axe axe-core, WCAG 2.0/2.1 A and AA rules: serious and critical violations fail, moderate and
* minor are reported
* minor are reported
@@ -368,11 +369,16 @@ function findText(text, value) {
@@ -368,11 +369,16 @@ function findText(text, value) {
const WEBPAGE_TYPES = /(^|,)(WebPage|CollectionPage|AboutPage|ContactPage|ProfilePage|SearchResultsPage|ItemPage|FAQPage|QAPage|CheckoutPage|MedicalWebPage|RealEstateListing)(,|$)/;
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
* A title-style name ("Content — OpenVibe.Live") also matches by its leading part, and a web page's name by
* the document title.
* the document title.
*/
*/
function visibleIn(entity, nojs, jsText) {
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 parts = entity.value.split(/\s+[—–|·-]\s+/);
const candidates = [entity.value, ...(parts.length > 1 && parts[0].length >= 3 ? [parts[0]] : [])];
const candidates = [entity.value, ...(parts.length > 1 && parts[0].length >= 3 ? [parts[0]] : [])];
const inText = (t) => candidates.some((c) => findText(t, c));
const inText = (t) => candidates.some((c) => findText(t, c));
@@ -395,6 +401,12 @@ function jsonLdEntities(blocks) {
@@ -395,6 +401,12 @@ function jsonLdEntities(blocks) {
if (!n || typeof n !== 'object') continue;
if (!n || typeof n !== 'object') continue;
const types = [].concat(n['@type'] || []).map(String);
const types = [].concat(n['@type'] || []).map(String);
if (types.some((t) => JSONLD_SKIP_TYPES.has(t))) continue;
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;
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() });
if (field) entities.push({ type: types.join(',') || '?', field, value: n[field].trim() });
}
}
@@ -551,7 +563,10 @@ function format(report, { markdown = false } = {}) {
@@ -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.canonical && r.canonical.note) d(`canonical: ${r.canonical.note}${r.canonical.href ? ` (${r.canonical.href})` : ''}`);
if (r.jsonld) {
if (r.jsonld) {
for (const e of r.jsonld.parseErrors) d(`JSON-LD does not parse: ${e}`);
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}`);
if (r.axe && r.axe.error) d(`axe: ${r.axe.error}`);
for (const v of (r.axe && r.axe.violations) || []) {
for (const v of (r.axe && r.axe.violations) || []) {
@@ -46,6 +46,14 @@ const h = require('../browser-harness');
@@ -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: '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');
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.
// Ignore rules: a RegExp on the message or URL, or { label, text, url } with every given RegExp matching; counted.
const split = h.splitErrors([
const split = h.splitErrors([
{ source: 'security', text: "Loading the script 'https://static.cloudflareinsights.com/beacon.min.js' violates CSP", url: 'https://a.example/' },
{ source: 'security', text: "Loading the script 'https://static.cloudflareinsights.com/beacon.min.js' violates CSP", url: 'https://a.example/' },