openvibers/openvibe.host Commits
Release-lifecycle acceptance suite (D46, roadmap WS-P task 16): lib/acceptance.js holds the 14 D46 scenarios as 63 gates and scripts/release-acceptance.js runs them.
Changes
nnew file mode 100644@@@ -0,0 +1,905 @@++'use strict';++/**++ * The release-lifecycle acceptance suite (D46, roadmap WS-P task 16): every D46 scenario as gates, each gate a++ * test in some repository (run as `node <file>` from that repository) or a production record, and a numeric++ * budget. scripts/release-acceptance.js is the command; docs/release-acceptance.md the table and the last run.++ *++ * A gate:++ * { id, what, repo, cwd?, files: [paths], expect?: [RegExp], pins?: [{ file, text }], budget, measure,++ * category, source, base?: true, ignoreExit?: true, timeoutMs? }++ * kind 'record' (a production proof in this repository's docs/deploy-proofs.md), 'manifest' (the site's++ * /release.json, with --base) or 'open' (known to be missing) instead of files.++ * Judging a test gate: the repository must be there, every pin (the assertion the budget rests on) must still be++ * in its test file, the test must pass (exit 0; a line `<name>: skipped (<why>)` makes it skipped), every expected++ * line must be printed, and the measured value must be inside the budget. A test that only asserts a bound gives++ * that bound, marked "asserted". Results: pass, fail, skipped (with the reason; never a pass) and open.++ */++const { spawn, execFileSync } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const HOST_DIR = path.join(__dirname, '..');++++/** The repositories the gates run in: <repos>/<dir>; Live is found separately (see resolveRepos). */++const REPOS = {++ host: { dir: 'OpenVibe.Host', name: 'OpenVibe.Host' },++ shared: { dir: 'OpenVibe.Shared', name: 'OpenVibe.Shared' },++ live: { dir: 'OpenVibe.Live', name: 'OpenVibe.Live' },++ tools: { dir: 'OpenVibe.Tools', name: 'OpenVibe.Tools' },++ chat: { dir: 'OpenVibe.Chat', name: 'OpenVibe.Chat' },++ media: { dir: 'OpenVibe.Media', name: 'OpenVibe.Media' },++ blog: { dir: 'OpenVibe.Blog', name: 'OpenVibe.Blog' },++};++++/** What D46 asks to measure; every gate names one. */++const CATEGORIES = ['affected sessions', 'drain duration', 'update eligibility', 'deferred and failed clients', 'reconnects', 'resource growth', 'rollback results'];++++// ── Measuring ───────────────────────────────────────────────────++++/** The test only asserts the bound: a pass means the budget holds exactly. */++const asserted = (note) => ({ asserted: true, note });++++/** Every `[metric] name=value` line, the last one of each name winning. */++function parseMetrics(out) {++ const m = {};++ for (const [, k, v] of String(out || '').matchAll(/^\[metric\] ([\w.-]+)=(-?\d+(?:\.\d+)?)\s*$/gm)) m[k] = Number(v);++ return m;++}++/** Reads one `[metric]` line. */++const metric = (name, text) => (out) => {++ const v = parseMetrics(out)[name];++ return v === undefined ? null : { value: v, text: text ? text(v) : undefined };++};++++/** Tools' graceful.test.js: "SIGTERM → exit 0 … gateway 365 ms, img 431 ms, …": the slowest stop. */++function gracefulStop(out) {++ const line = String(out || '').split('\n').find((l) => /SIGTERM/.test(l) && /\d+ ms/.test(l));++ if (!line) return null;++ const stops = [...line.matchAll(/([a-z][\w-]*) (\d+) ms/g)].map(([, name, ms]) => ({ name, ms: Number(ms) }));++ if (!stops.length) return null;++ const worst = stops.reduce((a, b) => (b.ms > a.ms ? b : a));++ return { value: worst.ms, text: `${worst.ms} ms (${worst.name}; ${stops.length} processes)` };++}++++/** Live's browser smoke: "lap probes: {…}\n {…}\n {…}" — growth from lap 2 to lap 3 of each counter. */++function lapProbes(out) {++ const s = String(out || '');++ const at = s.indexOf('lap probes:');++ if (at < 0) return null;++ const laps = [...s.slice(at).matchAll(/\{[^{}\n]*\}/g)].slice(0, 3).map((m) => { try { return JSON.parse(m[0]); } catch { return null; } });++ if (laps.length < 3 || laps.some((l) => !l)) return null;++ const [, second, third] = laps;++ const growth = {};++ for (const k of Object.keys(third)) growth[k] = third[k] - (second[k] || 0);++ return growth;++}++const lapGrowth = (keys) => (out) => {++ const g = lapProbes(out);++ if (!g) return null;++ const value = Math.max(...keys.map((k) => g[k] ?? 0));++ return { value, text: `${keys.map((k) => `${k} ${g[k] >= 0 ? '+' : ''}${g[k]}`).join(', ')} (laps 2→3)` };++};++++/** Live's browser smoke: duplicate script requests over SPA navigation. */++function duplicateScripts(out) {++ const s = String(out || '');++ const ok = /no script requested twice \((\d+) scripts\)/.exec(s);++ if (ok) return { value: 0, text: `0 of ${ok[1]} scripts` };++ const bad = /scripts requested more than once: (.+)$/m.exec(s);++ return bad ? { value: bad[1].split(', ').length, text: bad[1] } : null;++}++++/** Host's scripts/browser-check.js --json: the site's report (from stdout; progress goes to stderr). */++function browserReport(out, io) {++ const s = String((io && io.stdout) || out || '');++ const start = s.indexOf('[');++ if (start < 0) return null;++ try { const j = JSON.parse(s.slice(start)); return Array.isArray(j) ? j[0] : null; } catch { return null; }++}++function navigationGrowth(out, io) {++ const r = browserReport(out, io);++ const g = r && r.navigation && r.navigation.growth;++ if (!g || !g.measured) return null;++ const d = g.deltas || {};++ return { value: (g.over || []).length, text: `${(g.over || []).length} over; heap ${d.heapKB >= 0 ? '+' : ''}${d.heapKB} KB, nodes ${d.nodes >= 0 ? '+' : ''}${d.nodes}, listeners ${d.listeners >= 0 ? '+' : ''}${d.listeners}, intervals ${d.intervals}, timeouts ${d.timeouts}, sockets ${d.sockets} (laps ${g.from}→${g.laps})` };++}++function noJsRoutes(out, io) {++ const r = browserReport(out, io);++ const c = r && r.summary && r.summary.checks && r.summary.checks.nojs;++ if (!c) return null;++ const chars = (r.routes || []).filter((x) => x.nojs && typeof x.nojs.textChars === 'number').map((x) => x.nojs.textChars);++ return { value: c.fail, text: `${c.fail} of ${c.pass + c.fail} routes unreadable${chars.length ? ` (least text ${Math.min(...chars)} chars)` : ''}` };++}++++/**++ * A passing test's summary line, found: the value it guarantees (`value`, by default 0: the test fails on any++ * violation), with the counts the line gives for the text.++ */++const found = (re, text, value = 0) => (out) => {++ const m = re.exec(String(out || ''));++ return m ? { value, text: text(m) } : null;++};++++// ── Budgets ─────────────────────────────────────────────────────++++/** { max } | { min } | { equals }, with a name and an optional unit. */++function withinBudget(value, budget) {++ if (typeof value !== 'number' || Number.isNaN(value)) return false;++ if (budget.max !== undefined && value > budget.max) return false;++ if (budget.min !== undefined && value < budget.min) return false;++ if (budget.equals !== undefined && value !== budget.equals) return false;++ return true;++}++function formatBudget(budget) {++ const u = budget.unit ? ` ${budget.unit}` : '';++ if (budget.equals !== undefined) return `= ${budget.equals}${u}`;++ if (budget.max !== undefined && budget.min !== undefined) return `${budget.min}–${budget.max}${u}`;++ if (budget.max !== undefined) return `≤ ${budget.max}${u}`;++ return `≥ ${budget.min}${u}`;++}++/** The bound an asserted gate proves. */++const boundOf = (budget) => (budget.equals !== undefined ? budget.equals : budget.max !== undefined ? budget.max : budget.min);++const formatValue = (value, budget) => `${value}${budget && budget.unit ? ` ${budget.unit}` : ''}`;++++// ── The scenarios ───────────────────────────────────────────────++++const pass = (name) => new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: all checks passed\\s*$`, 'm');++const line = (text) => new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));++const ok = (text) => line(`✓ ${text}`);++++const SCENARIOS = [++ {++ n: 1, name: 'Home styles deployed during a broadcast',++ gates: [++ {++ id: '1a', what: 'a styles-only release is switched without restarting Live, streams live', repo: 'host', files: ['test/strategy-release-layout.test.js'],++ expect: [ok('live: a public/-only change switches current without a restart, even with streams live')],++ budget: { name: 'Live restarts (dropped streams)', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the test asserts host.restarts() is [] and the record says restarted: false',++ },++ {++ id: '1b', what: 'the same through Live\'s own deploy script on a simulated host', repo: 'live', files: ['test/deploy-sim.test.js'], timeoutMs: 300000,++ expect: [ok('public/-only change: current switches, process is NOT restarted')],++ budget: { name: 'Live restarts', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the test compares the process id before and after the switch',++ },++ {++ id: '1c', what: 'a tab broadcasting (live camera/mic) or playing a stream is never reloaded', repo: 'shared', files: ['test/release.test.js'],++ expect: [pass('release')], pins: [{ file: 'test/release.test.js', text: 'never while the camera/mic is live' }, { file: 'test/release.test.js', text: 'never while a stream is playing' }],++ budget: { name: 'reloads while capturing or playing', max: 0 }, measure: asserted('deferred: capture / media'), category: 'deferred and failed clients',++ source: 'asserts [reloads, metrics] = [0, { deferred: { capture: 1 } }] with a live track, and the same for a playing <video>',++ },++ {++ id: '1d', what: 'a style change is applied in place: only the changed stylesheet moves', repo: 'shared', files: ['test/release-update.test.js'],++ expect: [line('release-update: all checks passed')], pins: [{ file: 'test/release-update.test.js', text: "'no reload, no prompt'" }, { file: 'test/release-update.test.js', text: 'only the changed stylesheet moved, in its place' }],++ budget: { name: 'reloads + prompts for a styles-only release', max: 0 }, measure: asserted(), category: 'update eligibility',++ source: 'asserts [p.reloads, p.toasts.length] = [0, 0] after the update',++ },++ {++ id: '1e', what: 'Live\'s manifest declares style components, so its open tabs take styles in place', kind: 'manifest', base: true,++ check: (m) => {++ const styles = Object.entries(m.components || {}).filter(([, c]) => c && c.kind === 'style').map(([id]) => id);++ return { value: styles.length, text: `${styles.length} (components: ${Object.entries(m.components || {}).map(([id, c]) => `${id}:${c.kind}`).join(', ') || 'none'})` };++ },++ onMiss: 'open', missReason: 'the manifest declares no style component, so a styles-only release prompts open tabs (Reload) instead of swapping the stylesheet; never a reload under the person',++ budget: { name: 'style components in /release.json', min: 1 }, category: 'update eligibility',++ source: 'GET <base>/release.json',++ },++ ],++ },++ {++ n: 2, name: 'A shared navbar update',++ gates: [++ {++ id: '2a', what: 'a shared-package (navbar) bump changes the shell; a changed shell is never swapped in place', repo: 'shared', files: ['test/release.test.js', 'test/release-update.test.js'],++ expect: [pass('release'), line('release-update: all checks passed')],++ pins: [{ file: 'test/release.test.js', text: 'A platform package bump is a shell change' }, { file: 'test/release-update.test.js', text: 'a changed shell is never in place' }],++ budget: { name: 'shell updates applied in place', max: 0 }, measure: asserted('prompt instead'), category: 'update eligibility',++ source: 'release.test.js asserts the shell version moves when openvibe-sdk is bumped; release-update.test.js asserts plan() prompts',++ },++ {++ id: '2b', what: 'a visible tab someone is using is not reloaded for it', repo: 'shared', files: ['test/release.test.js'],++ expect: [pass('release')], pins: [{ file: 'test/release.test.js', text: 'a visible tab someone just used is not reloaded' }],++ budget: { name: 'reloads under an active user', max: 0 }, measure: asserted('deferred: active'), category: 'deferred and failed clients',++ source: 'asserts [reloads, metrics] = [0, { prompted: { window: 1 }, deferred: { active: 1 } }]',++ },++ {++ id: '2c', what: 'each Tools page runs the navbar its own pin serves, never Network\'s copy', repo: 'tools', cwd: 'apps/_shared', files: ['test/shared-pins.test.js'],++ expect: [/shared pins: \d+ pages/],++ budget: { name: 'pages loading shared files from another origin', max: 0 }, category: 'affected sessions',++ measure: found(/shared pins: (\d+) pages, (\d+) references, all to their app's own \/shared/, (m) => `0 of ${m[1]} pages (${m[2]} references)`),++ source: 'the test fails on any page loading a shared file from openvibe.network; the counts are its summary line',++ },++ {++ id: '2d', what: 'the previous release\'s client (N-1) against this server, every call it makes', repo: 'live', files: ['test/n-1.test.js'],++ expect: [/every call the N-1 client makes is answered compatibly/],++ budget: { name: 'incompatible N-1 calls', max: 0 }, category: 'update eligibility',++ measure: found(/answered compatibly \((\d+) requests/, (m) => `0 of ${m[1]} calls`),++ source: 'test/fixtures/n-1/client.json, recorded from the release in production; the count is the test\'s line',++ },++ {++ id: '2e', what: 'adjacent releases stay compatible in the mixed-version matrix; incompatible ones reload (contract)', repo: 'shared', files: ['test/release-mixed-version.test.js'],++ expect: [line('release-mixed-version: all checks passed')],++ budget: { name: 'adjacent pairs that break', max: 0 }, measure: asserted(), category: 'update eligibility',++ source: 'assertMixedVersion over R1/R2/R3 with real servers',++ },++ ],++ },++ {++ n: 3, name: 'An article or paste edit',++ gates: [++ {++ id: '3a', what: 'a content edit replaces its region in place (no script, frame or handler carried)', repo: 'shared', files: ['test/release-update.test.js'],++ expect: [line('release-update: all checks passed')],++ pins: [{ file: 'test/release-update.test.js', text: 'no scripts, frames, meta refreshes or inline handlers come along' }, { file: 'test/release-update.test.js', text: "'no reload, no prompt'" }],++ budget: { name: 'reloads + prompts for a content edit', max: 0 }, measure: asserted(), category: 'update eligibility',++ source: 'asserts [p.reloads, p.toasts.length] = [0, 0] and the region\'s new text',++ },++ {++ id: '3b', what: 'a region the person is typing in waits; a tab with unsent input is never reloaded', repo: 'shared', files: ['test/release-update.test.js', 'test/release.test.js'],++ expect: [line('release-update: all checks passed'), pass('release')],++ pins: [{ file: 'test/release-update.test.js', text: 'the focused region is not replaced' }, { file: 'test/release.test.js', text: 'never with unsent form input' }, { file: 'test/release.test.js', text: 'never while a text field has focus' }],++ budget: { name: 'input lost to an update', max: 0 }, measure: asserted('deferred: typing / dirty'), category: 'deferred and failed clients',++ source: 'asserts the region keeps its old text while focused and commits after blur; 0 reloads with focus or form[data-dirty]',++ },++ {++ id: '3c', what: 'Blog: an edit is a new revision; a stale save is refused (412), never lost; readers see a revision only once published', repo: 'blog', files: ['test/lifecycle.test.js'],++ expect: [ok('edit: a new immutable revision; a stale expected_revision is a 412 and loses nothing'), ok('a revision after publication does not change what readers see until it is published')],++ budget: { name: 'edits lost to a concurrent save', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the Blog lifecycle test (API with a member JWT)',++ },++ {++ id: '3d', what: 'Blog without JavaScript: a stale form is a clear refusal, not a lost edit', repo: 'blog', files: ['test/nojs-editor.test.js'],++ expect: [ok('a stale form (someone saved first) is a clear refusal, not a lost edit')],++ budget: { name: 'edits lost (no-JS form)', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the Blog no-JS editor test',++ },++ ],++ },++ {++ n: 4, name: 'A feed update while reading',++ gates: [++ {++ id: '4a', what: 'a feed region above the viewport changes height: the page scrolls by exactly that', repo: 'shared', files: ['test/release-reading.test.js'],++ expect: [line('release reading: all checks passed')],++ budget: { name: 'reading position shift', max: 0, unit: 'px' }, measure: metric('feed-reading.position-shift-px'), category: 'affected sessions',++ source: '[metric] feed-reading.position-shift-px: the feed\'s height change minus what the page scrolled (+300 and -200 px cases)',++ },++ {++ id: '4b', what: 'the region the person reads inside keeps its own scroll', repo: 'shared', files: ['test/release-reading.test.js'],++ expect: [line('release reading: all checks passed')],++ budget: { name: 'region scroll lost', max: 0, unit: 'px' }, measure: metric('feed-reading.region-scroll-lost-px'), category: 'affected sessions',++ source: '[metric] feed-reading.region-scroll-lost-px (also asserted in release-update.test.js)',++ },++ {++ id: '4c', what: 'a burst of new notifications re-reads the count once', repo: 'shared', files: ['test/notification-live.test.js'],++ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "'a burst is one request'" }],++ budget: { name: 'count requests per burst', max: 1 }, measure: asserted(), category: 'resource growth',++ source: 'asserts p.counts() === before + 1 after a burst',++ },++ {++ id: '4d', what: 'Live chat keeps following when content grows under the reader', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,++ expect: [ok('chat keeps following when content grows under it')],++ budget: { name: 'distance from the bottom after growth', max: 120, unit: 'px' }, measure: asserted(), category: 'affected sessions',++ source: 'test/browser/smoke.js 3b (fails above 120 px)',++ },++ ],++ },++ {++ n: 5, name: 'A tool upgrade during a job',++ gates: [++ {++ id: '5a', what: 'an accepted job survives a restart; a running one is re-queued (or failed retryable); reattach and SSE resume', repo: 'tools', cwd: 'apps/_shared', files: ['test/jobs.test.js'],++ expect: [/jobs \(shared runtime\): all checks passed/], pins: [{ file: 'test/jobs.test.js', text: "'accepted job after restart'" }, { file: 'test/jobs.test.js', text: "'the event log spans the restart'" }],++ budget: { name: 'accepted jobs lost across a restart', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the jobs runtime end to end over HTTP, the app closed and reopened',++ },++ {++ id: '5b', what: 'SIGTERM with a request in flight: answered, every Tools process stops within the manifest deadline', repo: 'tools', cwd: 'apps/_shared', files: ['test/graceful.test.js'],++ expect: [/graceful stop: helper ok/],++ budget: { name: 'slowest graceful stop', max: 5000, unit: 'ms' }, measure: gracefulStop, category: 'drain duration',++ source: 'measured per process by the test; the budget is the lifecycle manifest\'s 5 s shutdown deadline',++ },++ {++ id: '5c', what: 'a Tools deploy reports running jobs, --wait-idle holds for them, a oneshot job unit is never restarted', repo: 'host', files: ['test/strategy-in-place.test.js'],++ expect: [ok('tools: running jobs are reported (drain policy report); --wait-idle holds until they finish')], pins: [{ file: 'test/strategy-in-place.test.js', text: "'a oneshot job is not restarted'" }],++ budget: { name: 'job units restarted by a deploy', max: 0 }, measure: asserted(), category: 'drain duration',++ source: 'the multi-app strategy against the fake host',++ },++ ],++ },++ {++ n: 6, name: 'An API restart during streams or calls',++ gates: [++ {++ id: '6a', what: 'a Live deploy refuses to restart with streams live; --wait-idle holds until two idle checks', repo: 'host', files: ['test/strategy-release-layout.test.js'],++ expect: [ok('live: streams live refuse the restart (exit 5): the prepared release is removed, current never moves'), ok('live: --wait-idle holds until two idle checks, then deploys; --force drops streams loudly')],++ budget: { name: 'streams dropped by a deploy without --force', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'exit 5 and host.restarts() = []; --wait-idle deploys only after two idle polls',++ },++ {++ id: '6b', what: 'a stream that starts during the install stops the restart', repo: 'host', files: ['test/deploy.test.js'],++ expect: [ok('sessions that start during the install stop the restart and put the checkout back')],++ budget: { name: 'streams dropped (late start)', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the protected probe runs again immediately before the restart',++ },++ {++ id: '6c', what: 'systemd holds Live\'s listener across a restart (loopback socket unit)', repo: 'live', files: ['test/systemd-units.test.js'],++ expect: [line('systemd units: all checks passed')],++ budget: { name: 'connections refused during a restart', max: 0 }, measure: asserted('socket held by pid 1'), category: 'affected sessions',++ source: 'the fault the 2026-09-26 web drain proof found (4 × 502) is pinned here',++ },++ {++ id: '6d', what: 'Chat restarts (SIGTERM, new process): readers reconnect and read after their cursor', repo: 'chat', files: ['test/restart-resume.test.js'],++ expect: [ok('stream reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate'), ok('global reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate'), ok('channel reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate')],++ budget: { name: 'messages missed or repeated after a restart', max: 0 }, measure: asserted('3 readers'), category: 'reconnects',++ source: 'each reader\'s rows equal the database\'s, nothing twice',++ },++ {++ id: '6e', what: 'a Chat restart closes the calls it left open (failed / missed / ended, reason restart)', repo: 'chat', files: ['test/calls.test.js'],++ expect: [ok('lifecycle: transitions are one-way; a restart closes what was left open')],++ budget: { name: 'calls left open after a restart', max: 0 }, category: 'affected sessions',++ measure: found(/closed calls left open by the last run: (\{[^}]*\})/, (m) => `0 (closed at boot: ${m[1]})`),++ source: 'the calls lifecycle test; the closed counts are Chat\'s boot log',++ },++ {++ id: '6f', what: 'production web drain across a Live restart (release ab8abff)', kind: 'record', row: 'Web drain (Live)',++ quote: '57 requests from 10:05:07 to 10:05:24, all 200', value: 0, text: '0 of 57 requests (2026-09-26)',++ budget: { name: 'failed requests across a restart', max: 0 }, category: 'affected sessions', source: 'docs/deploy-proofs.md',++ },++ {++ id: '6g', what: 'production chat resume across a Chat restart', kind: 'record', row: 'Chat resume (Chat)',++ quote: 'The client was connected again at 11:29:36.6, 1.1 s later', value: 1.1, text: '1.1 s, 0 missed, 0 duplicate (2026-09-26)',++ budget: { name: 'reconnect after a restart', max: 2, unit: 's' }, category: 'reconnects', source: 'docs/deploy-proofs.md; budget: the client\'s 1 s first backoff plus a connect',++ },++ {++ id: '6h', what: 'production web drain: the longest a request waited across the Live restart', kind: 'record', row: 'Web drain (Live)',++ quote: 'The slowest, 0.955 s at 10:05:21', value: 0.955, text: '0.955 s (20 requests in the window, median 0.134 s; ready after 2 s)',++ budget: { name: 'longest wait across a restart', max: 2, unit: 's' }, category: 'drain duration',++ source: 'docs/deploy-proofs.md; budget: the 2 s the deploy took to report ready, the most a queued connection waits',++ },++ {++ id: '6i', what: 'a call in progress during a Chat deploy (production)', kind: 'open', category: 'affected sessions',++ budget: { name: 'calls dropped by a deploy', max: 0 },++ reason: 'no proof: Chat\'s drain policy is report (a deploy restarts with calls up); a restart ends them with end_reason restart (6e) and people call again',++ },++ ],++ },++ {++ n: 7, name: 'A media-worker rollout',++ gates: [++ {++ id: '7a', what: 'a Media deploy refuses to restart while a recording is in progress', repo: 'host', files: ['test/deploy.test.js'],++ expect: [ok('refuses to restart Media while a recording is in progress (read-only query as the service user)')],++ budget: { name: 'recordings cut by a deploy without --force', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'exit 5, nothing restarted',++ },++ {++ id: '7b', what: 'a job left running by the old worker is re-queued at start (or failed when out of attempts)', repo: 'media', files: ['test/jobs.test.js'],++ expect: [line('✅ a job left running by a dead process is requeued (or failed when out of attempts)'), line('jobs: all checks passed')],++ budget: { name: 'jobs lost across a worker restart', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'Media\'s jobs test',++ },++ {++ id: '7c', what: 'fencing: the old worker\'s late checkpoint, heartbeat and completion are refused after a takeover', repo: 'media', files: ['test/jobs-fencing.test.js'],++ expect: [line('✅ the worker aborts a job whose lease it lost, and its failure is refused'), line('jobs fencing: all checks passed')],++ budget: { name: 'stale writes accepted from a replaced worker', max: 0 }, measure: asserted(), category: 'rollback results',++ source: 'lease_token matched on renew, checkpoint, succeed and fail',++ },++ {++ id: '7d', what: 'the finalize job never touches a recording still live', repo: 'media', files: ['test/vod-finalize-job.test.js'],++ expect: [line('✅ a live recording is never finalized by the job; out of attempts the VOD stays needs_review, hidden')],++ budget: { name: 'live recordings finalized', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'Media\'s vod.finalize test',++ },++ {++ id: '7e', what: 'production: a recorder checkpoint across a Media deploy with a live ingest', kind: 'record', row: 'Recorder / media-worker checkpoint (Media)',++ budget: { name: 'recording gaps across a rollout', max: 0, unit: 's' }, category: 'affected sessions', source: 'docs/deploy-proofs.md',++ },++ ],++ },++ {++ n: 8, name: 'Duplicate or older notifications',++ gates: [++ {++ id: '8a', what: 'the bell\'s feed ignores replayed and older seqs, another person\'s events and other types', repo: 'shared', files: ['test/notification-live.test.js'],++ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "assert.deepStrictEqual(heard.map((h) => h[1]), [ALICE]);" }],++ budget: { name: 'duplicate or older notifications shown', max: 0 }, measure: asserted('1 of 5 frames heard'), category: 'deferred and failed clients',++ source: 'five frames (one replayed seq, one other person, two other types), one heard',++ },++ {++ id: '8b', what: 'the same in headless Chrome with a real EventSource: drop and resume, nothing lost or repeated', repo: 'shared', files: ['test/notification-live-chrome.test.js'],++ expect: [line('notification-live-chrome: all checks passed')], pins: [{ file: 'test/notification-live-chrome.test.js', text: 'the missed ones, in order, nothing twice' }],++ budget: { name: 'notifications repeated after a resume', max: 0 }, measure: asserted(), category: 'reconnects',++ source: 'a local stand-in for Network and Events; skipped without Chrome',++ },++ {++ id: '8c', what: 'release notifications: a replayed seq or a repeated event id is dropped; a burst is one check', repo: 'shared', files: ['test/release-watch-realtime.test.js'],++ expect: [line('release-watch realtime: ok')], pins: [{ file: 'test/release-watch-realtime.test.js', text: 'the same event id twice is one event' }, { file: 'test/release-watch-realtime.test.js', text: 'exactly one /release.json fetch' }],++ budget: { name: '/release.json reads per burst', max: 1 }, measure: asserted(), category: 'resource growth',++ source: 'asserts one fetch for a burst of four events, one of them a replay',++ },++ ],++ },++ {++ n: 9, name: 'An account switch during an update',++ gates: [++ {++ id: '9a', what: 'a region fetched for the previous account (waiting, or in flight) is fetched again, never committed', repo: 'shared', files: ['test/release-account-switch.test.js'],++ expect: [line('release account switch: all checks passed')],++ budget: { name: 'regions committed as rendered for the previous account', max: 0 }, measure: metric('account-switch.stale-regions-committed'), category: 'deferred and failed clients',++ source: '[metric] account-switch.stale-regions-committed (Shared 1.23.1 fixed this: it was 1 in each case)',++ },++ {++ id: '9b', what: 'the switch reloads nothing and prompts nothing', repo: 'shared', files: ['test/release-account-switch.test.js'],++ expect: [line('release account switch: all checks passed')],++ budget: { name: 'reloads caused by a switch', max: 0 }, measure: metric('account-switch.reloads'), category: 'affected sessions',++ source: '[metric] account-switch.reloads (also counted: prompts)',++ },++ {++ id: '9c', what: 'update eligibility never depends on the account: /release.json is read without credentials', repo: 'shared', files: ['test/release-account-switch.test.js'],++ expect: [line('release account switch: all checks passed')],++ budget: { name: 'manifest reads carrying the session', max: 0 }, measure: metric('account-switch.manifest-reads-with-credentials'), category: 'update eligibility',++ source: '[metric] account-switch.manifest-reads-with-credentials',++ },++ {++ id: '9d', what: 'still one release stream per tab after a switch', repo: 'shared', files: ['test/release-account-switch.test.js', 'test/release-watch-realtime.test.js'],++ expect: [line('release account switch: all checks passed'), line('release-watch realtime: ok')], pins: [{ file: 'test/release-watch-realtime.test.js', text: 'never a second EventSource' }],++ budget: { name: 'release streams per tab', max: 1 }, measure: metric('account-switch.release-streams'), category: 'resource growth',++ source: '[metric] account-switch.release-streams',++ },++ {++ id: '9e', what: 'the bell restarts for the new account: its token, no cursor carried over', repo: 'shared', files: ['test/notification-live.test.js'],++ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: 'restart (another account) forgets the cursor' }, { file: 'test/notification-live.test.js', text: "'Bearer jwt-bob'" }],++ budget: { name: 'events read with the previous account\'s ticket', max: 0 }, measure: asserted(), category: 'reconnects',++ source: 'setToken() asks a ticket with the new token and opens without last_event_id',++ },++ ],++ },++ {++ n: 10, name: 'A partial asset group',++ gates: [++ {++ id: '10a', what: 'a feature whose script fails rolls back: no hook, its stylesheets withdrawn, the retry fetches only what is missing', repo: 'shared', files: ['test/web-runtime.test.js'],++ expect: [line('web runtime: all checks passed')], pins: [{ file: 'test/web-runtime.test.js', text: "'no hook for a half-loaded feature'" }, { file: 'test/web-runtime.test.js', text: "'the failed tag is gone'" }],++ budget: { name: 'half-loaded features committed', max: 0 }, measure: asserted(), category: 'deferred and failed clients',++ source: 'linkedom page where tags load or fail on cue',++ },++ {++ id: '10b', what: 'the same in headless Chrome: a real 404 rolls the group back and the retry completes it', repo: 'shared', files: ['test/web-runtime-chrome.test.js'],++ expect: [line('web-runtime-chrome: all checks passed')],++ budget: { name: 'half-loaded features committed (Chrome)', max: 0 }, measure: asserted(), category: 'deferred and failed clients',++ source: 'skipped without Chrome',++ },++ {++ id: '10c', what: 'an in-place update whose stylesheet fails keeps the old one and commits nothing', repo: 'shared', files: ['test/release-update.test.js'],++ expect: [line('release-update: all checks passed')], pins: [{ file: 'test/release-update.test.js', text: 'a stylesheet that fails to load is removed; the old one stays' }, { file: 'test/release-update.test.js', text: 'nothing commits when a stylesheet fails' }],++ budget: { name: 'pages left half updated', max: 0 }, measure: asserted('failed: style → prompt'), category: 'deferred and failed clients',++ source: 'asserts the old <link>s and the old region after a failed or timed-out stylesheet',++ },++ {++ id: '10d', what: 'Live: no script requested twice over SPA navigation', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,++ expect: [/no script requested twice|scripts requested more than once/],++ budget: { name: 'scripts requested twice', max: 0 }, measure: duplicateScripts, category: 'resource growth',++ source: 'test/browser/smoke.js 2+3',++ },++ ],++ },++ {++ n: 11, name: 'Repeated navigation leaks',++ gates: [++ {++ id: '11a', what: 'a route\'s timers, listeners and fetches end with it; late registrations are refused', repo: 'shared', files: ['test/web-runtime.test.js'],++ expect: [line('web runtime: all checks passed')], pins: [{ file: 'test/web-runtime.test.js', text: "every interval cleared (the child\\'s too)" }, { file: 'test/web-runtime.test.js', text: "'every pending timeout cleared'" }],++ budget: { name: 'intervals + timeouts left after a route ends', max: 0 }, measure: asserted(), category: 'resource growth',++ source: 'asserts p.out.intervals.size === 0 and p.out.timers.length === 0 after nextRoute()',++ },++ {++ id: '11b', what: 'the same in headless Chrome: the route\'s interval stops and its fetch aborts', repo: 'shared', files: ['test/web-runtime-chrome.test.js'],++ expect: [line('web-runtime-chrome: all checks passed')],++ budget: { name: 'intervals left after a route ends (Chrome)', max: 0 }, measure: asserted(), category: 'resource growth',++ source: 'skipped without Chrome',++ },++ {++ id: '11c', what: 'Live: three laps of ten routes; intervals, sockets and listeners between laps 2 and 3', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,++ expect: [/lap probes:/],++ budget: { name: 'growth per counter', max: 2 }, measure: lapGrowth(['intervals', 'sockets', 'windowListeners', 'documentListeners']), category: 'resource growth',++ source: 'test/browser/smoke.js 4 (slack 2 per counter)',++ },++ {++ id: '11d', what: 'Live: DOM nodes between laps 2 and 3', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,++ expect: [/lap probes:/],++ budget: { name: 'DOM node growth', max: 150 }, measure: lapGrowth(['domNodes']), category: 'resource growth',++ source: 'test/browser/smoke.js 4 (slack 150 nodes)',++ },++ {++ id: '11e', what: 'home ↔ second route five times: heap, nodes, listeners, documents, intervals, timeouts, sockets', repo: 'host', files: ['scripts/browser-check.js'], base: true, timeoutMs: 600000,++ args: (ctx) => ['--sites', ctx.base, '--json', '--no-axe', '--widths', '1280', '--max-routes', '5'], expect: [/"growth":/],++ budget: { name: 'measures over their growth budget', max: 0 }, measure: navigationGrowth, category: 'resource growth',++ source: 'openvibe-shared/browser-harness growth budgets: heap 3 MB, nodes 300, listeners 30, documents 1, intervals 1, timeouts 10, sockets 1 (docs/browser-check.md)',++ },++ ],++ },++ {++ n: 12, name: 'JS-disabled routes',++ gates: [++ {++ id: '12a', what: 'Live channel, VOD and clip pages render a real body without JavaScript', repo: 'live', files: ['test/seo-ssr.test.js'],++ expect: [ok('without JavaScript the body shows (with the site links) and the empty app shell is hidden'), ok('the client still hydrates: the route boots its feature and the fragment is inlined')],++ budget: { name: 'SSR routes without a body', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'Live\'s SEO middleware and SPA fallback, mounted as server/index.js mounts them',++ },++ {++ id: '12b', what: 'Blog: write, edit, publish and comment with plain forms', repo: 'blog', files: ['test/nojs-editor.test.js'],++ expect: [ok('new draft, edit and publish with forms'), ok('the post page shows the Community thread and takes a comment by form')],++ budget: { name: 'journey steps needing JavaScript', max: 0 }, measure: asserted(), category: 'affected sessions',++ source: 'the Blog no-JS editor test',++ },++ {++ id: '12c', what: 'the running site\'s routes, read with JavaScript off', repo: 'host', files: ['scripts/browser-check.js'], base: true, timeoutMs: 600000,++ args: (ctx) => ['--sites', ctx.base, '--json', '--no-axe', '--widths', '1280', '--max-routes', '5'], expect: [/"nojs":/],++ budget: { name: 'routes unreadable without JS', max: 0 }, measure: noJsRoutes, category: 'affected sessions',++ source: 'openvibe-shared/browser-harness nojs check (a page needs its minimum of text)',++ },++ ],++ },++ {++ n: 13, name: 'Rollback with new writes',++ gates: [++ {++ id: '13a', what: 'the release of a week ago boots on this release\'s database, reads the newer rows and writes; then forward again', repo: 'live', files: ['test/rollback-newer-writes.test.js'], timeoutMs: 300000,++ expect: [/rollback with newer writes: the release of \w+ works on this release's database, and back again/],++ budget: { name: 'newer rows read after the rollback', min: 100, unit: '%' }, category: 'rollback results',++ measure: found(/the release of (\w+) works/, (m) => `100 % (user and setting written by the newer release; older release ${m[1]}, 0 boot errors)`, 100),++ source: 'a git worktree of the release of 7 days ago on a database this release wrote; skipped without git history',++ },++ {++ id: '13b', what: 'rolling back returns the old release and its own node_modules; a release never ready is switched back', repo: 'host', files: ['test/strategy-release-layout.test.js'],++ expect: [ok('live: a lockfile change installs into the new release only; rollback returns to the old release and its own node_modules'), ok('live: not ready after the restart: current switches back, the old units come back, exit 3')],++ budget: { name: 'rollbacks that leave new code or dependencies', max: 0 }, measure: asserted(), category: 'rollback results',++ source: 'the release-layout strategy against the fake host',++ },++ {++ id: '13c', what: 'the same through Live\'s deploy script', repo: 'live', files: ['test/deploy-sim.test.js'], timeoutMs: 300000,++ expect: [ok('--rollback returns code AND node_modules of the previous release'), ok('a release that never becomes ready is rolled back automatically (exit 3)')],++ budget: { name: 'rollbacks that leave new code or dependencies', max: 0 }, measure: asserted(), category: 'rollback results',++ source: 'simulated host with a real git origin',++ },++ {++ id: '13d', what: 'open tabs follow a rollback in place; schema generations say when a rollback is safe', repo: 'shared', files: ['test/release-update.test.js', 'test/release-mixed-version.test.js'],++ expect: [line('release-update: all checks passed'), line('release-mixed-version: all checks passed')],++ pins: [{ file: 'test/release-update.test.js', text: 'A rollback to the previous release is applied in place too' }, { file: 'test/release-mixed-version.test.js', text: 'compat.rollbackSafe(R3.manifest, R2.manifest).ok, false' }],++ budget: { name: 'reloads for a rollback of styles or content', max: 0 }, measure: asserted(), category: 'rollback results',++ source: 'release-update.test.js and release-compat rollbackSafe',++ },++ ],++ },++ {++ n: 14, name: 'Resuming an offline tab',++ gates: [++ {++ id: '14a', what: 'back online, the release stream reconnects at once from its cursor (given up or backing off)', repo: 'shared', files: ['test/release-offline-resume.test.js'],++ expect: [line('release offline resume: all checks passed')],++ budget: { name: 'stream reconnect delay after online', max: 0, unit: 'ms' }, measure: metric('offline-resume.stream-reconnect-delay-ms'), category: 'reconnects',++ source: '[metric] offline-resume.stream-reconnect-delay-ms (Shared 1.23.1 fixed this: 30 000 ms in the test, up to 8 min before)',++ },++ {++ id: '14b', what: 'one /release.json read brings it straight to the newest release, in place', repo: 'shared', files: ['test/release-offline-resume.test.js'],++ expect: [line('release offline resume: all checks passed')],++ budget: { name: 'manifest reads on resume', max: 1 }, measure: metric('offline-resume.manifest-reads-on-resume'), category: 'update eligibility',++ source: '[metric] offline-resume.manifest-reads-on-resume',++ },++ {++ id: '14c', what: 'offline: no prompt, no reload, nothing counted as failed; mid-update loss leaves the page whole', repo: 'shared', files: ['test/release-offline-resume.test.js'],++ expect: [line('release offline resume: all checks passed')],++ budget: { name: 'reloads + prompts + failures while offline, and half-updated pages', max: 0 },++ measure: (out) => {++ const m = parseMetrics(out);++ const keys = ['offline-resume.reloads-while-offline', 'offline-resume.prompts-while-offline', 'offline-resume.failures-counted-offline', 'offline-resume.partial-pages', 'offline-resume.reloads-while-typing'];++ if (keys.some((k) => m[k] === undefined)) return null;++ return { value: keys.reduce((a, k) => a + m[k], 0), text: keys.map((k) => `${k.split('.')[1]} ${m[k]}`).join(', ') };++ },++ category: 'deferred and failed clients',++ source: '[metric] offline-resume.* (reloads, prompts, failures, partial pages, reloads while typing)',++ },++ {++ id: '14d', what: 'the bell resumes from its cursor when the browser is back online', repo: 'shared', files: ['test/notification-live.test.js'],++ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "'back online: a new stream'" }, { file: 'test/notification-live.test.js', text: "'14', 'from the cursor'" }],++ budget: { name: 'notifications missed on resume', max: 0 }, measure: asserted(), category: 'reconnects',++ source: 'asserts a new stream with last_event_id = the last seq seen',++ },++ ],++ },++];++++// ── Repositories and versions ──────────────────────────────────++++const LIVE_WORKTREE = path.join(os.homedir(), 'orca', 'workspaces', 'OpenVibe.Live', 'seadragon');++++/** { host, shared, live, … } → absolute directories. Live: --live, else the seadragon worktree, else <repos>/OpenVibe.Live. */++function resolveRepos({ repos = path.join(os.homedir(), 'OpenVibers'), live = null, host = HOST_DIR, exists = fs.existsSync } = {}) {++ const out = {};++ for (const [id, r] of Object.entries(REPOS)) out[id] = path.join(repos, r.dir);++ out.host = host;++ out.live = live || (exists(path.join(LIVE_WORKTREE, 'package.json')) ? LIVE_WORKTREE : path.join(repos, REPOS.live.dir));++ return out;++}++++function git(dir, args) {++ try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim(); } catch { return null; }++}++function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }++++/** Release and component versions of each repository a gate ran in: commit, branch, changed files, package and pins. */++function recordVersions(dirs, used) {++ const out = {};++ for (const id of used) {++ const dir = dirs[id];++ if (!dir || !fs.existsSync(dir)) { out[id] = { dir, missing: true }; continue; }++ const pkg = readJson(path.join(dir, 'package.json')) || {};++ const status = git(dir, ['status', '--porcelain']);++ const pinned = (name) => {++ const installed = readJson(path.join(dir, 'node_modules', name, 'package.json'));++ if (installed) return installed.version;++ const spec = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }[name];++ const tag = spec && /\/v(\d+\.\d+\.\d+)$/.exec(spec);++ return tag ? tag[1] : spec || null;++ };++ out[id] = {++ dir, commit: git(dir, ['rev-parse', '--short=12', 'HEAD']), branch: git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),++ changed: status == null ? null : status.split('\n').filter(Boolean).length, version: pkg.version || null,++ shared: pinned('openvibe-shared'), contracts: pinned('openvibe-contracts'),++ };++ }++ return out;++}++++// ── Running ─────────────────────────────────────────────────────++++/** Runs `node <file> [args]` from `cwd`; resolves { code, signal, out (stdout and stderr), stdout, ms, timedOut }. */++function runNode({ node = process.execPath, file, args = [], cwd, env = {}, timeoutMs = 180000 }) {++ return new Promise((resolve) => {++ const started = Date.now();++ let out = ''; let stdout = ''; let timedOut = false;++ let child;++ try {++ child = spawn(node, [file, ...args], { cwd, env: { ...process.env, NODE_ENV: 'test', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });++ } catch (err) { resolve({ code: null, signal: null, out: String(err && err.message), stdout: '', ms: 0, timedOut: false }); return; }++ child.stdout.on('data', (c) => { out += c; stdout += c; });++ child.stderr.on('data', (c) => { out += c; });++ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);++ child.on('error', (err) => { out += `\n${err.message}`; });++ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ code, signal, out, stdout, ms: Date.now() - started, timedOut }); });++ });++}++++const SKIP_RE = /^[\w .()-]+: skipped \((.+)\)\s*$/m;++++/** Reads this repository's docs/deploy-proofs.md table: row name → { state, record }. */++function proofRows(text) {++ const rows = {};++ for (const l of String(text || '').split('\n')) {++ const cells = l.split('|').map((c) => c.trim());++ if (cells.length >= 4 && cells[1] && !/^-+$/.test(cells[1]) && cells[1] !== 'Proof') rows[cells[1]] = { state: cells[2], record: cells[3] };++ }++ return rows;++}++++function result(gate, fields) {++ const budgetText = gate.budget ? formatBudget(gate.budget) : '';++ return { id: gate.id, what: gate.what, repo: gate.repo || null, files: gate.files || [], category: gate.category, source: gate.source || null, budget: gate.budget || null, budgetText, measured: null, measuredText: '—', reason: null, note: null, ms: 0, ...fields };++}++++/**++ * Judges one gate. ctx: { dirs, base, node, run (cached runNode), readFile, fetchManifest, hostDir, timeoutMs }.++ */++async function judgeGate(gate, ctx) {++ const kind = gate.kind || 'test';++ if (kind === 'open') return result(gate, { result: 'open', reason: gate.reason });++ if (gate.base && !ctx.base) return result(gate, { result: 'skipped', reason: 'needs a running site: --base <url>' });++ if (kind === 'record') {++ const doc = ctx.readFile(path.join(ctx.hostDir, 'docs', 'deploy-proofs.md'));++ const row = doc == null ? null : proofRows(doc)[gate.row];++ if (!row) return result(gate, { result: 'fail', reason: `docs/deploy-proofs.md has no row "${gate.row}"` });++ if (!/^passed\b/i.test(row.state)) return result(gate, { result: 'open', reason: `${row.state}${row.record ? `: ${row.record}` : ''}` });++ if (gate.quote && !doc.includes(gate.quote)) return result(gate, { result: 'fail', reason: `the record no longer says "${gate.quote}"` });++ const inside = withinBudget(gate.value, gate.budget);++ return result(gate, { result: inside ? 'pass' : 'fail', measured: gate.value, measuredText: gate.text || formatValue(gate.value, gate.budget), note: `recorded: ${row.state}`, reason: inside ? null : 'the recorded value is outside the budget' });++ }++ if (kind === 'manifest') {++ let m;++ try { m = await ctx.fetchManifest(ctx.base); } catch (err) { return result(gate, { result: 'fail', reason: `GET ${ctx.base}/release.json: ${err.message}` }); }++ const v = gate.check(m);++ const inside = withinBudget(v.value, gate.budget);++ const base = { measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget) };++ if (inside) return result(gate, { result: 'pass', ...base });++ return result(gate, { result: gate.onMiss === 'open' ? 'open' : 'fail', reason: gate.missReason || 'outside the budget', ...base });++ }++++ const dir = ctx.dirs[gate.repo];++ const cwd = path.join(dir || '', gate.cwd || '');++ if (!dir || !fs.existsSync(cwd)) return result(gate, { result: 'skipped', reason: `${(REPOS[gate.repo] || { name: gate.repo }).name} not found at ${cwd}` });++ for (const f of gate.files) {++ if (!fs.existsSync(path.join(cwd, f))) return result(gate, { result: 'fail', reason: `${f} is missing in ${REPOS[gate.repo].name}` });++ }++ for (const pin of gate.pins || []) {++ const src = ctx.readFile(path.join(cwd, pin.file));++ if (src == null || !src.includes(pin.text)) return result(gate, { result: 'fail', reason: `the assertion this gate rests on is gone from ${pin.file}: ${pin.text}` });++ }++ let out = ''; let stdout = ''; let ms = 0;++ // What a failing test still printed is shown (a [metric] line comes before the assertion that fails).++ const shown = () => {++ if (!gate.measure || gate.measure.asserted) return {};++ let v = null;++ try { v = gate.measure(out, { stdout, ctx }); } catch { /* not measurable */ }++ return v && typeof v.value === 'number' ? { measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget) } : {};++ };++ for (const f of gate.files) {++ const args = typeof gate.args === 'function' ? gate.args(ctx) : gate.args || [];++ const env = gate.base ? { BASE: ctx.base, ...(ctx.channel ? { CHANNEL: ctx.channel } : {}) } : {};++ const r = await ctx.run({ repo: gate.repo, sub: gate.cwd || '', cwd, file: f, args, env, timeoutMs: gate.timeoutMs || ctx.timeoutMs || 180000 });++ out += r.out; stdout += r.stdout || ''; ms += r.ms;++ const skip = SKIP_RE.exec(r.out);++ if (r.code === 0 && skip) return result(gate, { result: 'skipped', reason: `${f}: ${skip[1]}`, ms, tail: tail(r.out) });++ if (r.timedOut) return result(gate, { result: 'fail', reason: `${f} timed out after ${Math.round((gate.timeoutMs || ctx.timeoutMs || 180000) / 1000)} s`, ms, tail: tail(r.out), ...shown() });++ if (r.code !== 0 && !gate.ignoreExit) return result(gate, { result: 'fail', reason: `${f} failed (exit ${r.code ?? r.signal})`, ms, tail: tail(r.out), ...shown() });++ }++ for (const re of gate.expect || []) {++ if (!re.test(out)) return result(gate, { result: 'fail', reason: `expected output missing: ${re.source.replace(/\\/g, '')}`, ms, tail: tail(out), ...shown() });++ }++ let v;++ if (gate.measure && gate.measure.asserted) {++ const b = boundOf(gate.budget);++ v = { value: b, text: `${formatValue(b, gate.budget)} (asserted${gate.measure.note ? `; ${gate.measure.note}` : ''})` };++ } else {++ try { v = gate.measure(out, { stdout, ctx }); } catch { v = null; }++ }++ if (!v || typeof v.value !== 'number') return result(gate, { result: 'fail', reason: 'the measured value is not in the output', ms, tail: tail(out) });++ const inside = withinBudget(v.value, gate.budget);++ const note = gate.ignoreExit && /\d+ browser check\(s\) failed/.test(out) ? `the smoke's other checks: ${/(\d+) browser check\(s\) failed/.exec(out)[1]} failed (not this gate's)` : null;++ return result(gate, { result: inside ? 'pass' : 'fail', measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget), note, reason: inside ? null : 'outside the budget', ms, tail: inside ? undefined : tail(out) });++}++++const tail = (out, n = 15) => String(out || '').trimEnd().split('\n').slice(-n).join('\n');++++/** Selects scenarios by number and gates by id: ['1', '9', '6a'] → the gates to judge, in table order. */++function select(scenarios, only) {++ const list = [];++ const want = (only || []).map(String);++ for (const s of scenarios) {++ for (const g of s.gates) {++ if (want.length && !want.includes(String(s.n)) && !want.includes(g.id)) continue;++ list.push({ scenario: s, gate: g });++ }++ }++ return list;++}++++async function defaultFetchManifest(base) {++ const r = await fetch(new URL('/release.json', base), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) });++ if (!r.ok) throw new Error(`HTTP ${r.status}`);++ return r.json();++}++++/**++ * Runs the suite. opts: { scenarios, only, repos, live, base, channel, node, timeoutMs, runner, fetchManifest,++ * readFile, hostDir, logs (a directory: each run's output as <repo>__<file>.log), onGate(row) }. Each test file++ * runs once per run (gates share its output).++ */++async function runAcceptance(opts = {}) {++ const scenarios = opts.scenarios || SCENARIOS;++ const dirs = opts.dirs || resolveRepos({ repos: opts.repos, live: opts.live });++ const cache = new Map();++ const runner = opts.runner || runNode;++ const ctx = {++ dirs, base: opts.base ? String(opts.base).replace(/\/$/, '') : null, channel: opts.channel || null, node: opts.node || process.execPath,++ timeoutMs: opts.timeoutMs, hostDir: opts.hostDir || HOST_DIR,++ readFile: opts.readFile || ((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }),++ fetchManifest: opts.fetchManifest || defaultFetchManifest,++ run: (spec) => {++ const key = JSON.stringify([spec.cwd, spec.file, spec.args, spec.env]);++ if (cache.has(key)) return cache.get(key).then((r) => ({ ...r, ms: 0, cached: true }));++ cache.set(key, runner({ node: ctx.node, ...spec }).then((r) => {++ if (opts.logs) {++ try { fs.mkdirSync(opts.logs, { recursive: true }); fs.writeFileSync(path.join(opts.logs, `${spec.repo}__${[spec.sub, spec.file].filter(Boolean).join('__').replace(/[^\w.-]+/g, '_')}.log`), r.out); } catch { /* best effort */ }++ }++ return r;++ }));++ return cache.get(key);++ },++ };++ const startedAt = new Date().toISOString();++ const selected = select(scenarios, opts.only);++ const used = [...new Set(['host', ...selected.filter(({ gate }) => gate.repo).map(({ gate }) => gate.repo)])];++ const versions = recordVersions(dirs, used);++ const rows = [];++ let site = null;++ if (ctx.base) { try { const m = await ctx.fetchManifest(ctx.base); site = { base: ctx.base, service: m.service, release: m.release, released_at: m.released_at, packages: m.packages, components: m.components ? Object.fromEntries(Object.entries(m.components).map(([k, c]) => [k, `${c.kind}@${c.version}`])) : null }; } catch (err) { site = { base: ctx.base, error: err.message }; } }++ for (const { scenario, gate } of selected) {++ const r = await judgeGate(gate, ctx);++ const row = { scenario: scenario.n, scenarioName: scenario.name, kind: gate.kind || 'test', ...r };++ rows.push(row);++ if (opts.onGate) opts.onGate(row);++ }++ // A repository that moved while its tests ran is named: the run tested a mix.++ const after = recordVersions(dirs, used);++ for (const id of used) {++ if (!versions[id].missing && (after[id].commit !== versions[id].commit || after[id].changed !== versions[id].changed)) versions[id].changedDuringRun = `${after[id].commit}${after[id].changed ? `, ${after[id].changed} uncommitted` : ''}`;++ }++ return { startedAt, finishedAt: new Date().toISOString(), node: process.version, base: ctx.base, site, versions, gates: rows, summary: summarize(rows) };++}++++/** pass/fail/skipped/open counts; ok only when nothing failed (skipped and open are never passes). */++function summarize(rows) {++ const s = { gates: rows.length, pass: 0, fail: 0, skipped: 0, open: 0 };++ for (const r of rows) s[r.result] = (s[r.result] || 0) + 1;++ s.ok = s.fail === 0;++ s.scenarios = [...new Set(rows.map((r) => r.scenario))].length;++ return s;++}++++// ── Reports ─────────────────────────────────────────────────────++++function versionLine(id, v) {++ if (v.missing) return `${id}: not found (${v.dir})`;++ return `${id} ${v.commit || '?'}${v.branch && v.branch !== 'HEAD' ? ` (${v.branch})` : ''}${v.changed ? `, ${v.changed} uncommitted` : ''}${v.version ? `, v${v.version}` : ''}${v.shared && id !== 'shared' ? `, shared ${v.shared}` : ''}${v.changedDuringRun ? ` — CHANGED DURING THE RUN (now ${v.changedDuringRun})` : ''}`;++}++++function formatTable(report) {++ const L = [];++ L.push(`Release-lifecycle acceptance (D46), ${report.startedAt} (node ${report.node})`);++ for (const [id, v] of Object.entries(report.versions)) L.push(` ${versionLine(id, v)}`);++ if (report.site) L.push(` site ${report.site.base}: ${report.site.error ? `unreachable (${report.site.error})` : `${report.site.service} ${report.site.release}, components ${Object.entries(report.site.components || {}).map(([k, c]) => `${k}=${c}`).join(' ') || 'none'}`}`);++ L.push('');++ const rows = report.gates.map((r) => [String(r.scenario), r.id, r.result, r.measuredText, r.budgetText, `${r.budget ? `${r.budget.name}: ` : ''}${r.what}`]);++ const head = ['#', 'gate', 'result', 'measured', 'budget', 'what'];++ const w = head.map((h, i) => Math.min(i === 3 ? 48 : 60, Math.max(h.length, ...rows.map((r) => r[i].length))));++ const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);++ L.push(head.map((h, i) => (i === head.length - 1 ? h : h.padEnd(w[i]))).join(' '));++ let last = null;++ for (let k = 0; k < rows.length; k++) {++ const r = report.gates[k];++ if (r.scenario !== last) { L.push(`── ${r.scenario}. ${r.scenarioName}`); last = r.scenario; }++ L.push(rows[k].map((c, i) => (i === rows[k].length - 1 ? c : cut(c, w[i]).padEnd(w[i]))).join(' '));++ if (r.reason && r.result !== 'pass') L.push(`${' '.repeat(w[0] + w[1] + 4)}↳ ${r.reason}`);++ else if (r.note && r.kind !== 'record') L.push(`${' '.repeat(w[0] + w[1] + 4)}↳ ${r.note}`);++ }++ const s = report.summary;++ L.push('');++ L.push(`${s.pass} passed, ${s.fail} failed, ${s.skipped} skipped, ${s.open} open (${s.gates} gates, ${s.scenarios} scenarios)${s.ok ? '' : ' — FAILED'}`);++ // One excerpt per failing output (gates that share a test share its failure).++ const tails = new Map();++ for (const r of report.gates.filter((x) => x.result === 'fail' && x.tail)) {++ const k = `${r.reason}\n${r.tail}`;++ if (!tails.has(k)) tails.set(k, { ids: [], reason: r.reason, tail: r.tail });++ tails.get(k).ids.push(r.id);++ }++ for (const t of tails.values()) L.push('', `── ${t.ids.join(', ')} (${t.reason}) ──`, t.tail);++ return L.join('\n');++}++++const md = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/\n/g, ' ');++function formatMarkdown(report) {++ const L = [];++ L.push(`## Run ${report.startedAt.slice(0, 16).replace('T', ' ')} UTC`, '');++ L.push(`Node ${report.node}${report.base ? `, --base ${report.base}` : ', no --base'}.`, '');++ L.push('| Repository | Commit | Uncommitted | Version | openvibe-shared |', '|---|---|---|---|---|');++ for (const [id, v] of Object.entries(report.versions)) L.push(v.missing ? `| ${id} | not found | | | |` : `| ${id} | \`${v.commit}\`${v.branch && v.branch !== 'HEAD' ? ` (${v.branch})` : ''}${v.changedDuringRun ? ` (changed during the run: now ${v.changedDuringRun})` : ''} | ${v.changed ?? '?'} | ${v.version || ''} | ${id === 'shared' ? '(this)' : v.shared || ''} |`);++ if (report.site) L.push('', report.site.error ? `Site ${report.site.base}: unreachable (${report.site.error}).` : `Site ${report.site.base}: \`${report.site.service}\` release \`${report.site.release}\` (${report.site.released_at}), components ${Object.entries(report.site.components || {}).map(([k, c]) => `\`${k}\` ${c}`).join(', ') || 'none'}.`);++ L.push('', '| # | Gate | Result | Measured | Budget | What |', '|---|---|---|---|---|---|');++ for (const r of report.gates) {++ const res = r.result === 'pass' ? `pass${r.note ? ` (${md(r.note)})` : ''}` : `**${r.result}**${r.reason ? `: ${md(r.reason)}` : ''}`;++ L.push(`| ${r.scenario} | ${r.id} | ${res} | ${md(r.measuredText)} | ${md(r.budgetText)} | ${md(r.budget ? `${r.budget.name}: ${r.what}` : r.what)} |`);++ }++ const s = report.summary;++ L.push('', `${s.pass} passed, ${s.fail} failed, ${s.skipped} skipped, ${s.open} open (${s.gates} gates, ${s.scenarios} scenarios).`);++ return L.join('\n');++}++++module.exports = {++ SCENARIOS, REPOS, CATEGORIES, HOST_DIR,++ parseMetrics, metric, gracefulStop, lapProbes, lapGrowth, duplicateScripts, navigationGrowth, noJsRoutes, proofRows,++ withinBudget, formatBudget, boundOf,++ resolveRepos, recordVersions, runNode, judgeGate, select, runAcceptance, summarize, formatTable, formatMarkdown,++};nnew file mode 100644@@@ -0,0 +1,75 @@++#!/usr/bin/env node++'use strict';++/**++ * Release-lifecycle acceptance (D46, roadmap WS-P task 16): the 14 scenarios in lib/acceptance.js, each gate's++ * tests run with `node <file>` from its repository, then a table of result, measured value and budget.++ *++ * node scripts/release-acceptance.js # every scenario; site gates skipped++ * node scripts/release-acceptance.js --only 9,14,6a # scenarios by number, gates by id++ * node scripts/release-acceptance.js --base https://openvibe.live --channel /@JapaneseOldGuy++ * node scripts/release-acceptance.js --json # the report as JSON on stdout++ * node scripts/release-acceptance.js --out run.md # also write the run as Markdown (overwrites)++ *++ * --repos <dir> where the repositories are checked out (default ~/OpenVibers); this repository is always++ * the Host one++ * --live <dir> OpenVibe.Live (default ~/orca/workspaces/OpenVibe.Live/seadragon, else <repos>/OpenVibe.Live)++ * --base <url> a running site for the browser gates (Live's test/browser/smoke.js with BASE, this++ * repository's scripts/browser-check.js) and the manifest gate; they only read pages.++ * Without it they are reported as skipped, never as passing++ * --channel </@x> the channel page the Live smoke visits (its default /@admin exists only locally)++ * --node <path> the node that runs the tests (default: this one; the repositories test on Node 22)++ * --timeout <s> the default per-gate timeout (180 s; the browser gates have their own)++ * --logs <dir> keep each test's full output there (<repo>__<file>.log)++ * --strict exit 1 unless every selected gate passed (skipped and open count against it)++ * --list print the table of gates without running anything++ *++ * Exit 0 when no gate failed (skipped and open gates are listed with their reason, never counted as passes),++ * 1 when one failed, 2 on bad arguments.++ */++const fs = require('fs');++const path = require('path');++const acceptance = require('../lib/acceptance');++++const argv = process.argv.slice(2);++const has = (n) => argv.includes(`--${n}`);++const arg = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };++const expand = (p) => (p && p.startsWith('~/') ? path.join(require('os').homedir(), p.slice(2)) : p);++++const KNOWN = new Set(['repos', 'live', 'base', 'channel', 'node', 'timeout', 'only', 'out', 'logs', 'json', 'strict', 'list', 'help']);++for (const a of argv) {++ if (a.startsWith('--') && !KNOWN.has(a.slice(2))) { console.error(`unknown option ${a} (see the header of scripts/release-acceptance.js)`); process.exit(2); }++}++if (has('help')) {++ console.log(fs.readFileSync(__filename, 'utf8').split('\n').filter((l) => l.startsWith(' *')).map((l) => l.slice(3)).join('\n'));++ process.exit(0);++}++const only = arg('only') ? arg('only').split(',').map((s) => s.trim()).filter(Boolean) : [];++const known = new Set(acceptance.SCENARIOS.flatMap((s) => [String(s.n), ...s.gates.map((g) => g.id)]));++const unknown = only.filter((o) => !known.has(o));++if (unknown.length) { console.error(`--only: no scenario or gate ${unknown.join(', ')} (scenarios 1-${acceptance.SCENARIOS.length}, gates like 6a)`); process.exit(2); }++const base = arg('base');++if (base && !/^https?:\/\/[^/]+/.test(base)) { console.error('--base takes an http(s) origin, e.g. https://openvibe.live'); process.exit(2); }++const timeout = arg('timeout') ? Number(arg('timeout')) : null;++if (timeout !== null && !(timeout > 0)) { console.error('--timeout takes seconds'); process.exit(2); }++++if (has('list')) {++ for (const { scenario, gate } of acceptance.select(acceptance.SCENARIOS, only)) {++ const where = gate.kind === 'record' ? 'record: docs/deploy-proofs.md' : gate.kind === 'manifest' ? 'manifest: <base>/release.json' : gate.kind === 'open' ? 'open' : `${gate.repo}${gate.cwd ? `/${gate.cwd}` : ''}: ${gate.files.join(', ')}`;++ console.log(`${String(scenario.n).padStart(2)} ${gate.id.padEnd(4)} ${acceptance.formatBudget(gate.budget).padEnd(10)} ${gate.budget.name}${gate.base ? ' [--base]' : ''}\n ${where}`);++ }++ process.exit(0);++}++++(async () => {++ const report = await acceptance.runAcceptance({++ only, base, channel: arg('channel'), node: arg('node') || process.execPath,++ repos: expand(arg('repos')) || undefined, live: expand(arg('live')) || undefined,++ timeoutMs: timeout ? timeout * 1000 : undefined, logs: expand(arg('logs')) || undefined,++ onGate: has('json') ? null : (r) => process.stderr.write(` ${r.id.padEnd(4)} ${r.result}${r.ms ? ` (${(r.ms / 1000).toFixed(1)} s)` : ''}\n`),++ });++ if (has('json')) console.log(JSON.stringify(report, null, 2));++ else console.log(acceptance.formatTable(report));++ if (arg('out')) fs.writeFileSync(arg('out'), `${acceptance.formatMarkdown(report)}\n`);++ const s = report.summary;++ process.exit(!s.ok || (has('strict') && s.pass !== s.gates) ? 1 : 0);++})().catch((err) => { console.error(err.stack || err.message); process.exit(2); });nnew file mode 100644@@@ -0,0 +1,279 @@++'use strict';++/**++ * The release-lifecycle acceptance runner (lib/acceptance.js, scripts/release-acceptance.js; roadmap WS-P task 16)++ * without the other repositories: the scenario table is complete, budgets compare, every parser reads what the++ * real tests print, and a gate is judged pass, fail, skipped or open for the right reason. Fixture "repositories"++ * are temporary directories holding tiny test scripts.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++const { test, runTests } = require('./helpers');++const a = require('../lib/acceptance');++++const D46 = [++ 'Home styles deployed during a broadcast', 'A shared navbar update', 'An article or paste edit', 'A feed update while reading',++ 'A tool upgrade during a job', 'An API restart during streams or calls', 'A media-worker rollout', 'Duplicate or older notifications',++ 'An account switch during an update', 'A partial asset group', 'Repeated navigation leaks', 'JS-disabled routes',++ 'Rollback with new writes', 'Resuming an offline tab',++];++++const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ov-acceptance-'));++/** A fixture repository: files { 'test/x.test.js': source }. */++function repo(files) {++ const dir = tmp();++ for (const [f, src] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), src); }++ return dir;++}++/** A ctx whose runner answers from `outputs` (file → { code, out }) and counts the runs. */++function fakeCtx(dirs, outputs, extra = {}) {++ const runs = [];++ return {++ runs,++ ctx: {++ dirs, base: null, hostDir: extra.hostDir || tmp(), timeoutMs: 1000,++ readFile: (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } },++ fetchManifest: extra.fetchManifest || (async () => { throw new Error('no site'); }),++ run: async (spec) => { runs.push(spec); const o = outputs[spec.file] || { code: 0, out: '' }; return { code: o.code, signal: null, out: o.out, stdout: o.stdout || o.out, ms: 1, timedOut: !!o.timedOut }; },++ ...extra.ctx,++ },++ };++}++const gate = (o) => ({ id: 'x1', what: 'w', repo: 'shared', files: ['test/t.test.js'], budget: { name: 'n', max: 0 }, measure: { asserted: true }, category: 'affected sessions', ...o });++++runTests([++ test('the table: 14 scenarios in D46 order, each with gates that can run locally, every gate complete', () => {++ assert.deepStrictEqual(a.SCENARIOS.map((s) => s.n), D46.map((_, i) => i + 1));++ assert.deepStrictEqual(a.SCENARIOS.map((s) => s.name), D46);++ const ids = new Set();++ for (const s of a.SCENARIOS) {++ assert.ok(s.gates.length >= 1, `scenario ${s.n} has a gate`);++ assert.ok(s.gates.some((g) => !g.base && ['test', 'record', undefined].includes(g.kind)), `scenario ${s.n} has evidence a local run judges`);++ assert.ok(s.gates.some((g) => !g.kind && !g.base), `scenario ${s.n} has at least one test gate without a running site`);++ for (const g of s.gates) {++ assert.ok(!ids.has(g.id), `gate ${g.id} is unique`); ids.add(g.id);++ assert.match(g.id, new RegExp(`^${s.n}[a-z]$`), `${g.id} is named after its scenario`);++ assert.ok(g.what && g.budget && g.budget.name, `${g.id}: what and a named budget`);++ assert.ok(['max', 'min', 'equals'].some((k) => typeof g.budget[k] === 'number'), `${g.id}: a numeric bound`);++ assert.ok(a.CATEGORIES.includes(g.category), `${g.id}: a D46 category (${g.category})`);++ const kind = g.kind || 'test';++ if (kind === 'test') {++ assert.ok(a.REPOS[g.repo], `${g.id}: a known repository`);++ assert.ok(g.files.length && g.files.every((f) => /^[\w./-]+\.js$/.test(f) && !path.isAbsolute(f)), `${g.id}: relative .js files`);++ assert.ok(g.measure && (g.measure.asserted || typeof g.measure === 'function'), `${g.id}: a measure`);++ assert.ok((g.expect || []).length, `${g.id}: at least one line it expects`);++ for (const p of g.pins || []) assert.ok(g.files.includes(p.file) && p.text, `${g.id}: pins name a file it runs`);++ assert.ok(g.source, `${g.id}: where the number comes from`);++ } else if (kind === 'record') assert.ok(g.row, `${g.id}: a proof row`);++ else if (kind === 'manifest') assert.ok(g.base && typeof g.check === 'function', `${g.id}: a manifest check needs --base`);++ else { assert.strictEqual(kind, 'open'); assert.ok(g.reason, `${g.id}: why it is open`); }++ }++ }++ // Host's own gates name files that exist here.++ for (const s of a.SCENARIOS) for (const g of s.gates.filter((x) => x.repo === 'host')) for (const f of g.files) assert.ok(fs.existsSync(path.join(a.HOST_DIR, f)), f);++ // Every record names a row of docs/deploy-proofs.md.++ const rows = a.proofRows(fs.readFileSync(path.join(a.HOST_DIR, 'docs', 'deploy-proofs.md'), 'utf8'));++ for (const s of a.SCENARIOS) for (const g of s.gates.filter((x) => x.kind === 'record')) assert.ok(rows[g.row], `deploy-proofs.md: ${g.row}`);++ }),++++ test('budgets: max, min and equals compare; the text shows the bound and unit', () => {++ assert.strictEqual(a.withinBudget(0, { max: 0 }), true);++ assert.strictEqual(a.withinBudget(1, { max: 0 }), false);++ assert.strictEqual(a.withinBudget(660, { max: 5000 }), true);++ assert.strictEqual(a.withinBudget(100, { min: 100 }), true);++ assert.strictEqual(a.withinBudget(99.9, { min: 100 }), false);++ assert.strictEqual(a.withinBudget(1, { equals: 1 }), true);++ assert.strictEqual(a.withinBudget(NaN, { max: 1 }), false);++ assert.strictEqual(a.withinBudget(undefined, { max: 1 }), false);++ assert.strictEqual(a.formatBudget({ max: 5000, unit: 'ms' }), '≤ 5000 ms');++ assert.strictEqual(a.formatBudget({ min: 100, unit: '%' }), '≥ 100 %');++ assert.strictEqual(a.formatBudget({ equals: 1 }), '= 1');++ assert.strictEqual(a.boundOf({ min: 100 }), 100);++ }),++++ test('parsers read what the real tests print', () => {++ assert.deepStrictEqual(a.parseMetrics('[metric] a.b=0\nnoise\n[metric] c-d=12.5\n[metric] a.b=3\n[metric] bad=x\n'), { 'a.b': 3, 'c-d': 12.5 });++ assert.deepStrictEqual(a.metric('c-d')('[metric] c-d=7'), { value: 7, text: undefined });++ assert.strictEqual(a.metric('missing')('[metric] c-d=7'), null);++ // Tools' graceful.test.js summary line.++ const g = a.gracefulStop('graceful stop: helper ok; SIGTERM → exit 0 with the request in flight answered: gateway 365 ms, img 431 ms, food 661 ms');++ assert.deepStrictEqual([g.value, g.text], [661, '661 ms (food; 3 processes)']);++ assert.strictEqual(a.gracefulStop('graceful stop: helper ok'), null);++ // Live's smoke lap probes.++ const laps = ' ✓ x\n lap probes: {"intervals":14,"sockets":0,"windowListeners":58,"domNodes":6363}\n {"intervals":14,"sockets":1,"windowListeners":58,"domNodes":6370}\n {"intervals":15,"sockets":1,"windowListeners":61,"domNodes":6300}\n ✓ repeated navigation';++ assert.deepStrictEqual(a.lapProbes(laps), { intervals: 1, sockets: 0, windowListeners: 3, domNodes: -70 });++ assert.deepStrictEqual(a.lapGrowth(['intervals', 'windowListeners'])(laps), { value: 3, text: 'intervals +1, windowListeners +3 (laps 2→3)' });++ assert.strictEqual(a.lapGrowth(['domNodes'])(laps).value, -70);++ assert.strictEqual(a.lapProbes('lap probes: {"a":1}'), null, 'three laps or nothing');++ assert.deepStrictEqual(a.duplicateScripts(' ✓ no script requested twice (48 scripts)'), { value: 0, text: '0 of 48 scripts' });++ assert.strictEqual(a.duplicateScripts(' ✗ scripts requested more than once: app.js×2, chat.js×2').value, 2);++ // Host's browser-check --json (stdout), with progress on stderr.++ const report = [{ id: 'live', navigation: { growth: { measured: true, laps: 5, from: 2, deltas: { heapKB: 61, nodes: 5, listeners: 1, documents: 0, intervals: 0, timeouts: 0, sockets: 0 }, over: [] } },++ routes: [{ nojs: { textChars: 2058 } }, { nojs: { textChars: 1529 } }, { nojs: null }], summary: { checks: { nojs: { pass: 2, warn: 0, fail: 0, skip: 1 } } } }];++ const io = { stdout: JSON.stringify(report, null, 2) };++ assert.deepStrictEqual(a.navigationGrowth('progress', io), { value: 0, text: '0 over; heap +61 KB, nodes +5, listeners +1, intervals 0, timeouts 0, sockets 0 (laps 2→5)' });++ assert.deepStrictEqual(a.noJsRoutes('progress', io), { value: 0, text: '0 of 2 routes unreadable (least text 1529 chars)' });++ assert.strictEqual(a.navigationGrowth('not json', { stdout: 'not json' }), null);++ // docs/deploy-proofs.md's table.++ const rows = a.proofRows('| Proof | State | Record |\n|---|---|---|\n| Web drain (Live) | passed 2026-09-26 | [below](#x) |\n| Recorder (Media) | open | needs a live ingest |\n');++ assert.deepStrictEqual(rows, { 'Web drain (Live)': { state: 'passed 2026-09-26', record: '[below](#x)' }, 'Recorder (Media)': { state: 'open', record: 'needs a live ingest' } });++ }),++++ test('skipped: no --base, a repository not there, or a test that says it skipped; never a pass', async () => {++ const dir = repo({ 'test/t.test.js': '' });++ const { ctx, runs } = fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 'notification-live-chrome: skipped (no Chrome; set CHROME_BIN)\n' } });++ const base = await a.judgeGate(gate({ base: true }), ctx);++ assert.deepStrictEqual([base.result, base.reason], ['skipped', 'needs a running site: --base <url>']);++ const missing = await a.judgeGate(gate({ repo: 'media' }), fakeCtx({ media: path.join(dir, 'nope') }, {}).ctx);++ assert.strictEqual(missing.result, 'skipped');++ assert.match(missing.reason, /OpenVibe\.Media not found at .*nope/);++ const said = await a.judgeGate(gate({ expect: [/all checks passed/] }), ctx);++ assert.deepStrictEqual([said.result, said.reason], ['skipped', 'test/t.test.js: no Chrome; set CHROME_BIN']);++ assert.strictEqual(runs.length, 1, 'nothing ran for the first two');++ assert.deepStrictEqual(a.summarize([base, missing, said]), { gates: 3, pass: 0, fail: 0, skipped: 3, open: 0, ok: true, scenarios: 1 });++ }),++++ test('fail: exit code, a missing expected line, a missing pin, over budget, unmeasurable, a timeout', async () => {++ const dir = repo({ 'test/t.test.js': "assert.strictEqual(x, 0, 'never while typing');\n" });++ const run = (out, code = 0, extra = {}) => fakeCtx({ shared: dir }, { 'test/t.test.js': { code, out, ...extra } });++ const exit = await a.judgeGate(gate({ measure: a.metric('x.n'), expect: [/ok/] }), run('[metric] x.n=1\nAssertionError', 1).ctx);++ assert.deepStrictEqual([exit.result, exit.reason, exit.measured, exit.measuredText], ['fail', 'test/t.test.js failed (exit 1)', 1, '1'], 'a failing test still shows what it measured');++ assert.match(exit.tail, /AssertionError/);++ const line = await a.judgeGate(gate({ expect: [/x: all checks passed/] }), run('x: something else').ctx);++ assert.deepStrictEqual([line.result, line.reason], ['fail', 'expected output missing: x: all checks passed']);++ const { ctx, runs } = run('x: all checks passed');++ const pin = await a.judgeGate(gate({ expect: [/passed/], pins: [{ file: 'test/t.test.js', text: 'never while the camera is live' }] }), ctx);++ assert.strictEqual(pin.result, 'fail');++ assert.match(pin.reason, /the assertion this gate rests on is gone from test\/t\.test\.js: never while the camera is live/);++ assert.strictEqual(runs.length, 0, 'a gate whose assertion is gone does not even run');++ const over = await a.judgeGate(gate({ expect: [/ok/], measure: a.metric('delay'), budget: { name: 'delay', max: 0, unit: 'ms' } }), run('[metric] delay=30000\nok').ctx);++ assert.deepStrictEqual([over.result, over.reason, over.measuredText], ['fail', 'outside the budget', '30000 ms']);++ const none = await a.judgeGate(gate({ expect: [/ok/], measure: a.metric('delay') }), run('ok').ctx);++ assert.deepStrictEqual([none.result, none.reason], ['fail', 'the measured value is not in the output']);++ const slow = await a.judgeGate(gate({ timeoutMs: 1000 }), run('', null, { timedOut: true }).ctx);++ assert.deepStrictEqual([slow.result, slow.reason], ['fail', 'test/t.test.js timed out after 1 s']);++ const gone = await a.judgeGate(gate({ files: ['test/gone.test.js'] }), run('').ctx);++ assert.deepStrictEqual([gone.result, gone.reason], ['fail', 'test/gone.test.js is missing in OpenVibe.Shared']);++ assert.strictEqual(a.summarize([exit, line, pin, over]).ok, false);++ }),++++ test('pass: an asserted bound, a measured value, a smoke whose other checks failed (ignoreExit)', async () => {++ const dir = repo({ 'test/t.test.js': "'never while typing'" });++ const ok = await a.judgeGate(gate({ expect: [/all checks passed/], pins: [{ file: 'test/t.test.js', text: 'never while typing' }], measure: { asserted: true, note: 'deferred: typing' } }), fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 't: all checks passed' } }).ctx);++ assert.deepStrictEqual([ok.result, ok.measured, ok.measuredText, ok.budgetText], ['pass', 0, '0 (asserted; deferred: typing)', '≤ 0']);++ const min = await a.judgeGate(gate({ expect: [/works/], budget: { name: 'rows', min: 100, unit: '%' }, measure: { asserted: true } }), fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 'it works' } }).ctx);++ assert.deepStrictEqual([min.result, min.measuredText], ['pass', '100 % (asserted)']);++ const smoke = ' lap probes: {"intervals":14}\n {"intervals":14}\n {"intervals":14}\n ✗ 1440px /@admin: active=page-home\n\n6 browser check(s) failed\n';++ const { ctx } = fakeCtx({ live: dir }, { 'test/t.test.js': { code: 1, out: smoke } });++ ctx.base = 'https://site.test';++ const leak = await a.judgeGate(gate({ repo: 'live', base: true, ignoreExit: true, expect: [/lap probes:/], budget: { name: 'growth', max: 2 }, measure: a.lapGrowth(['intervals']) }), ctx);++ assert.deepStrictEqual([leak.result, leak.measured, leak.reason, leak.note], ['pass', 0, null, "the smoke's other checks: 6 failed (not this gate's)"]);++ }),++++ test('records, manifests and open gates', async () => {++ const hostDir = repo({ 'docs/deploy-proofs.md': '| Proof | State | Record |\n|---|---|---|\n| Web drain (Live) | passed 2026-09-26 | [below](#w) |\n| Recorder (Media) | open | needs a live ingest during a Media deploy |\n\n57 requests, all 200.\n' });++ const { ctx } = fakeCtx({}, {}, { hostDir });++ const rec = (o) => ({ id: 'r1', kind: 'record', what: 'w', budget: { name: 'failed', max: 0 }, category: 'affected sessions', ...o });++ const passed = await a.judgeGate(rec({ row: 'Web drain (Live)', quote: '57 requests, all 200', value: 0, text: '0 of 57' }), ctx);++ assert.deepStrictEqual([passed.result, passed.measuredText, passed.note], ['pass', '0 of 57', 'recorded: passed 2026-09-26']);++ const open = await a.judgeGate(rec({ row: 'Recorder (Media)' }), ctx);++ assert.deepStrictEqual([open.result, open.reason], ['open', 'open: needs a live ingest during a Media deploy']);++ const changed = await a.judgeGate(rec({ row: 'Web drain (Live)', quote: '99 requests', value: 0 }), ctx);++ assert.deepStrictEqual([changed.result, changed.reason], ['fail', 'the record no longer says "99 requests"']);++ const noRow = await a.judgeGate(rec({ row: 'Nope' }), ctx);++ assert.strictEqual(noRow.result, 'fail');++ const over = await a.judgeGate(rec({ row: 'Web drain (Live)', value: 4 }), ctx);++ assert.strictEqual(over.result, 'fail');++++ const styles = a.SCENARIOS[0].gates.find((g) => g.kind === 'manifest');++ const site = (m) => fakeCtx({}, {}, { fetchManifest: async () => m, ctx: { base: 'https://site.test' } }).ctx;++ const without = await a.judgeGate(styles, site({ components: { shell: { kind: 'script', version: 'a' }, server: { kind: 'server', version: 'a' } } }));++ assert.deepStrictEqual([without.result, without.measured, without.measuredText], ['open', 0, '0 (components: shell:script, server:server)']);++ const withStyles = await a.judgeGate(styles, site({ components: { styles: { kind: 'style', version: 'b' } } }));++ assert.strictEqual(withStyles.result, 'pass');++ const down = await a.judgeGate(styles, fakeCtx({}, {}, { ctx: { base: 'https://site.test' } }).ctx);++ assert.deepStrictEqual([down.result, down.reason], ['fail', 'GET https://site.test/release.json: no site']);++ const known = await a.judgeGate({ id: 'o1', kind: 'open', what: 'w', budget: { name: 'n', max: 0 }, reason: 'no proof yet' }, site({}));++ assert.deepStrictEqual([known.result, known.reason], ['open', 'no proof yet']);++ assert.deepStrictEqual(a.summarize([passed, open, known]), { gates: 3, pass: 1, fail: 0, skipped: 0, open: 2, ok: true, scenarios: 1 });++ }),++++ test('runAcceptance runs real processes: node <file> from the repository, once per file, with a timeout; versions recorded', async () => {++ const counter = path.join(tmp(), 'runs.log');++ const dir = repo({++ 'test/good.test.js': `require('fs').appendFileSync(${JSON.stringify(counter)}, 'good\\n'); console.log('[metric] x.delay=0'); console.log('cwd ' + process.cwd()); console.log('good: all checks passed');`,++ 'test/bad.test.js': "console.log('[metric] x.left=2'); console.error('AssertionError: left 2'); process.exit(1);",++ 'test/slow.test.js': 'setTimeout(() => {}, 60000);',++ 'test/env.test.js': "console.log('env ' + process.env.NODE_ENV + ' ' + (process.env.BASE || '-'));",++ });++ const scenarios = [++ { n: 1, name: 'One', gates: [++ gate({ id: '1a', files: ['test/good.test.js'], expect: [/good: all checks passed/], measure: a.metric('x.delay'), budget: { name: 'delay', max: 0 } }),++ gate({ id: '1b', files: ['test/good.test.js'], expect: [new RegExp(`cwd ${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')] }),++ gate({ id: '1c', files: ['test/env.test.js'], expect: [/env test -/] }),++ ] },++ { n: 2, name: 'Two', gates: [++ gate({ id: '2a', files: ['test/bad.test.js'], expect: [/never/], measure: a.metric('x.left') }),++ gate({ id: '2b', files: ['test/slow.test.js'], timeoutMs: 500 }),++ gate({ id: '2c', repo: 'live', files: ['test/good.test.js'], base: true }),++ ] },++ ];++ const seen = [];++ const report = await a.runAcceptance({ scenarios, dirs: { shared: dir, live: dir, host: a.HOST_DIR }, onGate: (r) => seen.push(r.id) });++ const by = Object.fromEntries(report.gates.map((r) => [r.id, r]));++ assert.deepStrictEqual(seen, ['1a', '1b', '1c', '2a', '2b', '2c']);++ assert.deepStrictEqual(['1a', '1b', '1c', '2a', '2b', '2c'].map((id) => by[id].result), ['pass', 'pass', 'pass', 'fail', 'fail', 'skipped']);++ assert.strictEqual(fs.readFileSync(counter, 'utf8'), 'good\n', 'a file two gates share runs once');++ assert.deepStrictEqual([by['2a'].measured, by['2a'].reason], [2, 'test/bad.test.js failed (exit 1)']);++ assert.match(by['2b'].reason, /timed out after 1 s/);++ assert.deepStrictEqual(report.summary, { gates: 6, pass: 3, fail: 2, skipped: 1, open: 0, ok: false, scenarios: 2 });++ assert.strictEqual(report.versions.shared.dir, dir);++ assert.strictEqual(report.versions.shared.commit, null, 'not a git checkout: no commit');++ assert.ok(report.versions.host.commit, 'the Host checkout records its commit');++ // The reports.++ const table = a.formatTable(report);++ assert.match(table, /── 1\. One/);++ assert.match(table, /3 passed, 2 failed, 1 skipped, 0 open \(6 gates, 2 scenarios\) — FAILED/);++ assert.match(table, /── 2a \(test\/bad\.test\.js failed \(exit 1\)\) ──\n[\s\S]*AssertionError: left 2/);++ const mdown = a.formatMarkdown({ ...report, gates: [...report.gates, { ...by['1a'], what: 'a | b' }] });++ assert.match(mdown, /\| 1 \| 1a \| pass \| 0 \| ≤ 0 \|/);++ assert.match(mdown, /\| 2 \| 2c \| \*\*skipped\*\*: needs a running site: --base <url> \|/);++ assert.match(mdown, /a \\\| b/, 'pipes are escaped');++ }),++++ test('a repository that moves while its tests run is named in the report', async () => {++ const dir = repo({ 'test/commit.test.js': "const { execFileSync } = require('child_process'); require('fs').writeFileSync('new.txt', 'x'); execFileSync('git', ['add', 'new.txt']); execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'during']); console.log('ok');" });++ const g = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });++ g('init', '-q'); g('add', '.'); g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'first');++ const before = g('rev-parse', '--short=12', 'HEAD').stdout.trim();++ const logs = tmp();++ const report = await a.runAcceptance({ scenarios: [{ n: 1, name: 'One', gates: [gate({ id: '1a', files: ['test/commit.test.js'], expect: [/ok/] })] }], dirs: { shared: dir, host: a.HOST_DIR }, logs });++ const v = report.versions.shared;++ assert.strictEqual(v.commit, before, 'the commit the run started from');++ assert.strictEqual(v.changedDuringRun, g('rev-parse', '--short=12', 'HEAD').stdout.trim());++ assert.match(a.formatTable(report), /CHANGED DURING THE RUN/);++ assert.deepStrictEqual(fs.readdirSync(logs), ['shared__test_commit.test.js.log'], '--logs keeps each run\'s output');++ }),++++ test('scripts/release-acceptance.js: bad arguments exit 2; without the repositories every gate is skipped and the run exits 0 with 0 passes', () => {++ const script = path.join(a.HOST_DIR, 'scripts', 'release-acceptance.js');++ const run = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', timeout: 60000 });++ assert.strictEqual(run('--only', '99').status, 2);++ assert.strictEqual(run('--bogus').status, 2);++ assert.strictEqual(run('--base', 'openvibe.live').status, 2);++ const list = run('--list');++ assert.strictEqual(list.status, 0);++ assert.strictEqual((list.stdout.match(/^ ?\d+ \d+[a-z] /gm) || []).length, a.SCENARIOS.reduce((n, s) => n + s.gates.length, 0));++ const empty = tmp();++ const r = run('--repos', empty, '--live', path.join(empty, 'live'), '--only', '9,14', '--json');++ assert.strictEqual(r.status, 0, r.stderr);++ const report = JSON.parse(r.stdout);++ assert.strictEqual(report.summary.pass, 0, 'skipped gates are never passes');++ assert.strictEqual(report.summary.skipped, report.summary.gates);++ assert.ok(report.gates.every((g) => /OpenVibe\.Shared not found/.test(g.reason)));++ const strict = run('--repos', empty, '--live', path.join(empty, 'live'), '--only', '9a', '--strict');++ assert.strictEqual(strict.status, 1, '--strict: a skipped gate fails the run');++ }),++]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,905 @@
@@ -0,0 +1,905 @@
+'use strict';
+/**
+ * The release-lifecycle acceptance suite (D46, roadmap WS-P task 16): every D46 scenario as gates, each gate a
+ * test in some repository (run as `node <file>` from that repository) or a production record, and a numeric
+ * budget. scripts/release-acceptance.js is the command; docs/release-acceptance.md the table and the last run.
+ *
+ * A gate:
+ * { id, what, repo, cwd?, files: [paths], expect?: [RegExp], pins?: [{ file, text }], budget, measure,
+ * category, source, base?: true, ignoreExit?: true, timeoutMs? }
+ * kind 'record' (a production proof in this repository's docs/deploy-proofs.md), 'manifest' (the site's
+ * /release.json, with --base) or 'open' (known to be missing) instead of files.
+ * Judging a test gate: the repository must be there, every pin (the assertion the budget rests on) must still be
+ * in its test file, the test must pass (exit 0; a line `<name>: skipped (<why>)` makes it skipped), every expected
+ * line must be printed, and the measured value must be inside the budget. A test that only asserts a bound gives
+ * that bound, marked "asserted". Results: pass, fail, skipped (with the reason; never a pass) and open.
+ */
+const { spawn, execFileSync } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const HOST_DIR = path.join(__dirname, '..');
+
+/** The repositories the gates run in: <repos>/<dir>; Live is found separately (see resolveRepos). */
+const REPOS = {
+ host: { dir: 'OpenVibe.Host', name: 'OpenVibe.Host' },
+ shared: { dir: 'OpenVibe.Shared', name: 'OpenVibe.Shared' },
+ live: { dir: 'OpenVibe.Live', name: 'OpenVibe.Live' },
+ tools: { dir: 'OpenVibe.Tools', name: 'OpenVibe.Tools' },
+ chat: { dir: 'OpenVibe.Chat', name: 'OpenVibe.Chat' },
+ media: { dir: 'OpenVibe.Media', name: 'OpenVibe.Media' },
+ blog: { dir: 'OpenVibe.Blog', name: 'OpenVibe.Blog' },
+};
+
+/** What D46 asks to measure; every gate names one. */
+const CATEGORIES = ['affected sessions', 'drain duration', 'update eligibility', 'deferred and failed clients', 'reconnects', 'resource growth', 'rollback results'];
+
+// ── Measuring ───────────────────────────────────────────────────
+
+/** The test only asserts the bound: a pass means the budget holds exactly. */
+const asserted = (note) => ({ asserted: true, note });
+
+/** Every `[metric] name=value` line, the last one of each name winning. */
+function parseMetrics(out) {
+ const m = {};
+ for (const [, k, v] of String(out || '').matchAll(/^\[metric\] ([\w.-]+)=(-?\d+(?:\.\d+)?)\s*$/gm)) m[k] = Number(v);
+ return m;
+}
+/** Reads one `[metric]` line. */
+const metric = (name, text) => (out) => {
+ const v = parseMetrics(out)[name];
+ return v === undefined ? null : { value: v, text: text ? text(v) : undefined };
+};
+
+/** Tools' graceful.test.js: "SIGTERM → exit 0 … gateway 365 ms, img 431 ms, …": the slowest stop. */
+function gracefulStop(out) {
+ const line = String(out || '').split('\n').find((l) => /SIGTERM/.test(l) && /\d+ ms/.test(l));
+ if (!line) return null;
+ const stops = [...line.matchAll(/([a-z][\w-]*) (\d+) ms/g)].map(([, name, ms]) => ({ name, ms: Number(ms) }));
+ if (!stops.length) return null;
+ const worst = stops.reduce((a, b) => (b.ms > a.ms ? b : a));
+ return { value: worst.ms, text: `${worst.ms} ms (${worst.name}; ${stops.length} processes)` };
+}
+
+/** Live's browser smoke: "lap probes: {…}\n {…}\n {…}" — growth from lap 2 to lap 3 of each counter. */
+function lapProbes(out) {
+ const s = String(out || '');
+ const at = s.indexOf('lap probes:');
+ if (at < 0) return null;
+ const laps = [...s.slice(at).matchAll(/\{[^{}\n]*\}/g)].slice(0, 3).map((m) => { try { return JSON.parse(m[0]); } catch { return null; } });
+ if (laps.length < 3 || laps.some((l) => !l)) return null;
+ const [, second, third] = laps;
+ const growth = {};
+ for (const k of Object.keys(third)) growth[k] = third[k] - (second[k] || 0);
+ return growth;
+}
+const lapGrowth = (keys) => (out) => {
+ const g = lapProbes(out);
+ if (!g) return null;
+ const value = Math.max(...keys.map((k) => g[k] ?? 0));
+ return { value, text: `${keys.map((k) => `${k} ${g[k] >= 0 ? '+' : ''}${g[k]}`).join(', ')} (laps 2→3)` };
+};
+
+/** Live's browser smoke: duplicate script requests over SPA navigation. */
+function duplicateScripts(out) {
+ const s = String(out || '');
+ const ok = /no script requested twice \((\d+) scripts\)/.exec(s);
+ if (ok) return { value: 0, text: `0 of ${ok[1]} scripts` };
+ const bad = /scripts requested more than once: (.+)$/m.exec(s);
+ return bad ? { value: bad[1].split(', ').length, text: bad[1] } : null;
+}
+
+/** Host's scripts/browser-check.js --json: the site's report (from stdout; progress goes to stderr). */
+function browserReport(out, io) {
+ const s = String((io && io.stdout) || out || '');
+ const start = s.indexOf('[');
+ if (start < 0) return null;
+ try { const j = JSON.parse(s.slice(start)); return Array.isArray(j) ? j[0] : null; } catch { return null; }
+}
+function navigationGrowth(out, io) {
+ const r = browserReport(out, io);
+ const g = r && r.navigation && r.navigation.growth;
+ if (!g || !g.measured) return null;
+ const d = g.deltas || {};
+ return { value: (g.over || []).length, text: `${(g.over || []).length} over; heap ${d.heapKB >= 0 ? '+' : ''}${d.heapKB} KB, nodes ${d.nodes >= 0 ? '+' : ''}${d.nodes}, listeners ${d.listeners >= 0 ? '+' : ''}${d.listeners}, intervals ${d.intervals}, timeouts ${d.timeouts}, sockets ${d.sockets} (laps ${g.from}→${g.laps})` };
+}
+function noJsRoutes(out, io) {
+ const r = browserReport(out, io);
+ const c = r && r.summary && r.summary.checks && r.summary.checks.nojs;
+ if (!c) return null;
+ const chars = (r.routes || []).filter((x) => x.nojs && typeof x.nojs.textChars === 'number').map((x) => x.nojs.textChars);
+ return { value: c.fail, text: `${c.fail} of ${c.pass + c.fail} routes unreadable${chars.length ? ` (least text ${Math.min(...chars)} chars)` : ''}` };
+}
+
+/**
+ * A passing test's summary line, found: the value it guarantees (`value`, by default 0: the test fails on any
+ * violation), with the counts the line gives for the text.
+ */
+const found = (re, text, value = 0) => (out) => {
+ const m = re.exec(String(out || ''));
+ return m ? { value, text: text(m) } : null;
+};
+
+// ── Budgets ─────────────────────────────────────────────────────
+
+/** { max } | { min } | { equals }, with a name and an optional unit. */
+function withinBudget(value, budget) {
+ if (typeof value !== 'number' || Number.isNaN(value)) return false;
+ if (budget.max !== undefined && value > budget.max) return false;
+ if (budget.min !== undefined && value < budget.min) return false;
+ if (budget.equals !== undefined && value !== budget.equals) return false;
+ return true;
+}
+function formatBudget(budget) {
+ const u = budget.unit ? ` ${budget.unit}` : '';
+ if (budget.equals !== undefined) return `= ${budget.equals}${u}`;
+ if (budget.max !== undefined && budget.min !== undefined) return `${budget.min}–${budget.max}${u}`;
+ if (budget.max !== undefined) return `≤ ${budget.max}${u}`;
+ return `≥ ${budget.min}${u}`;
+}
+/** The bound an asserted gate proves. */
+const boundOf = (budget) => (budget.equals !== undefined ? budget.equals : budget.max !== undefined ? budget.max : budget.min);
+const formatValue = (value, budget) => `${value}${budget && budget.unit ? ` ${budget.unit}` : ''}`;
+
+// ── The scenarios ───────────────────────────────────────────────
+
+const pass = (name) => new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: all checks passed\\s*$`, 'm');
+const line = (text) => new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+const ok = (text) => line(`✓ ${text}`);
+
+const SCENARIOS = [
+ {
+ n: 1, name: 'Home styles deployed during a broadcast',
+ gates: [
+ {
+ id: '1a', what: 'a styles-only release is switched without restarting Live, streams live', repo: 'host', files: ['test/strategy-release-layout.test.js'],
+ expect: [ok('live: a public/-only change switches current without a restart, even with streams live')],
+ budget: { name: 'Live restarts (dropped streams)', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the test asserts host.restarts() is [] and the record says restarted: false',
+ },
+ {
+ id: '1b', what: 'the same through Live\'s own deploy script on a simulated host', repo: 'live', files: ['test/deploy-sim.test.js'], timeoutMs: 300000,
+ expect: [ok('public/-only change: current switches, process is NOT restarted')],
+ budget: { name: 'Live restarts', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the test compares the process id before and after the switch',
+ },
+ {
+ id: '1c', what: 'a tab broadcasting (live camera/mic) or playing a stream is never reloaded', repo: 'shared', files: ['test/release.test.js'],
+ expect: [pass('release')], pins: [{ file: 'test/release.test.js', text: 'never while the camera/mic is live' }, { file: 'test/release.test.js', text: 'never while a stream is playing' }],
+ budget: { name: 'reloads while capturing or playing', max: 0 }, measure: asserted('deferred: capture / media'), category: 'deferred and failed clients',
+ source: 'asserts [reloads, metrics] = [0, { deferred: { capture: 1 } }] with a live track, and the same for a playing <video>',
+ },
+ {
+ id: '1d', what: 'a style change is applied in place: only the changed stylesheet moves', repo: 'shared', files: ['test/release-update.test.js'],
+ expect: [line('release-update: all checks passed')], pins: [{ file: 'test/release-update.test.js', text: "'no reload, no prompt'" }, { file: 'test/release-update.test.js', text: 'only the changed stylesheet moved, in its place' }],
+ budget: { name: 'reloads + prompts for a styles-only release', max: 0 }, measure: asserted(), category: 'update eligibility',
+ source: 'asserts [p.reloads, p.toasts.length] = [0, 0] after the update',
+ },
+ {
+ id: '1e', what: 'Live\'s manifest declares style components, so its open tabs take styles in place', kind: 'manifest', base: true,
+ check: (m) => {
+ const styles = Object.entries(m.components || {}).filter(([, c]) => c && c.kind === 'style').map(([id]) => id);
+ return { value: styles.length, text: `${styles.length} (components: ${Object.entries(m.components || {}).map(([id, c]) => `${id}:${c.kind}`).join(', ') || 'none'})` };
+ },
+ onMiss: 'open', missReason: 'the manifest declares no style component, so a styles-only release prompts open tabs (Reload) instead of swapping the stylesheet; never a reload under the person',
+ budget: { name: 'style components in /release.json', min: 1 }, category: 'update eligibility',
+ source: 'GET <base>/release.json',
+ },
+ ],
+ },
+ {
+ n: 2, name: 'A shared navbar update',
+ gates: [
+ {
+ id: '2a', what: 'a shared-package (navbar) bump changes the shell; a changed shell is never swapped in place', repo: 'shared', files: ['test/release.test.js', 'test/release-update.test.js'],
+ expect: [pass('release'), line('release-update: all checks passed')],
+ pins: [{ file: 'test/release.test.js', text: 'A platform package bump is a shell change' }, { file: 'test/release-update.test.js', text: 'a changed shell is never in place' }],
+ budget: { name: 'shell updates applied in place', max: 0 }, measure: asserted('prompt instead'), category: 'update eligibility',
+ source: 'release.test.js asserts the shell version moves when openvibe-sdk is bumped; release-update.test.js asserts plan() prompts',
+ },
+ {
+ id: '2b', what: 'a visible tab someone is using is not reloaded for it', repo: 'shared', files: ['test/release.test.js'],
+ expect: [pass('release')], pins: [{ file: 'test/release.test.js', text: 'a visible tab someone just used is not reloaded' }],
+ budget: { name: 'reloads under an active user', max: 0 }, measure: asserted('deferred: active'), category: 'deferred and failed clients',
+ source: 'asserts [reloads, metrics] = [0, { prompted: { window: 1 }, deferred: { active: 1 } }]',
+ },
+ {
+ id: '2c', what: 'each Tools page runs the navbar its own pin serves, never Network\'s copy', repo: 'tools', cwd: 'apps/_shared', files: ['test/shared-pins.test.js'],
+ expect: [/shared pins: \d+ pages/],
+ budget: { name: 'pages loading shared files from another origin', max: 0 }, category: 'affected sessions',
+ measure: found(/shared pins: (\d+) pages, (\d+) references, all to their app's own \/shared/, (m) => `0 of ${m[1]} pages (${m[2]} references)`),
+ source: 'the test fails on any page loading a shared file from openvibe.network; the counts are its summary line',
+ },
+ {
+ id: '2d', what: 'the previous release\'s client (N-1) against this server, every call it makes', repo: 'live', files: ['test/n-1.test.js'],
+ expect: [/every call the N-1 client makes is answered compatibly/],
+ budget: { name: 'incompatible N-1 calls', max: 0 }, category: 'update eligibility',
+ measure: found(/answered compatibly \((\d+) requests/, (m) => `0 of ${m[1]} calls`),
+ source: 'test/fixtures/n-1/client.json, recorded from the release in production; the count is the test\'s line',
+ },
+ {
+ id: '2e', what: 'adjacent releases stay compatible in the mixed-version matrix; incompatible ones reload (contract)', repo: 'shared', files: ['test/release-mixed-version.test.js'],
+ expect: [line('release-mixed-version: all checks passed')],
+ budget: { name: 'adjacent pairs that break', max: 0 }, measure: asserted(), category: 'update eligibility',
+ source: 'assertMixedVersion over R1/R2/R3 with real servers',
+ },
+ ],
+ },
+ {
+ n: 3, name: 'An article or paste edit',
+ gates: [
+ {
+ id: '3a', what: 'a content edit replaces its region in place (no script, frame or handler carried)', repo: 'shared', files: ['test/release-update.test.js'],
+ expect: [line('release-update: all checks passed')],
+ pins: [{ file: 'test/release-update.test.js', text: 'no scripts, frames, meta refreshes or inline handlers come along' }, { file: 'test/release-update.test.js', text: "'no reload, no prompt'" }],
+ budget: { name: 'reloads + prompts for a content edit', max: 0 }, measure: asserted(), category: 'update eligibility',
+ source: 'asserts [p.reloads, p.toasts.length] = [0, 0] and the region\'s new text',
+ },
+ {
+ id: '3b', what: 'a region the person is typing in waits; a tab with unsent input is never reloaded', repo: 'shared', files: ['test/release-update.test.js', 'test/release.test.js'],
+ expect: [line('release-update: all checks passed'), pass('release')],
+ pins: [{ file: 'test/release-update.test.js', text: 'the focused region is not replaced' }, { file: 'test/release.test.js', text: 'never with unsent form input' }, { file: 'test/release.test.js', text: 'never while a text field has focus' }],
+ budget: { name: 'input lost to an update', max: 0 }, measure: asserted('deferred: typing / dirty'), category: 'deferred and failed clients',
+ source: 'asserts the region keeps its old text while focused and commits after blur; 0 reloads with focus or form[data-dirty]',
+ },
+ {
+ id: '3c', what: 'Blog: an edit is a new revision; a stale save is refused (412), never lost; readers see a revision only once published', repo: 'blog', files: ['test/lifecycle.test.js'],
+ expect: [ok('edit: a new immutable revision; a stale expected_revision is a 412 and loses nothing'), ok('a revision after publication does not change what readers see until it is published')],
+ budget: { name: 'edits lost to a concurrent save', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the Blog lifecycle test (API with a member JWT)',
+ },
+ {
+ id: '3d', what: 'Blog without JavaScript: a stale form is a clear refusal, not a lost edit', repo: 'blog', files: ['test/nojs-editor.test.js'],
+ expect: [ok('a stale form (someone saved first) is a clear refusal, not a lost edit')],
+ budget: { name: 'edits lost (no-JS form)', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the Blog no-JS editor test',
+ },
+ ],
+ },
+ {
+ n: 4, name: 'A feed update while reading',
+ gates: [
+ {
+ id: '4a', what: 'a feed region above the viewport changes height: the page scrolls by exactly that', repo: 'shared', files: ['test/release-reading.test.js'],
+ expect: [line('release reading: all checks passed')],
+ budget: { name: 'reading position shift', max: 0, unit: 'px' }, measure: metric('feed-reading.position-shift-px'), category: 'affected sessions',
+ source: '[metric] feed-reading.position-shift-px: the feed\'s height change minus what the page scrolled (+300 and -200 px cases)',
+ },
+ {
+ id: '4b', what: 'the region the person reads inside keeps its own scroll', repo: 'shared', files: ['test/release-reading.test.js'],
+ expect: [line('release reading: all checks passed')],
+ budget: { name: 'region scroll lost', max: 0, unit: 'px' }, measure: metric('feed-reading.region-scroll-lost-px'), category: 'affected sessions',
+ source: '[metric] feed-reading.region-scroll-lost-px (also asserted in release-update.test.js)',
+ },
+ {
+ id: '4c', what: 'a burst of new notifications re-reads the count once', repo: 'shared', files: ['test/notification-live.test.js'],
+ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "'a burst is one request'" }],
+ budget: { name: 'count requests per burst', max: 1 }, measure: asserted(), category: 'resource growth',
+ source: 'asserts p.counts() === before + 1 after a burst',
+ },
+ {
+ id: '4d', what: 'Live chat keeps following when content grows under the reader', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,
+ expect: [ok('chat keeps following when content grows under it')],
+ budget: { name: 'distance from the bottom after growth', max: 120, unit: 'px' }, measure: asserted(), category: 'affected sessions',
+ source: 'test/browser/smoke.js 3b (fails above 120 px)',
+ },
+ ],
+ },
+ {
+ n: 5, name: 'A tool upgrade during a job',
+ gates: [
+ {
+ id: '5a', what: 'an accepted job survives a restart; a running one is re-queued (or failed retryable); reattach and SSE resume', repo: 'tools', cwd: 'apps/_shared', files: ['test/jobs.test.js'],
+ expect: [/jobs \(shared runtime\): all checks passed/], pins: [{ file: 'test/jobs.test.js', text: "'accepted job after restart'" }, { file: 'test/jobs.test.js', text: "'the event log spans the restart'" }],
+ budget: { name: 'accepted jobs lost across a restart', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the jobs runtime end to end over HTTP, the app closed and reopened',
+ },
+ {
+ id: '5b', what: 'SIGTERM with a request in flight: answered, every Tools process stops within the manifest deadline', repo: 'tools', cwd: 'apps/_shared', files: ['test/graceful.test.js'],
+ expect: [/graceful stop: helper ok/],
+ budget: { name: 'slowest graceful stop', max: 5000, unit: 'ms' }, measure: gracefulStop, category: 'drain duration',
+ source: 'measured per process by the test; the budget is the lifecycle manifest\'s 5 s shutdown deadline',
+ },
+ {
+ id: '5c', what: 'a Tools deploy reports running jobs, --wait-idle holds for them, a oneshot job unit is never restarted', repo: 'host', files: ['test/strategy-in-place.test.js'],
+ expect: [ok('tools: running jobs are reported (drain policy report); --wait-idle holds until they finish')], pins: [{ file: 'test/strategy-in-place.test.js', text: "'a oneshot job is not restarted'" }],
+ budget: { name: 'job units restarted by a deploy', max: 0 }, measure: asserted(), category: 'drain duration',
+ source: 'the multi-app strategy against the fake host',
+ },
+ ],
+ },
+ {
+ n: 6, name: 'An API restart during streams or calls',
+ gates: [
+ {
+ id: '6a', what: 'a Live deploy refuses to restart with streams live; --wait-idle holds until two idle checks', repo: 'host', files: ['test/strategy-release-layout.test.js'],
+ expect: [ok('live: streams live refuse the restart (exit 5): the prepared release is removed, current never moves'), ok('live: --wait-idle holds until two idle checks, then deploys; --force drops streams loudly')],
+ budget: { name: 'streams dropped by a deploy without --force', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'exit 5 and host.restarts() = []; --wait-idle deploys only after two idle polls',
+ },
+ {
+ id: '6b', what: 'a stream that starts during the install stops the restart', repo: 'host', files: ['test/deploy.test.js'],
+ expect: [ok('sessions that start during the install stop the restart and put the checkout back')],
+ budget: { name: 'streams dropped (late start)', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the protected probe runs again immediately before the restart',
+ },
+ {
+ id: '6c', what: 'systemd holds Live\'s listener across a restart (loopback socket unit)', repo: 'live', files: ['test/systemd-units.test.js'],
+ expect: [line('systemd units: all checks passed')],
+ budget: { name: 'connections refused during a restart', max: 0 }, measure: asserted('socket held by pid 1'), category: 'affected sessions',
+ source: 'the fault the 2026-09-26 web drain proof found (4 × 502) is pinned here',
+ },
+ {
+ id: '6d', what: 'Chat restarts (SIGTERM, new process): readers reconnect and read after their cursor', repo: 'chat', files: ['test/restart-resume.test.js'],
+ expect: [ok('stream reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate'), ok('global reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate'), ok('channel reader: reconnect, join, read after_id=<cursor> — no gap, no duplicate')],
+ budget: { name: 'messages missed or repeated after a restart', max: 0 }, measure: asserted('3 readers'), category: 'reconnects',
+ source: 'each reader\'s rows equal the database\'s, nothing twice',
+ },
+ {
+ id: '6e', what: 'a Chat restart closes the calls it left open (failed / missed / ended, reason restart)', repo: 'chat', files: ['test/calls.test.js'],
+ expect: [ok('lifecycle: transitions are one-way; a restart closes what was left open')],
+ budget: { name: 'calls left open after a restart', max: 0 }, category: 'affected sessions',
+ measure: found(/closed calls left open by the last run: (\{[^}]*\})/, (m) => `0 (closed at boot: ${m[1]})`),
+ source: 'the calls lifecycle test; the closed counts are Chat\'s boot log',
+ },
+ {
+ id: '6f', what: 'production web drain across a Live restart (release ab8abff)', kind: 'record', row: 'Web drain (Live)',
+ quote: '57 requests from 10:05:07 to 10:05:24, all 200', value: 0, text: '0 of 57 requests (2026-09-26)',
+ budget: { name: 'failed requests across a restart', max: 0 }, category: 'affected sessions', source: 'docs/deploy-proofs.md',
+ },
+ {
+ id: '6g', what: 'production chat resume across a Chat restart', kind: 'record', row: 'Chat resume (Chat)',
+ quote: 'The client was connected again at 11:29:36.6, 1.1 s later', value: 1.1, text: '1.1 s, 0 missed, 0 duplicate (2026-09-26)',
+ budget: { name: 'reconnect after a restart', max: 2, unit: 's' }, category: 'reconnects', source: 'docs/deploy-proofs.md; budget: the client\'s 1 s first backoff plus a connect',
+ },
+ {
+ id: '6h', what: 'production web drain: the longest a request waited across the Live restart', kind: 'record', row: 'Web drain (Live)',
+ quote: 'The slowest, 0.955 s at 10:05:21', value: 0.955, text: '0.955 s (20 requests in the window, median 0.134 s; ready after 2 s)',
+ budget: { name: 'longest wait across a restart', max: 2, unit: 's' }, category: 'drain duration',
+ source: 'docs/deploy-proofs.md; budget: the 2 s the deploy took to report ready, the most a queued connection waits',
+ },
+ {
+ id: '6i', what: 'a call in progress during a Chat deploy (production)', kind: 'open', category: 'affected sessions',
+ budget: { name: 'calls dropped by a deploy', max: 0 },
+ reason: 'no proof: Chat\'s drain policy is report (a deploy restarts with calls up); a restart ends them with end_reason restart (6e) and people call again',
+ },
+ ],
+ },
+ {
+ n: 7, name: 'A media-worker rollout',
+ gates: [
+ {
+ id: '7a', what: 'a Media deploy refuses to restart while a recording is in progress', repo: 'host', files: ['test/deploy.test.js'],
+ expect: [ok('refuses to restart Media while a recording is in progress (read-only query as the service user)')],
+ budget: { name: 'recordings cut by a deploy without --force', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'exit 5, nothing restarted',
+ },
+ {
+ id: '7b', what: 'a job left running by the old worker is re-queued at start (or failed when out of attempts)', repo: 'media', files: ['test/jobs.test.js'],
+ expect: [line('✅ a job left running by a dead process is requeued (or failed when out of attempts)'), line('jobs: all checks passed')],
+ budget: { name: 'jobs lost across a worker restart', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'Media\'s jobs test',
+ },
+ {
+ id: '7c', what: 'fencing: the old worker\'s late checkpoint, heartbeat and completion are refused after a takeover', repo: 'media', files: ['test/jobs-fencing.test.js'],
+ expect: [line('✅ the worker aborts a job whose lease it lost, and its failure is refused'), line('jobs fencing: all checks passed')],
+ budget: { name: 'stale writes accepted from a replaced worker', max: 0 }, measure: asserted(), category: 'rollback results',
+ source: 'lease_token matched on renew, checkpoint, succeed and fail',
+ },
+ {
+ id: '7d', what: 'the finalize job never touches a recording still live', repo: 'media', files: ['test/vod-finalize-job.test.js'],
+ expect: [line('✅ a live recording is never finalized by the job; out of attempts the VOD stays needs_review, hidden')],
+ budget: { name: 'live recordings finalized', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'Media\'s vod.finalize test',
+ },
+ {
+ id: '7e', what: 'production: a recorder checkpoint across a Media deploy with a live ingest', kind: 'record', row: 'Recorder / media-worker checkpoint (Media)',
+ budget: { name: 'recording gaps across a rollout', max: 0, unit: 's' }, category: 'affected sessions', source: 'docs/deploy-proofs.md',
+ },
+ ],
+ },
+ {
+ n: 8, name: 'Duplicate or older notifications',
+ gates: [
+ {
+ id: '8a', what: 'the bell\'s feed ignores replayed and older seqs, another person\'s events and other types', repo: 'shared', files: ['test/notification-live.test.js'],
+ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "assert.deepStrictEqual(heard.map((h) => h[1]), [ALICE]);" }],
+ budget: { name: 'duplicate or older notifications shown', max: 0 }, measure: asserted('1 of 5 frames heard'), category: 'deferred and failed clients',
+ source: 'five frames (one replayed seq, one other person, two other types), one heard',
+ },
+ {
+ id: '8b', what: 'the same in headless Chrome with a real EventSource: drop and resume, nothing lost or repeated', repo: 'shared', files: ['test/notification-live-chrome.test.js'],
+ expect: [line('notification-live-chrome: all checks passed')], pins: [{ file: 'test/notification-live-chrome.test.js', text: 'the missed ones, in order, nothing twice' }],
+ budget: { name: 'notifications repeated after a resume', max: 0 }, measure: asserted(), category: 'reconnects',
+ source: 'a local stand-in for Network and Events; skipped without Chrome',
+ },
+ {
+ id: '8c', what: 'release notifications: a replayed seq or a repeated event id is dropped; a burst is one check', repo: 'shared', files: ['test/release-watch-realtime.test.js'],
+ expect: [line('release-watch realtime: ok')], pins: [{ file: 'test/release-watch-realtime.test.js', text: 'the same event id twice is one event' }, { file: 'test/release-watch-realtime.test.js', text: 'exactly one /release.json fetch' }],
+ budget: { name: '/release.json reads per burst', max: 1 }, measure: asserted(), category: 'resource growth',
+ source: 'asserts one fetch for a burst of four events, one of them a replay',
+ },
+ ],
+ },
+ {
+ n: 9, name: 'An account switch during an update',
+ gates: [
+ {
+ id: '9a', what: 'a region fetched for the previous account (waiting, or in flight) is fetched again, never committed', repo: 'shared', files: ['test/release-account-switch.test.js'],
+ expect: [line('release account switch: all checks passed')],
+ budget: { name: 'regions committed as rendered for the previous account', max: 0 }, measure: metric('account-switch.stale-regions-committed'), category: 'deferred and failed clients',
+ source: '[metric] account-switch.stale-regions-committed (Shared 1.23.1 fixed this: it was 1 in each case)',
+ },
+ {
+ id: '9b', what: 'the switch reloads nothing and prompts nothing', repo: 'shared', files: ['test/release-account-switch.test.js'],
+ expect: [line('release account switch: all checks passed')],
+ budget: { name: 'reloads caused by a switch', max: 0 }, measure: metric('account-switch.reloads'), category: 'affected sessions',
+ source: '[metric] account-switch.reloads (also counted: prompts)',
+ },
+ {
+ id: '9c', what: 'update eligibility never depends on the account: /release.json is read without credentials', repo: 'shared', files: ['test/release-account-switch.test.js'],
+ expect: [line('release account switch: all checks passed')],
+ budget: { name: 'manifest reads carrying the session', max: 0 }, measure: metric('account-switch.manifest-reads-with-credentials'), category: 'update eligibility',
+ source: '[metric] account-switch.manifest-reads-with-credentials',
+ },
+ {
+ id: '9d', what: 'still one release stream per tab after a switch', repo: 'shared', files: ['test/release-account-switch.test.js', 'test/release-watch-realtime.test.js'],
+ expect: [line('release account switch: all checks passed'), line('release-watch realtime: ok')], pins: [{ file: 'test/release-watch-realtime.test.js', text: 'never a second EventSource' }],
+ budget: { name: 'release streams per tab', max: 1 }, measure: metric('account-switch.release-streams'), category: 'resource growth',
+ source: '[metric] account-switch.release-streams',
+ },
+ {
+ id: '9e', what: 'the bell restarts for the new account: its token, no cursor carried over', repo: 'shared', files: ['test/notification-live.test.js'],
+ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: 'restart (another account) forgets the cursor' }, { file: 'test/notification-live.test.js', text: "'Bearer jwt-bob'" }],
+ budget: { name: 'events read with the previous account\'s ticket', max: 0 }, measure: asserted(), category: 'reconnects',
+ source: 'setToken() asks a ticket with the new token and opens without last_event_id',
+ },
+ ],
+ },
+ {
+ n: 10, name: 'A partial asset group',
+ gates: [
+ {
+ id: '10a', what: 'a feature whose script fails rolls back: no hook, its stylesheets withdrawn, the retry fetches only what is missing', repo: 'shared', files: ['test/web-runtime.test.js'],
+ expect: [line('web runtime: all checks passed')], pins: [{ file: 'test/web-runtime.test.js', text: "'no hook for a half-loaded feature'" }, { file: 'test/web-runtime.test.js', text: "'the failed tag is gone'" }],
+ budget: { name: 'half-loaded features committed', max: 0 }, measure: asserted(), category: 'deferred and failed clients',
+ source: 'linkedom page where tags load or fail on cue',
+ },
+ {
+ id: '10b', what: 'the same in headless Chrome: a real 404 rolls the group back and the retry completes it', repo: 'shared', files: ['test/web-runtime-chrome.test.js'],
+ expect: [line('web-runtime-chrome: all checks passed')],
+ budget: { name: 'half-loaded features committed (Chrome)', max: 0 }, measure: asserted(), category: 'deferred and failed clients',
+ source: 'skipped without Chrome',
+ },
+ {
+ id: '10c', what: 'an in-place update whose stylesheet fails keeps the old one and commits nothing', repo: 'shared', files: ['test/release-update.test.js'],
+ expect: [line('release-update: all checks passed')], pins: [{ file: 'test/release-update.test.js', text: 'a stylesheet that fails to load is removed; the old one stays' }, { file: 'test/release-update.test.js', text: 'nothing commits when a stylesheet fails' }],
+ budget: { name: 'pages left half updated', max: 0 }, measure: asserted('failed: style → prompt'), category: 'deferred and failed clients',
+ source: 'asserts the old <link>s and the old region after a failed or timed-out stylesheet',
+ },
+ {
+ id: '10d', what: 'Live: no script requested twice over SPA navigation', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,
+ expect: [/no script requested twice|scripts requested more than once/],
+ budget: { name: 'scripts requested twice', max: 0 }, measure: duplicateScripts, category: 'resource growth',
+ source: 'test/browser/smoke.js 2+3',
+ },
+ ],
+ },
+ {
+ n: 11, name: 'Repeated navigation leaks',
+ gates: [
+ {
+ id: '11a', what: 'a route\'s timers, listeners and fetches end with it; late registrations are refused', repo: 'shared', files: ['test/web-runtime.test.js'],
+ expect: [line('web runtime: all checks passed')], pins: [{ file: 'test/web-runtime.test.js', text: "every interval cleared (the child\\'s too)" }, { file: 'test/web-runtime.test.js', text: "'every pending timeout cleared'" }],
+ budget: { name: 'intervals + timeouts left after a route ends', max: 0 }, measure: asserted(), category: 'resource growth',
+ source: 'asserts p.out.intervals.size === 0 and p.out.timers.length === 0 after nextRoute()',
+ },
+ {
+ id: '11b', what: 'the same in headless Chrome: the route\'s interval stops and its fetch aborts', repo: 'shared', files: ['test/web-runtime-chrome.test.js'],
+ expect: [line('web-runtime-chrome: all checks passed')],
+ budget: { name: 'intervals left after a route ends (Chrome)', max: 0 }, measure: asserted(), category: 'resource growth',
+ source: 'skipped without Chrome',
+ },
+ {
+ id: '11c', what: 'Live: three laps of ten routes; intervals, sockets and listeners between laps 2 and 3', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,
+ expect: [/lap probes:/],
+ budget: { name: 'growth per counter', max: 2 }, measure: lapGrowth(['intervals', 'sockets', 'windowListeners', 'documentListeners']), category: 'resource growth',
+ source: 'test/browser/smoke.js 4 (slack 2 per counter)',
+ },
+ {
+ id: '11d', what: 'Live: DOM nodes between laps 2 and 3', repo: 'live', files: ['test/browser/smoke.js'], base: true, ignoreExit: true, timeoutMs: 1200000,
+ expect: [/lap probes:/],
+ budget: { name: 'DOM node growth', max: 150 }, measure: lapGrowth(['domNodes']), category: 'resource growth',
+ source: 'test/browser/smoke.js 4 (slack 150 nodes)',
+ },
+ {
+ id: '11e', what: 'home ↔ second route five times: heap, nodes, listeners, documents, intervals, timeouts, sockets', repo: 'host', files: ['scripts/browser-check.js'], base: true, timeoutMs: 600000,
+ args: (ctx) => ['--sites', ctx.base, '--json', '--no-axe', '--widths', '1280', '--max-routes', '5'], expect: [/"growth":/],
+ budget: { name: 'measures over their growth budget', max: 0 }, measure: navigationGrowth, category: 'resource growth',
+ source: 'openvibe-shared/browser-harness growth budgets: heap 3 MB, nodes 300, listeners 30, documents 1, intervals 1, timeouts 10, sockets 1 (docs/browser-check.md)',
+ },
+ ],
+ },
+ {
+ n: 12, name: 'JS-disabled routes',
+ gates: [
+ {
+ id: '12a', what: 'Live channel, VOD and clip pages render a real body without JavaScript', repo: 'live', files: ['test/seo-ssr.test.js'],
+ expect: [ok('without JavaScript the body shows (with the site links) and the empty app shell is hidden'), ok('the client still hydrates: the route boots its feature and the fragment is inlined')],
+ budget: { name: 'SSR routes without a body', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'Live\'s SEO middleware and SPA fallback, mounted as server/index.js mounts them',
+ },
+ {
+ id: '12b', what: 'Blog: write, edit, publish and comment with plain forms', repo: 'blog', files: ['test/nojs-editor.test.js'],
+ expect: [ok('new draft, edit and publish with forms'), ok('the post page shows the Community thread and takes a comment by form')],
+ budget: { name: 'journey steps needing JavaScript', max: 0 }, measure: asserted(), category: 'affected sessions',
+ source: 'the Blog no-JS editor test',
+ },
+ {
+ id: '12c', what: 'the running site\'s routes, read with JavaScript off', repo: 'host', files: ['scripts/browser-check.js'], base: true, timeoutMs: 600000,
+ args: (ctx) => ['--sites', ctx.base, '--json', '--no-axe', '--widths', '1280', '--max-routes', '5'], expect: [/"nojs":/],
+ budget: { name: 'routes unreadable without JS', max: 0 }, measure: noJsRoutes, category: 'affected sessions',
+ source: 'openvibe-shared/browser-harness nojs check (a page needs its minimum of text)',
+ },
+ ],
+ },
+ {
+ n: 13, name: 'Rollback with new writes',
+ gates: [
+ {
+ id: '13a', what: 'the release of a week ago boots on this release\'s database, reads the newer rows and writes; then forward again', repo: 'live', files: ['test/rollback-newer-writes.test.js'], timeoutMs: 300000,
+ expect: [/rollback with newer writes: the release of \w+ works on this release's database, and back again/],
+ budget: { name: 'newer rows read after the rollback', min: 100, unit: '%' }, category: 'rollback results',
+ measure: found(/the release of (\w+) works/, (m) => `100 % (user and setting written by the newer release; older release ${m[1]}, 0 boot errors)`, 100),
+ source: 'a git worktree of the release of 7 days ago on a database this release wrote; skipped without git history',
+ },
+ {
+ id: '13b', what: 'rolling back returns the old release and its own node_modules; a release never ready is switched back', repo: 'host', files: ['test/strategy-release-layout.test.js'],
+ expect: [ok('live: a lockfile change installs into the new release only; rollback returns to the old release and its own node_modules'), ok('live: not ready after the restart: current switches back, the old units come back, exit 3')],
+ budget: { name: 'rollbacks that leave new code or dependencies', max: 0 }, measure: asserted(), category: 'rollback results',
+ source: 'the release-layout strategy against the fake host',
+ },
+ {
+ id: '13c', what: 'the same through Live\'s deploy script', repo: 'live', files: ['test/deploy-sim.test.js'], timeoutMs: 300000,
+ expect: [ok('--rollback returns code AND node_modules of the previous release'), ok('a release that never becomes ready is rolled back automatically (exit 3)')],
+ budget: { name: 'rollbacks that leave new code or dependencies', max: 0 }, measure: asserted(), category: 'rollback results',
+ source: 'simulated host with a real git origin',
+ },
+ {
+ id: '13d', what: 'open tabs follow a rollback in place; schema generations say when a rollback is safe', repo: 'shared', files: ['test/release-update.test.js', 'test/release-mixed-version.test.js'],
+ expect: [line('release-update: all checks passed'), line('release-mixed-version: all checks passed')],
+ pins: [{ file: 'test/release-update.test.js', text: 'A rollback to the previous release is applied in place too' }, { file: 'test/release-mixed-version.test.js', text: 'compat.rollbackSafe(R3.manifest, R2.manifest).ok, false' }],
+ budget: { name: 'reloads for a rollback of styles or content', max: 0 }, measure: asserted(), category: 'rollback results',
+ source: 'release-update.test.js and release-compat rollbackSafe',
+ },
+ ],
+ },
+ {
+ n: 14, name: 'Resuming an offline tab',
+ gates: [
+ {
+ id: '14a', what: 'back online, the release stream reconnects at once from its cursor (given up or backing off)', repo: 'shared', files: ['test/release-offline-resume.test.js'],
+ expect: [line('release offline resume: all checks passed')],
+ budget: { name: 'stream reconnect delay after online', max: 0, unit: 'ms' }, measure: metric('offline-resume.stream-reconnect-delay-ms'), category: 'reconnects',
+ source: '[metric] offline-resume.stream-reconnect-delay-ms (Shared 1.23.1 fixed this: 30 000 ms in the test, up to 8 min before)',
+ },
+ {
+ id: '14b', what: 'one /release.json read brings it straight to the newest release, in place', repo: 'shared', files: ['test/release-offline-resume.test.js'],
+ expect: [line('release offline resume: all checks passed')],
+ budget: { name: 'manifest reads on resume', max: 1 }, measure: metric('offline-resume.manifest-reads-on-resume'), category: 'update eligibility',
+ source: '[metric] offline-resume.manifest-reads-on-resume',
+ },
+ {
+ id: '14c', what: 'offline: no prompt, no reload, nothing counted as failed; mid-update loss leaves the page whole', repo: 'shared', files: ['test/release-offline-resume.test.js'],
+ expect: [line('release offline resume: all checks passed')],
+ budget: { name: 'reloads + prompts + failures while offline, and half-updated pages', max: 0 },
+ measure: (out) => {
+ const m = parseMetrics(out);
+ const keys = ['offline-resume.reloads-while-offline', 'offline-resume.prompts-while-offline', 'offline-resume.failures-counted-offline', 'offline-resume.partial-pages', 'offline-resume.reloads-while-typing'];
+ if (keys.some((k) => m[k] === undefined)) return null;
+ return { value: keys.reduce((a, k) => a + m[k], 0), text: keys.map((k) => `${k.split('.')[1]} ${m[k]}`).join(', ') };
+ },
+ category: 'deferred and failed clients',
+ source: '[metric] offline-resume.* (reloads, prompts, failures, partial pages, reloads while typing)',
+ },
+ {
+ id: '14d', what: 'the bell resumes from its cursor when the browser is back online', repo: 'shared', files: ['test/notification-live.test.js'],
+ expect: [line('notification live: all checks passed')], pins: [{ file: 'test/notification-live.test.js', text: "'back online: a new stream'" }, { file: 'test/notification-live.test.js', text: "'14', 'from the cursor'" }],
+ budget: { name: 'notifications missed on resume', max: 0 }, measure: asserted(), category: 'reconnects',
+ source: 'asserts a new stream with last_event_id = the last seq seen',
+ },
+ ],
+ },
+];
+
+// ── Repositories and versions ──────────────────────────────────
+
+const LIVE_WORKTREE = path.join(os.homedir(), 'orca', 'workspaces', 'OpenVibe.Live', 'seadragon');
+
+/** { host, shared, live, … } → absolute directories. Live: --live, else the seadragon worktree, else <repos>/OpenVibe.Live. */
+function resolveRepos({ repos = path.join(os.homedir(), 'OpenVibers'), live = null, host = HOST_DIR, exists = fs.existsSync } = {}) {
+ const out = {};
+ for (const [id, r] of Object.entries(REPOS)) out[id] = path.join(repos, r.dir);
+ out.host = host;
+ out.live = live || (exists(path.join(LIVE_WORKTREE, 'package.json')) ? LIVE_WORKTREE : path.join(repos, REPOS.live.dir));
+ return out;
+}
+
+function git(dir, args) {
+ try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim(); } catch { return null; }
+}
+function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
+
+/** Release and component versions of each repository a gate ran in: commit, branch, changed files, package and pins. */
+function recordVersions(dirs, used) {
+ const out = {};
+ for (const id of used) {
+ const dir = dirs[id];
+ if (!dir || !fs.existsSync(dir)) { out[id] = { dir, missing: true }; continue; }
+ const pkg = readJson(path.join(dir, 'package.json')) || {};
+ const status = git(dir, ['status', '--porcelain']);
+ const pinned = (name) => {
+ const installed = readJson(path.join(dir, 'node_modules', name, 'package.json'));
+ if (installed) return installed.version;
+ const spec = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }[name];
+ const tag = spec && /\/v(\d+\.\d+\.\d+)$/.exec(spec);
+ return tag ? tag[1] : spec || null;
+ };
+ out[id] = {
+ dir, commit: git(dir, ['rev-parse', '--short=12', 'HEAD']), branch: git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
+ changed: status == null ? null : status.split('\n').filter(Boolean).length, version: pkg.version || null,
+ shared: pinned('openvibe-shared'), contracts: pinned('openvibe-contracts'),
+ };
+ }
+ return out;
+}
+
+// ── Running ─────────────────────────────────────────────────────
+
+/** Runs `node <file> [args]` from `cwd`; resolves { code, signal, out (stdout and stderr), stdout, ms, timedOut }. */
+function runNode({ node = process.execPath, file, args = [], cwd, env = {}, timeoutMs = 180000 }) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ let out = ''; let stdout = ''; let timedOut = false;
+ let child;
+ try {
+ child = spawn(node, [file, ...args], { cwd, env: { ...process.env, NODE_ENV: 'test', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (err) { resolve({ code: null, signal: null, out: String(err && err.message), stdout: '', ms: 0, timedOut: false }); return; }
+ child.stdout.on('data', (c) => { out += c; stdout += c; });
+ child.stderr.on('data', (c) => { out += c; });
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
+ child.on('error', (err) => { out += `\n${err.message}`; });
+ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ code, signal, out, stdout, ms: Date.now() - started, timedOut }); });
+ });
+}
+
+const SKIP_RE = /^[\w .()-]+: skipped \((.+)\)\s*$/m;
+
+/** Reads this repository's docs/deploy-proofs.md table: row name → { state, record }. */
+function proofRows(text) {
+ const rows = {};
+ for (const l of String(text || '').split('\n')) {
+ const cells = l.split('|').map((c) => c.trim());
+ if (cells.length >= 4 && cells[1] && !/^-+$/.test(cells[1]) && cells[1] !== 'Proof') rows[cells[1]] = { state: cells[2], record: cells[3] };
+ }
+ return rows;
+}
+
+function result(gate, fields) {
+ const budgetText = gate.budget ? formatBudget(gate.budget) : '';
+ return { id: gate.id, what: gate.what, repo: gate.repo || null, files: gate.files || [], category: gate.category, source: gate.source || null, budget: gate.budget || null, budgetText, measured: null, measuredText: '—', reason: null, note: null, ms: 0, ...fields };
+}
+
+/**
+ * Judges one gate. ctx: { dirs, base, node, run (cached runNode), readFile, fetchManifest, hostDir, timeoutMs }.
+ */
+async function judgeGate(gate, ctx) {
+ const kind = gate.kind || 'test';
+ if (kind === 'open') return result(gate, { result: 'open', reason: gate.reason });
+ if (gate.base && !ctx.base) return result(gate, { result: 'skipped', reason: 'needs a running site: --base <url>' });
+ if (kind === 'record') {
+ const doc = ctx.readFile(path.join(ctx.hostDir, 'docs', 'deploy-proofs.md'));
+ const row = doc == null ? null : proofRows(doc)[gate.row];
+ if (!row) return result(gate, { result: 'fail', reason: `docs/deploy-proofs.md has no row "${gate.row}"` });
+ if (!/^passed\b/i.test(row.state)) return result(gate, { result: 'open', reason: `${row.state}${row.record ? `: ${row.record}` : ''}` });
+ if (gate.quote && !doc.includes(gate.quote)) return result(gate, { result: 'fail', reason: `the record no longer says "${gate.quote}"` });
+ const inside = withinBudget(gate.value, gate.budget);
+ return result(gate, { result: inside ? 'pass' : 'fail', measured: gate.value, measuredText: gate.text || formatValue(gate.value, gate.budget), note: `recorded: ${row.state}`, reason: inside ? null : 'the recorded value is outside the budget' });
+ }
+ if (kind === 'manifest') {
+ let m;
+ try { m = await ctx.fetchManifest(ctx.base); } catch (err) { return result(gate, { result: 'fail', reason: `GET ${ctx.base}/release.json: ${err.message}` }); }
+ const v = gate.check(m);
+ const inside = withinBudget(v.value, gate.budget);
+ const base = { measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget) };
+ if (inside) return result(gate, { result: 'pass', ...base });
+ return result(gate, { result: gate.onMiss === 'open' ? 'open' : 'fail', reason: gate.missReason || 'outside the budget', ...base });
+ }
+
+ const dir = ctx.dirs[gate.repo];
+ const cwd = path.join(dir || '', gate.cwd || '');
+ if (!dir || !fs.existsSync(cwd)) return result(gate, { result: 'skipped', reason: `${(REPOS[gate.repo] || { name: gate.repo }).name} not found at ${cwd}` });
+ for (const f of gate.files) {
+ if (!fs.existsSync(path.join(cwd, f))) return result(gate, { result: 'fail', reason: `${f} is missing in ${REPOS[gate.repo].name}` });
+ }
+ for (const pin of gate.pins || []) {
+ const src = ctx.readFile(path.join(cwd, pin.file));
+ if (src == null || !src.includes(pin.text)) return result(gate, { result: 'fail', reason: `the assertion this gate rests on is gone from ${pin.file}: ${pin.text}` });
+ }
+ let out = ''; let stdout = ''; let ms = 0;
+ // What a failing test still printed is shown (a [metric] line comes before the assertion that fails).
+ const shown = () => {
+ if (!gate.measure || gate.measure.asserted) return {};
+ let v = null;
+ try { v = gate.measure(out, { stdout, ctx }); } catch { /* not measurable */ }
+ return v && typeof v.value === 'number' ? { measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget) } : {};
+ };
+ for (const f of gate.files) {
+ const args = typeof gate.args === 'function' ? gate.args(ctx) : gate.args || [];
+ const env = gate.base ? { BASE: ctx.base, ...(ctx.channel ? { CHANNEL: ctx.channel } : {}) } : {};
+ const r = await ctx.run({ repo: gate.repo, sub: gate.cwd || '', cwd, file: f, args, env, timeoutMs: gate.timeoutMs || ctx.timeoutMs || 180000 });
+ out += r.out; stdout += r.stdout || ''; ms += r.ms;
+ const skip = SKIP_RE.exec(r.out);
+ if (r.code === 0 && skip) return result(gate, { result: 'skipped', reason: `${f}: ${skip[1]}`, ms, tail: tail(r.out) });
+ if (r.timedOut) return result(gate, { result: 'fail', reason: `${f} timed out after ${Math.round((gate.timeoutMs || ctx.timeoutMs || 180000) / 1000)} s`, ms, tail: tail(r.out), ...shown() });
+ if (r.code !== 0 && !gate.ignoreExit) return result(gate, { result: 'fail', reason: `${f} failed (exit ${r.code ?? r.signal})`, ms, tail: tail(r.out), ...shown() });
+ }
+ for (const re of gate.expect || []) {
+ if (!re.test(out)) return result(gate, { result: 'fail', reason: `expected output missing: ${re.source.replace(/\\/g, '')}`, ms, tail: tail(out), ...shown() });
+ }
+ let v;
+ if (gate.measure && gate.measure.asserted) {
+ const b = boundOf(gate.budget);
+ v = { value: b, text: `${formatValue(b, gate.budget)} (asserted${gate.measure.note ? `; ${gate.measure.note}` : ''})` };
+ } else {
+ try { v = gate.measure(out, { stdout, ctx }); } catch { v = null; }
+ }
+ if (!v || typeof v.value !== 'number') return result(gate, { result: 'fail', reason: 'the measured value is not in the output', ms, tail: tail(out) });
+ const inside = withinBudget(v.value, gate.budget);
+ const note = gate.ignoreExit && /\d+ browser check\(s\) failed/.test(out) ? `the smoke's other checks: ${/(\d+) browser check\(s\) failed/.exec(out)[1]} failed (not this gate's)` : null;
+ return result(gate, { result: inside ? 'pass' : 'fail', measured: v.value, measuredText: v.text || formatValue(v.value, gate.budget), note, reason: inside ? null : 'outside the budget', ms, tail: inside ? undefined : tail(out) });
+}
+
+const tail = (out, n = 15) => String(out || '').trimEnd().split('\n').slice(-n).join('\n');
+
+/** Selects scenarios by number and gates by id: ['1', '9', '6a'] → the gates to judge, in table order. */
+function select(scenarios, only) {
+ const list = [];
+ const want = (only || []).map(String);
+ for (const s of scenarios) {
+ for (const g of s.gates) {
+ if (want.length && !want.includes(String(s.n)) && !want.includes(g.id)) continue;
+ list.push({ scenario: s, gate: g });
+ }
+ }
+ return list;
+}
+
+async function defaultFetchManifest(base) {
+ const r = await fetch(new URL('/release.json', base), { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(15000) });
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+}
+
+/**
+ * Runs the suite. opts: { scenarios, only, repos, live, base, channel, node, timeoutMs, runner, fetchManifest,
+ * readFile, hostDir, logs (a directory: each run's output as <repo>__<file>.log), onGate(row) }. Each test file
+ * runs once per run (gates share its output).
+ */
+async function runAcceptance(opts = {}) {
+ const scenarios = opts.scenarios || SCENARIOS;
+ const dirs = opts.dirs || resolveRepos({ repos: opts.repos, live: opts.live });
+ const cache = new Map();
+ const runner = opts.runner || runNode;
+ const ctx = {
+ dirs, base: opts.base ? String(opts.base).replace(/\/$/, '') : null, channel: opts.channel || null, node: opts.node || process.execPath,
+ timeoutMs: opts.timeoutMs, hostDir: opts.hostDir || HOST_DIR,
+ readFile: opts.readFile || ((f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }),
+ fetchManifest: opts.fetchManifest || defaultFetchManifest,
+ run: (spec) => {
+ const key = JSON.stringify([spec.cwd, spec.file, spec.args, spec.env]);
+ if (cache.has(key)) return cache.get(key).then((r) => ({ ...r, ms: 0, cached: true }));
+ cache.set(key, runner({ node: ctx.node, ...spec }).then((r) => {
+ if (opts.logs) {
+ try { fs.mkdirSync(opts.logs, { recursive: true }); fs.writeFileSync(path.join(opts.logs, `${spec.repo}__${[spec.sub, spec.file].filter(Boolean).join('__').replace(/[^\w.-]+/g, '_')}.log`), r.out); } catch { /* best effort */ }
+ }
+ return r;
+ }));
+ return cache.get(key);
+ },
+ };
+ const startedAt = new Date().toISOString();
+ const selected = select(scenarios, opts.only);
+ const used = [...new Set(['host', ...selected.filter(({ gate }) => gate.repo).map(({ gate }) => gate.repo)])];
+ const versions = recordVersions(dirs, used);
+ const rows = [];
+ let site = null;
+ if (ctx.base) { try { const m = await ctx.fetchManifest(ctx.base); site = { base: ctx.base, service: m.service, release: m.release, released_at: m.released_at, packages: m.packages, components: m.components ? Object.fromEntries(Object.entries(m.components).map(([k, c]) => [k, `${c.kind}@${c.version}`])) : null }; } catch (err) { site = { base: ctx.base, error: err.message }; } }
+ for (const { scenario, gate } of selected) {
+ const r = await judgeGate(gate, ctx);
+ const row = { scenario: scenario.n, scenarioName: scenario.name, kind: gate.kind || 'test', ...r };
+ rows.push(row);
+ if (opts.onGate) opts.onGate(row);
+ }
+ // A repository that moved while its tests ran is named: the run tested a mix.
+ const after = recordVersions(dirs, used);
+ for (const id of used) {
+ if (!versions[id].missing && (after[id].commit !== versions[id].commit || after[id].changed !== versions[id].changed)) versions[id].changedDuringRun = `${after[id].commit}${after[id].changed ? `, ${after[id].changed} uncommitted` : ''}`;
+ }
+ return { startedAt, finishedAt: new Date().toISOString(), node: process.version, base: ctx.base, site, versions, gates: rows, summary: summarize(rows) };
+}
+
+/** pass/fail/skipped/open counts; ok only when nothing failed (skipped and open are never passes). */
+function summarize(rows) {
+ const s = { gates: rows.length, pass: 0, fail: 0, skipped: 0, open: 0 };
+ for (const r of rows) s[r.result] = (s[r.result] || 0) + 1;
+ s.ok = s.fail === 0;
+ s.scenarios = [...new Set(rows.map((r) => r.scenario))].length;
+ return s;
+}
+
+// ── Reports ─────────────────────────────────────────────────────
+
+function versionLine(id, v) {
+ if (v.missing) return `${id}: not found (${v.dir})`;
+ return `${id} ${v.commit || '?'}${v.branch && v.branch !== 'HEAD' ? ` (${v.branch})` : ''}${v.changed ? `, ${v.changed} uncommitted` : ''}${v.version ? `, v${v.version}` : ''}${v.shared && id !== 'shared' ? `, shared ${v.shared}` : ''}${v.changedDuringRun ? ` — CHANGED DURING THE RUN (now ${v.changedDuringRun})` : ''}`;
+}
+
+function formatTable(report) {
+ const L = [];
+ L.push(`Release-lifecycle acceptance (D46), ${report.startedAt} (node ${report.node})`);
+ for (const [id, v] of Object.entries(report.versions)) L.push(` ${versionLine(id, v)}`);
+ if (report.site) L.push(` site ${report.site.base}: ${report.site.error ? `unreachable (${report.site.error})` : `${report.site.service} ${report.site.release}, components ${Object.entries(report.site.components || {}).map(([k, c]) => `${k}=${c}`).join(' ') || 'none'}`}`);
+ L.push('');
+ const rows = report.gates.map((r) => [String(r.scenario), r.id, r.result, r.measuredText, r.budgetText, `${r.budget ? `${r.budget.name}: ` : ''}${r.what}`]);
+ const head = ['#', 'gate', 'result', 'measured', 'budget', 'what'];
+ const w = head.map((h, i) => Math.min(i === 3 ? 48 : 60, Math.max(h.length, ...rows.map((r) => r[i].length))));
+ const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
+ L.push(head.map((h, i) => (i === head.length - 1 ? h : h.padEnd(w[i]))).join(' '));
+ let last = null;
+ for (let k = 0; k < rows.length; k++) {
+ const r = report.gates[k];
+ if (r.scenario !== last) { L.push(`── ${r.scenario}. ${r.scenarioName}`); last = r.scenario; }
+ L.push(rows[k].map((c, i) => (i === rows[k].length - 1 ? c : cut(c, w[i]).padEnd(w[i]))).join(' '));
+ if (r.reason && r.result !== 'pass') L.push(`${' '.repeat(w[0] + w[1] + 4)}↳ ${r.reason}`);
+ else if (r.note && r.kind !== 'record') L.push(`${' '.repeat(w[0] + w[1] + 4)}↳ ${r.note}`);
+ }
+ const s = report.summary;
+ L.push('');
+ L.push(`${s.pass} passed, ${s.fail} failed, ${s.skipped} skipped, ${s.open} open (${s.gates} gates, ${s.scenarios} scenarios)${s.ok ? '' : ' — FAILED'}`);
+ // One excerpt per failing output (gates that share a test share its failure).
+ const tails = new Map();
+ for (const r of report.gates.filter((x) => x.result === 'fail' && x.tail)) {
+ const k = `${r.reason}\n${r.tail}`;
+ if (!tails.has(k)) tails.set(k, { ids: [], reason: r.reason, tail: r.tail });
+ tails.get(k).ids.push(r.id);
+ }
+ for (const t of tails.values()) L.push('', `── ${t.ids.join(', ')} (${t.reason}) ──`, t.tail);
+ return L.join('\n');
+}
+
+const md = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
+function formatMarkdown(report) {
+ const L = [];
+ L.push(`## Run ${report.startedAt.slice(0, 16).replace('T', ' ')} UTC`, '');
+ L.push(`Node ${report.node}${report.base ? `, --base ${report.base}` : ', no --base'}.`, '');
+ L.push('| Repository | Commit | Uncommitted | Version | openvibe-shared |', '|---|---|---|---|---|');
+ for (const [id, v] of Object.entries(report.versions)) L.push(v.missing ? `| ${id} | not found | | | |` : `| ${id} | \`${v.commit}\`${v.branch && v.branch !== 'HEAD' ? ` (${v.branch})` : ''}${v.changedDuringRun ? ` (changed during the run: now ${v.changedDuringRun})` : ''} | ${v.changed ?? '?'} | ${v.version || ''} | ${id === 'shared' ? '(this)' : v.shared || ''} |`);
+ if (report.site) L.push('', report.site.error ? `Site ${report.site.base}: unreachable (${report.site.error}).` : `Site ${report.site.base}: \`${report.site.service}\` release \`${report.site.release}\` (${report.site.released_at}), components ${Object.entries(report.site.components || {}).map(([k, c]) => `\`${k}\` ${c}`).join(', ') || 'none'}.`);
+ L.push('', '| # | Gate | Result | Measured | Budget | What |', '|---|---|---|---|---|---|');
+ for (const r of report.gates) {
+ const res = r.result === 'pass' ? `pass${r.note ? ` (${md(r.note)})` : ''}` : `**${r.result}**${r.reason ? `: ${md(r.reason)}` : ''}`;
+ L.push(`| ${r.scenario} | ${r.id} | ${res} | ${md(r.measuredText)} | ${md(r.budgetText)} | ${md(r.budget ? `${r.budget.name}: ${r.what}` : r.what)} |`);
+ }
+ const s = report.summary;
+ L.push('', `${s.pass} passed, ${s.fail} failed, ${s.skipped} skipped, ${s.open} open (${s.gates} gates, ${s.scenarios} scenarios).`);
+ return L.join('\n');
+}
+
+module.exports = {
+ SCENARIOS, REPOS, CATEGORIES, HOST_DIR,
+ parseMetrics, metric, gracefulStop, lapProbes, lapGrowth, duplicateScripts, navigationGrowth, noJsRoutes, proofRows,
+ withinBudget, formatBudget, boundOf,
+ resolveRepos, recordVersions, runNode, judgeGate, select, runAcceptance, summarize, formatTable, formatMarkdown,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Release-lifecycle acceptance (D46, roadmap WS-P task 16): the 14 scenarios in lib/acceptance.js, each gate's
+ * tests run with `node <file>` from its repository, then a table of result, measured value and budget.
+ *
+ * node scripts/release-acceptance.js # every scenario; site gates skipped
+ * node scripts/release-acceptance.js --only 9,14,6a # scenarios by number, gates by id
+ * node scripts/release-acceptance.js --base https://openvibe.live --channel /@JapaneseOldGuy
+ * node scripts/release-acceptance.js --json # the report as JSON on stdout
+ * node scripts/release-acceptance.js --out run.md # also write the run as Markdown (overwrites)
+ *
+ * --repos <dir> where the repositories are checked out (default ~/OpenVibers); this repository is always
+ * the Host one
+ * --live <dir> OpenVibe.Live (default ~/orca/workspaces/OpenVibe.Live/seadragon, else <repos>/OpenVibe.Live)
+ * --base <url> a running site for the browser gates (Live's test/browser/smoke.js with BASE, this
+ * repository's scripts/browser-check.js) and the manifest gate; they only read pages.
+ * Without it they are reported as skipped, never as passing
+ * --channel </@x> the channel page the Live smoke visits (its default /@admin exists only locally)
+ * --node <path> the node that runs the tests (default: this one; the repositories test on Node 22)
+ * --timeout <s> the default per-gate timeout (180 s; the browser gates have their own)
+ * --logs <dir> keep each test's full output there (<repo>__<file>.log)
+ * --strict exit 1 unless every selected gate passed (skipped and open count against it)
+ * --list print the table of gates without running anything
+ *
+ * Exit 0 when no gate failed (skipped and open gates are listed with their reason, never counted as passes),
+ * 1 when one failed, 2 on bad arguments.
+ */
+const fs = require('fs');
+const path = require('path');
+const acceptance = require('../lib/acceptance');
+
+const argv = process.argv.slice(2);
+const has = (n) => argv.includes(`--${n}`);
+const arg = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
+const expand = (p) => (p && p.startsWith('~/') ? path.join(require('os').homedir(), p.slice(2)) : p);
+
+const KNOWN = new Set(['repos', 'live', 'base', 'channel', 'node', 'timeout', 'only', 'out', 'logs', 'json', 'strict', 'list', 'help']);
+for (const a of argv) {
+ if (a.startsWith('--') && !KNOWN.has(a.slice(2))) { console.error(`unknown option ${a} (see the header of scripts/release-acceptance.js)`); process.exit(2); }
+}
+if (has('help')) {
+ console.log(fs.readFileSync(__filename, 'utf8').split('\n').filter((l) => l.startsWith(' *')).map((l) => l.slice(3)).join('\n'));
+ process.exit(0);
+}
+const only = arg('only') ? arg('only').split(',').map((s) => s.trim()).filter(Boolean) : [];
+const known = new Set(acceptance.SCENARIOS.flatMap((s) => [String(s.n), ...s.gates.map((g) => g.id)]));
+const unknown = only.filter((o) => !known.has(o));
+if (unknown.length) { console.error(`--only: no scenario or gate ${unknown.join(', ')} (scenarios 1-${acceptance.SCENARIOS.length}, gates like 6a)`); process.exit(2); }
+const base = arg('base');
+if (base && !/^https?:\/\/[^/]+/.test(base)) { console.error('--base takes an http(s) origin, e.g. https://openvibe.live'); process.exit(2); }
+const timeout = arg('timeout') ? Number(arg('timeout')) : null;
+if (timeout !== null && !(timeout > 0)) { console.error('--timeout takes seconds'); process.exit(2); }
+
+if (has('list')) {
+ for (const { scenario, gate } of acceptance.select(acceptance.SCENARIOS, only)) {
+ const where = gate.kind === 'record' ? 'record: docs/deploy-proofs.md' : gate.kind === 'manifest' ? 'manifest: <base>/release.json' : gate.kind === 'open' ? 'open' : `${gate.repo}${gate.cwd ? `/${gate.cwd}` : ''}: ${gate.files.join(', ')}`;
+ console.log(`${String(scenario.n).padStart(2)} ${gate.id.padEnd(4)} ${acceptance.formatBudget(gate.budget).padEnd(10)} ${gate.budget.name}${gate.base ? ' [--base]' : ''}\n ${where}`);
+ }
+ process.exit(0);
+}
+
+(async () => {
+ const report = await acceptance.runAcceptance({
+ only, base, channel: arg('channel'), node: arg('node') || process.execPath,
+ repos: expand(arg('repos')) || undefined, live: expand(arg('live')) || undefined,
+ timeoutMs: timeout ? timeout * 1000 : undefined, logs: expand(arg('logs')) || undefined,
+ onGate: has('json') ? null : (r) => process.stderr.write(` ${r.id.padEnd(4)} ${r.result}${r.ms ? ` (${(r.ms / 1000).toFixed(1)} s)` : ''}\n`),
+ });
+ if (has('json')) console.log(JSON.stringify(report, null, 2));
+ else console.log(acceptance.formatTable(report));
+ if (arg('out')) fs.writeFileSync(arg('out'), `${acceptance.formatMarkdown(report)}\n`);
+ const s = report.summary;
+ process.exit(!s.ok || (has('strict') && s.pass !== s.gates) ? 1 : 0);
+})().catch((err) => { console.error(err.stack || err.message); process.exit(2); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,279 @@
@@ -0,0 +1,279 @@
+'use strict';
+/**
+ * The release-lifecycle acceptance runner (lib/acceptance.js, scripts/release-acceptance.js; roadmap WS-P task 16)
+ * without the other repositories: the scenario table is complete, budgets compare, every parser reads what the
+ * real tests print, and a gate is judged pass, fail, skipped or open for the right reason. Fixture "repositories"
+ * are temporary directories holding tiny test scripts.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { test, runTests } = require('./helpers');
+const a = require('../lib/acceptance');
+
+const D46 = [
+ 'Home styles deployed during a broadcast', 'A shared navbar update', 'An article or paste edit', 'A feed update while reading',
+ 'A tool upgrade during a job', 'An API restart during streams or calls', 'A media-worker rollout', 'Duplicate or older notifications',
+ 'An account switch during an update', 'A partial asset group', 'Repeated navigation leaks', 'JS-disabled routes',
+ 'Rollback with new writes', 'Resuming an offline tab',
+];
+
+const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ov-acceptance-'));
+/** A fixture repository: files { 'test/x.test.js': source }. */
+function repo(files) {
+ const dir = tmp();
+ for (const [f, src] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); fs.writeFileSync(path.join(dir, f), src); }
+ return dir;
+}
+/** A ctx whose runner answers from `outputs` (file → { code, out }) and counts the runs. */
+function fakeCtx(dirs, outputs, extra = {}) {
+ const runs = [];
+ return {
+ runs,
+ ctx: {
+ dirs, base: null, hostDir: extra.hostDir || tmp(), timeoutMs: 1000,
+ readFile: (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } },
+ fetchManifest: extra.fetchManifest || (async () => { throw new Error('no site'); }),
+ run: async (spec) => { runs.push(spec); const o = outputs[spec.file] || { code: 0, out: '' }; return { code: o.code, signal: null, out: o.out, stdout: o.stdout || o.out, ms: 1, timedOut: !!o.timedOut }; },
+ ...extra.ctx,
+ },
+ };
+}
+const gate = (o) => ({ id: 'x1', what: 'w', repo: 'shared', files: ['test/t.test.js'], budget: { name: 'n', max: 0 }, measure: { asserted: true }, category: 'affected sessions', ...o });
+
+runTests([
+ test('the table: 14 scenarios in D46 order, each with gates that can run locally, every gate complete', () => {
+ assert.deepStrictEqual(a.SCENARIOS.map((s) => s.n), D46.map((_, i) => i + 1));
+ assert.deepStrictEqual(a.SCENARIOS.map((s) => s.name), D46);
+ const ids = new Set();
+ for (const s of a.SCENARIOS) {
+ assert.ok(s.gates.length >= 1, `scenario ${s.n} has a gate`);
+ assert.ok(s.gates.some((g) => !g.base && ['test', 'record', undefined].includes(g.kind)), `scenario ${s.n} has evidence a local run judges`);
+ assert.ok(s.gates.some((g) => !g.kind && !g.base), `scenario ${s.n} has at least one test gate without a running site`);
+ for (const g of s.gates) {
+ assert.ok(!ids.has(g.id), `gate ${g.id} is unique`); ids.add(g.id);
+ assert.match(g.id, new RegExp(`^${s.n}[a-z]$`), `${g.id} is named after its scenario`);
+ assert.ok(g.what && g.budget && g.budget.name, `${g.id}: what and a named budget`);
+ assert.ok(['max', 'min', 'equals'].some((k) => typeof g.budget[k] === 'number'), `${g.id}: a numeric bound`);
+ assert.ok(a.CATEGORIES.includes(g.category), `${g.id}: a D46 category (${g.category})`);
+ const kind = g.kind || 'test';
+ if (kind === 'test') {
+ assert.ok(a.REPOS[g.repo], `${g.id}: a known repository`);
+ assert.ok(g.files.length && g.files.every((f) => /^[\w./-]+\.js$/.test(f) && !path.isAbsolute(f)), `${g.id}: relative .js files`);
+ assert.ok(g.measure && (g.measure.asserted || typeof g.measure === 'function'), `${g.id}: a measure`);
+ assert.ok((g.expect || []).length, `${g.id}: at least one line it expects`);
+ for (const p of g.pins || []) assert.ok(g.files.includes(p.file) && p.text, `${g.id}: pins name a file it runs`);
+ assert.ok(g.source, `${g.id}: where the number comes from`);
+ } else if (kind === 'record') assert.ok(g.row, `${g.id}: a proof row`);
+ else if (kind === 'manifest') assert.ok(g.base && typeof g.check === 'function', `${g.id}: a manifest check needs --base`);
+ else { assert.strictEqual(kind, 'open'); assert.ok(g.reason, `${g.id}: why it is open`); }
+ }
+ }
+ // Host's own gates name files that exist here.
+ for (const s of a.SCENARIOS) for (const g of s.gates.filter((x) => x.repo === 'host')) for (const f of g.files) assert.ok(fs.existsSync(path.join(a.HOST_DIR, f)), f);
+ // Every record names a row of docs/deploy-proofs.md.
+ const rows = a.proofRows(fs.readFileSync(path.join(a.HOST_DIR, 'docs', 'deploy-proofs.md'), 'utf8'));
+ for (const s of a.SCENARIOS) for (const g of s.gates.filter((x) => x.kind === 'record')) assert.ok(rows[g.row], `deploy-proofs.md: ${g.row}`);
+ }),
+
+ test('budgets: max, min and equals compare; the text shows the bound and unit', () => {
+ assert.strictEqual(a.withinBudget(0, { max: 0 }), true);
+ assert.strictEqual(a.withinBudget(1, { max: 0 }), false);
+ assert.strictEqual(a.withinBudget(660, { max: 5000 }), true);
+ assert.strictEqual(a.withinBudget(100, { min: 100 }), true);
+ assert.strictEqual(a.withinBudget(99.9, { min: 100 }), false);
+ assert.strictEqual(a.withinBudget(1, { equals: 1 }), true);
+ assert.strictEqual(a.withinBudget(NaN, { max: 1 }), false);
+ assert.strictEqual(a.withinBudget(undefined, { max: 1 }), false);
+ assert.strictEqual(a.formatBudget({ max: 5000, unit: 'ms' }), '≤ 5000 ms');
+ assert.strictEqual(a.formatBudget({ min: 100, unit: '%' }), '≥ 100 %');
+ assert.strictEqual(a.formatBudget({ equals: 1 }), '= 1');
+ assert.strictEqual(a.boundOf({ min: 100 }), 100);
+ }),
+
+ test('parsers read what the real tests print', () => {
+ assert.deepStrictEqual(a.parseMetrics('[metric] a.b=0\nnoise\n[metric] c-d=12.5\n[metric] a.b=3\n[metric] bad=x\n'), { 'a.b': 3, 'c-d': 12.5 });
+ assert.deepStrictEqual(a.metric('c-d')('[metric] c-d=7'), { value: 7, text: undefined });
+ assert.strictEqual(a.metric('missing')('[metric] c-d=7'), null);
+ // Tools' graceful.test.js summary line.
+ const g = a.gracefulStop('graceful stop: helper ok; SIGTERM → exit 0 with the request in flight answered: gateway 365 ms, img 431 ms, food 661 ms');
+ assert.deepStrictEqual([g.value, g.text], [661, '661 ms (food; 3 processes)']);
+ assert.strictEqual(a.gracefulStop('graceful stop: helper ok'), null);
+ // Live's smoke lap probes.
+ const laps = ' ✓ x\n lap probes: {"intervals":14,"sockets":0,"windowListeners":58,"domNodes":6363}\n {"intervals":14,"sockets":1,"windowListeners":58,"domNodes":6370}\n {"intervals":15,"sockets":1,"windowListeners":61,"domNodes":6300}\n ✓ repeated navigation';
+ assert.deepStrictEqual(a.lapProbes(laps), { intervals: 1, sockets: 0, windowListeners: 3, domNodes: -70 });
+ assert.deepStrictEqual(a.lapGrowth(['intervals', 'windowListeners'])(laps), { value: 3, text: 'intervals +1, windowListeners +3 (laps 2→3)' });
+ assert.strictEqual(a.lapGrowth(['domNodes'])(laps).value, -70);
+ assert.strictEqual(a.lapProbes('lap probes: {"a":1}'), null, 'three laps or nothing');
+ assert.deepStrictEqual(a.duplicateScripts(' ✓ no script requested twice (48 scripts)'), { value: 0, text: '0 of 48 scripts' });
+ assert.strictEqual(a.duplicateScripts(' ✗ scripts requested more than once: app.js×2, chat.js×2').value, 2);
+ // Host's browser-check --json (stdout), with progress on stderr.
+ const report = [{ id: 'live', navigation: { growth: { measured: true, laps: 5, from: 2, deltas: { heapKB: 61, nodes: 5, listeners: 1, documents: 0, intervals: 0, timeouts: 0, sockets: 0 }, over: [] } },
+ routes: [{ nojs: { textChars: 2058 } }, { nojs: { textChars: 1529 } }, { nojs: null }], summary: { checks: { nojs: { pass: 2, warn: 0, fail: 0, skip: 1 } } } }];
+ const io = { stdout: JSON.stringify(report, null, 2) };
+ assert.deepStrictEqual(a.navigationGrowth('progress', io), { value: 0, text: '0 over; heap +61 KB, nodes +5, listeners +1, intervals 0, timeouts 0, sockets 0 (laps 2→5)' });
+ assert.deepStrictEqual(a.noJsRoutes('progress', io), { value: 0, text: '0 of 2 routes unreadable (least text 1529 chars)' });
+ assert.strictEqual(a.navigationGrowth('not json', { stdout: 'not json' }), null);
+ // docs/deploy-proofs.md's table.
+ const rows = a.proofRows('| Proof | State | Record |\n|---|---|---|\n| Web drain (Live) | passed 2026-09-26 | [below](#x) |\n| Recorder (Media) | open | needs a live ingest |\n');
+ assert.deepStrictEqual(rows, { 'Web drain (Live)': { state: 'passed 2026-09-26', record: '[below](#x)' }, 'Recorder (Media)': { state: 'open', record: 'needs a live ingest' } });
+ }),
+
+ test('skipped: no --base, a repository not there, or a test that says it skipped; never a pass', async () => {
+ const dir = repo({ 'test/t.test.js': '' });
+ const { ctx, runs } = fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 'notification-live-chrome: skipped (no Chrome; set CHROME_BIN)\n' } });
+ const base = await a.judgeGate(gate({ base: true }), ctx);
+ assert.deepStrictEqual([base.result, base.reason], ['skipped', 'needs a running site: --base <url>']);
+ const missing = await a.judgeGate(gate({ repo: 'media' }), fakeCtx({ media: path.join(dir, 'nope') }, {}).ctx);
+ assert.strictEqual(missing.result, 'skipped');
+ assert.match(missing.reason, /OpenVibe\.Media not found at .*nope/);
+ const said = await a.judgeGate(gate({ expect: [/all checks passed/] }), ctx);
+ assert.deepStrictEqual([said.result, said.reason], ['skipped', 'test/t.test.js: no Chrome; set CHROME_BIN']);
+ assert.strictEqual(runs.length, 1, 'nothing ran for the first two');
+ assert.deepStrictEqual(a.summarize([base, missing, said]), { gates: 3, pass: 0, fail: 0, skipped: 3, open: 0, ok: true, scenarios: 1 });
+ }),
+
+ test('fail: exit code, a missing expected line, a missing pin, over budget, unmeasurable, a timeout', async () => {
+ const dir = repo({ 'test/t.test.js': "assert.strictEqual(x, 0, 'never while typing');\n" });
+ const run = (out, code = 0, extra = {}) => fakeCtx({ shared: dir }, { 'test/t.test.js': { code, out, ...extra } });
+ const exit = await a.judgeGate(gate({ measure: a.metric('x.n'), expect: [/ok/] }), run('[metric] x.n=1\nAssertionError', 1).ctx);
+ assert.deepStrictEqual([exit.result, exit.reason, exit.measured, exit.measuredText], ['fail', 'test/t.test.js failed (exit 1)', 1, '1'], 'a failing test still shows what it measured');
+ assert.match(exit.tail, /AssertionError/);
+ const line = await a.judgeGate(gate({ expect: [/x: all checks passed/] }), run('x: something else').ctx);
+ assert.deepStrictEqual([line.result, line.reason], ['fail', 'expected output missing: x: all checks passed']);
+ const { ctx, runs } = run('x: all checks passed');
+ const pin = await a.judgeGate(gate({ expect: [/passed/], pins: [{ file: 'test/t.test.js', text: 'never while the camera is live' }] }), ctx);
+ assert.strictEqual(pin.result, 'fail');
+ assert.match(pin.reason, /the assertion this gate rests on is gone from test\/t\.test\.js: never while the camera is live/);
+ assert.strictEqual(runs.length, 0, 'a gate whose assertion is gone does not even run');
+ const over = await a.judgeGate(gate({ expect: [/ok/], measure: a.metric('delay'), budget: { name: 'delay', max: 0, unit: 'ms' } }), run('[metric] delay=30000\nok').ctx);
+ assert.deepStrictEqual([over.result, over.reason, over.measuredText], ['fail', 'outside the budget', '30000 ms']);
+ const none = await a.judgeGate(gate({ expect: [/ok/], measure: a.metric('delay') }), run('ok').ctx);
+ assert.deepStrictEqual([none.result, none.reason], ['fail', 'the measured value is not in the output']);
+ const slow = await a.judgeGate(gate({ timeoutMs: 1000 }), run('', null, { timedOut: true }).ctx);
+ assert.deepStrictEqual([slow.result, slow.reason], ['fail', 'test/t.test.js timed out after 1 s']);
+ const gone = await a.judgeGate(gate({ files: ['test/gone.test.js'] }), run('').ctx);
+ assert.deepStrictEqual([gone.result, gone.reason], ['fail', 'test/gone.test.js is missing in OpenVibe.Shared']);
+ assert.strictEqual(a.summarize([exit, line, pin, over]).ok, false);
+ }),
+
+ test('pass: an asserted bound, a measured value, a smoke whose other checks failed (ignoreExit)', async () => {
+ const dir = repo({ 'test/t.test.js': "'never while typing'" });
+ const ok = await a.judgeGate(gate({ expect: [/all checks passed/], pins: [{ file: 'test/t.test.js', text: 'never while typing' }], measure: { asserted: true, note: 'deferred: typing' } }), fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 't: all checks passed' } }).ctx);
+ assert.deepStrictEqual([ok.result, ok.measured, ok.measuredText, ok.budgetText], ['pass', 0, '0 (asserted; deferred: typing)', '≤ 0']);
+ const min = await a.judgeGate(gate({ expect: [/works/], budget: { name: 'rows', min: 100, unit: '%' }, measure: { asserted: true } }), fakeCtx({ shared: dir }, { 'test/t.test.js': { code: 0, out: 'it works' } }).ctx);
+ assert.deepStrictEqual([min.result, min.measuredText], ['pass', '100 % (asserted)']);
+ const smoke = ' lap probes: {"intervals":14}\n {"intervals":14}\n {"intervals":14}\n ✗ 1440px /@admin: active=page-home\n\n6 browser check(s) failed\n';
+ const { ctx } = fakeCtx({ live: dir }, { 'test/t.test.js': { code: 1, out: smoke } });
+ ctx.base = 'https://site.test';
+ const leak = await a.judgeGate(gate({ repo: 'live', base: true, ignoreExit: true, expect: [/lap probes:/], budget: { name: 'growth', max: 2 }, measure: a.lapGrowth(['intervals']) }), ctx);
+ assert.deepStrictEqual([leak.result, leak.measured, leak.reason, leak.note], ['pass', 0, null, "the smoke's other checks: 6 failed (not this gate's)"]);
+ }),
+
+ test('records, manifests and open gates', async () => {
+ const hostDir = repo({ 'docs/deploy-proofs.md': '| Proof | State | Record |\n|---|---|---|\n| Web drain (Live) | passed 2026-09-26 | [below](#w) |\n| Recorder (Media) | open | needs a live ingest during a Media deploy |\n\n57 requests, all 200.\n' });
+ const { ctx } = fakeCtx({}, {}, { hostDir });
+ const rec = (o) => ({ id: 'r1', kind: 'record', what: 'w', budget: { name: 'failed', max: 0 }, category: 'affected sessions', ...o });
+ const passed = await a.judgeGate(rec({ row: 'Web drain (Live)', quote: '57 requests, all 200', value: 0, text: '0 of 57' }), ctx);
+ assert.deepStrictEqual([passed.result, passed.measuredText, passed.note], ['pass', '0 of 57', 'recorded: passed 2026-09-26']);
+ const open = await a.judgeGate(rec({ row: 'Recorder (Media)' }), ctx);
+ assert.deepStrictEqual([open.result, open.reason], ['open', 'open: needs a live ingest during a Media deploy']);
+ const changed = await a.judgeGate(rec({ row: 'Web drain (Live)', quote: '99 requests', value: 0 }), ctx);
+ assert.deepStrictEqual([changed.result, changed.reason], ['fail', 'the record no longer says "99 requests"']);
+ const noRow = await a.judgeGate(rec({ row: 'Nope' }), ctx);
+ assert.strictEqual(noRow.result, 'fail');
+ const over = await a.judgeGate(rec({ row: 'Web drain (Live)', value: 4 }), ctx);
+ assert.strictEqual(over.result, 'fail');
+
+ const styles = a.SCENARIOS[0].gates.find((g) => g.kind === 'manifest');
+ const site = (m) => fakeCtx({}, {}, { fetchManifest: async () => m, ctx: { base: 'https://site.test' } }).ctx;
+ const without = await a.judgeGate(styles, site({ components: { shell: { kind: 'script', version: 'a' }, server: { kind: 'server', version: 'a' } } }));
+ assert.deepStrictEqual([without.result, without.measured, without.measuredText], ['open', 0, '0 (components: shell:script, server:server)']);
+ const withStyles = await a.judgeGate(styles, site({ components: { styles: { kind: 'style', version: 'b' } } }));
+ assert.strictEqual(withStyles.result, 'pass');
+ const down = await a.judgeGate(styles, fakeCtx({}, {}, { ctx: { base: 'https://site.test' } }).ctx);
+ assert.deepStrictEqual([down.result, down.reason], ['fail', 'GET https://site.test/release.json: no site']);
+ const known = await a.judgeGate({ id: 'o1', kind: 'open', what: 'w', budget: { name: 'n', max: 0 }, reason: 'no proof yet' }, site({}));
+ assert.deepStrictEqual([known.result, known.reason], ['open', 'no proof yet']);
+ assert.deepStrictEqual(a.summarize([passed, open, known]), { gates: 3, pass: 1, fail: 0, skipped: 0, open: 2, ok: true, scenarios: 1 });
+ }),
+
+ test('runAcceptance runs real processes: node <file> from the repository, once per file, with a timeout; versions recorded', async () => {
+ const counter = path.join(tmp(), 'runs.log');
+ const dir = repo({
+ 'test/good.test.js': `require('fs').appendFileSync(${JSON.stringify(counter)}, 'good\\n'); console.log('[metric] x.delay=0'); console.log('cwd ' + process.cwd()); console.log('good: all checks passed');`,
+ 'test/bad.test.js': "console.log('[metric] x.left=2'); console.error('AssertionError: left 2'); process.exit(1);",
+ 'test/slow.test.js': 'setTimeout(() => {}, 60000);',
+ 'test/env.test.js': "console.log('env ' + process.env.NODE_ENV + ' ' + (process.env.BASE || '-'));",
+ });
+ const scenarios = [
+ { n: 1, name: 'One', gates: [
+ gate({ id: '1a', files: ['test/good.test.js'], expect: [/good: all checks passed/], measure: a.metric('x.delay'), budget: { name: 'delay', max: 0 } }),
+ gate({ id: '1b', files: ['test/good.test.js'], expect: [new RegExp(`cwd ${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')] }),
+ gate({ id: '1c', files: ['test/env.test.js'], expect: [/env test -/] }),
+ ] },
+ { n: 2, name: 'Two', gates: [
+ gate({ id: '2a', files: ['test/bad.test.js'], expect: [/never/], measure: a.metric('x.left') }),
+ gate({ id: '2b', files: ['test/slow.test.js'], timeoutMs: 500 }),
+ gate({ id: '2c', repo: 'live', files: ['test/good.test.js'], base: true }),
+ ] },
+ ];
+ const seen = [];
+ const report = await a.runAcceptance({ scenarios, dirs: { shared: dir, live: dir, host: a.HOST_DIR }, onGate: (r) => seen.push(r.id) });
+ const by = Object.fromEntries(report.gates.map((r) => [r.id, r]));
+ assert.deepStrictEqual(seen, ['1a', '1b', '1c', '2a', '2b', '2c']);
+ assert.deepStrictEqual(['1a', '1b', '1c', '2a', '2b', '2c'].map((id) => by[id].result), ['pass', 'pass', 'pass', 'fail', 'fail', 'skipped']);
+ assert.strictEqual(fs.readFileSync(counter, 'utf8'), 'good\n', 'a file two gates share runs once');
+ assert.deepStrictEqual([by['2a'].measured, by['2a'].reason], [2, 'test/bad.test.js failed (exit 1)']);
+ assert.match(by['2b'].reason, /timed out after 1 s/);
+ assert.deepStrictEqual(report.summary, { gates: 6, pass: 3, fail: 2, skipped: 1, open: 0, ok: false, scenarios: 2 });
+ assert.strictEqual(report.versions.shared.dir, dir);
+ assert.strictEqual(report.versions.shared.commit, null, 'not a git checkout: no commit');
+ assert.ok(report.versions.host.commit, 'the Host checkout records its commit');
+ // The reports.
+ const table = a.formatTable(report);
+ assert.match(table, /── 1\. One/);
+ assert.match(table, /3 passed, 2 failed, 1 skipped, 0 open \(6 gates, 2 scenarios\) — FAILED/);
+ assert.match(table, /── 2a \(test\/bad\.test\.js failed \(exit 1\)\) ──\n[\s\S]*AssertionError: left 2/);
+ const mdown = a.formatMarkdown({ ...report, gates: [...report.gates, { ...by['1a'], what: 'a | b' }] });
+ assert.match(mdown, /\| 1 \| 1a \| pass \| 0 \| ≤ 0 \|/);
+ assert.match(mdown, /\| 2 \| 2c \| \*\*skipped\*\*: needs a running site: --base <url> \|/);
+ assert.match(mdown, /a \\\| b/, 'pipes are escaped');
+ }),
+
+ test('a repository that moves while its tests run is named in the report', async () => {
+ const dir = repo({ 'test/commit.test.js': "const { execFileSync } = require('child_process'); require('fs').writeFileSync('new.txt', 'x'); execFileSync('git', ['add', 'new.txt']); execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'during']); console.log('ok');" });
+ const g = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
+ g('init', '-q'); g('add', '.'); g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'first');
+ const before = g('rev-parse', '--short=12', 'HEAD').stdout.trim();
+ const logs = tmp();
+ const report = await a.runAcceptance({ scenarios: [{ n: 1, name: 'One', gates: [gate({ id: '1a', files: ['test/commit.test.js'], expect: [/ok/] })] }], dirs: { shared: dir, host: a.HOST_DIR }, logs });
+ const v = report.versions.shared;
+ assert.strictEqual(v.commit, before, 'the commit the run started from');
+ assert.strictEqual(v.changedDuringRun, g('rev-parse', '--short=12', 'HEAD').stdout.trim());
+ assert.match(a.formatTable(report), /CHANGED DURING THE RUN/);
+ assert.deepStrictEqual(fs.readdirSync(logs), ['shared__test_commit.test.js.log'], '--logs keeps each run\'s output');
+ }),
+
+ test('scripts/release-acceptance.js: bad arguments exit 2; without the repositories every gate is skipped and the run exits 0 with 0 passes', () => {
+ const script = path.join(a.HOST_DIR, 'scripts', 'release-acceptance.js');
+ const run = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', timeout: 60000 });
+ assert.strictEqual(run('--only', '99').status, 2);
+ assert.strictEqual(run('--bogus').status, 2);
+ assert.strictEqual(run('--base', 'openvibe.live').status, 2);
+ const list = run('--list');
+ assert.strictEqual(list.status, 0);
+ assert.strictEqual((list.stdout.match(/^ ?\d+ \d+[a-z] /gm) || []).length, a.SCENARIOS.reduce((n, s) => n + s.gates.length, 0));
+ const empty = tmp();
+ const r = run('--repos', empty, '--live', path.join(empty, 'live'), '--only', '9,14', '--json');
+ assert.strictEqual(r.status, 0, r.stderr);
+ const report = JSON.parse(r.stdout);
+ assert.strictEqual(report.summary.pass, 0, 'skipped gates are never passes');
+ assert.strictEqual(report.summary.skipped, report.summary.gates);
+ assert.ok(report.gates.every((g) => /OpenVibe\.Shared not found/.test(g.reason)));
+ const strict = run('--repos', empty, '--live', path.join(empty, 'live'), '--only', '9a', '--strict');
+ assert.strictEqual(strict.status, 1, '--strict: a skipped gate fails the run');
+ }),
+]);