The sweep (main's item 1): 199 tracked text files, 783 lines. The founder's full name, first name and possessive become "the founder" (sentence starts capitalised); the lowercase operating-system user name in WSL paths and commands becomes <user>; the second owner login becomes "the second owner login"; the three earlier businesses and the two other brands become "the other business", "the earlier entity", "the earlier business" and "another brand"; the Chrome profile rule names the igneum.network profile, not the profile's label. The standing commit login igneum-labs is not a founder term here: the fresh-repository step renames it in the history (docs/plans/history-rewrite.md, tools/repo/fresh-repo.sh). The patterns never appear in plain text in the tree (a plaintext list would be the hit): tools/ci/founder-strings.b64 (perl regex, tab, a sample per row) is read by tools/ci/founder-strings-check.sh (every tracked text file, perl, known-failed first: the self-test plants each row's sample in a fixture and the hit must name the file), by tools/community/discord-hooks.mjs (the guard's founder and business rows; the test takes its fixtures from the samples) and by tools/repo/fresh-repo.sh (the business names of the rewrite rules). site/forbidden-strings.txt carries the same patterns as b64: lines, decoded case-insensitive by site/scrub.mjs and tools/ci/launch-gates-check.mjs (whose fixture now plants an encoded made-up name). The check runs in the gate's tree checks on every merge. Not in this commit, by main's word: the 105 commit messages and 40 personal-identity commits that need the history rewrite (listed, not run), and the secrets found by gitleaks over the history (reported with owners). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
458 lines
32 KiB
JavaScript
458 lines
32 KiB
JavaScript
// node --test tools/community/discord-hooks.test.mjs
|
||
// Shaping against fixtures cut from the live API on 6 October 2026 (fixtures/discord), the guards, the limits,
|
||
// idempotency, the 429 backoff, the watcher's state machine and the London scheduler. Nothing here touches the network.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
shapePulse, shapeDigest, shapeWeekly, shapeRelease, shapeIncidentOpen, shapeIncidentResolve, changedLinesFromPlan, plainLine,
|
||
guardText, guardPayload, embed, embedLength, LIMITS, snapshot, devnet2Line, versionShare, watchPass, WATCH, WATCH_CONDITIONS,
|
||
Poster, postWebhook, dueNow, ukStamp, londonParts, fmtHash, fmtDur, fmtDelta, fmtChangePct, renderPreview, ICON, EMBER,
|
||
shapeFeed, feedMilestones, shapeMilestone, hashOriginLine, runFeed, FEED_MILESTONES, founderPatterns } from './discord-hooks.mjs';
|
||
|
||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
const fx = name => JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'discord', `${name}.json`), 'utf8'));
|
||
const NOW = new Date('2026-10-06T18:44:50Z'); // the fixtures' moment, 19:44 UK
|
||
const data = () => ({ stats: fx('stats'), live: fx('live'), supply: fx('supply'), fleet: fx('fleet'), now: NOW });
|
||
const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'discord-hooks-'));
|
||
|
||
// ---------- the pulse ----------
|
||
test('pulse: one embed, ember, icon, at most eight fields, UK footer with UTC in brackets', () => {
|
||
const { payload } = shapePulse(data(), null);
|
||
guardPayload(payload);
|
||
const e = payload.embeds[0];
|
||
assert.equal(payload.embeds.length, 1);
|
||
assert.equal(e.color, EMBER);
|
||
assert.equal(e.thumbnail.url, ICON);
|
||
assert.ok(e.fields.length <= 8);
|
||
assert.match(e.footer.text, /^6 Oct 2026, 19:44 UK \(18:44 UTC\)/);
|
||
assert.deepEqual(payload.allowed_mentions, { parse: [] });
|
||
assert.equal(payload.username, 'Igneum');
|
||
});
|
||
|
||
test('pulse: first pulse has no window; the second computes blocks per second and the difficulty change from the snapshot', () => {
|
||
const first = shapePulse(data(), null);
|
||
assert.match(first.payload.embeds[0].fields[0].value, /blocks\/s last 60 s/);
|
||
assert.match(first.payload.embeds[0].fields[2].value, /first pulse/);
|
||
const prev = { ...first.snapshot, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString(), block_count: first.snapshot.block_count - 21600, difficulty: first.snapshot.difficulty / 1.1, paid_shards_total: first.snapshot.paid_shards_total - 40 };
|
||
const second = shapePulse(data(), prev);
|
||
const f = second.payload.embeds[0].fields;
|
||
assert.match(f[0].value, /1\.00 blocks\/s over 6 h/);
|
||
assert.match(f[2].value, /\+10\.0% in 6 h/);
|
||
assert.match(f[4].value, /\+40 shards paid in 6 h/);
|
||
});
|
||
|
||
test('pulse: the fields carry the asked numbers in plain words', () => {
|
||
const { payload } = shapePulse(data(), null);
|
||
const text = payload.embeds[0].fields.map(f => `${f.name}: ${f.value}`).join('\n');
|
||
assert.match(text, /Chain block: 141,642/);
|
||
assert.match(text, /2\.56 GH\/s/);
|
||
assert.match(text, /56 vote keys active \(a card runs several\)/);
|
||
assert.match(text, /checkpoint 6,842 at \d+\.\d% of weight/);
|
||
assert.match(text, /1\.09 IGN to provers in the last 5 min/);
|
||
assert.match(text, /median n\/a/); // the fixture's median_proof_lag_s is null
|
||
assert.match(text, /0 provers in 10 min/);
|
||
assert.match(text, /ramp 17\.3%/);
|
||
});
|
||
|
||
test('pulse: Devnet 2 is numbers and the gate word only; the seed address and the state text never pass', () => {
|
||
const line = devnet2Line(fx('fleet'), NOW.getTime());
|
||
assert.equal(line, '4 boxes, height 3,848, last gate FAIL 37 min ago');
|
||
assert.equal(devnet2Line(null), null);
|
||
assert.equal(devnet2Line({ devnet2: { boxes: 2, height: 10, last_gate: 'PASS: abc', last_gate_at: NOW.toISOString() } }, NOW.getTime()), '2 boxes, height 10, last gate PASS 0 s ago');
|
||
assert.throws(() => guardText(fx('fleet').devnet2.seed), /IP address/);
|
||
});
|
||
|
||
test('pulse: finality paused puts a since line in the description, from the newest lock', () => {
|
||
const { payload } = shapePulse(paused(), null);
|
||
assert.match(payload.embeds[0].description, /\nFinality paused since 2026-10-06 18:4\d UTC: nothing is final until two thirds/);
|
||
assert.doesNotMatch(shapePulse(data(), null).payload.embeds[0].description, /Finality paused/);
|
||
});
|
||
test('pulse: a stale observer changes the first line and keeps the numbers', () => {
|
||
const d = data(); d.stats.stale = true; d.stats.age_s = 95;
|
||
const { payload } = shapePulse(d, null);
|
||
assert.match(payload.embeds[0].description, /^Observer stale for 2 min: every number is last known/);
|
||
});
|
||
|
||
test('pulse: miner version share appears only when engines are exposed', () => {
|
||
const d = data();
|
||
assert.equal(versionShare(d.live), null);
|
||
d.live.miners[0].engine = '0.3.14'; d.live.miners[1].engine = '0.3.14'; d.live.miners[2].engine = '0.3.13';
|
||
assert.equal(versionShare(d.live), '0.3.14 67%, 0.3.13 33%');
|
||
const { payload } = shapePulse(d, null);
|
||
assert.ok(payload.embeds[0].fields.some(f => f.name === 'Miner versions'));
|
||
assert.ok(payload.embeds[0].fields.length <= 8);
|
||
});
|
||
|
||
// ---------- digest and weekly ----------
|
||
test('digest: a monospace table with yesterday from the snapshot closest to 24 h ago', () => {
|
||
const cur = snapshot(data());
|
||
const yesterday = { ...cur, at: new Date(NOW.getTime() - 24 * 3600e3 - 600e3).toISOString(), height: cur.height - 80000, locked_index: cur.locked_index - 2800, paid_shards_total: cur.paid_shards_total - 400, circulating_ign: cur.circulating_ign - 450000 };
|
||
const { payload } = shapeDigest(data(), [yesterday, { ...cur, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString() }]);
|
||
guardPayload(payload);
|
||
const d = payload.embeds[0].description;
|
||
assert.match(d, /```\n24 h/);
|
||
assert.match(d, /chain block\s+61,642\s+141,642/);
|
||
assert.match(d, /locks \+2,800/);
|
||
assert.match(d, /minted \+450,000 IGN/);
|
||
assert.equal(payload.embeds[0].thumbnail, undefined);
|
||
const none = shapeDigest(data(), []);
|
||
assert.match(none.payload.embeds[0].description, /No snapshot from 24 h ago yet/);
|
||
});
|
||
|
||
test('weekly: the reddit template as an embed with the ledger link and a last-week column', () => {
|
||
const first = shapeWeekly(data(), null, []);
|
||
guardPayload(first.payload);
|
||
const e = first.payload.embeds[0];
|
||
assert.equal(e.title, 'Devnet numbers, week of 5 Oct 2026');
|
||
assert.match(e.description, /Ledger: https:\/\/igneum\.network\/ledger/);
|
||
assert.match(e.description, /First weekly post/);
|
||
assert.equal(e.fields.length, 6);
|
||
const prev = { ...first.snapshot, height: 100000, locked_index: 4000, paid_shards_total: 1000 };
|
||
const second = shapeWeekly(data(), prev, ['Example incident sentence.']);
|
||
const text = second.payload.embeds[0].fields.map(f => f.value).join('\n');
|
||
assert.match(text, /height 141,642 \(last week 100,000\)/);
|
||
assert.match(text, /locks this week \+2,842/);
|
||
assert.match(text, /shards this week \+1,258/);
|
||
assert.match(text, /• Example incident sentence\./);
|
||
assert.match(text, /check\.rule_match 500 \/ 500/);
|
||
});
|
||
|
||
// ---------- the release ----------
|
||
const REL = {
|
||
version: '0.3.14',
|
||
windows: 'https://dl.igneum.network/public/igneum-miner-windows.exe', windowsSha: '08e75effc732f88bca11006b3b7cdf2bc533576f56819f79509fee47a8f5dd59',
|
||
mac: 'https://dl.igneum.network/public/igneum-miner-mac.dmg', macSha: 'e9e4c24db81a262a36b6bac11609ce6d24ed03220973da4142df935d41fc4e40',
|
||
hive: 'https://dl.igneum.network/public/igneum-miner-hive.tar.gz', hiveSha: 'c4699f2487580dcf06b5c22a36a2eb8383a3307b6137699c76c45eefebdd4c88',
|
||
nodeCommit: '4c6b129d', digest: 'b18ed271 (thirteen fields, unchanged)', now: NOW,
|
||
};
|
||
test('release: three downloads with full sha256, node commit, digest, up to five changed lines; a sha256 passes the 32-hex guard', () => {
|
||
const { payload } = shapeRelease({ ...REL, changed: ['One.', 'Two.', 'Three.', 'Four.', 'Five.', 'Six.'] });
|
||
guardPayload(payload);
|
||
const e = payload.embeds[0];
|
||
assert.equal(e.title, 'Igneum Miner 0.3.14');
|
||
assert.equal(e.fields.length, 6);
|
||
assert.match(e.fields[0].value, /\[Download\]\(https:\/\/dl\.igneum\.network\/public\/igneum-miner-windows\.exe\)\n`08e75eff[0-9a-f]{56}`/);
|
||
assert.equal((e.description.match(/^• /gm) || []).length, 5);
|
||
assert.doesNotMatch(e.description, /Six/);
|
||
});
|
||
test('release: refuses a missing value or a hash that is not sha256', () => {
|
||
assert.throws(() => shapeRelease({ ...REL, hive: '' }), /release needs hive/);
|
||
assert.throws(() => shapeRelease({ ...REL, macSha: 'abc' }), /macSha is not a sha256/);
|
||
});
|
||
test('release: changed lines from a plan section, table rows by first cell, bullets as they are, guarded lines dropped', () => {
|
||
const plan = `# Plan\n\n## 1. Why\n\nIntro.\n\n| Change | Where |\n|---|---|\n| A deep reorg reloads the newest generation; never a genesis replay | there |\n| \`exec_restart_state_root\`, the fourteenth field (block 27,276's root 0x${'e'.repeat(64)}, read on node 1 and confirmed by the fleet on both node kinds; set) | there |\n| A flag under /root was ignored silently | there |\n\n- A bullet line.\n\n## 2. Next\n\n| Not | this |\n`;
|
||
const warned = [];
|
||
const lines = changedLinesFromPlan(plan, '1. Why', 5, m => warned.push(m));
|
||
assert.deepEqual(lines, ['A deep reorg reloads the newest generation', 'exec_restart_state_root, the fourteenth field', 'A bullet line.']);
|
||
assert.equal(warned.length, 1);
|
||
assert.match(warned[0], /internal path/);
|
||
assert.throws(() => changedLinesFromPlan(plan, 'Missing'), /section not found/);
|
||
});
|
||
test('plainLine: strips code ticks, cuts at the first semicolon, drops a long parenthetical, never cuts mid-word', () => {
|
||
assert.equal(plainLine('`A thing` that **reloads** the generation; never a replay'), 'A thing that reloads the generation');
|
||
const long = `Start of a long line that goes on (${'x'.repeat(200)}) and ends here with more words`;
|
||
assert.equal(plainLine(long), 'Start of a long line that goes on and ends here with more words');
|
||
const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(' ');
|
||
const cut = plainLine(words);
|
||
assert.ok(cut.length <= 160);
|
||
assert.ok(words.startsWith(cut));
|
||
assert.ok(!cut.endsWith(' '));
|
||
});
|
||
|
||
// ---------- incidents ----------
|
||
test('incident open and resolve: UTC times, the three answers, the duration', () => {
|
||
const open = shapeIncidentOpen({ id: 'inc-1', at: new Date('2026-10-06T15:44:00Z'), what: 'What happened.', affected: 'Miners: fine. Provers: paused.', doing: 'Replaying.', now: NOW });
|
||
guardPayload(open.payload);
|
||
assert.equal(open.payload.embeds[0].title, 'Incident open');
|
||
assert.equal(open.payload.embeds[0].fields[0].value, '2026-10-06 15:44 UTC');
|
||
const res = shapeIncidentResolve({ id: 'inc-1', openedAt: '2026-10-06T15:44:00Z', at: new Date('2026-10-06T17:10:00Z'), cause: 'A cause.', fix: 'A rule.', what: 'What happened.', now: NOW });
|
||
guardPayload(res.payload);
|
||
assert.equal(res.payload.embeds[0].title, 'Incident resolved');
|
||
assert.equal(res.payload.embeds[0].fields[1].value, '1 h 26 min');
|
||
assert.throws(() => shapeIncidentOpen({ id: 'x', what: 'w', affected: '', doing: 'd' }), /needs affected/);
|
||
});
|
||
|
||
// ---------- the watcher ----------
|
||
const at = (base, s) => new Date(base.getTime() + s * 1000);
|
||
const paused = () => { const d = data(); d.stats.finality.active = false; return d; };
|
||
// clear data at +s seconds: finality active and the lock index advancing one checkpoint per 30 s (an unchanged index reads as paused)
|
||
const advancing = s => { const d = data(); const idx = 6842 + Math.floor(s / 30); d.stats.finality.latest_locked_index = idx; d.live.finality.latest_locked_index = idx; return d; };
|
||
test('watcher: a condition already firing at the first look is pre-existing: never opened, cleared silently; the next start opens', () => {
|
||
const state = { watch: {} };
|
||
assert.deepEqual(watchPass(paused(), state, NOW), [], 'first look: paused already, nothing observed to start');
|
||
assert.ok(state.watch.finality_paused.preexisting);
|
||
assert.deepEqual(watchPass(paused(), state, at(NOW, 900)), [], 'held 15 min, still nothing: no open to match a resolve');
|
||
assert.deepEqual(watchPass(advancing(1200), state, at(NOW, 1200)), [], 'clears silently');
|
||
assert.equal(state.watch.finality_paused.preexisting, null);
|
||
assert.deepEqual(watchPass(paused(), state, at(NOW, 1300)), [], 'a start the watcher saw: the hold begins');
|
||
const a = watchPass(paused(), state, at(NOW, 1300 + WATCH.finalityPausedS + 1));
|
||
assert.equal(a.length, 1); assert.equal(a[0].kind, 'open');
|
||
});
|
||
test('watcher: finality paused opens after five minutes, once, and resolves after two clear minutes', () => {
|
||
const state = { watch: {} };
|
||
assert.deepEqual(watchPass(data(), state, at(NOW, -60)), [], 'the first look is clear');
|
||
assert.deepEqual(watchPass(paused(), state, NOW), []);
|
||
assert.deepEqual(watchPass(paused(), state, at(NOW, 240)), []);
|
||
const a = watchPass(paused(), state, at(NOW, 301));
|
||
assert.equal(a.length, 1); assert.equal(a[0].kind, 'open'); assert.equal(a[0].key, 'finality_paused');
|
||
assert.match(a[0].what, /Finality paused/);
|
||
assert.match(a[0].affected, /Miners keep mining/);
|
||
assert.deepEqual(watchPass(paused(), state, at(NOW, 600)), [], 'no second open while one is open');
|
||
assert.deepEqual(watchPass(advancing(700), state, at(NOW, 700)), [], 'clear must hold');
|
||
const r = watchPass(advancing(700 + WATCH.clearHoldS), state, at(NOW, 700 + WATCH.clearHoldS));
|
||
assert.equal(r.length, 1); assert.equal(r[0].kind, 'resolve'); assert.equal(r[0].id, a[0].id);
|
||
assert.equal(state.watch.finality_paused.open, null);
|
||
});
|
||
test('watcher: a lock index that does not move for five minutes counts as paused even with active true', () => {
|
||
const state = { watch: {} };
|
||
watchPass(data(), state, NOW);
|
||
assert.deepEqual(watchPass(data(), state, at(NOW, 299)), []);
|
||
const a = watchPass(data(), state, at(NOW, 301));
|
||
// the hold starts when the stuck test first fires (at 301 s), so the open comes one threshold later
|
||
assert.deepEqual(a, []);
|
||
const b = watchPass(data(), state, at(NOW, 301 + WATCH.finalityPausedS));
|
||
assert.equal(b.length, 1); assert.match(b[0].what, /no new lock/);
|
||
});
|
||
test('watcher: proof lag over 15 minutes opens at once; observer silence needs three minutes; a failed fetch counts as silence', () => {
|
||
const state = { watch: {} };
|
||
watchPass(data(), state, at(NOW, -60));
|
||
const lag = data(); lag.live.proving.median_proof_lag_s = 1000;
|
||
const a = watchPass(lag, state, NOW);
|
||
assert.equal(a.length, 1); assert.equal(a[0].key, 'proof_lag'); assert.match(a[0].what, /17 min against a target under 60 s/);
|
||
const s2 = { watch: {} };
|
||
watchPass(data(), s2, at(NOW, -60));
|
||
const failed = { stats: { stale: true, age_s: null, finality: null }, live: {}, supply: {}, now: NOW, fetchFailed: true };
|
||
assert.deepEqual(watchPass(failed, s2, NOW), []);
|
||
const b = watchPass(failed, s2, at(NOW, WATCH.observerSilentS + 1));
|
||
assert.equal(b.length, 1); assert.equal(b[0].key, 'observer_silent'); assert.match(b[0].what, /did not answer/);
|
||
});
|
||
test('watcher: every automatic text passes the guard', () => {
|
||
for (const c of Object.values(WATCH_CONDITIONS)) {
|
||
const d = data(); d.stats.age_s = 200; d.live.proving.median_proof_lag_s = 1000;
|
||
guardText([c.what(d), c.affected, c.doing, c.cause, c.fix].join('\n'));
|
||
}
|
||
});
|
||
|
||
// ---------- guards and limits ----------
|
||
test('guard: refuses the founder, hosts, machine ids, paths, IPs, 32-hex tokens, token download paths, webhook URLs, mentions', () => {
|
||
const bad = {
|
||
[`${founderPatterns()[0].sample} fixed it`]: /founder/, [`by ${founderPatterns()[1].sample.toLowerCase()} at night`]: /founder/, [`for ${founderPatterns()[4].sample}`]: /earlier business/, 'on Hetzner': /hosting/, 'RunPod box': /hosting/, 'host igneum-build-1': /host name/,
|
||
'from DESKTOP-ABC1234': /machine id/, 'target ae432dc7': /machine id/, 'box p2-3090-4': /machine id/,
|
||
'seed 213.173.110.229:11927': /IP address/, 'see /Users/x/file': /internal path/, 'under /srv/discord-hooks/env': /internal path/, 'in C:\\Users': /internal path/,
|
||
[`token ${'a1'.repeat(16)}`]: /32-hex/, 'https://dl.igneum.network/fleet-22adafa34bc2/fleet.json': /outside \/public/, 'https://dl.igneum.network/dl/abc/x.exe': /outside \/public/,
|
||
'https://discord.com/api/webhooks/1/x': /webhook URL/, 'hello @everyone': /mention/, 'ping <@123>': /mention/, 'at 19:00 BST': /internal reference/, 'on the MacBook': /forbidden-strings/, 'box.ts.net': /forbidden-strings/, 'LOG_INTAKE_KEY=x': /forbidden-strings/,
|
||
};
|
||
for (const [text, why] of Object.entries(bad)) assert.throws(() => guardText(text), why, text);
|
||
});
|
||
test('guard: passes a sha256, a public download URL, an igneum.network link, a 16-hex checkpoint hash and plain words', () => {
|
||
guardText(`sha ${'ab'.repeat(32)} public https://dl.igneum.network/public/igneum-miner-mac.dmg live https://igneum.network/live hash a56a00dfb657036d 4 boxes, height 3,848`);
|
||
});
|
||
test('limits: more than eight fields, a value over 1,024, a total over 6,000, a stray mention setting are refused', () => {
|
||
const big = embed({ title: 'T', fields: Array.from({ length: 9 }, (_, i) => ({ name: `f${i}`, value: 'v' })), now: NOW });
|
||
assert.equal(big.embeds[0].fields.length, 8, 'embed() itself caps at eight');
|
||
big.embeds[0].fields.push({ name: 'f9', value: 'v' });
|
||
assert.throws(() => guardPayload(big), /more than 8 fields/);
|
||
const long = embed({ title: 'T', fields: [{ name: 'f', value: 'x'.repeat(1025) }], now: NOW });
|
||
assert.throws(() => guardPayload(long), /field value over 1024/);
|
||
const total = embed({ title: 'T', description: 'd'.repeat(4000), fields: [{ name: 'a', value: 'y'.repeat(1024) }, { name: 'b', value: 'y'.repeat(1024) }], now: NOW });
|
||
assert.ok(embedLength(total.embeds[0]) > LIMITS.total);
|
||
assert.throws(() => guardPayload(total), /embed over 6000/);
|
||
const mention = embed({ title: 'T', fields: [{ name: 'a', value: 'b' }], now: NOW }); mention.allowed_mentions = { parse: ['everyone'] };
|
||
assert.throws(() => guardPayload(mention), /allowed_mentions/);
|
||
});
|
||
|
||
// ---------- idempotency, dry run, backoff ----------
|
||
test('poster: dry run writes the payload and the preview, a rerun under the same key is skipped, --force posts again', async () => {
|
||
const out = tmpDir();
|
||
const p = new Poster({ live: false, outDir: out, log: () => {} });
|
||
const { payload } = shapePulse(data(), null);
|
||
const r1 = await p.post('numbers', 'pulse:test', payload);
|
||
assert.equal(r1.skipped, false);
|
||
assert.ok(fs.existsSync(path.join(out, 'pulse_test.json')));
|
||
assert.ok(fs.existsSync(path.join(out, 'preview.html')));
|
||
const r2 = await p.post('numbers', 'pulse:test', payload);
|
||
assert.equal(r2.skipped, true);
|
||
const f = new Poster({ live: false, outDir: out, force: true, log: () => {} });
|
||
const r3 = await f.post('numbers', 'pulse:test', payload);
|
||
assert.equal(r3.skipped, false);
|
||
const html = fs.readFileSync(path.join(out, 'preview.html'), 'utf8');
|
||
assert.match(html, /Network pulse/);
|
||
assert.match(html, /#numbers/);
|
||
});
|
||
test('poster: live mode without a credentials file refuses to start, and never prints a URL', () => {
|
||
assert.throws(() => new Poster({ live: true, credFile: path.join(tmpDir(), 'none'), outDir: tmpDir() }), /no credentials file/);
|
||
});
|
||
test('poster: live mode posts once, stores the message id, skips the rerun; the state is written only after success', async () => {
|
||
const dir = tmpDir();
|
||
const cred = path.join(dir, 'discord'); fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/secret\n', { mode: 0o600 });
|
||
const calls = [];
|
||
const fetchImpl = async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '42', channel_id: '7' }) }; };
|
||
const p = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl });
|
||
const { payload } = shapePulse(data(), null);
|
||
const r = await p.post('numbers', 'pulse:live', payload);
|
||
assert.equal(r.posted, true); assert.equal(r.id, '42');
|
||
assert.equal(calls.length, 1); assert.match(calls[0].url, /\?wait=true$/);
|
||
assert.deepEqual(calls[0].body.allowed_mentions, { parse: [] });
|
||
const saved = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
||
assert.equal(saved.posts['pulse:live'].id, '42');
|
||
const again = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl });
|
||
assert.equal((await again.post('numbers', 'pulse:live', payload)).skipped, true);
|
||
assert.equal(calls.length, 1);
|
||
await assert.rejects(() => p.post('incidents', 'inc:x', payload), /no DISCORD_WEBHOOK_INCIDENTS/);
|
||
});
|
||
test('poster: a partial credentials file names its missing keys; the watcher advances its state and posts nothing without the incidents webhook', async () => {
|
||
const dir = tmpDir();
|
||
const cred = path.join(dir, 'discord'); fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/secret\n', { mode: 0o600 });
|
||
const calls = [];
|
||
const fetchImpl = async (url, init) => { calls.push(url); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '1' }) }; };
|
||
const logs = [];
|
||
const p = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: m => logs.push(m), fetchImpl });
|
||
assert.deepEqual(p.missingKeys(), ['DISCORD_WEBHOOK_ANNOUNCEMENTS', 'DISCORD_WEBHOOK_INCIDENTS']);
|
||
assert.equal(p.has('numbers'), true); assert.equal(p.has('incidents'), false);
|
||
const { runWatch } = await import('./discord-hooks.mjs');
|
||
watchPass(data(), p.state, at(NOW, -60));
|
||
const lag = data(); lag.live.proving.median_proof_lag_s = 1000;
|
||
const r = await runWatch(p, lag);
|
||
assert.deepEqual(r, []);
|
||
assert.equal(calls.length, 0, 'nothing sent');
|
||
assert.match(logs.join('\n'), /open auto-proof_lag-\S+ not posted, no DISCORD_WEBHOOK_INCIDENTS/);
|
||
assert.ok(p.state.watch.proof_lag.open, 'the state still records the open, so the key landing later does not flood');
|
||
});
|
||
test('backoff: a 429 waits retry_after then doubles; success returns the id; six failures give up', async () => {
|
||
const sleeps = [];
|
||
let n = 0;
|
||
const fetchImpl = async () => { n++; if (n <= 2) return { ok: false, status: 429, headers: new Map([['retry-after', '2']]), json: async () => ({ retry_after: 2.5 }) }; return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '9' }) }; };
|
||
const r = await postWebhook('https://example.invalid/hook', { embeds: [] }, { fetchImpl, sleepImpl: async ms => sleeps.push(ms) });
|
||
assert.equal(r.id, '9'); assert.equal(n, 3);
|
||
assert.deepEqual(sleeps, [2500, 2500]);
|
||
const always = async () => ({ ok: false, status: 429, headers: new Map(), json: async () => ({}) });
|
||
const s2 = [];
|
||
await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: always, sleepImpl: async ms => s2.push(ms) }), /after 6 tries/);
|
||
assert.deepEqual(s2, [1000, 2000, 4000, 8000, 16000]);
|
||
const bad = async () => ({ ok: false, status: 400, headers: new Map(), json: async () => ({}) });
|
||
await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: bad }), /answered 400/);
|
||
});
|
||
|
||
// ---------- the scheduler and the clock ----------
|
||
test('scheduler: Monday 09:xx UK is pulse + digest + weekly, each once; other hours only the pulse slots; nothing twice', () => {
|
||
const monday9 = new Date('2026-10-12T08:05:00Z'); // 09:05 UK in BST
|
||
assert.equal(londonParts(monday9).weekday, 'Mon'); assert.equal(londonParts(monday9).hour, 9);
|
||
const due = dueNow(monday9, { posts: {} });
|
||
assert.deepEqual(due.map(d => d.kind), ['pulse', 'digest', 'weekly']);
|
||
assert.equal(due[0].key, 'pulse:2026-10-12:09');
|
||
const posted = { posts: Object.fromEntries(due.map(d => [d.key, { id: '1' }])) };
|
||
assert.deepEqual(dueNow(monday9, posted), []);
|
||
assert.deepEqual(dueNow(new Date('2026-10-13T09:30:00Z'), { posts: {} }), [], '10:30 UK is not a slot');
|
||
assert.deepEqual(dueNow(new Date('2026-10-13T20:59:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:21']);
|
||
assert.deepEqual(dueNow(new Date('2026-10-13T02:00:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:03']);
|
||
});
|
||
test('clock: the footer carries UK time with UTC in brackets, in summer and in winter', () => {
|
||
assert.equal(ukStamp(new Date('2026-10-06T18:51:00Z')), '6 Oct 2026, 19:51 UK (18:51 UTC)');
|
||
assert.equal(ukStamp(new Date('2026-11-06T18:51:00Z')), '6 Nov 2026, 18:51 UK (18:51 UTC)');
|
||
assert.equal(ukStamp(new Date('2026-10-25T00:30:00Z')), '25 Oct 2026, 01:30 UK (00:30 UTC)', 'the clocks go back at 01:00 UTC on 25 Oct 2026');
|
||
});
|
||
test('formatting: hash units, durations, deltas with signs', () => {
|
||
assert.equal(fmtHash(2562646398), '2.56 GH/s'); assert.equal(fmtHash(169e6), '169.0 MH/s'); assert.equal(fmtHash(null), 'n/a');
|
||
assert.equal(fmtDur(45), '45 s'); assert.equal(fmtDur(406.5), '7 min'); assert.equal(fmtDur(5160), '1 h 26 min'); assert.equal(fmtDur(3 * 86400), '3 days');
|
||
assert.equal(fmtDelta(10, 4), '+6'); assert.equal(fmtDelta(4, 10), '−6'); assert.equal(fmtDelta(4, 4), '±0'); assert.equal(fmtDelta(null, 4), 'n/a');
|
||
assert.equal(fmtChangePct(110, 100), '+10.0%'); assert.equal(fmtChangePct(90, 100), '−10.0%');
|
||
});
|
||
test('preview: renders every item as a Discord-like card with the channel and the length', () => {
|
||
const items = [{ channel: 'numbers', key: 'pulse:x', payload: shapePulse(data(), null).payload }, { channel: 'announcements', key: 'release:0.3.14', payload: shapeRelease({ ...REL, changed: ['One.'] }).payload }];
|
||
const html = renderPreview(items);
|
||
assert.equal((html.match(/class="msg"/g) || []).length, 2);
|
||
assert.match(html, /#announcements/);
|
||
assert.match(html, /<a href="https:\/\/dl\.igneum\.network\/public\/igneum-miner-windows\.exe">Download<\/a>/);
|
||
assert.match(html, /border-left-color:#f2541b/);
|
||
assert.doesNotMatch(html, /<script/);
|
||
});
|
||
|
||
// ---------- the hourly network feed ----------
|
||
test('feed: one small embed with height, hash rate, keys and the last lock from /api/live; no hash-origin field without the report', () => {
|
||
const { payload, snapshot: snap, hashOriginDate } = shapeFeed(data(), null);
|
||
guardPayload(payload);
|
||
const e = payload.embeds[0];
|
||
assert.equal(e.title, 'Network feed');
|
||
assert.deepEqual(e.fields.map(f => f.name), ['Height', 'Hash rate', 'Keys', 'Last lock']);
|
||
assert.match(e.fields[0].value, /^141,642\n/);
|
||
assert.match(e.fields[1].value, /2\.56 GH\/s/);
|
||
assert.match(e.fields[2].value, /56 active in 10 min \(a card runs several\)\n93 voters in the 30-day window/);
|
||
assert.match(e.fields[3].value, /checkpoint 6,842 at \d+\.\d% of weight/);
|
||
assert.match(e.footer.text, /every hour from \/api\/live/);
|
||
assert.equal(hashOriginDate, null);
|
||
assert.equal(snap.height, 141642);
|
||
assert.ok(embedLength(e) < 700, 'the feed stays small');
|
||
});
|
||
test('feed: the second feed computes blocks per second over its own window', () => {
|
||
const first = shapeFeed(data(), null);
|
||
const prev = { ...first.snapshot, at: new Date(NOW.getTime() - 3600e3).toISOString(), block_count: first.snapshot.block_count - 3600 };
|
||
const second = shapeFeed(data(), prev);
|
||
assert.match(second.payload.embeds[0].fields[0].value, /1\.00 blocks\/s over 1 h/);
|
||
});
|
||
test('feed: the hash-origin line comes from live.state.hash_origin, once per report date, and never estimates a missing field', () => {
|
||
const d = data();
|
||
assert.equal(hashOriginLine(d.live), null);
|
||
d.live.state.hash_origin = { date: '2026-10-07', keys: { with_block: 113, above_dust_30d: 95, top10_share: 0.45 }, fleet: { share: 0.384 }, pools: { attested: 0 } };
|
||
const ho = hashOriginLine(d.live);
|
||
assert.equal(ho.date, '2026-10-07');
|
||
assert.equal(ho.text, '113 keys found a block, 95 above dust, ten largest 45.0% of blocks, project fleet 38.4%, 0 attested pools. Full report in #numbers.');
|
||
const { payload, hashOriginDate } = shapeFeed(d, null);
|
||
assert.equal(hashOriginDate, '2026-10-07');
|
||
assert.equal(payload.embeds[0].fields[4].name, 'Hash origin, 2026-10-07');
|
||
delete d.live.state.hash_origin.fleet;
|
||
assert.doesNotMatch(hashOriginLine(d.live).text, /fleet/);
|
||
});
|
||
test('milestones: none on the first feed; every 100,000 blocks, 10,000 locks and 10,000 shards; the first crossing of a key count and a hash-rate step; each once', () => {
|
||
const base = { height: 99990, locked_index: 9998, paid_shards_total: 19990, miners_10m: 90, hashrate: 0.9e9 };
|
||
assert.deepEqual(feedMilestones(base, null), []);
|
||
const next = { height: 200010, locked_index: 10002, paid_shards_total: 20001, miners_10m: 260, hashrate: 1.2e9 };
|
||
const ms = feedMilestones(next, base);
|
||
assert.deepEqual(ms.map(m => m.key), ['milestone:height:100000', 'milestone:height:200000', 'milestone:locks:10000', 'milestone:shards:20000', 'milestone:keys:100', 'milestone:keys:250', 'milestone:hashrate:1000000000']);
|
||
assert.match(ms[0].text, /Chain block 100,000/);
|
||
assert.match(ms[6].text, /passed 1\.00 GH\/s/);
|
||
assert.deepEqual(feedMilestones(next, next), []);
|
||
for (const m of ms) guardPayload(shapeMilestone(m.text, NOW));
|
||
assert.deepEqual(feedMilestones({ height: null }, { height: 1 }), []);
|
||
assert.equal(FEED_MILESTONES.height, 100000);
|
||
});
|
||
test('feed: runFeed posts the feed, then the milestones, seeds the snapshot, and posts the hash-origin field once a day', async () => {
|
||
const dir = tmpDir();
|
||
const p = new Poster({ live: false, outDir: dir, stateFile: path.join(dir, 'state.json'), log: () => {} });
|
||
const d = data();
|
||
d.live.state.hash_origin = { date: '2026-10-06', keys: { with_block: 1, above_dust_30d: 1, top10_share: 1 } };
|
||
const r1 = await runFeed(p, d, 'feed:t:1');
|
||
assert.equal(r1.length, 1, 'no milestone on the seed');
|
||
assert.equal(p.state.feed_last.height, 141642);
|
||
assert.equal(p.state.feed_hash_origin_date, '2026-10-06');
|
||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'feed_t_1.json'), 'utf8')).payload.embeds[0].fields.length, 5);
|
||
const d2 = data(); d2.now = at(NOW, 3600); d2.live.state.hash_origin = d.live.state.hash_origin; d2.stats.miners_10m = 120; d2.live.state.miners_10m = 120;
|
||
const r2 = await runFeed(p, d2, 'feed:t:2');
|
||
assert.equal(r2.length, 2, 'the feed and one milestone (keys 100)');
|
||
assert.ok(fs.existsSync(path.join(dir, 'milestone_keys_100.json')));
|
||
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'feed_t_2.json'), 'utf8')).payload.embeds[0].fields.length, 4, 'the same report date is not repeated');
|
||
assert.equal((await runFeed(p, d2, 'feed:t:2'))[0].skipped, true);
|
||
});
|
||
test('feed: the scheduler adds the hourly feed only when asked; --via updates routes the feed through the updates webhook; an unknown via is refused', async () => {
|
||
const mon = new Date('2026-10-05T08:30:00Z'); // Monday 09:30 UK
|
||
assert.deepEqual(dueNow(mon, { posts: {} }).map(d => d.kind), ['pulse', 'digest', 'weekly']);
|
||
assert.deepEqual(dueNow(mon, { posts: {} }, { feed: true }).map(d => d.kind), ['feed', 'pulse', 'digest', 'weekly']);
|
||
assert.equal(dueNow(mon, { posts: {} }, { feed: true })[0].key, 'feed:2026-10-05:09');
|
||
const dir = tmpDir();
|
||
const cred = path.join(dir, 'discord');
|
||
fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/a\nDISCORD_WEBHOOK_UPDATES=https://discord.com/api/webhooks/2/b\n', { mode: 0o600 });
|
||
const calls = [];
|
||
const fetchImpl = async (url) => { calls.push(url); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '7' }) }; };
|
||
const plain = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 's1.json'), outDir: dir, log: () => {}, fetchImpl });
|
||
assert.equal(plain.has('feed'), false, 'no DISCORD_WEBHOOK_FEED, the feed is off');
|
||
assert.deepEqual(plain.missingKeys(), ['DISCORD_WEBHOOK_ANNOUNCEMENTS', 'DISCORD_WEBHOOK_INCIDENTS'], 'the feed key is optional');
|
||
const via = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 's2.json'), outDir: dir, log: () => {}, fetchImpl, via: { feed: 'updates' } });
|
||
assert.equal(via.has('feed'), true);
|
||
const { payload } = shapeFeed(data(), null);
|
||
await via.post('feed', 'feed:via', payload);
|
||
assert.match(calls[0], /webhooks\/2\/b/);
|
||
assert.throws(() => new Poster({ live: false, outDir: dir, via: { feed: 'numbers' } }), /unknown --via/);
|
||
});
|