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.

OP OpenVibers committed 28 Sept 2026 · 919c10811c · 8 files, +185 −60


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 76dea88..2813825 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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.
ddiff --git a/README.md b/README.md
iindex 7b49542..3e7e0da 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex e508c9e..0ccc080 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/package-lock.json b/package-lock.json
iindex e459658..e74198a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 74656a0..44a79eb 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test-runner.js b/test-runner.js
nnew file mode 100644
iindex 0000000..fd50d49
---- /dev/null
++++ b/test-runner.js
@@@ -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 };
ddiff --git a/test/run.js b/test/run.js
iindex bcb9b2c..bff9d1f 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -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 });
ddiff --git a/test/test-runner.test.js b/test/test-runner.test.js
nnew file mode 100644
iindex 0000000..7e1991d
---- /dev/null
++++ b/test/test-runner.test.js
@@@ -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); });