openvibers/openvibe.network Commits

Run Network's tests in parallel on openvibe-shared v2.8.0 (#49)

OP OpenVibers committed 5 Oct 2026 · ce9af39213 · 7 files, +87 −43

* Run Network's tests in parallel on openvibe-shared v2.8.0

openvibe-shared 2.8.0's test-runner runs files in parallel by CPU, and each
test process already owns its database, so test/run.js drops its serial
pin for parallel: 'auto' (both the PGlite and the test:pg suites).

What parallel runs exposed, fixed so each file stands alone:
- test/helpers/pg-preload.mjs: under test:pg every process's migrate()
  queued on the SDK's one advisory lock through createTestDb's fixed 15 s
  query timeout, so a later process died with "Query read timeout" before
  its test started. createTestDb now only creates the schema and roles for
  'pg', and the migration runs on the owner connection with a 120 s
  timeout (also for __ovNetworkMigrate). PGlite keeps createTestDb's path.
- test/perf-budget.test.js: the boot deadline is 150 s (a fresh PGlite
  migrates slower with files in parallel), and a child that dies before
  answering (the freePort() race) boots again on a new port, up to 3 tries;
  transient socket errors on the asset walk are retried.
- test/security-crawl.js: a GET that hits a pooled socket the server just
  closed is retried (up to 3 tries); writes stay single-shot.
- test/pg-cutover-rehearsal.test.js: the stalled-poll upper bound is 30 s
  (it still must wait at least 2.5 s for the 3 s deadline).

