igneum/relay/test/parse.test.mjs
igneum-labs ca1a3753b2 app: GPU hot-plug (re-detection every minute, WM_DEVICECHANGE on Windows), faulty cards listed with the problem code, integrated GPUs off by default, the card list in the console
5 October 2026: an RX 9070 XT went into PC 1 through a Sonnet USB4 box while the app ran and nothing noticed; the
app detected cards once at start. Now src/hotplug.rs compares every enumeration with the list (key, else vendor +
name when unique; a card whose tool did not answer is never called removed): a new usable card starts a worker,
enabled by default like a card at start, with "New card: <name>, mining" on the strip and in the log; a card
Windows lists with a problem code (Win32_VideoController Status / ConfigManagerErrorCode) is shown as "<name>: not
usable (Code 43)" with the reboot-or-reinstall hint and no worker; a card that disappears has its worker stopped
(quit, 8 s) and its row says removed for five minutes, then hides; an unchanged list touches nothing. The Windows
host sends "detect" on WM_DEVICECHANGE; the engine polls every 60 s (300 s on macOS, no GPU hot-plug there).

detect.rs: the Ryzen iGPU is "gfx1036" to the OpenCL worker, so the APU gfx codes count as integrated, plus the
adapter row's Intel processor string and a dedicated memory under 1 GB; integrated defaults to off with "integrated
GPU, off by default (2 to 3 MH/s for 30 W)" on the row, and the user's choice is kept across re-detections and
restarts (settings, found by key or by vendor + name when the index moved).

Console: the engine logs "cards: <name> [<kind>, <state>] | ..." at start, on every change and every 10 minutes;
relay/lib/parse.mjs reads it and the hot-plug events, the machines API and tools/console.mjs machines show them.

