One notice at a time, the most important first (app/igneum-app/ui/app.js, Notices; pure, unit tested):
0 update installing, urgent or failed 1 job failed 2 clock 3 job running
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Lower notices wait their turn. Every notice has a close control; closing hides that notice's key until the
state moves on (a new status, version or job id).
States and their rules:
update available "Igneum Miner X is available." Install now, Later. Key update:X:pending.
update downloading "Downloading Igneum Miner X: 43%." (no percent when unknown), progress bar; same key as
available and checking, so Later hides the whole download until it is ready.
update checking "Checking Igneum Miner X." (the engine's staging step).
update ready "Igneum Miner X is ready. It installs by itself at a quiet moment." (auto on) or just
"... is ready." Install now, Later. Key update:X:ready.
update waiting Windows, nobody answered the administrator prompt: "... is waiting for permission. It
installs the next time someone is at this PC. Mining continues."
update manual "... is downloaded. Open it and drag the app over the old one." Open the download.
update installing "Installing Igneum Miner X. The app restarts itself. Mining continues until then."
(on a Mac, where the engine quits at once: "The app restarts itself in a moment.")
Also while the engine says "installing now" after Install now.
update urgent the engine's consensus-deadline text, ember, downloading percent when it downloads.
update failed "The update to X failed." plus one line of cause and Try again; rolled back:
"Igneum Miner X did not stay up and was rolled back." A dev build with no manifest
configured shows nothing (Settings still says it).
updated "Updated to Igneum Miner X from Y." Gone 60 s after the new version started.
job running "Job: <title> running, N min. <Stage>." with the last RESULT line underneath.
job done "Job: <title> done after N min. Report uploaded." Gone after 5 minutes.
job failed "Job: <title> failed after N min, exit C. Report not uploaded." plus the first error
line (BUILD FAILED / error / failed / panic among the result lines, else the summary).
Stays until closed. Timeout and aborted are "hit its time cap" and "was stopped".
clock as before: the engine's words, Sync clock, the manual hint; on the setup screens only
(the node card carries it on the dashboard). Jobs show on the dashboard only.
Layout: the strip reserves no height while empty; when a notice appears or goes, main's top moves once with a
150 ms transition (none under prefers-reduced-motion). Existing tokens only, nothing newer than 2022 CSS.
Screenshots: ?update=<state> as before, ?job=running|done|failed added (packaging/ota/README.md).
Test: node --test app/igneum-app/ui/notices.test.mjs (ordering, dismissed keys, wording, the 5-minute and
60-second timers); added to the CI site job. Built once with cargo (include_str) and checked against the
ui-mock scenarios and a scratch engine instance (IGNEUM_APP_DATA in a temp dir, fake worker).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
141 lines
11 KiB
JavaScript
141 lines
11 KiB
JavaScript
// node --test app/igneum-app/ui/notices.test.mjs (no dependencies; CI runs it in the site job)
|
|
// Loads the Notices block at the top of app.js (plain browser JS: the file is run with `module` defined and no
|
|
// `document`, so only the pure block executes) and checks the ordering, the keys, the wording and the timers.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
|
|
const mod = { exports: {} };
|
|
new Function('module', src)(mod);
|
|
const N = mod.exports;
|
|
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
|
|
|
|
const NOW = 1_800_000_000;
|
|
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
|
|
const run = (over) => ({ allowed: true, active: true, id: 'job-7', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', message: 'some output line', started_at: NOW - 400, results: ['STAGE prove', 'RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], last: {}, last_results: [], history: [], ...over });
|
|
const fin = (status, over) => ({ allowed: true, active: false, id: '', last: { id: 'job-6', kind: 'build', title: 'build', status, started_at: NOW - 2000, finished_at: NOW - 60, exit: status === 'done' ? 0 : 1, run_id: 'r', uploaded: status === 'done', summary: status === 'done' ? 'build finished, exit 0' : 'build exited with code 1' }, last_results: status === 'done' ? ['RESULT ok'] : ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo`'], history: [], ...over });
|
|
const s = (over) => ({ version: '0.3.5', uptime_s: 5000, now: NOW, quitting: false, update: upd({ status: 'current', available: false, ready: false, downloaded: false }), jobs: { active: false, last: {} }, clock: { severity: 'none' }, ...over });
|
|
|
|
test('ordering: installing > job failed > clock > job running > update available > job done > updated', () => {
|
|
const all = [
|
|
updateNotice(upd({ status: 'applying', applying: true }), s()),
|
|
jobNotice(fin('failed'), NOW),
|
|
clockNotice({ severity: 'warn', message: 'Clock 40 s ahead.', hint: 'Settings' }),
|
|
jobNotice(run(), NOW),
|
|
updateNotice(upd(), s()),
|
|
jobNotice(fin('done'), NOW),
|
|
updateNotice(upd({ status: 'current', updated_from: '0.3.4' }), s({ uptime_s: 10 })),
|
|
];
|
|
const kinds = ['update-installing', 'job-failed', 'clock', 'job-running', 'update-available', 'job-done', 'update-installed'];
|
|
assert.deepEqual(all.map((n) => n.kind), kinds);
|
|
// the levels climb, and shuffling the list changes nothing: the lowest level wins
|
|
for (let i = 1; i < all.length; i++) assert.ok(all[i].level > all[i - 1].level, `${all[i].kind} after ${all[i - 1].kind}`);
|
|
const shuffled = [all[5], all[2], all[6], all[0], all[3], all[1], all[4]];
|
|
for (let i = 0; i < kinds.length; i++) {
|
|
const rest = shuffled.filter((n) => kinds.indexOf(n.kind) >= i);
|
|
assert.equal(pick(rest, {}).kind, kinds[i]);
|
|
}
|
|
// urgent and failed updates sit with installing at the top
|
|
assert.equal(updateNotice(upd({ urgent: true, urgent_text: 'Consensus upgrade at height 120000.' }), s()).level, 0);
|
|
assert.equal(updateNotice(upd({ status: 'error', error: 'sha256 mismatch' }), s()).level, 0);
|
|
});
|
|
|
|
test('a closed key hides that notice and the next one waits its turn', () => {
|
|
const list = [jobNotice(fin('failed'), NOW), jobNotice(run({ id: 'job-8' }), NOW)];
|
|
assert.equal(pick(list, {}).kind, 'job-failed');
|
|
const dismissed = { 'job:failed:job-6': true };
|
|
assert.equal(pick(list, dismissed).kind, 'job-running');
|
|
assert.equal(pick([list[0]], dismissed), null);
|
|
// a new job id is a new key, so it shows again
|
|
const again = jobNotice(fin('failed', { last: { ...fin('failed').last, id: 'job-9' } }), NOW);
|
|
assert.equal(pick([again], dismissed).kind, 'job-failed');
|
|
});
|
|
|
|
test('update keys: Later on the download hides it until it is ready; Later on ready hides that version', () => {
|
|
const avail = updateNotice(upd({ status: 'available', ready: false, downloaded: false, progress: 0 }), s());
|
|
const down = updateNotice(upd({ status: 'downloading', ready: false, downloaded: false, progress: 0.43 }), s());
|
|
const stage = updateNotice(upd({ status: 'staging', ready: false }), s());
|
|
const ready = updateNotice(upd(), s());
|
|
assert.equal(avail.key, down.key); assert.equal(down.key, stage.key); assert.notEqual(stage.key, ready.key);
|
|
const dismissed = { [avail.key]: true };
|
|
assert.equal(pick([down], dismissed), null);
|
|
assert.equal(pick([ready], dismissed).kind, 'update-available');
|
|
// a different version is a different key
|
|
assert.notEqual(updateNotice(upd({ version: '0.3.7' }), s()).key, ready.key);
|
|
// percent changes do not change the key
|
|
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0.9 }), s()).key, down.key);
|
|
});
|
|
|
|
test('update wording: available, downloading with percent, installing, failed with one line of cause, updated', () => {
|
|
const avail = updateNotice(upd({ status: 'available' }), s());
|
|
assert.equal(avail.text, 'Igneum Miner 0.3.6 is available.');
|
|
assert.deepEqual(avail.actions.map((a) => a.label), ['Install now', 'Later']);
|
|
const down = updateNotice(upd({ status: 'downloading', progress: 0.43 }), s());
|
|
assert.equal(down.text, 'Downloading Igneum Miner 0.3.6: 43%.');
|
|
assert.equal(down.progress, 0.43);
|
|
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0 }), s()).text, 'Downloading Igneum Miner 0.3.6.');
|
|
const inst = updateNotice(upd({ status: 'applying', applying: true }), s());
|
|
assert.equal(inst.text, 'Installing Igneum Miner 0.3.6. The app restarts itself. Mining continues until then.');
|
|
assert.equal(updateNotice(upd({ status: 'applying', applying: true }), s({ quitting: true })).text, 'Installing Igneum Miner 0.3.6. The app restarts itself in a moment.');
|
|
// Install now on a ready update: the engine says "installing now" for up to 3 s before it flips to applying
|
|
assert.equal(updateNotice(upd({ wait: 'installing now' }), s()).kind, 'update-installing');
|
|
const err = updateNotice(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line' }), s());
|
|
assert.equal(err.text, 'The update to 0.3.6 failed.');
|
|
assert.equal(err.detail, 'sha256 mismatch: the file is not what the manifest signed');
|
|
assert.deepEqual(err.actions.map((a) => a.label), ['Try again']);
|
|
assert.equal(err.tone, 'bad');
|
|
const rb = updateNotice(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.3.6: did not stay up' }), s());
|
|
assert.equal(rb.text, 'Igneum Miner 0.3.6 did not stay up and was rolled back.');
|
|
const ready = updateNotice(upd(), s());
|
|
assert.equal(ready.text, 'Igneum Miner 0.3.6 is ready. It installs by itself at a quiet moment.');
|
|
assert.equal(updateNotice(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), s()).text, 'Igneum Miner 0.3.6 is ready.');
|
|
assert.equal(updateNotice(upd({ status: 'manual', wait: '/Applications is not writable; open the downloaded disk image and drag the app over the old one' }), s()).actions[0].label, 'Open the download');
|
|
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
|
|
// updated: gone within 60 s of the new version starting
|
|
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
|
|
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
|
|
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
|
|
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
|
|
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
|
|
assert.equal(updateNotice(upd({ status: 'error', error: 'no update manifest configured in this build' }), s()), null);
|
|
assert.equal(updateNotice(upd({ status: 'checking', available: false }), s()), null);
|
|
});
|
|
|
|
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
|
|
const r = jobNotice(run(), NOW);
|
|
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
|
|
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
|
|
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
|
|
const d = jobNotice(fin('done'), NOW);
|
|
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
|
|
assert.equal(d.key, 'job:done:job-6');
|
|
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
|
|
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
|
|
const f = jobNotice(fin('failed'), NOW);
|
|
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
|
|
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
|
|
assert.equal(f.tone, 'bad');
|
|
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
|
|
// no error line among the results: the summary is the cause
|
|
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
|
|
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
|
|
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
|
|
assert.equal(jobNotice(null, NOW), null);
|
|
});
|
|
|
|
test('clock: the engine words, Sync clock, the hint; gather() keeps it off the dashboard and the job off the setup screens', () => {
|
|
const c = clockNotice({ severity: 'block', message: 'This clock is 7 min behind.', hint: 'Date & Time', syncing: false });
|
|
assert.equal(c.text, 'This clock is 7 min behind.'); assert.equal(c.detail, 'Date & Time'); assert.equal(c.tone, 'bad');
|
|
assert.equal(c.actions[0].label, 'Sync clock'); assert.equal(c.actions[0].disabled, false);
|
|
assert.equal(clockNotice({ severity: 'warn', message: 'x', syncing: true }).actions[0].label, 'Syncing');
|
|
assert.equal(clockNotice({ severity: 'none' }), null);
|
|
const st = s({ clock: { severity: 'warn', message: 'Clock 40 s ahead.' }, jobs: run(), update: upd({ status: 'available' }) });
|
|
assert.deepEqual(gather(st, { dashboard: true }).map((n) => n.kind), ['update-available', 'job-running']);
|
|
assert.deepEqual(gather(st, { dashboard: false }).map((n) => n.kind), ['update-available', 'clock']);
|
|
assert.equal(pick(gather(st, { dashboard: true }), {}).kind, 'job-running');
|
|
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
|
|
assert.deepEqual(gather({}, { dashboard: true }), []);
|
|
});
|