openvibers/openvibe.shared Commits
Shared 1.25.0: openvibe-shared/test-runner, no fake green in npm test (roadmap WS-Q task 7). Every service carried its own test/run.js, and each counted a test file that exited 0 as passed, including one that printed "…: skipped (no ffmpeg)" and checked nothing, so "150/150 test files passed" could hide skipped work. The runner is now one module, main({ dir, timeoutMs, pad, hide, env }) (run() returns the results): the copies' behaviour (each *.test.js in its own process, a few at a time, NODE_ENV=test, a timeout per file, name filters, the last 40 lines of a failure minus hide lines), plus skips: a test prints "<label>: skipped (<why>)" for what it could not run (the line OpenVibe.Host's acceptance runner already reads), and that file is listed with ○ and its reasons and not counted as passed; the summary reads "148/150 test files passed, 2 skipped (…)", and only a run with nothing skipped says N/N. Skips alone exit 0; --strict or OV_TEST_STRICT=1 makes them fail. Exported as ./test-runner; this package's test/run.js uses it; test/test-runner.test.js covers pass, skip, partial skip, failure, hidden lines, filters and strict. README and CHANGELOG describe it. npm test (Node 22) 39/39.
Changes
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.25.0 — 2026-09-28++++**`openvibe-shared/test-runner`: no fake green in `npm test`** (roadmap WS-Q task 7). Every service carried its own copy of `test/run.js`, and each counted a test file that exited 0 as passed, including one that printed `…: skipped (no ffmpeg)` and checked nothing. The runner is now one module: `require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs, pad, hide, env })` (`run()` returns the results for a caller that wants them).++- Same behaviour as the copies: every `*.test.js` in `dir` in its own process, a few at a time, `NODE_ENV=test`, a timeout per file, name filters from the arguments, and the last 40 lines of a failed file (minus `hide` lines).++- **Skips.** A test prints `<label>: skipped (<why>)` for each thing it could not run (the convention OpenVibe.Host's acceptance runner already reads). Such a file is listed with ○ and its reasons, and is not counted as passed: the summary reads `148/150 test files passed, 2 skipped (…)`, and only a run with nothing skipped says `N/N test files passed`. Skips alone exit 0; `--strict` (or `OV_TEST_STRICT=1`) makes them fail the run.++- This package's `test/run.js` uses it; `test/test-runner.test.js` covers pass, skip, partial skip, failure, hidden lines, filters and strict.++## 1.24.0 — 2026-09-28**No fake green in readiness** (roadmap WS-Q task 7: "skipped counts as yellow; nothing may be marked green by being synthesised from skipped parts"). `ready.js` had only `ok` and `fail`, so a check whose feature was not configured had to claim `ok` (Network's Discord bot, Media's events outbox and News's source pull did) or fail and turn the service yellow for a setting.@@@ -20,7 +20,7 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "1.24.0",++ "version": "1.25.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.24.0",++ "version": "1.25.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.24.0",++ "version": "1.25.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.24.0",++ "version": "1.25.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -51,6 +51,7 @@"./serve": "./serve.js","./shipped": "./shipped.js","./sso-client": "./sso-client.js",++ "./test-runner": "./test-runner.js","./theme-loader": "./theme-loader.js","./theme-sync": "./theme-sync.js","./tooltip": "./tooltip.js",nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/test-runner — the `npm test` runner every service uses (test/run.js), with no fake green++// (roadmap WS-Q task 7: "skipped counts as yellow; nothing may be marked green by being synthesised from++// skipped parts").++//++// // test/run.js++// require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 120000, hide: /^\[DB\] / });++//++// npm test # every *.test.js in dir, each in its own process, a few at a time++// npm test -- quota cache # only files whose name contains one of the words++// npm test -- --strict # a skip fails the run too (or OV_TEST_STRICT=1)++//++// A test that cannot run something (no ffmpeg, no Chrome, no git history) prints one line per thing it++// skipped, `<label>: skipped (<why>)`, and still exits 0. Such a file is listed with ○ and its reasons,++// and is NOT counted as passed: the summary reads `148/150 test files passed, 2 skipped (…)`. Only a run++// with nothing skipped prints `N/N test files passed`. Exit status: 1 when a file failed (or, strict, when++// one skipped), else 0.++// ═══════════════════════════════════════════════════════════════++const { spawn } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');++++/** `<label>: skipped (<why>)` on a line of its own (the convention OpenVibe.Host's acceptance runner reads too). */++const SKIP_RE = /^[ \t]*[\w .,'()/+#-]{1,120}: skipped \((.+)\)[ \t]*$/gm;++++/** The reasons a test's output gives for what it skipped, in order, without repeats. */++function skipsIn(output) {++ const out = [];++ for (const m of String(output || '').matchAll(SKIP_RE)) {++ const line = m[0].trim();++ if (!out.includes(line)) out.push(line);++ }++ return out;++}++++function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {++ return new Promise((resolve) => {++ const started = Date.now();++ const child = spawn(process.execPath, [...nodeArgs, path.join(dir, file)], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });++ let output = '';++ child.stdout.on('data', (c) => { output += c; });++ child.stderr.on('data', (c) => { output += c; });++ const timer = setTimeout(() => { output += `\n[run] timed out after ${timeoutMs}ms`; child.kill('SIGKILL'); }, timeoutMs);++ child.on('close', (code, signal) => {++ clearTimeout(timer);++ const ok = code === 0;++ const skips = ok ? skipsIn(output) : [];++ resolve({ file, ok, state: !ok ? 'fail' : skips.length ? 'skip' : 'pass', skips, code: code ?? signal, ms: Date.now() - started, output });++ });++ });++}++++/**++ * Run the tests; resolves { results, passed, skipped, failed, strict, exitCode, summary } and prints as it goes.++ * opts: dir (required: the test directory), argv (default process.argv.slice(2)), cwd (default dir/..),++ * timeoutMs (60000), parallel (min(4, cpus-1)), env (added to process.env; NODE_ENV=test by default),++ * hide (a RegExp: output lines of a failed file left out, e.g. /^\[DB\] /), pad (file-name column, 44),++ * match (which files, default /\.test\.js$/), nodeArgs ([]), log (console.log).++ */++async function run(opts = {}) {++ const dir = opts.dir;++ if (!dir) throw new TypeError('test-runner: dir required');++ const argv = opts.argv || process.argv.slice(2);++ const log = opts.log || console.log;++ const strict = argv.includes('--strict') || process.env.OV_TEST_STRICT === '1';++ const filters = argv.filter((a) => !a.startsWith('-'));++ const match = opts.match || /\.test\.js$/;++ const files = fs.readdirSync(dir).filter((f) => match.test(f))++ .filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();++ if (!files.length) {++ log('no test files matched');++ return { results: [], passed: 0, skipped: 0, failed: 0, strict, exitCode: 1, summary: 'no test files matched' };++ }++ const cfg = {++ dir, cwd: opts.cwd || path.join(dir, '..'), timeoutMs: opts.timeoutMs || 60000, nodeArgs: opts.nodeArgs || [],++ env: { ...process.env, NODE_ENV: 'test', ...(opts.env || {}) },++ };++ const parallel = Math.max(1, opts.parallel || Math.min(4, os.cpus().length - 1));++ const pad = opts.pad || 44;++ const queue = [...files];++ const results = [];++ await Promise.all(Array.from({ length: parallel }, async () => {++ while (queue.length) {++ const r = await runOne(queue.shift(), cfg);++ results.push(r);++ const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';++ log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);++ }++ }));++ results.sort((a, b) => a.file.localeCompare(b.file));++ const failed = results.filter((r) => r.state === 'fail');++ const skipped = results.filter((r) => r.state === 'skip');++ for (const r of failed) {++ log(`\n── ${r.file} (exit ${r.code}) ──`);++ log(r.output.split('\n').filter((l) => !(opts.hide && opts.hide.test(l))).slice(-40).join('\n'));++ }++ const passed = results.length - failed.length - skipped.length;++ const summary = skipped.length++ ? `${passed}/${results.length} test files passed, ${skipped.length} skipped (${skipped.map((r) => `${r.file}: ${r.skips.join('; ')}`).join(' | ')})${strict ? ' — strict: skips fail the run' : ''}`++ : `${passed}/${results.length} test files passed`;++ log(`\n${summary}`);++ const exitCode = failed.length || (strict && skipped.length) ? 1 : 0;++ return { results, passed, skipped: skipped.length, failed: failed.length, strict, exitCode, summary };++}++++/** run(), then exit with its status: what a test/run.js calls. */++function main(opts) {++ run(opts).then((r) => process.exit(r.exitCode), (err) => { console.error(err); process.exit(1); });++}++++module.exports = { run, main, skipsIn, SKIP_RE };@@@ -1,64 +1,16 @@#!/usr/bin/env node/*** Runs every test in test/ — the files named *.test.js — each in its own process, a few at a-- * time, and fails if any of them fails.++ * time, and fails if any of them fails (openvibe-shared/test-runner, this package's own copy).** npm test # everything* npm test -- navbar icons # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too*-- * Plain Node with stubbed browser globals: no network, no running site. The one exception is-- * browser-harness-chrome.test.js, which starts headless Chrome against a local fixture when Chrome is-- * installed and skips otherwise (OV_SKIP_BROWSER=1 skips it too).++ * Plain Node with stubbed browser globals: no network, no running site. The exceptions are the++ * *-chrome.test.js files, which start headless Chrome against a local fixture when Chrome is++ * installed and print `<name>: skipped (no Chrome; …)` otherwise (OV_SKIP_BROWSER=1 skips them too):++ * a skipped file is listed with ○ and never counted as passed.*/'use strict';--const { spawn } = require('child_process');--const fs = require('fs');--const os = require('os');--const path = require('path');----const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));--const files = fs.readdirSync(__dirname)-- .filter((f) => f.endsWith('.test.js'))-- .filter((f) => !filters.length || filters.some((w) => f.includes(w)))-- .sort();--const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));--const TIMEOUT_MS = 60000;----function runOne(file) {-- return new Promise((resolve) => {-- const started = Date.now();-- const child = spawn(process.execPath, [path.join(__dirname, file)], {-- cwd: path.join(__dirname, '..'),-- env: { ...process.env, NODE_ENV: 'test' },-- stdio: ['ignore', 'pipe', 'pipe'],-- });-- let output = '';-- child.stdout.on('data', (c) => { output += c; });-- child.stderr.on('data', (c) => { output += c; });-- const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);-- child.on('close', (code, signal) => {-- clearTimeout(timer);-- resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });-- });-- });--}----(async () => {-- if (!files.length) { console.error('no test files matched'); process.exit(1); }-- const queue = [...files];-- const results = [];-- await Promise.all(Array.from({ length: PARALLEL }, async () => {-- while (queue.length) {-- const r = await runOne(queue.shift());-- results.push(r);-- console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(36)} ${String(r.ms).padStart(6)}ms`);-- }-- }));-- const failed = results.filter((r) => !r.ok);-- for (const r of failed) {-- console.log(`\n── ${r.file} (exit ${r.code}) ──`);-- console.log(r.output.split('\n').slice(-40).join('\n'));-- }-- console.log(`\n${results.length - failed.length}/${results.length} test files passed`);-- process.exit(failed.length ? 1 : 0);--})();++require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36 });nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++// test-runner.js: each file in its own process; a file that prints `<label>: skipped (<why>)` and exits 0 is++// skipped (○), never counted as passed (roadmap WS-Q task 7); only a run with nothing skipped says N/N passed;++// failures exit 1, and --strict makes skips fail too.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { run, skipsIn } = require('../test-runner');++++(async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-test-runner-'));++ const write = (name, body) => fs.writeFileSync(path.join(dir, name), body);++ write('a-pass.test.js', "console.log('a: all checks passed');\n");++ write('b-skip.test.js', "console.log('transcript via AI: skipped (no ffmpeg/ffprobe)'); process.exit(0);\n");++ write('c-partial.test.js', "console.log('check B: skipped (no local model)'); console.log('check B: skipped (no local model)'); console.log('c: all checks passed');\n");++ write('d-fail.test.js', "console.log('[DB] noise');\nconsole.log('d: skipped (never read on a failure)');\nthrow new Error('boom');\n");++ write('e-mention.test.js', "console.log('[Jobs] run skipped: overlapping'); console.log('e: all checks passed');\n");++ write('not-a-test.js', 'process.exit(1);\n');++ const lines = [];++ const log = (l) => lines.push(String(l));++++ let r = await run({ dir, argv: [], log, hide: /^\[DB\] / });++ assert.deepStrictEqual(r.results.map((x) => [x.file, x.state]), [['a-pass.test.js', 'pass'], ['b-skip.test.js', 'skip'], ['c-partial.test.js', 'skip'], ['d-fail.test.js', 'fail'], ['e-mention.test.js', 'pass']]);++ assert.deepStrictEqual([r.passed, r.skipped, r.failed, r.exitCode], [2, 2, 1, 1]);++ assert.deepStrictEqual(r.results[2].skips, ['check B: skipped (no local model)'], 'repeats once');++ assert.match(r.summary, /^2\/5 test files passed, 2 skipped \(b-skip\.test\.js: transcript via AI: skipped \(no ffmpeg\/ffprobe\) \| c-partial/);++ assert.ok(lines.some((l) => l.startsWith('○ b-skip.test.js')), 'skips are marked ○');++ assert.ok(lines.some((l) => l.startsWith('✗ d-fail.test.js')));++ assert.ok(!lines.join('\n').includes('[DB] noise'), 'hidden lines stay out of a failure');++ assert.ok(lines.join('\n').includes('boom'));++++ // Only the passing files: N/N, exit 0.++ lines.length = 0;++ r = await run({ dir, argv: ['a-pass', 'e-mention'], log });++ assert.deepStrictEqual([r.summary, r.exitCode], ['2/2 test files passed', 0]);++++ // Skips alone exit 0 (yellow), and not with --strict.++ r = await run({ dir, argv: ['b-skip', 'a-pass'], log });++ assert.deepStrictEqual([r.passed, r.skipped, r.exitCode], [1, 1, 0]);++ assert.match(r.summary, /^1\/2 test files passed, 1 skipped/);++ r = await run({ dir, argv: ['b-skip', 'a-pass', '--strict'], log });++ assert.strictEqual(r.exitCode, 1);++ assert.match(r.summary, /strict: skips fail the run$/);++++ r = await run({ dir, argv: ['nothing-matches'], log });++ assert.strictEqual(r.exitCode, 1);++ assert.deepStrictEqual(skipsIn('x\n ACK test: skipped (ffmpeg not installed) \nrollback with newer writes: skipped (no git history)\nOK B: skipped — old style'),++ ['ACK test: skipped (ffmpeg not installed)', 'rollback with newer writes: skipped (no git history)']);++ fs.rmSync(dir, { recursive: true, force: true });++ console.log('test-runner: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.25.0 — 2026-09-28
+
+**`openvibe-shared/test-runner`: no fake green in `npm test`** (roadmap WS-Q task 7). Every service carried its own copy of `test/run.js`, and each counted a test file that exited 0 as passed, including one that printed `…: skipped (no ffmpeg)` and checked nothing. The runner is now one module: `require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs, pad, hide, env })` (`run()` returns the results for a caller that wants them).
+- Same behaviour as the copies: every `*.test.js` in `dir` in its own process, a few at a time, `NODE_ENV=test`, a timeout per file, name filters from the arguments, and the last 40 lines of a failed file (minus `hide` lines).
+- **Skips.** A test prints `<label>: skipped (<why>)` for each thing it could not run (the convention OpenVibe.Host's acceptance runner already reads). Such a file is listed with ○ and its reasons, and is not counted as passed: the summary reads `148/150 test files passed, 2 skipped (…)`, and only a run with nothing skipped says `N/N test files passed`. Skips alone exit 0; `--strict` (or `OV_TEST_STRICT=1`) makes them fail the run.
+- This package's `test/run.js` uses it; `test/test-runner.test.js` covers pass, skip, partial skip, failure, hidden lines, filters and strict.
+
## 1.24.0 — 2026-09-28
## 1.24.0 — 2026-09-28
**No fake green in readiness** (roadmap WS-Q task 7: "skipped counts as yellow; nothing may be marked green by being synthesised from skipped parts"). `ready.js` had only `ok` and `fail`, so a check whose feature was not configured had to claim `ok` (Network's Discord bot, Media's events outbox and News's source pull did) or fail and turn the service yellow for a setting.
**No fake green in readiness** (roadmap WS-Q task 7: "skipped counts as yellow; nothing may be marked green by being synthesised from skipped parts"). `ready.js` had only `ok` and `fail`, so a check whose feature was not configured had to claim `ok` (Network's Discord bot, Media's events outbox and News's source pull did) or fail and turn the service yellow for a setting.
@@ -20,7 +20,7 @@ were reconciled first.
@@ -20,7 +20,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.24.0",
+ "version": "1.25.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.24.0",
+ "version": "1.25.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.24.0",
+ "version": "1.25.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.24.0",
+ "version": "1.25.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -51,6 +51,7 @@
@@ -51,6 +51,7 @@
"./serve": "./serve.js",
"./serve": "./serve.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
+ "./test-runner": "./test-runner.js",
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
"./theme-sync": "./theme-sync.js",
"./tooltip": "./tooltip.js",
"./tooltip": "./tooltip.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/test-runner — the `npm test` runner every service uses (test/run.js), with no fake green
+// (roadmap WS-Q task 7: "skipped counts as yellow; nothing may be marked green by being synthesised from
+// skipped parts").
+//
+// // test/run.js
+// require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 120000, hide: /^\[DB\] / });
+//
+// npm test # every *.test.js in dir, each in its own process, a few at a time
+// npm test -- quota cache # only files whose name contains one of the words
+// npm test -- --strict # a skip fails the run too (or OV_TEST_STRICT=1)
+//
+// A test that cannot run something (no ffmpeg, no Chrome, no git history) prints one line per thing it
+// skipped, `<label>: skipped (<why>)`, and still exits 0. Such a file is listed with ○ and its reasons,
+// and is NOT counted as passed: the summary reads `148/150 test files passed, 2 skipped (…)`. Only a run
+// with nothing skipped prints `N/N test files passed`. Exit status: 1 when a file failed (or, strict, when
+// one skipped), else 0.
+// ═══════════════════════════════════════════════════════════════
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+/** `<label>: skipped (<why>)` on a line of its own (the convention OpenVibe.Host's acceptance runner reads too). */
+const SKIP_RE = /^[ \t]*[\w .,'()/+#-]{1,120}: skipped \((.+)\)[ \t]*$/gm;
+
+/** The reasons a test's output gives for what it skipped, in order, without repeats. */
+function skipsIn(output) {
+ const out = [];
+ for (const m of String(output || '').matchAll(SKIP_RE)) {
+ const line = m[0].trim();
+ if (!out.includes(line)) out.push(line);
+ }
+ return out;
+}
+
+function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const child = spawn(process.execPath, [...nodeArgs, path.join(dir, file)], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+ child.stdout.on('data', (c) => { output += c; });
+ child.stderr.on('data', (c) => { output += c; });
+ const timer = setTimeout(() => { output += `\n[run] timed out after ${timeoutMs}ms`; child.kill('SIGKILL'); }, timeoutMs);
+ child.on('close', (code, signal) => {
+ clearTimeout(timer);
+ const ok = code === 0;
+ const skips = ok ? skipsIn(output) : [];
+ resolve({ file, ok, state: !ok ? 'fail' : skips.length ? 'skip' : 'pass', skips, code: code ?? signal, ms: Date.now() - started, output });
+ });
+ });
+}
+
+/**
+ * Run the tests; resolves { results, passed, skipped, failed, strict, exitCode, summary } and prints as it goes.
+ * opts: dir (required: the test directory), argv (default process.argv.slice(2)), cwd (default dir/..),
+ * timeoutMs (60000), parallel (min(4, cpus-1)), env (added to process.env; NODE_ENV=test by default),
+ * hide (a RegExp: output lines of a failed file left out, e.g. /^\[DB\] /), pad (file-name column, 44),
+ * match (which files, default /\.test\.js$/), nodeArgs ([]), log (console.log).
+ */
+async function run(opts = {}) {
+ const dir = opts.dir;
+ if (!dir) throw new TypeError('test-runner: dir required');
+ const argv = opts.argv || process.argv.slice(2);
+ const log = opts.log || console.log;
+ const strict = argv.includes('--strict') || process.env.OV_TEST_STRICT === '1';
+ const filters = argv.filter((a) => !a.startsWith('-'));
+ const match = opts.match || /\.test\.js$/;
+ const files = fs.readdirSync(dir).filter((f) => match.test(f))
+ .filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
+ if (!files.length) {
+ log('no test files matched');
+ return { results: [], passed: 0, skipped: 0, failed: 0, strict, exitCode: 1, summary: 'no test files matched' };
+ }
+ const cfg = {
+ dir, cwd: opts.cwd || path.join(dir, '..'), timeoutMs: opts.timeoutMs || 60000, nodeArgs: opts.nodeArgs || [],
+ env: { ...process.env, NODE_ENV: 'test', ...(opts.env || {}) },
+ };
+ const parallel = Math.max(1, opts.parallel || Math.min(4, os.cpus().length - 1));
+ const pad = opts.pad || 44;
+ const queue = [...files];
+ const results = [];
+ await Promise.all(Array.from({ length: parallel }, async () => {
+ while (queue.length) {
+ const r = await runOne(queue.shift(), cfg);
+ results.push(r);
+ const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
+ log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
+ }
+ }));
+ results.sort((a, b) => a.file.localeCompare(b.file));
+ const failed = results.filter((r) => r.state === 'fail');
+ const skipped = results.filter((r) => r.state === 'skip');
+ for (const r of failed) {
+ log(`\n── ${r.file} (exit ${r.code}) ──`);
+ log(r.output.split('\n').filter((l) => !(opts.hide && opts.hide.test(l))).slice(-40).join('\n'));
+ }
+ const passed = results.length - failed.length - skipped.length;
+ const summary = skipped.length
+ ? `${passed}/${results.length} test files passed, ${skipped.length} skipped (${skipped.map((r) => `${r.file}: ${r.skips.join('; ')}`).join(' | ')})${strict ? ' — strict: skips fail the run' : ''}`
+ : `${passed}/${results.length} test files passed`;
+ log(`\n${summary}`);
+ const exitCode = failed.length || (strict && skipped.length) ? 1 : 0;
+ return { results, passed, skipped: skipped.length, failed: failed.length, strict, exitCode, summary };
+}
+
+/** run(), then exit with its status: what a test/run.js calls. */
+function main(opts) {
+ run(opts).then((r) => process.exit(r.exitCode), (err) => { console.error(err); process.exit(1); });
+}
+
+module.exports = { run, main, skipsIn, SKIP_RE };
@@ -1,64 +1,16 @@
@@ -1,64 +1,16 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
/**
* Runs every test in test/ — the files named *.test.js — each in its own process, a few at a
* Runs every test in test/ — the files named *.test.js — each in its own process, a few at a
- * time, and fails if any of them fails.
+ * time, and fails if any of them fails (openvibe-shared/test-runner, this package's own copy).
*
*
* npm test # everything
* npm test # everything
* npm test -- navbar icons # only files whose name contains one of the words
* npm test -- navbar icons # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
*
*
- * Plain Node with stubbed browser globals: no network, no running site. The one exception is
- * browser-harness-chrome.test.js, which starts headless Chrome against a local fixture when Chrome is
- * installed and skips otherwise (OV_SKIP_BROWSER=1 skips it too).
+ * Plain Node with stubbed browser globals: no network, no running site. The exceptions are the
+ * *-chrome.test.js files, which start headless Chrome against a local fixture when Chrome is
+ * installed and print `<name>: skipped (no Chrome; …)` otherwise (OV_SKIP_BROWSER=1 skips them too):
+ * a skipped file is listed with ○ and never counted as passed.
*/
*/
'use strict';
'use strict';
-const { spawn } = require('child_process');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
-const files = fs.readdirSync(__dirname)
- .filter((f) => f.endsWith('.test.js'))
- .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
- .sort();
-const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));
-const TIMEOUT_MS = 60000;
-
-function runOne(file) {
- return new Promise((resolve) => {
- const started = Date.now();
- const child = spawn(process.execPath, [path.join(__dirname, file)], {
- cwd: path.join(__dirname, '..'),
- env: { ...process.env, NODE_ENV: 'test' },
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- let output = '';
- child.stdout.on('data', (c) => { output += c; });
- child.stderr.on('data', (c) => { output += c; });
- const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
- child.on('close', (code, signal) => {
- clearTimeout(timer);
- resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
- });
- });
-}
-
-(async () => {
- if (!files.length) { console.error('no test files matched'); process.exit(1); }
- const queue = [...files];
- const results = [];
- await Promise.all(Array.from({ length: PARALLEL }, async () => {
- while (queue.length) {
- const r = await runOne(queue.shift());
- results.push(r);
- console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(36)} ${String(r.ms).padStart(6)}ms`);
- }
- }));
- const failed = results.filter((r) => !r.ok);
- for (const r of failed) {
- console.log(`\n── ${r.file} (exit ${r.code}) ──`);
- console.log(r.output.split('\n').slice(-40).join('\n'));
- }
- console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
- process.exit(failed.length ? 1 : 0);
-})();
+require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+// test-runner.js: each file in its own process; a file that prints `<label>: skipped (<why>)` and exits 0 is
+// skipped (○), never counted as passed (roadmap WS-Q task 7); only a run with nothing skipped says N/N passed;
+// failures exit 1, and --strict makes skips fail too.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { run, skipsIn } = require('../test-runner');
+
+(async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-test-runner-'));
+ const write = (name, body) => fs.writeFileSync(path.join(dir, name), body);
+ write('a-pass.test.js', "console.log('a: all checks passed');\n");
+ write('b-skip.test.js', "console.log('transcript via AI: skipped (no ffmpeg/ffprobe)'); process.exit(0);\n");
+ write('c-partial.test.js', "console.log('check B: skipped (no local model)'); console.log('check B: skipped (no local model)'); console.log('c: all checks passed');\n");
+ write('d-fail.test.js', "console.log('[DB] noise');\nconsole.log('d: skipped (never read on a failure)');\nthrow new Error('boom');\n");
+ write('e-mention.test.js', "console.log('[Jobs] run skipped: overlapping'); console.log('e: all checks passed');\n");
+ write('not-a-test.js', 'process.exit(1);\n');
+ const lines = [];
+ const log = (l) => lines.push(String(l));
+
+ let r = await run({ dir, argv: [], log, hide: /^\[DB\] / });
+ assert.deepStrictEqual(r.results.map((x) => [x.file, x.state]), [['a-pass.test.js', 'pass'], ['b-skip.test.js', 'skip'], ['c-partial.test.js', 'skip'], ['d-fail.test.js', 'fail'], ['e-mention.test.js', 'pass']]);
+ assert.deepStrictEqual([r.passed, r.skipped, r.failed, r.exitCode], [2, 2, 1, 1]);
+ assert.deepStrictEqual(r.results[2].skips, ['check B: skipped (no local model)'], 'repeats once');
+ assert.match(r.summary, /^2\/5 test files passed, 2 skipped \(b-skip\.test\.js: transcript via AI: skipped \(no ffmpeg\/ffprobe\) \| c-partial/);
+ assert.ok(lines.some((l) => l.startsWith('○ b-skip.test.js')), 'skips are marked ○');
+ assert.ok(lines.some((l) => l.startsWith('✗ d-fail.test.js')));
+ assert.ok(!lines.join('\n').includes('[DB] noise'), 'hidden lines stay out of a failure');
+ assert.ok(lines.join('\n').includes('boom'));
+
+ // Only the passing files: N/N, exit 0.
+ lines.length = 0;
+ r = await run({ dir, argv: ['a-pass', 'e-mention'], log });
+ assert.deepStrictEqual([r.summary, r.exitCode], ['2/2 test files passed', 0]);
+
+ // Skips alone exit 0 (yellow), and not with --strict.
+ r = await run({ dir, argv: ['b-skip', 'a-pass'], log });
+ assert.deepStrictEqual([r.passed, r.skipped, r.exitCode], [1, 1, 0]);
+ assert.match(r.summary, /^1\/2 test files passed, 1 skipped/);
+ r = await run({ dir, argv: ['b-skip', 'a-pass', '--strict'], log });
+ assert.strictEqual(r.exitCode, 1);
+ assert.match(r.summary, /strict: skips fail the run$/);
+
+ r = await run({ dir, argv: ['nothing-matches'], log });
+ assert.strictEqual(r.exitCode, 1);
+ assert.deepStrictEqual(skipsIn('x\n ACK test: skipped (ffmpeg not installed) \nrollback with newer writes: skipped (no git history)\nOK B: skipped — old style'),
+ ['ACK test: skipped (ffmpeg not installed)', 'rollback with newer writes: skipped (no git history)']);
+ fs.rmSync(dir, { recursive: true, force: true });
+ console.log('test-runner: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });