A new #mining section invites visitors to mine on p2pool.eom.dev and shows live node/sidechain/network hashrate, connected miners and shares from the same-origin /p2pool/stats.json summary published by ansible-role-monero. The stats load progressively: static placeholders and join instructions remain when the endpoint is unavailable (including in the local preview and on the onion mirror). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018STDcc5BaYxPMNA9S8Wycs
171 lines
7.9 KiB
JavaScript
171 lines
7.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
ENDPOINT, MAX_BYTES, REFRESH_MS, normalizeStats, formatHashrate, formatCount,
|
|
fetchStats, statValues, statusText, initP2PoolStats,
|
|
} from '../htdocs/static/p2pool-stats.mjs';
|
|
|
|
const SUMMARY = {
|
|
version: 1, generated_at: 1794098000, sidechain: 'nano',
|
|
node: { hashrate_15m: 8123, hashrate_1h: 7990, hashrate_24h: 8011, miners: 3, shares_found: 42,
|
|
shares_failed: 1, updated_at: 1794097990, stale: false },
|
|
pool: { hashrate: 25_000_000, miners: 512, updated_at: 1794097990, stale: false },
|
|
network: { hashrate: 7_160_416_666.7, difficulty: 859_250_000_000, updated_at: 1794097900, stale: false },
|
|
};
|
|
|
|
function response(body, { status = 200, type = 'application/json', length, redirected = false } = {}) {
|
|
const bytes = new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body));
|
|
const headers = new Map([['content-type', type]]);
|
|
if (length !== undefined) headers.set('content-length', String(length));
|
|
return {
|
|
ok: status >= 200 && status < 300, status, redirected,
|
|
headers: { get: name => headers.get(name.toLowerCase()) ?? null },
|
|
body: { getReader() {
|
|
let done = false;
|
|
return { read: async () => done ? { done: true } : (done = true, { done: false, value: bytes }),
|
|
cancel: async () => {} };
|
|
} },
|
|
};
|
|
}
|
|
|
|
test('normalizeStats copies known numeric fields and rejects unknown documents', () => {
|
|
const stats = normalizeStats(SUMMARY);
|
|
assert.equal(stats.sidechain, 'nano');
|
|
assert.equal(stats.node.hashrate_15m, 8123);
|
|
assert.equal(stats.node.miners, 3);
|
|
assert.equal(stats.node.updatedAt, 1794097990);
|
|
assert.equal(stats.pool.hashrate, 25_000_000);
|
|
assert.equal('shares_failed' in stats.node, false);
|
|
const odd = normalizeStats({ ...SUMMARY, sidechain: '<b>x</b>', node: { hashrate_15m: '9', miners: -1,
|
|
shares_found: Infinity, updated_at: 1.5, stale: 'yes' }, pool: [], network: null });
|
|
assert.equal(odd.sidechain, null);
|
|
assert.deepEqual([odd.node.hashrate_15m, odd.node.miners, odd.node.shares_found, odd.node.updatedAt, odd.node.stale],
|
|
[null, null, null, null, false]);
|
|
assert.equal(odd.pool, null);
|
|
assert.equal(odd.network, null);
|
|
for (const bad of [null, [], 'x', { version: 2 }, {}]) assert.throws(() => normalizeStats(bad));
|
|
});
|
|
|
|
test('hashrates and counts format compactly with placeholders for missing data', () => {
|
|
assert.equal(formatHashrate(0), '0 H/s');
|
|
assert.equal(formatHashrate(950), '950 H/s');
|
|
assert.equal(formatHashrate(8123), '8.12 kH/s');
|
|
assert.equal(formatHashrate(25_000_000), '25.0 MH/s');
|
|
assert.equal(formatHashrate(7_160_416_666.7), '7.16 GH/s');
|
|
assert.equal(formatHashrate(512_000), '512 kH/s');
|
|
for (const missing of [null, undefined, NaN, -1, '8']) assert.equal(formatHashrate(missing), '—');
|
|
assert.equal(formatCount(1234), '1,234');
|
|
assert.equal(formatCount(null), '—');
|
|
});
|
|
|
|
test('statValues prefers the 15-minute node average and falls back to longer windows', () => {
|
|
assert.deepEqual(statValues(normalizeStats(SUMMARY)), {
|
|
'node-hashrate': '8.12 kH/s', 'node-miners': '3', 'node-shares': '42',
|
|
'pool-hashrate': '25.0 MH/s', 'network-hashrate': '7.16 GH/s',
|
|
});
|
|
const fallback = normalizeStats({ ...SUMMARY, node: { ...SUMMARY.node, hashrate_15m: null, hashrate_1h: null } });
|
|
assert.equal(statValues(fallback)['node-hashrate'], '8.01 kH/s');
|
|
assert.equal(statValues(normalizeStats({ version: 1 }))['node-hashrate'], '—');
|
|
});
|
|
|
|
test('status text distinguishes live, stale and missing node stats', () => {
|
|
assert.match(statusText(normalizeStats(SUMMARY)), /^Live · updated \d\d:\d\d UTC · nano sidechain/u);
|
|
assert.match(statusText(normalizeStats({ ...SUMMARY, node: { ...SUMMARY.node, stale: true } })), /last reported stats at/u);
|
|
assert.match(statusText(normalizeStats({ version: 1 })), /unavailable/u);
|
|
});
|
|
|
|
test('fetchStats requests only the fixed same-origin endpoint without credentials', async () => {
|
|
let seen;
|
|
const stats = await fetchStats({ fetchImpl: async (url, init) => { seen = { url, init }; return response(SUMMARY); } });
|
|
assert.equal(seen.url, ENDPOINT);
|
|
assert.equal(seen.init.credentials, 'omit');
|
|
assert.equal(seen.init.redirect, 'error');
|
|
assert.equal(seen.init.cache, 'no-store');
|
|
assert.equal(stats.node.hashrate_15m, 8123);
|
|
});
|
|
|
|
test('fetchStats rejects errors, wrong types, oversized and malformed bodies', async () => {
|
|
const cases = [
|
|
response({ version: 1, node: null }, { status: 503 }),
|
|
response(SUMMARY, { type: 'text/html' }),
|
|
response(SUMMARY, { length: MAX_BYTES + 1 }),
|
|
response(SUMMARY, { redirected: true }),
|
|
response('x'.repeat(MAX_BYTES + 1)),
|
|
response('{not json'),
|
|
response({ version: 99 }),
|
|
];
|
|
for (const reply of cases) await assert.rejects(fetchStats({ fetchImpl: async () => reply }));
|
|
await assert.rejects(fetchStats({ timeoutMs: 20, fetchImpl: () => new Promise(() => {}) }), /aborted/u);
|
|
await assert.rejects(fetchStats({ timeoutMs: 60_000 }), /bounds/u);
|
|
});
|
|
|
|
function fakeDocument({ hidden = false } = {}) {
|
|
const listeners = new Map();
|
|
const node = (attrs = {}) => ({ textContent: '—', attrs, setAttribute(name, value) { attrs[name] = value; },
|
|
getAttribute(name) { return attrs[name] ?? null; } });
|
|
const status = node({ 'data-p2pool-status': '' });
|
|
const cells = ['node-hashrate', 'node-miners', 'node-shares', 'pool-hashrate', 'network-hashrate']
|
|
.map(key => node({ 'data-stat': key }));
|
|
const root = { querySelector: selector => selector === '[data-p2pool-status]' ? status : null,
|
|
querySelectorAll: selector => selector === '[data-p2pool-stats] [data-stat]' ? cells : [] };
|
|
return {
|
|
hidden, status, cells,
|
|
querySelector: selector => selector === '#p2pool-stats' ? root : null,
|
|
addEventListener: (type, fn) => listeners.set(type, fn),
|
|
removeEventListener: type => listeners.delete(type),
|
|
fire(type) { listeners.get(type)?.(); },
|
|
listeners,
|
|
};
|
|
}
|
|
|
|
const timers = () => {
|
|
const state = { intervals: new Map(), next: 1 };
|
|
return { state, setIntervalImpl: (fn, ms) => { state.intervals.set(state.next, { fn, ms }); return state.next++; },
|
|
clearIntervalImpl: id => state.intervals.delete(id) };
|
|
};
|
|
|
|
test('initP2PoolStats fills the stat cells and refreshes every minute while visible', async () => {
|
|
const document = fakeDocument();
|
|
const clock = timers();
|
|
let calls = 0;
|
|
const widget = initP2PoolStats(document, { ...clock,
|
|
fetchImpl: async () => { calls++; return response(SUMMARY); } });
|
|
await widget.ready;
|
|
assert.deepEqual(document.cells.map(cell => cell.textContent), ['8.12 kH/s', '3', '42', '25.0 MH/s', '7.16 GH/s']);
|
|
assert.match(document.status.textContent, /^Live/u);
|
|
assert.equal(document.status.attrs.role, 'status');
|
|
const [timer] = clock.state.intervals.values();
|
|
assert.equal(timer.ms, REFRESH_MS);
|
|
timer.fn();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(calls, 2);
|
|
document.hidden = true;
|
|
document.fire('visibilitychange');
|
|
assert.equal(clock.state.intervals.size, 0);
|
|
widget.stop();
|
|
assert.equal(document.listeners.size, 0);
|
|
});
|
|
|
|
test('initP2PoolStats keeps placeholders or previous figures when the endpoint fails', async () => {
|
|
const document = fakeDocument();
|
|
let fail = true;
|
|
const widget = initP2PoolStats(document, { ...timers(),
|
|
fetchImpl: async () => fail ? response({ version: 1 }, { status: 503 }) : response(SUMMARY) });
|
|
await widget.ready;
|
|
assert.ok(document.cells.every(cell => cell.textContent === '—'));
|
|
assert.match(document.status.textContent, /unavailable right now/u);
|
|
fail = false;
|
|
await widget.refresh();
|
|
fail = true;
|
|
await widget.refresh();
|
|
assert.equal(document.cells[0].textContent, '8.12 kH/s');
|
|
assert.match(document.status.textContent, /last figures received/u);
|
|
widget.stop();
|
|
});
|
|
|
|
test('initP2PoolStats is inert without its markup', async () => {
|
|
const widget = initP2PoolStats({ querySelector: () => null });
|
|
await widget.ready;
|
|
widget.stop();
|
|
});
|