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: 'x', 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(); });