openvibers/openvibe.host Commits

Drills run an acceptance subset (WS-S task 2): drill.acceptance lists GETs the restored instance alone must answer with a status and top-level JSON keys — for services whose answers differ from production by nature or that have nothing public to compare; failures are the 'acceptance' stage in the log and the Markdown row

OP OpenVibers committed 25 Sept 2026 · 561d731005 · 3 files, +62 −2


Changes

ddiff --git a/lib/commands/drill.js b/lib/commands/drill.js
iindex c0d4b01..c4ac462 100644
---- a/lib/commands/drill.js
++++ b/lib/commands/drill.js
@@@ -250,6 +250,7 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
ready: null,
compare: [],
counts: [],
++ acceptance: [],
stop: null,
kept: false,
result: 'failed',
@@@ -381,6 +382,23 @@ async function drill(ctx, id, { backup: backupArg = null, keep = false } = {}) {
}
const bad = [...record.compare.filter((r) => !r.match).map((r) => `${r.path} (${r.detail})`), ...record.counts.filter((r) => !r.match).map((r) => `${r.table} ${r.production} ≠ ${r.restored}`)];
if (bad.length) throw fail('compare', `differs from production: ${bad.join('; ')}`);
++ // The acceptance subset (WS-S task 2): the restored instance alone answers each declared GET with
++ // the declared status and, for JSON, the declared top-level keys.
++ for (const a of (d.acceptance || [])) {
++ const r = await exec.http(`http://127.0.0.1:${d.port}${a.path}`, { timeoutMs: 10000, headers: { ...headers, ...a.headers } });
++ let detail = null;
++ if (r.status !== a.status) detail = `status ${r.status || r.error || 0}, expected ${a.status}`;
++ else if (a.keys.length) {
++ let body = null;
++ try { body = JSON.parse(r.body || ''); } catch { body = null; }
++ const missing = body && typeof body === 'object' ? a.keys.filter((k) => !(k in body)) : a.keys;
++ if (missing.length) detail = `missing ${missing.join(', ')}`;
++ }
++ record.acceptance.push({ path: a.path, ok: !detail, ...(detail ? { detail } : {}) });
++ log(`${detail ? 'FAILED' : 'ok'} ${a.path}${detail ? `: ${detail}` : ''}`);
++ }
++ const unaccepted = record.acceptance.filter((r) => !r.ok).map((r) => `${r.path} (${r.detail})`);
++ if (unaccepted.length) throw fail('acceptance', unaccepted.join('; '));
} catch (err) {
if (!(err instanceof Abort)) fail('error', err.message);
}
@@@ -443,7 +461,10 @@ function markdownRow(r) {
if (same.length) checks.push(`${same.join(', ')} identical to production${r.compare.some((c) => c.match && c.mode && c.mode !== 'bytes') ? ' (JSON without volatile keys where declared)' : ''}`);
checks.push(...diff);
for (const c of r.counts) checks.push(`${c.table} ${c.production == null ? '?' : c.production} ${c.match ? '=' : '≠'} ${c.restored}`);
-- if (r.failure && r.failure.stage !== 'compare') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);
++ const accepted = (r.acceptance || []).filter((a) => a.ok).map((a) => `\`${a.path}\``);
++ if (accepted.length) checks.push(`acceptance ${accepted.join(', ')}`);
++ if (r.failure && r.failure.stage !== 'compare' && r.failure.stage !== 'acceptance') checks.push(`${r.failure.stage}: ${cell(r.failure.message)}`);
++ if (r.failure && r.failure.stage === 'acceptance') checks.push(`acceptance failed: ${cell(r.failure.message)}`);
const when = r.startedAt.replace('T', ' ').slice(0, 16);
return `| ${when} | ${r.service} | \`${r.backup}/\` (\`ovhost drill ${r.service}\`) | ${checks.join('; ')} | ${r.result} |`;
}
ddiff --git a/lib/inventory.js b/lib/inventory.js
iindex 6964f16..a28314d 100644
---- a/lib/inventory.js
++++ b/lib/inventory.js
@@@ -165,6 +165,9 @@ const PLACEHOLDER_RE = /\{(tmp|port|db:[A-Za-z0-9._-]+)\}/g;
* ready readiness path; default: the path of the service's ready URL
* compare [{ path, ignore: [volatile keys], headers }] read-only GETs compared with production
* counts [{ db, table }] row counts compared between production and the restored copy
++ * acceptance [{ path, status: 200, keys: [top-level JSON keys] }] GETs the restored instance alone must
++ * answer (roadmap WS-S task 2): the acceptance subset for services whose answers differ from
++ * production by nature (queues, clocks) or that have no public read to compare
* outbound true lets the instance reach non-loopback addresses (default: loopback only)
*/
function normaliseDrill(raw, svc, where) {
@@@ -259,7 +262,16 @@ function normaliseDrill(raw, svc, where) {
if (!TABLE_RE.test(c.table || '')) throw new InventoryError(`${w}.counts[${i}].table must be a table name`);
return { db: c.db, table: c.table };
});
-- if (!compare.length && !counts.length) throw new InventoryError(`${w} needs at least one compare path or row count`);
++ const acceptance = (raw.acceptance || []).map((a, i) => {
++ const aa = typeof a === 'string' ? { path: a } : a;
++ if (!aa || typeof aa.path !== 'string' || !/^\/[^/]/.test(aa.path) || /\s/.test(aa.path)) throw new InventoryError(`${w}.acceptance[${i}].path must be a path such as /api/health`);
++ const status = aa.status == null ? 200 : Number(aa.status);
++ if (!Number.isInteger(status) || status < 100 || status > 599) throw new InventoryError(`${w}.acceptance[${i}].status must be an HTTP status`);
++ const keys = aa.keys || [];
++ if (!Array.isArray(keys) || keys.some((k) => typeof k !== 'string' || !k)) throw new InventoryError(`${w}.acceptance[${i}].keys must be a list of key names`);
++ return { path: aa.path, status, keys, headers: aa.headers || {} };
++ });
++ if (!compare.length && !counts.length && !acceptance.length) throw new InventoryError(`${w} needs at least one compare path, row count or acceptance check`);
const readyTimeoutSeconds = Number(raw.readyTimeoutSeconds || (svc.ready && svc.ready.timeoutSeconds) || 90);
return {
supported: true,
@@@ -276,6 +288,7 @@ function normaliseDrill(raw, svc, where) {
readyTimeoutSeconds,
compare,
counts,
++ acceptance,
outbound: raw.outbound === true,
outboundReason: raw.outboundReason || null,
runtimeMaxSeconds: Number(raw.runtimeMaxSeconds || 1800),
ddiff --git a/test/drill.test.js b/test/drill.test.js
iindex 69382fa..1d779fd 100644
---- a/test/drill.test.js
++++ b/test/drill.test.js
@@@ -368,6 +368,32 @@ runTests([
assert.ok(!host.files.has(rec.dir));
}),
++ test('acceptance: the restored instance alone answers each declared GET with its status and JSON keys (WS-S task 2)', async () => {
++ const accept = [{ path: '/api/ready', keys: ['status'] }, { path: '/api/v1/pulse?limit=5', keys: ['items', 'generated_at'] }];
++ let host = await drillScenario({ drill: { acceptance: accept } });
++ let r = await host.cli('drill', 'community');
++ assert.strictEqual(r.code, 0, r.out);
++ let rec = lastLog(host);
++ assert.deepStrictEqual(rec.acceptance, [{ path: '/api/ready', ok: true }, { path: '/api/v1/pulse?limit=5', ok: true }]);
++ assert.match(rec.markdown, /acceptance `\/api\/ready`, `\/api\/v1\/pulse\?limit=5`/);
++
++ host = await drillScenario({ drill: { acceptance: [{ path: '/api/v1/pulse?limit=5', keys: ['items', 'cursor'] }, { path: '/api/ready', status: 204 }] } });
++ r = await host.cli('drill', 'community');
++ assert.strictEqual(r.code, 2, r.out);
++ rec = lastLog(host);
++ assert.strictEqual(rec.failure.stage, 'acceptance');
++ assert.deepStrictEqual(rec.acceptance.map((a) => [a.ok, a.detail || null]), [[false, 'missing cursor'], [false, 'status 200, expected 204']]);
++ assert.match(rec.markdown, /acceptance failed: /);
++ assert.ok(!host.files.has(rec.dir), 'still cleaned up');
++ }),
++
++ test('acceptance entries are validated in the inventory', async () => {
++ const host = await drillScenario({ drill: { acceptance: [{ path: 'no-slash' }] }, backup: false });
++ const r = await host.cli('drill', 'community');
++ assert.notStrictEqual(r.code, 0);
++ assert.match(r.out, /acceptance\[0\]\.path must be a path/);
++ }),
++
test('compare: a status mismatch or a failing production answer is never a match', () => {
const e = { path: '/x', ignore: [] };
assert.strictEqual(compareBodies(e, { status: 500, body: 'e' }, { status: 500, body: 'e' }).match, false);