Tests: hotplug.rs (added, removed, moved, errored, recovered, revived, unchanged, twins, user override kept,
the console line), detect.rs (PC 1's adapter lines, the Mac, kind classification, the unusable row),
notices.test.mjs (card notices), relay parse.test.mjs (cards line). cargo test -p igneum-app: 91 passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 17:45:05 +00:00

106 lines
8.5 KiB
JavaScript

// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S } from '../lib/parse.mjs';
test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => {
assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 });
assert.deepEqual(parseLabel('miner-other-37ba0461-1'), { id: '37ba0461', platform: 'win', stream: 'miner', vendor: 'other', card: 1 });
assert.deepEqual(parseLabel('miner-intel-37ba0461-2'), { id: '37ba0461', platform: 'win', stream: 'miner', vendor: 'intel', card: 2 });
assert.deepEqual(parseLabel('miner-mac-d937c69d-1'), { id: 'd937c69d', platform: 'mac', stream: 'miner', vendor: 'mac', card: 1 });
assert.deepEqual(parseLabel('nodelog-win-1ccfe586'), { id: '1ccfe586', platform: 'win', stream: 'node' });
assert.deepEqual(parseLabel('win-1ccfe586'), { id: '1ccfe586', platform: 'win', stream: 'app' });
assert.equal(parseLabel('nodelog-DESKTOP-KMCV30N').platform, 'legacy');
assert.equal(parseLabel('job-run'), null);
});
test('miner tail: the last STATUS line gives the numbers, ACCEPTED lines are counted without one', () => {
const tail = [
"1791140549.352 STATUS 'other-37ba0461-1' [worker]: 30s jobs=15 accepted=1 rejected=0 mismatched=0 extra=0 rate=0.03 blocks/s hash=1.04 MH/s wall (1.57 MH/s inside jobs) now=1.04 MH/s wall",
'1791140553.360 ACCEPTED block nonce=0xa27b8e5b000a82f6 (gpu worker, cpu re-check ok, identity other-37ba0461-1)',
"1791140579.479 STATUS 'other-37ba0461-1' [worker]: 61s jobs=37 accepted=2 rejected=0 mismatched=0 extra=0 rate=0.02 blocks/s hash=1.28 MH/s wall (1.55 MH/s inside jobs) now=1.53 MH/s wall",
].join('\n');
const c = parseMinerTail(tail);
assert.equal(c.accepted, 2); assert.equal(c.hash_now, 1.53); assert.equal(c.hash_avg, 1.28); assert.equal(c.uptime_s, 61);
assert.equal(c.status_at, new Date(1791140579479).toISOString());
assert.equal(c.faults, 0);
const noStatus = parseMinerTail('1 ACCEPTED block x\n2 ACCEPTED block y\n3 WORKER MISMATCH z');
assert.equal(noStatus.accepted, 2); assert.equal(noStatus.status_at, null); assert.equal(noStatus.faults, 1);
});
test('stale: a card is "now" within STALE_S of its STATUS line and stale after it or without one', () => {
const now = Date.parse('2026-10-04T19:03:00Z');
const fresh = { status_at: new Date(now - 58_000).toISOString(), hash_now: 18.9 };
const old = { status_at: new Date(now - 4 * 60_000).toISOString(), hash_now: 117.2 };
const none = { status_at: null, hash_now: 0 };
markStale([fresh, old, none], now);
assert.equal(fresh.stale, false); assert.equal(fresh.status_age_s, 58);
assert.equal(old.stale, true); assert.equal(old.status_age_s, 240);
assert.equal(none.stale, true); assert.equal(none.status_age_s, null);
assert.ok(STALE_S >= 160, 'one missed 60 s upload plus a 30 s STATUS age plus the 10 s cache is not stale');
const oneMissed = { status_at: new Date(now - 150_000).toISOString(), hash_now: 18 };
markStale([oneMissed], now); assert.equal(oneMissed.stale, false);
});
test('app tail: a clean quit after the last status line is "stopped (quit)", an update is "stopped (update)", a running app is not stopped', async () => {
const { parseAppTail } = await import('../lib/parse.mjs');
const running = ['1791126700 status: accepted 10 blocks (5 this run), 18.00 MH/s, mining | node 100 blocks, 4 peers, synced | up 00:10:00'].join('\n');
assert.equal(parseAppTail(running).stopped, undefined);
const quit = [
'1791126700 status: accepted 10 blocks (5 this run), 18.00 MH/s, mining | node 100 blocks, 4 peers, synced | up 00:10:00',
'1791126735 quit: stopping the miners, then the node', '1791126735 stopping the miners (quit)', '1791126736 node stopped (exit Some(0))',
'1791126736 SUMMARY after 00:26:53: node started 1 time(s), restarts 0, 1165 accepted blocks this run (1270 lifetime); data stays in x', '1791126737 stopped',
].join('\n');
const q = parseAppTail(quit);
assert.deepEqual([q.stopped.reason, q.stopped.at], ['quit', new Date(1791126737000).toISOString()]);
const update = [
'1791125100 status: accepted 1 blocks (1 this run), 1.00 MH/s, mining | node 1 blocks, 1 peers, synced | up 00:01:00',
'1791125117 [info] installing Igneum Miner 0.3.2: the miners stop, then the node, then the app opens again',
'1791125118 quit: stopping the miners, then the node', '1791125121 stopped',
].join('\n');
assert.equal(parseAppTail(update).stopped.reason, 'update');
// a quit line from an earlier run in the same tail, with a status line after it: still running
const resumed = quit + '\n1791126800 status: accepted 11 blocks (1 this run), 18.00 MH/s, mining | node 120 blocks, 4 peers, synced | up 00:01:00';
assert.equal(parseAppTail(resumed).stopped, undefined);
});
test('app tail: the newest update line decides the OTA state: staged beats an older check, a finished run says current, a newer OTA line wins', async () => {
const { parseAppTail } = await import('../lib/parse.mjs');
const st = n => `${n} status: accepted 1 blocks (1 this run), 1.00 MH/s, mining | node 1 blocks, 1 peers, synced | up 00:01:00`;
const mac = ['1791141355 update check: 0.3.3 is current (manifest 0.3.3)', st(1791145000),
'1791145529 [info] Igneum Miner 0.3.4 is available: downloading (39 MB). Finality rule v3',
'1791145533 update: /Users/x/Library/Application Support/Igneum/app/updates/Igneum-Miner-0.3.4.dmg downloaded and verified',
'1791145562 update: staged bundle digest 2b640b8c', '1791145562 [ok] Igneum Miner 0.3.4 is ready; it installs at the next safe moment', st(1791146000)].join('\n');
const m = parseAppTail(mac);
assert.deepEqual([m.ota.state, m.ota.version, m.ota.at], ['staged', '0.3.4', new Date(1791145562000).toISOString()]);
const after = ['1791146000 IGNEUM-APP version=0.3.4 machine=1ccfe586 platform=windows node=igneumd/2.1.0', '1791146426 update check: 0.3.4 is current (manifest 0.3.4)', st(1791146430)].join('\n');
const a = parseAppTail(after);
assert.deepEqual([a.ota.state, a.ota.version], ['current', '0.3.4']);
assert.notEqual(parseAppTail(after + "\n1791146500 OTA: 0.3.5 downloaded, waiting for this machine's minute").ota.state, 'current');
assert.equal(parseAppTail('1791146400 OTA: 0.3.4 installed\n' + after).ota.state, 'current');
assert.equal(parseAppTail(mac + '\n1791146600 update: 0.3.4 is marked failed; it will not be applied again by itself').ota.state, 'failed');
assert.equal(parseAppTail(mac + '\n1791146600 update to 0.3.4 complete (from 0.3.3); keeping the previous version for a rollback').ota.state, 'updated');
assert.equal(parseAppTail(st(1)).ota, undefined);
const inst = parseAppTail(mac + '\n1791146700 [info] installing Igneum Miner 0.3.4: the miners stop, then the node, then the app opens again');
assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']);
});
test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => {
assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'),
[{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]);
assert.deepEqual(parseCardsLine('none'), []);
const tail = [
'1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]',
'1791140560 [ok] New card: gfx1201, mining',
'1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]',
'1791140800 [warn] Card removed: gfx1201; its worker stopped',
'1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]',
'1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h',
].join('\n');
const a = parseAppTail(tail);
assert.equal(a.gpus.length, 3);
assert.equal(a.gpus[2].state, 'removed');
assert.equal(a.gpus_at, new Date(1791140801000).toISOString());
assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']);
assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined);
});