* pg preload: a failed owner migration drops the process's schema and roles (review)

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 3b2395f7..4137fa27 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
@@@ -1601,9 +1601,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.7.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
-- "integrity": "sha512-Vi5SjtNO8oX5zk6cH4emIFDTSwHpte3NNUVHchZ4gLVg1m5cZ7cUvaqiDvkcz+aw8qyNBw8WQm3A5Y8OS5rxYQ==",
++ "version": "2.8.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
++ "integrity": "sha512-QUHp0gGl5VdHa3yvGHXhGNJVD04CFkcWuTK44xZlu39KEnN+vijKiG4LN+3H5KDa9SBVgMlglIXXpgRdR0/dDw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex aa769626..97e76956 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.8.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
ddiff --git a/test/helpers/pg-preload.mjs b/test/helpers/pg-preload.mjs
iindex cce733eb..1a5850ac 100644
---- a/test/helpers/pg-preload.mjs
++++ b/test/helpers/pg-preload.mjs
@@@ -9,8 +9,22 @@ import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
++const MIGRATIONS = path.join(root, 'migrations');
++const store = process.env.NETWORK_TEST_STORE || 'pglite';
const { createTestDb } = require('openvibe-sdk/testing');
--const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });
++// On the containers createTestDb migrates through its own fixed 15 s client query timeout, and every test process's
++// migrate() queues on the SDK's single global advisory lock (openvibe-sdk/src/db/migrate.js LOCK_KEY): with test files
++// in parallel, a later process waits past 15 s and dies with "Query read timeout" before its test starts. So for 'pg'
++// createTestDb only creates this process's schema and roles, and the migration runs here on the owner connection with
++// a timeout that covers the queue. PGlite migrates in-process with no lock, so it keeps createTestDb's own path.
++const t = await createTestDb({ migrations: store === 'pg' ? null : MIGRATIONS, store, service: 'network', max: 4 });
++if (t.directUrl) {
++ const { createDb } = require('openvibe-sdk/db');
++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });
++ // A failed migration drops this process's schema and roles, as createTestDb does when its own migrate fails.
++ try { await owner.migrate({ dir: MIGRATIONS }); } catch (e) { await owner.close(); await t.close(); throw e; }
++ await owner.close();
++}
// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids
// are set to start at 100000 here, clear of every id a test names.
for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns
@@@ -31,7 +45,7 @@ globalThis.__ovNetworkDdl = async (sql) => {
globalThis.__ovNetworkMigrate = async (opts) => {
if (!t.directUrl) return t.db.migrate(opts);
const { createDb } = require('openvibe-sdk/db');
-- const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1, queryTimeoutMs: 120000 });
try { return await owner.migrate(opts); } finally { await owner.close(); }
};
// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 38a2f4ee..ea88d095 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -25,41 +25,54 @@ const freePort = () => new Promise((resolve) => { const s = net.createServer();
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-budget-'));
-- const port = await freePort();
-- const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
-- env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, 'pglite'), NODE_ENV: 'test' },
-- stdio: ['ignore', 'ignore', 'pipe'],
-- });
-- let stderr = '';
-- child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
-- const base = `http://127.0.0.1:${port}`;
-- try {
-- let up = false;
-- const startupDeadline = Date.now() + 60000; // a fresh PGlite database takes well over 10 s to migrate and seed
-- while (!up && Date.now() < startupDeadline) {
-- up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
-- if (!up) await new Promise((r) => setTimeout(r, 100));
++ // Node's fetch pools keep-alive sockets; the server closes an idle one after its default 5 s keepAliveTimeout, and a
++ // slow asset walk under load can hit that window and fail with a transient connection error. This test measures
++ // sizes, not connection reuse, so retry those.
++ const RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);
++ const retryFetch = async (url, init) => {
++ for (let attempt = 0; ; attempt++) {
++ try { return await fetch(url, init); }
++ catch (e) {
++ if (attempt >= 2 || !RETRY.has(e?.cause?.code)) throw e;
++ await new Promise((r) => setTimeout(r, 250));
++ }
}
-- assert.ok(up, `the server did not start:\n${stderr}`);
-- // Node's fetch pools keep-alive sockets; the server closes an idle one after its default
-- // 5 s keepAliveTimeout, and a slow asset walk under load can hit that window and fail with
-- // UND_ERR_SOCKET ("other side closed"). This test measures sizes, not connection reuse.
-- const retryFetch = async (url, init) => {
-- for (let attempt = 0; ; attempt++) {
-- try { return await fetch(url, init); }
-- catch (e) {
-- if (attempt >= 2 || e?.cause?.code !== 'UND_ERR_SOCKET') throw e;
-- await new Promise((r) => setTimeout(r, 250));
-- }
++ };
++ let child, exitInfo = null;
++ try {
++ // The server is a child on an ephemeral port: a fresh PGlite migrates and seeds before it answers, which with
++ // test files in parallel is slower than the 60 s once allowed, and the port freePort() picked can be claimed in
++ // the gap before the child binds. Boot again on a new port if the child dies before it answers.
++ let base, stderr = '';
++ for (let attempt = 0; ; attempt++) {
++ const port = await freePort();
++ exitInfo = null;
++ stderr = '';
++ child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, `pglite-${attempt}`), NODE_ENV: 'test' },
++ stdio: ['ignore', 'ignore', 'pipe'],
++ });
++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
++ child.once('exit', (code, signal) => { exitInfo = { code, signal, stderr }; });
++ base = `http://127.0.0.1:${port}`;
++ let up = false;
++ const startupDeadline = Date.now() + 150000; // a fresh PGlite database takes well over 10 s to migrate and seed
++ while (!up && !exitInfo && Date.now() < startupDeadline) {
++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
++ if (!up) await new Promise((r) => setTimeout(r, 100));
}
-- };
++ if (up) break;
++ child.kill();
++ if (exitInfo && attempt < 2) continue; // the port was taken, or another early boot failure: try a fresh one
++ assert.ok(up, `the server did not start:\n${(exitInfo && exitInfo.stderr) || stderr}`);
++ }
const m = await measure({ base, fetch: retryFetch });
const over = check(m, BUDGETS);
assert.deepStrictEqual(over, [], format(m, over));
console.log(format(m));
console.log('perf budget: all checks passed');
} finally {
-- child.kill();
++ if (child) child.kill();
fs.rmSync(dir, { recursive: true, force: true });
}
})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/pg-cutover-rehearsal.test.js b/test/pg-cutover-rehearsal.test.js
iindex 659460f3..6cd936f5 100644
---- a/test/pg-cutover-rehearsal.test.js
++++ b/test/pg-cutover-rehearsal.test.js
@@@ -144,7 +144,10 @@ function rehearse(args, extraEnv = {}) {
const p = await pollReady(`http://127.0.0.1:${stalled.address().port}`, { timeoutMs: 3000, requestMs: 1000 });
const took = Date.now() - t;
assert.strictEqual(p.body, null, 'no answer read');
-- assert.ok(took >= 2500 && took < 6000, `returned in ${took} ms (deadline 3000)`);
++ // It must wait for the deadline (>= 2500), and must give up near it rather than hang. The upper bound is loose:
++ // with other test files in parallel this process can be descheduled past the timer, so wall-clock can stretch
++ // well beyond the 3 s deadline even though the poll itself honoured it.
++ assert.ok(took >= 2500 && took < 30000, `returned in ${took} ms (deadline 3000)`);
} finally { stalled.closeAllConnections(); await new Promise((r) => stalled.close(r)); }
}
ddiff --git a/test/run.js b/test/run.js
iindex 67a1df1e..2725da80 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -15,4 +15,4 @@
const { pathToFileURL } = require('url');
// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).
const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;
--require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: process.env.NETWORK_TEST_STORE === 'pg' ? 2 : 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });
++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 'auto', hide: /^\[DB\] /, nodeArgs: ['--import', preload] });
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
iindex 1bde33fb..5a383a81 100644
---- a/test/security-crawl.js
++++ b/test/security-crawl.js
@@@ -15,6 +15,9 @@ const path = require('path');
const ROOT = path.join(__dirname, '..');
++/** Transient connection errors worth one more try on an idempotent request (a pooled socket the server closed). */
++const NET_RETRY = new Set(['UND_ERR_SOCKET', 'ECONNRESET', 'EPIPE', 'UND_ERR_CONNECT_TIMEOUT']);
++
/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
function mountPath(layer) {
if (!layer.regexp || layer.regexp.fast_slash) return '';
@@@ -171,14 +174,25 @@ async function crawl(base, paths, people, needlesFor, { method = 'GET', body, co
await Promise.all(Array.from({ length: concurrency }, async () => {
while (queue.length) {
const p = queue.shift();
++ const h = { 'x-forwarded-for': nextIp(), ...headers };
++ let b;
++ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
++ // The server closes a pooled connection after its 5 s keep-alive. With test files in parallel a crawl can
++ // outrun that between requests, and undici then hands back a socket the server just closed ("fetch failed",
++ // status 0), which used to fail `answered` spuriously. Retry that race on a GET (no side effects); a write
++ // stays single-shot so a retry can never duplicate it.
++ const retriable = method === 'GET';
let r;
-- try {
-- const h = { 'x-forwarded-for': nextIp(), ...headers };
-- let b;
-- if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
-- const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
-- r = { status: res.status, headers: res.headers, text: await res.text() };
-- } catch { r = { status: 0, text: '', headers: {} }; }
++ for (let attempt = 0; ; attempt++) {
++ try {
++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
++ r = { status: res.status, headers: res.headers, text: await res.text() };
++ break;
++ } catch (e) {
++ if (!retriable || attempt >= 2 || !NET_RETRY.has(e?.cause?.code)) { r = { status: 0, text: '', headers: {} }; break; }
++ await new Promise((res) => setTimeout(res, 250));
++ }
++ }
if (r.status) answered++;
const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
statuses[cls] = (statuses[cls] || 0) + 1;