igneum/tools/finality-attacks/run.mjs
igneum-labs d783b13733 Fast time: the devnet at 60x for test networks (override-60x.json, --fast-time in both harnesses, proof script)
infra/fast-time/override-60x.json is the devnet with every clock-like consensus parameter divided by 60 and every
block count unchanged (finality window, ban and min_daa 120 DAA; merge depth 60; Kaspa finality depth 720; pruning
depth at the anticone bound 13,838; coinbase maturity 2; the hourly program epoch 60 blocks with a 10-block lead;
the dataset day 24 minutes). The epoch length, lead and day are consensus parameters of the node since devnet-v4
a5ef8b07, carried by the override file. README lists each field, why it scales or not, the flags and the numbers.

Measured (simnet.mjs, three devnet-v4 nodes, three vmine voters at 1 block/s, one real-hash CPU miner): next
epoch seed in the template at 56.1 s, program swap at 65.1 s wall (DAA 60), first finality lock at 185.5 s wall
(checkpoint 5, DAA 149). Both harnesses take --fast-time: finality-attacks s3 PASS in 113 s wall with 16 locks
per node (the devnet rule needs 20 min of warm-up at 6 blocks/s before any lock); harness s3 partition and heal
43 s wall for three cuts against 983 s for four on the devnet profile with the same binary. Bench-log entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 10:34:19 +00:00

