openvibers/openvibe.shared Commits

The repository root is the package: exports for every module, test runner, CI, v1.0.0 changelog

OP OpenVibers committed 23 Sept 2026 · 6806014c50 · 10 files, +399 −11

A tag's GitHub tarball installs as openvibe-shared directly (checked with npm pack and a
git-archive tarball installed into an empty project). Every subpath the Network copy exported
resolves to the same file; every other module, ./files and ./package.json are exported too.
test/run.js runs the package tests; new tests cover browser-bundle hygiene (no require() or
Node globals in served files) and export resolution. CI runs on Node 22.22.1 and installs a
tag-style tarball. .gitattributes pins LF (the Network checkout had CRLF working copies).

Changes

ddiff --git a/.gitattributes b/.gitattributes
nnew file mode 100644
iindex 0000000..1125b702
---- /dev/null
++++ b/.gitattributes
@@@ -0,0 +1,3 @@
++* text=auto eol=lf
++*.png binary
++*.woff2 binary
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..e8c46a6
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,27 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ tags: ['v*']
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ - name: size report
++ run: node scripts/size-report.js
++ # The release artifact is GitHub's tarball of the tag (repository root = package root):
++ # install one built the same way into an empty project and load it by its package name.
++ - name: install from a tag-style tarball
++ run: |
++ git archive --format=tar.gz --prefix=OpenVibe.Shared-ci/ -o "$RUNNER_TEMP/openvibe-shared.tgz" HEAD
++ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
++ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-shared.tgz"
++ node -e "require('openvibe-shared'); require('openvibe-shared/navbar'); require('openvibe-shared/legal'); const f = require('openvibe-shared/files'); for (const n of f.BROWSER) require('fs').accessSync(f.path(n)); console.log('ok', require('openvibe-shared/package.json').version)"
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..2d98f97
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,2 @@
++node_modules/
++*.tgz
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..f52f9cf
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,38 @@
++# Changelog
++
++All notable changes to `openvibe-shared`. Versions follow [semver](https://semver.org/): a
++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.0.0 — 2026-09-22
++
++First release from its own repository. Imported history-free from
++`OpenVibe.Network/packages/openvibe-shared` at Network commit `f337eb6`; every module is
++byte-identical to that commit except `navbar.js`, which only changed as listed below.
++Vendored copies in Media, Tools and Community carried a stale `ov-icons.js` (and Community a
++stale navbar test); canonical was correct everywhere — see `docs/divergence-2026-09-22.md`.
++
++### Changed
++- **navbar.js no longer ships every glyph.** The 87 Font Awesome solid glyph paths moved to a new
++ browser file, `nav-icons.js`. navbar.js keeps the 13 glyphs it draws itself (account menu,
++ admin link, wallet chip) plus the widths of the rest, and fetches `nav-icons.js?v=<content hash>`
++ once, after first paint, the first time a site's link, chip or menu row names another glyph —
++ from the directory navbar.js was loaded from, else `https://openvibe.network/shared/`. The slot
++ is an empty `<svg>` of the glyph's exact size until the path arrives, so nothing moves; the
++ finished DOM and pixels are identical to the Network copy (checked in headless Chrome). If
++ `nav-icons.js` cannot load, those icons fall back to `<i class="fa-solid …">` (the behaviour
++ before inline glyphs). A page that loads `nav-icons.js` before navbar.js gets every glyph
++ synchronously. New option: `iconsUrl`.
++ navbar.js: 151.8 KB raw / 39.0 KB brotli → 118.6 KB / 27.6 KB.
++- `scripts/build-nav-icons.py` now writes `nav-icons.js`; `scripts/build-navbar-icons.js`
++ regenerates navbar.js's block from it (`--check` in the tests).
++
++### Added
++- `files.js` (`require('openvibe-shared/files')`): the list of browser files a site may serve at
++ `/shared/<file>`, the package directory, `isBrowserFile()` and `path()`.
++- `exports` for every module (`openvibe-shared/navbar`, `/nav-icons`, `/theme-loader`, … and
++ `/package.json`); every subpath the Network copy exported still resolves to the same file.
++- Tests: `test/run.js`, a navbar.js size budget (29 KB brotli) and icon availability test,
++ browser-bundle hygiene (no `require()` or Node globals in served files), package exports.
++- `scripts/size-report.js`: raw and brotli (q11) size of every browser file.
++- CI on Node 22.22.1, including an install of a tag-style tarball into an empty project.
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..e7fc91d
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,154 @@
++{
++ "name": "openvibe-shared",
++ "version": "1.0.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-shared",
++ "version": "1.0.0",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
iindex 01bacf2..f04b1c5 100644
---- a/package.json
++++ b/package.json
@@@ -1,24 +1,58 @@
{
"name": "openvibe-shared",
"version": "1.0.0",
-- "description": "Shared auth, theme, and brand utilities for the OpenVibe",
++ "description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
".": "./index.js",
++ "./package.json": "./package.json",
++ "./files": "./files.js",
++ "./account-switcher": "./account-switcher.js",
"./analytics": "./analytics.js",
++ "./app-icon": "./app-icon.js",
"./auth-client": "./auth-client.js",
-- "./theme-sync": "./theme-sync.js",
"./brand": "./brand.js",
++ "./builtin-themes": "./builtin-themes.js",
++ "./chrome-ssr": "./chrome-ssr.js",
++ "./footer": "./footer.js",
++ "./history": "./history.js",
++ "./icons": "./ov-icons.js",
++ "./island": "./island.js",
++ "./legal": "./legal.js",
"./middleware": "./middleware.js",
++ "./nav-icons": "./nav-icons.js",
++ "./navbar": "./navbar.js",
++ "./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
-- "./url-resolver": "./url-resolver.js",
++ "./openvibe-sw": "./openvibe-sw.js",
++ "./ov-icons": "./ov-icons.js",
++ "./ov-mark": "./ov-mark.js",
++ "./panels": "./panels.js",
"./seo": "./seo.js",
-- "./icons": "./ov-icons.js",
-- "./legal": "./legal.js",
-- "./footer": "./footer.js",
-- "./chrome-ssr": "./chrome-ssr.js",
-- "./app-icon": "./app-icon.js"
++ "./sso-client": "./sso-client.js",
++ "./theme-loader": "./theme-loader.js",
++ "./theme-sync": "./theme-sync.js",
++ "./tooltip": "./tooltip.js",
++ "./ui": "./ui.js",
++ "./url-resolver": "./url-resolver.js",
++ "./user-card": "./user-card.js"
++ },
++ "files": [
++ "*.js",
++ "scripts/",
++ "CHANGELOG.md"
++ ],
++ "scripts": {
++ "test": "node test/run.js",
++ "size": "node scripts/size-report.js",
++ "build:icons": "python3 scripts/build-nav-icons.py",
++ "build:themes": "node scripts/build-theme-loader.js"
++ },
++ "repository": {
++ "type": "git",
++ "url": "git+https://github.com/OpenVibers/OpenVibe.Shared.git"
},
++ "homepage": "https://github.com/OpenVibers/OpenVibe.Shared#readme",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/scripts/build-theme-loader.js b/scripts/build-theme-loader.js
iindex 1e10ea1..84034cf 100644
---- a/scripts/build-theme-loader.js
++++ b/scripts/build-theme-loader.js
@@@ -6,8 +6,8 @@
* theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
* it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
*
-- * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
-- * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
++ * node scripts/build-theme-loader.js # rewrite
++ * node scripts/build-theme-loader.js --check # exit 1 if stale
*
* Every entry carries the FULL variable set (the theme's own values over the default theme's), so
* switching themes replaces every token — a theme that does not name --success no longer keeps the
@@@ -46,6 +46,6 @@ const { a, b } = currentBlock(src);
const next = src.slice(0, a) + render() + src.slice(b);
const check = process.argv.includes('--check');
if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
--if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
++if (check) { console.error('theme-loader.js is stale: run node scripts/build-theme-loader.js'); process.exit(1); }
fs.writeFileSync(LOADER, next);
console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
ddiff --git a/test/browser-bundles.test.js b/test/browser-bundles.test.js
nnew file mode 100644
iindex 0000000..c0068b1
---- /dev/null
++++ b/test/browser-bundles.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++// Browser files (files.js BROWSER) run as plain <script> tags on every OpenVibe site: no
++// require(), no Node globals, CommonJS export only behind a typeof check, and every one parses.
++// Node-only modules stay out of the list.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { BROWSER, dir, isBrowserFile, path: filePath } = require('../files');
++
++const SERVER_ONLY = [
++ [/\brequire\s*\(/, 'require()'],
++ [/\brequire\.resolve\b/, 'require.resolve'],
++ [/\b__dirname\b|\b__filename\b/, '__dirname/__filename'],
++ [/\bprocess\.(env|argv|cwd|exit)\b/, 'process.*'],
++ [/\bBuffer\.(from|alloc|concat)\b/, 'Buffer'],
++];
++// Comments may mention require('openvibe-shared/…') in usage notes.
++const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
++
++for (const f of BROWSER) {
++ const file = path.join(dir, f);
++ assert.ok(fs.existsSync(file), `${f} exists`);
++ const src = fs.readFileSync(file, 'utf8');
++ const code = stripComments(src);
++ for (const [re, what] of SERVER_ONLY) assert.ok(!re.test(code), `${f} uses ${what} (server-only)`);
++ for (const m of code.matchAll(/module\.exports\s*=/g)) {
++ const line = code.slice(code.lastIndexOf('\n', m.index) + 1, code.indexOf('\n', m.index));
++ assert.ok(/typeof module/.test(line), `${f}: module.exports only behind a typeof module check (${line.trim()})`);
++ }
++ assert.doesNotThrow(() => new vm.Script(src, { filename: f }), `${f} parses as a classic script`);
++}
++
++// Every root module that pulls in other modules with require() is Node-only and never served.
++for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.js'))) {
++ if (/\brequire\s*\(/.test(stripComments(fs.readFileSync(path.join(dir, f), 'utf8')))) assert.ok(!isBrowserFile(f), `${f} requires modules; it must not be listed as a browser file`);
++}
++assert.ok(!isBrowserFile('middleware.js') && !isBrowserFile('../package.json'));
++assert.throws(() => filePath('auth-client.js'), /not a browser file/);
++assert.strictEqual(filePath('navbar.js'), path.join(dir, 'navbar.js'));
++
++console.log(`browser bundles: ${BROWSER.length} files clean`);
ddiff --git a/test/package.test.js b/test/package.test.js
nnew file mode 100644
iindex 0000000..807b382
---- /dev/null
++++ b/test/package.test.js
@@@ -0,0 +1,26 @@
++'use strict';
++// Every subpath consumers use, and every module file, resolves through the package's own name
++// (Node self-reference), so require('openvibe-shared/<x>') works the same once installed.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const pkg = require('../package.json');
++
++const ROOT = path.join(__dirname, '..');
++const modules = fs.readdirSync(ROOT).filter((f) => f.endsWith('.js'));
++const targets = new Set(Object.values(pkg.exports).map((t) => t.replace(/^\.\//, '')));
++for (const f of modules) assert.ok(targets.has(f), `${f} is exported`);
++for (const [sub, target] of Object.entries(pkg.exports)) {
++ assert.ok(fs.existsSync(path.join(ROOT, target)), `${sub} -> ${target} exists`);
++ assert.strictEqual(require.resolve(sub === '.' ? pkg.name : `${pkg.name}/${sub.slice(2)}`), path.join(ROOT, target), `${sub} resolves`);
++}
++// Subpaths in use across Network, Live, Media, Tools, Community and Sites (2026-09-22).
++for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'theme-sync', 'url-resolver', 'package.json', 'navbar', 'files']) {
++ assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
++}
++// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons']) require(`openvibe-shared/${sub}`);
++assert.strictEqual(typeof require('openvibe-shared/navbar').init, 'function');
++assert.ok(/^\d+\.\d+\.\d+$/.test(pkg.version), 'semver version');
++assert.ok(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8').includes(`## ${pkg.version}`), 'CHANGELOG has an entry for this version');
++console.log(`package: ${Object.keys(pkg.exports).length} exports resolve`);
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..8af63e0
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,62 @@
++#!/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.
++ *
++ * npm test # everything
++ * npm test -- navbar icons # only files whose name contains one of the words
++ *
++ * Plain Node with stubbed browser globals: no browser, no network, no running site.
++ */
++'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);
++})();