353 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Finality v2 attack runner (docs/spec/03-finality.md, gate 3). Drives a private network of our own igneumd
// nodes with the test-only hostile vmine miners and records, per scenario, the spec pass criterion and a
// measured result. Priority order 3, 2, 1, 6, 4, 8, 5, 7 (as time allows). Read-only against the spec; the
// hostile behaviour lives in igneum-miner test flags, never in honest node or consensus code.
//
// node tools/finality-attacks/run.mjs # the achievable catalogue
// node tools/finality-attacks/run.mjs s1 s6 # named scenarios
// node tools/finality-attacks/run.mjs --quick # short durations (smoke)
// SCALE=0.5 node tools/finality-attacks/run.mjs # scale every duration
// node tools/finality-attacks/run.mjs s3 --fast-time # the 60x profile (infra/fast-time): the weight window fills
// # at DAA 120 instead of 7,200, so locks start within a minute;
// # durations default to the --quick scale
import { Node, Miner, Proxy, stopAll, sleep, log, assertBinaries, TMP, FAST_TIME, IGNEUMD } from './lib/net.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
const QUICK = process.argv.includes('--quick');
const SCALE = process.env.SCALE ? (parseFloat(process.env.SCALE) || 1) : (QUICK || FAST_TIME ? 0.35 : 1);
const dur = (s) => Math.max(20, Math.round(s * SCALE));
const results = [];
mkdirSync(TMP, { recursive: true });
const sumLocked = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').length;
const maxLockedIndex = (cp) => (cp?.checkpoints || []).filter(c => c.state === 'locked').reduce((m, c) => Math.max(m, c.index), 0);
function minerLockLatency(miners) {
const xs = [];
for (const m of miners) {
const t = m.logText();
const match = t.match(/lock_latency=median (\d+) ms, max (\d+) ms/);
if (match) xs.push(+match[1]);
}
xs.sort((a, b) => a - b);
return xs.length ? xs[Math.floor(xs.length / 2)] : null;
}
function minerFound(miner) {
const m = miner.logText().match(/engine=vmine found=(\d+)/);
return m ? +m[1] : null;
}
// ---------------------------------------------------------------------------------------------
// Scenario 3: dishonest aggregators. Six voters across three nodes; every node aggregates (anyone MAY). A
// certificate below quorum cannot lock (code: FinalityManager::lock_test). We measure that locks still form on
// every node, latency stays under 2 s, and votes carried in blocks let participation be computed (F3).
async function s3() {
const name = 's3-dishonest-aggregators';
const secs = dur(300);
const n0 = await new Node(0).start();
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
const miners = [];
// two voters per node
const plan = [[n0, 'a0'], [n0, 'a1'], [n1, 'b0'], [n1, 'b1'], [n2, 'c0'], [n2, 'c1']];
for (const [node, label] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs }).start());
await sleep(secs * 1000 + 3000);
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
const locked = cps.map(sumLocked);
const maxIdx = cps.map(maxLockedIndex);
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
const lat = minerLockLatency(miners);
// agreement: every node's set of locked (index->hash) is consistent
const hashAt = (cp, idx) => (cp.checkpoints.find(c => c.index === idx && c.state === 'locked') || {}).hash;
let agree = true;
const common = Math.min(...maxIdx);
for (let i = 1; i <= common; i++) { const h = cps.map(cp => hashAt(cp, i)).filter(Boolean); if (new Set(h).size > 1) agree = false; }
const pass = locked.every(l => l > 3) && conflicts.every(c => c === 0) && agree && lat != null && lat < 2000;
for (const m of miners) await m.stop();
results.push({ name, secs,
criterion: 'other aggregators’ certs still lock; sub-quorum cert cannot lock (lock_test); block-carried votes give participation (F3); 0 conflicting certs; lock latency < 2 s median',
result: `locked per node ${locked.join('/')}, conflicting certs ${conflicts.join('/')}, cross-node lock hashes agree=${agree}, median lock latency ${lat} ms`,
pass });
await stopAll();
}
// ---------------------------------------------------------------------------------------------
// Scenario 2: Sybil dust. One miner mints 200 keys below dust (4 blocks each; dust = 5) and 200 above (6 each).
// A few honest voters advance the chain. Criterion: dust keys carry zero weight and are no voters; above-dust
// keys carry weight = their blocks; total weight = blue blocks of voters. Aggregator sortition must pick by
// weight not key count (F17): the implementation picks per key, reported as a FAIL with its blast radius.
async function s2() {
const name = 's2-sybil-dust';
const secs = dur(300);
const n0 = await new Node(0).start();
const miners = [];
// honest voters keep checkpoints advancing and are the real voter set
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs }).start());
// one Sybil miner mints both populations (200 x 4 dust, 200 x 6 above) from a single process
const sybil = new Miner(n0, { label: 'syb', secs, sybil: '200:4:200:6', vote: false }).start();
// wait for the sybil to finish minting (or the run deadline)
const t0 = Date.now();
while (Date.now() - t0 < secs * 1000) { if (sybil.exited) break; await sleep(2000); }
await sleep(3000);
const w = await n0.rpc.call('getFinalityWeights', {});
const keys = w.keys || [];
const dust = keys.filter(k => k.pubkey && k.blocks > 0 && k.blocks < 5);
const above = keys.filter(k => k.voter);
const dustNonzeroWeightVoters = dust.filter(k => k.voter).length;
const totalOfVoters = above.reduce((s, k) => s + k.blocks, 0);
const totalMatches = totalOfVoters === w.totalWeight;
// F17: aggregator sortition is per key (is_aggregator counts voters, not weight). Inspect a recent checkpoint.
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 5 });
const aggCount = (cp.checkpoints.slice(-1)[0] || {}).aggregators?.length ?? 0;
const sortitionByKey = (w.voters || 0) > 8; // with >8 voters the per-key draw is observable
const weightsOk = dustNonzeroWeightVoters === 0 && totalMatches && above.length > 0;
const pass = weightsOk && !sortitionByKey; // FAIL whenever per-key sortition is observable among >8 voters
for (const m of miners) await m.stop();
await sybil.stop();
results.push({ name, secs,
criterion: 'dust keys zero weight and no voters; above-dust weight = blocks; total weight = blue blocks of voters; sortition by weight not key count (F17)',
result: `dust keys seen ${dust.length} (all non-voters: ${dustNonzeroWeightVoters === 0}); above-dust voters ${above.length}; total weight ${w.totalWeight} = sum of voter blocks ${totalOfVoters} (${totalMatches}); voters=${w.voters}; aggregator sortition is PER KEY (is_aggregator counts voters, not weight), observable with ${w.voters} voters, agg/checkpoint=${aggCount}`,
pass });
await stopAll();
}
// ---------------------------------------------------------------------------------------------
// Scenario 1: equivocation at scale. Six voters across three nodes, two equivocate at every index. Criterion:
// both keys stripped within one checkpoint on every node, no conflicting certificate ever forms, honest locks
// continue.
async function s1() {
const name = 's1-equivocation';
const secs = dur(300);
const n0 = await new Node(0).start();
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
const n2 = await new Node(2, { connect: [n0.p2p] }).start();
const miners = [];
const plan = [[n0, 'e0', true], [n0, 'e1', true], [n1, 'h0', false], [n1, 'h1', false], [n2, 'h2', false], [n2, 'h3', false]];
for (const [node, label, eq] of plan) miners.push(new Miner(node, { label, share: 1 / 6, bps: 6, secs, equivocate: eq }).start());
await sleep(secs * 1000 + 3000);
const ws = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityWeights', {}).catch(() => null)));
const strippedPerNode = ws.map(w => (w.keys || []).filter(k => k.strippedUntilDaa > 0).length);
const conflicts = [n0, n1, n2].map(n => n.grepLog(/CONFLICTING certificate/).length);
const equivDetections = [n0, n1, n2].map(n => n.grepLog(/EQUIVOCATION by key/).length);
const cps = await Promise.all([n0, n1, n2].map(n => n.rpc.call('getFinalityCheckpoints', { last: 400 }).catch(() => null)));
const locked = cps.map(sumLocked);
// locks continued after the first strip: the latest locked index is well past the first detection
const pass = strippedPerNode.every(s => s >= 2) && conflicts.every(c => c === 0) && locked.every(l => l > 3);
for (const m of miners) await m.stop();
results.push({ name, secs,
criterion: '2 equivocating keys stripped within one checkpoint on every node; no conflicting certificate forms; honest locks continue',
result: `stripped keys per node ${strippedPerNode.join('/')} (want >=2 each); equivocation detections ${equivDetections.join('/')}; conflicting certs ${conflicts.join('/')}; locked checkpoints per node ${locked.join('/')}`,
pass });
await stopAll();
}
// ---------------------------------------------------------------------------------------------
// Scenario 6: partition with the floor. Two nodes over a proxy, 6 keys known from a shared warmup. Part A: 3/3
// split, neither side holds 56.7% of total, so zero new locks on either side; heal, locks resume. Part B: 4/2
// split, the 4 side holds 66.7% and locks, the 2 side does not.
async function s6() {
const name = 's6-partition-floor';
// The floor protects a partition only while the majority side's fresh blocks stay small against its weight
// window (devnet window 7,200 DAA). So warm up long enough to fill most of the window, then split briefly. At
// ~12 bps the window fills in about 600 s; a 3-miner side adds ~3 bps, so a split under ~300 s keeps the
// majority below the 56.7% floor (3.3.1 on a real DAG; spec 3.7 item 8). --quick shrinks both, which exposes
// the under-filled-window breach instead (reported honestly as the time-to-breach).
const warm = dur(420), split = dur(150), healWin = dur(150);
async function partition(plan0, plan1, tag) {
const secs = warm + split + healWin + 90;
const n0 = await new Node(0).start();
const proxy = await new Proxy(0, n0.p2pPort).start();
const n1 = await new Node(1, { connect: [proxy.addr] }).start();
const miners = [];
for (const l of plan0) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
for (const l of plan1) miners.push(new Miner(n1, { label: l, share: 1 / 6, bps: 6, secs }).start());
await sleep(warm * 1000);
const w0 = await n0.rpc.call('getFinalityWeights', {}).catch(() => ({}));
const before0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 300 }));
const before1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 300 }));
log(`s6 ${tag} cut at warm ${warm}s: window daa ~${w0.daaScore}, voters ${w0.voters}, max locked ${before0}/${before1}`);
proxy.cut();
// Poll during the split: record the first new lock beyond the pre-cut index on each side (time to breach)
const t0 = Date.now();
let breach0 = null, breach1 = null, maxNew0 = before0, maxNew1 = before1;
while (Date.now() - t0 < split * 1000) {
const c0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
const c1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 500 }).catch(() => null));
if (c0 > maxNew0) maxNew0 = c0;
if (c1 > maxNew1) maxNew1 = c1;
if (breach0 == null && c0 > before0) breach0 = Math.round((Date.now() - t0) / 1000);
if (breach1 == null && c1 > before1) breach1 = Math.round((Date.now() - t0) / 1000);
await sleep(3000);
}
const newLocks = (maxNew0 - before0) + (maxNew1 - before1);
proxy.heal();
await sleep(healWin * 1000);
const after0 = maxLockedIndex(await n0.rpc.call('getFinalityCheckpoints', { last: 800 }));
const after1 = maxLockedIndex(await n1.rpc.call('getFinalityCheckpoints', { last: 800 }));
const healed = after0 > maxNew0 && after1 > maxNew1;
const conflicts = [n0, n1].map(n => n.grepLog(/CONFLICTING certificate/).length);
for (const m of miners) await m.stop();
await stopAll();
return { before0, before1, maxNew0, maxNew1, newLocks, breach0, breach1, after0, after1, healed, conflicts, daa: w0.daaScore, voters: w0.voters };
}
// Part A: 3/3. Neither side holds 56.7% of the (filled) total, so no side should lock during a short split.
const a = await partition(['a0', 'a1', 'a2'], ['b0', 'b1', 'b2'], 'A(3/3)');
const passA = a.newLocks === 0 && a.healed && a.conflicts.every(c => c === 0);
results.push({ name: name + '-A(3/3)', secs: warm + split + healWin,
criterion: '3/3 split on a filled window: zero new locks on either side (neither holds 56.7% of total); locks resume after healing; no conflicting certificates',
result: `warmup window daa ~${a.daa} (voters ${a.voters}); new locks during ${split}s split ${a.newLocks} (time to first new lock side0=${a.breach0 ?? 'none'}s side1=${a.breach1 ?? 'none'}s); resumed after heal ${a.healed}; conflicting certs ${a.conflicts.join('/')}`,
pass: passA });
// Part B: 4/2. The 4 side holds 66.7% of total and should keep locking once the 2 silent keys decay out of the
// active denominator (presence window); the 2 side (33%) must not lock.
const b = await partition(['p0', 'p1', 'p2', 'p3'], ['q0', 'q1'], 'B(4/2)');
const passB = b.maxNew0 > b.before0 && b.maxNew1 === b.before1 && b.conflicts.every(c => c === 0);
results.push({ name: name + '-B(4/2)', secs: warm + split + healWin,
criterion: '4/2 split: the 4 side (66.7% of total) locks (after the 2 silent keys decay from active); the 2 side (33%) does not; no conflicting certificates',
result: `4-side locked ${b.before0}->${b.maxNew0} (advanced ${b.maxNew0 > b.before0}, first new lock at ${b.breach0 ?? 'none'}s); 2-side locked ${b.before1}->${b.maxNew1} (stalled ${b.maxNew1 === b.before1}); conflicting certs ${b.conflicts.join('/')}`,
pass: passB });
}
// ---------------------------------------------------------------------------------------------
// Scenario 4: a block producer that includes no votes in its blocks. One dropper with 40% of blocks on node A;
// node B learns votes only through other blocks and p2p. Criterion: participation and locks unaffected; measure
// the delay. A control run without dropping gives the baseline latency.
async function s4() {
const name = 's4-vote-dropping-producer';
const secs = dur(300);
async function run(drop) {
const n0 = await new Node(0).start();
const n1 = await new Node(1, { connect: [n0.p2p] }).start();
const miners = [];
// dropper: 40% of blocks on node A; four honest voters share the rest
miners.push(new Miner(n0, { label: 'drop', share: 0.40, bps: 6, secs, dropVotes: drop }).start());
for (const [node, label, sh] of [[n0, 'h0', 0.15], [n0, 'h1', 0.15], [n1, 'h2', 0.15], [n1, 'h3', 0.15]])
miners.push(new Miner(node, { label, share: sh, bps: 6, secs }).start());
await sleep(secs * 1000 + 3000);
const cpB = await n1.rpc.call('getFinalityCheckpoints', { last: 400 });
const lockedB = sumLocked(cpB);
const lat = minerLockLatency(miners);
for (const m of miners) await m.stop();
await stopAll();
return { lockedB, lat };
}
const withDrop = await run(true);
const control = await run(false);
const delta = (withDrop.lat != null && control.lat != null) ? withDrop.lat - control.lat : null;
const pass = withDrop.lockedB > 3 && withDrop.lat != null && withDrop.lat < 2000;
results.push({ name, secs,
criterion: 'participation and locks unaffected because other blocks carry the votes; delay measured',
result: `node B locked checkpoints ${withDrop.lockedB} with a 40% vote-dropping producer; median lock latency ${withDrop.lat} ms vs control ${control.lat} ms (delay ${delta} ms)`,
pass });
}
// ---------------------------------------------------------------------------------------------
// Scenario 8 (RPC half): malformed, mis-signed and replayed votes over submitFinalityVote. Criterion: each is
// rejected without a crash. The p2p message-70 half (certificates, oversized bitmaps) needs a finality-aware
// p2p probe, which is not built this session; noted in the README.
async function s8() {
const name = 's8-malformed-rpc';
const secs = dur(90);
const n0 = await new Node(0).start();
const miners = [];
for (const l of ['h0', 'h1', 'h2']) miners.push(new Miner(n0, { label: l, share: 1 / 3, bps: 3, secs: secs + 60 }).start());
await sleep(secs * 1000); // let some checkpoints form so there is a known checkpoint to attack
const { spawnSync } = await import('node:child_process');
const { MINER } = await import('./lib/net.mjs');
const out = spawnSync(MINER, ['fin-rpc-attack', n0.grpc], { encoding: 'utf8', timeout: 120000 });
const txt = (out.stdout || '') + (out.stderr || '');
const m = txt.match(/node_alive_after_each=(\d+)\/(\d+)/);
const alive = m ? (+m[1] === +m[2] && +m[2] >= 8) : false;
const control = /\[control-valid\] accepted=true/.test(txt);
const badSigRejected = /\[bad-signature\] accepted=false/.test(txt);
const wrongChainRejected = /\[wrong-chain-id\] accepted=false/.test(txt);
const replayDeduped = /\[replay\] accepted=true equivocation=false reason=already known/.test(txt);
const garbageRejected = /\[short-garbage\] rpc error/.test(txt) && /\[oversized-2mb\] rpc error/.test(txt) && /\[non-hex\] rpc error/.test(txt);
const stillUp = (await n0.rpc.call('getInfo').catch(() => null)) != null;
writeFileSync(`${TMP}/s8-fin-rpc-attack.out`, txt);
for (const mm of miners) await mm.stop();
const pass = alive && control && badSigRejected && wrongChainRejected && replayDeduped && garbageRejected && stillUp;
results.push({ name, secs,
criterion: 'wrong signatures, wrong index/checkpoint, replays, oversized and non-hex payloads rejected without a crash; node stays up (p2p-70 half needs a probe, not built)',
result: `node answered getInfo after every case=${alive}; control accepted=${control}; bad-sig rejected=${badSigRejected}; wrong-chain rejected=${wrongChainRejected}; replay deduped=${replayDeduped}; garbage/oversized/non-hex rejected=${garbageRejected}; node up after=${stillUp}; transcript ${TMP}/s8-fin-rpc-attack.out`,
pass });
await stopAll();
}
// ---------------------------------------------------------------------------------------------
// Scenario 5 (light): pulsed rental against the difficulty controller. A miner bursts to 50x for a short duty
// window each period; the DAA controller (Kaspa's rule on master) is in the loop via block cadence. Criterion
// (F14): the burst earns weight proportional to its share of blocks over the window; it cannot lock alone.
async function s5() {
const name = 's5-pulse';
const secs = dur(360);
const n0 = await new Node(0).start();
const miners = [];
// Five steady voters plus one burster, all base share 1/6. The burster pulses 10x for 20 s of every 120 s, so
// over the window it produces about a third of the blocks and stays below 2/3. F14: its weight share should
// equal its block share (no retarget amplification), and a third cannot lock alone (needs 2/3 + the floor).
for (const l of ['s0', 's1', 's2', 's3', 's4']) miners.push(new Miner(n0, { label: l, share: 1 / 6, bps: 6, secs }).start());
const burst = new Miner(n0, { label: 'burst', share: 1 / 6, bps: 6, secs, pulse: '10:20:120' }).start();
miners.push(burst);
await sleep(secs * 1000 + 3000);
const w = await n0.rpc.call('getFinalityWeights', {});
const keys = (w.keys || []).filter(k => k.pubkey);
const burstHash = (burst.logText().match(/key=([0-9a-f]{64})/) || [])[1];
const burstKey = burstHash ? keys.find(k => k.keyHash === burstHash) : null;
const total = w.totalWeight || keys.reduce((s, k) => s + k.blocks, 0);
const burstBlocks = burstKey ? burstKey.blocks : null;
const burstWeightShare = burstBlocks != null ? (burstBlocks / total) : null;
// Block share from what the burster actually submitted vs all miners
const allFound = miners.map(minerFound).filter(x => x != null).reduce((a, b) => a + b, 0);
const burstFound = minerFound(burst);
const burstBlockShare = (burstFound != null && allFound > 0) ? (burstFound / allFound) : null;
const ratio = (burstWeightShare != null && burstBlockShare) ? burstWeightShare / burstBlockShare : null;
// Could it lock alone? A lock with only the burster's single vote would show votesSeen == 1. Check no lock did.
const cp = await n0.rpc.call('getFinalityCheckpoints', { last: 400 });
const soloLocks = (cp.checkpoints || []).filter(c => c.state === 'locked' && c.votesSeen < 2).length;
const conflicts = n0.grepLog(/CONFLICTING certificate/).length;
for (const m of miners) await m.stop();
const pass = ratio != null && ratio > 0.82 && ratio < 1.18 && burstWeightShare < 0.567 && soloLocks === 0 && conflicts === 0;
results.push({ name, secs,
criterion: 'the burst earns weight proportional to its block share over the window (no retarget amplification, W2/F14) and cannot lock alone',
result: burstWeightShare != null
? `burster weight share ${(burstWeightShare * 100).toFixed(1)}% vs block share ${(burstBlockShare * 100).toFixed(1)}% (ratio ${ratio.toFixed(3)}, want ~1.0 = no amplification); below the 56.7% floor so cannot lock alone; locks with <2 votes ${soloLocks}; conflicting certs ${conflicts}`
: `could not identify the burster key; conflicting certs ${conflicts}`,
pass });
await stopAll();
}
// ---------------------------------------------------------------------------------------------
const ALL = { s3, s2, s1, s6, s4, s8, s5 };
const ORDER = ['s3', 's2', 's1', 's6', 's4', 's8', 's5'];
async function main() {
assertBinaries();
if (FAST_TIME) log(`fast-time 60x profile: node ${IGNEUMD}`);
const asked = process.argv.slice(2).filter(a => !a.startsWith('--'));
const run = asked.length ? asked : ORDER;
for (const key of run) {
const fn = ALL[key];
if (!fn) { log(`unknown scenario ${key}`); continue; }
log(`=== ${key} starting (scale ${SCALE}) ===`);
try { await fn(); } catch (e) { log(`${key} threw: ${e.stack || e}`); results.push({ name: key, criterion: '(scenario errored)', result: String(e.message || e), pass: false }); await stopAll(); }
log(`=== ${key} done ===`);
}
// report
const lines = ['', 'SCENARIO RESULTS', '================'];
for (const r of results) {
lines.push(`\n[${r.pass ? 'PASS' : 'FAIL'}] ${r.name} (${r.secs || '?'} s)`);
lines.push(` criterion: ${r.criterion}`);
lines.push(` result: ${r.result}`);
}
const text = lines.join('\n');
console.log(text);
writeFileSync(`${TMP}/results.txt`, text);
writeFileSync(`${TMP}/results.json`, JSON.stringify(results, null, 2));
await stopAll();
process.exit(results.some(r => !r.pass) ? 1 : 0);
}
main();