750 lines
30 KiB
TypeScript
750 lines
30 KiB
TypeScript
/**
|
|
* health-view-smoke (beta11 item 2).
|
|
*
|
|
* Unit-tests the PURE cores of `morphit-ops health` — the live indexer
|
|
* health view that talks to the indexer's own /v1/health HTTP endpoint
|
|
* (so it needs NO config file + NO DB, and can't EACCES as a non-root
|
|
* user the way the `status` dashboard can):
|
|
*
|
|
* - ensureHealthPath: append /v1/health to a bare origin, leave an
|
|
* explicit path alone, pass an unparseable string through.
|
|
* - resolveHealthUrl: the flag/env precedence ladder, entirely from
|
|
* flags + env (no file access), so it can never hit a permission
|
|
* error on a root-owned config.
|
|
* - summarizeHealth: tolerant parsing of the /v1/health body (missing
|
|
* fields → null; stale/degraded → not synced; the "all RPC down"
|
|
* signal).
|
|
* - classifyHealthResult: fetch-result → outcome + EXIT CODE (0 synced,
|
|
* 1 reachable-but-behind, 2 can't-reach / wrong-thing-answered).
|
|
*
|
|
* Plus structural wiring assertions on main.ts: `health` dispatches in
|
|
* the pre-DB group, and url/host/port are value-taking flags. The actual
|
|
* HTTP GET runs against a live indexer and isn't exercised here.
|
|
*/
|
|
|
|
import {
|
|
ensureHealthPath,
|
|
resolveHealthUrl,
|
|
resolveRelayHealthUrl,
|
|
summarizeHealth,
|
|
parsePriceFeed,
|
|
parsePriceFeedsHealth,
|
|
parseFastPath,
|
|
FASTPATH_HEALTHY_LAG_BLOCKS,
|
|
classifyHealthResult,
|
|
bridgeGatewayHosts,
|
|
candidateHealthUrls,
|
|
hasExplicitTarget,
|
|
checkCanary,
|
|
bytesToGB,
|
|
clampPct,
|
|
cpuTimesTotals,
|
|
cpuBusyPct,
|
|
parseMeminfo,
|
|
readSystemResources,
|
|
DEFAULT_INDEXER_HOST,
|
|
DEFAULT_INDEXER_PORT,
|
|
DEFAULT_RELAY_PORT
|
|
} from '../src/commands/health.ts';
|
|
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const ok = (m: string) => {
|
|
pass++;
|
|
console.log(` \u2713 ${m}`);
|
|
};
|
|
const bad = (m: string, d = '') => {
|
|
fail++;
|
|
console.log(` \u2717 ${m}`);
|
|
if (d) console.log(` ${d}`);
|
|
};
|
|
const expect = (n: string, c: boolean, d = '') => (c ? ok(n) : bad(n, d));
|
|
|
|
// ─── HV-1: ensureHealthPath ─────────────────────────────────────────
|
|
expect(
|
|
'HV-1a bare origin gets /v1/health appended',
|
|
ensureHealthPath('http://127.0.0.1:8081') === 'http://127.0.0.1:8081/v1/health'
|
|
);
|
|
expect(
|
|
'HV-1b bare https origin gets /v1/health appended',
|
|
ensureHealthPath('https://morphit.io') === 'https://morphit.io/v1/health'
|
|
);
|
|
expect(
|
|
'HV-1c trailing-slash-only path gets /v1/health',
|
|
ensureHealthPath('http://127.0.0.1:8081/') === 'http://127.0.0.1:8081/v1/health'
|
|
);
|
|
expect(
|
|
'HV-1d an explicit path is left alone',
|
|
ensureHealthPath('https://morphit.io/custom/health') === 'https://morphit.io/custom/health'
|
|
);
|
|
expect('HV-1e an unparseable string passes through', ensureHealthPath('not a url') === 'not a url');
|
|
|
|
// ─── HV-2: resolveHealthUrl precedence ──────────────────────────────
|
|
{
|
|
// (1) --url wins over everything, and gets /v1/health appended.
|
|
expect(
|
|
'HV-2a --url beats env + host/port, path appended',
|
|
resolveHealthUrl(
|
|
{ url: 'http://10.0.0.5:9000', host: 'ignored', port: '1' },
|
|
{ MORPHIT_OPS_HEALTH_URL: 'http://env', MORPHIT_INDEXER_LISTEN_HOST: 'envhost' }
|
|
) === 'http://10.0.0.5:9000/v1/health'
|
|
);
|
|
// (2) env URL when no --url.
|
|
expect(
|
|
'HV-2b MORPHIT_OPS_HEALTH_URL used when no --url',
|
|
resolveHealthUrl({}, { MORPHIT_OPS_HEALTH_URL: 'https://morphit.io/v1/health' }) ===
|
|
'https://morphit.io/v1/health'
|
|
);
|
|
// (3) --host/--port beat env host/port.
|
|
expect(
|
|
'HV-2c --host/--port beat env host/port',
|
|
resolveHealthUrl(
|
|
{ host: '192.168.1.9', port: '7000' },
|
|
{ MORPHIT_INDEXER_LISTEN_HOST: 'envhost', MORPHIT_INDEXER_LISTEN_PORT: '1234' }
|
|
) === 'http://192.168.1.9:7000/v1/health'
|
|
);
|
|
// (4) env host/port when no flags.
|
|
expect(
|
|
'HV-2d env host/port when no flags',
|
|
resolveHealthUrl(
|
|
{},
|
|
{ MORPHIT_INDEXER_LISTEN_HOST: '172.20.0.3', MORPHIT_INDEXER_LISTEN_PORT: '8081' }
|
|
) === 'http://172.20.0.3:8081/v1/health'
|
|
);
|
|
// (5) canonical loopback default when nothing is set.
|
|
expect(
|
|
'HV-2e default 127.0.0.1:8081 when nothing set',
|
|
resolveHealthUrl({}, {}) === `http://${DEFAULT_INDEXER_HOST}:${DEFAULT_INDEXER_PORT}/v1/health`
|
|
);
|
|
// whitespace-only values fall through to the next source / default.
|
|
expect(
|
|
'HV-2f whitespace --url is ignored (falls through to default)',
|
|
resolveHealthUrl({ url: ' ' }, {}) === 'http://127.0.0.1:8081/v1/health'
|
|
);
|
|
expect(
|
|
'HV-2g whitespace host falls back to the default host',
|
|
resolveHealthUrl({ host: ' ', port: '9' }, {}) === 'http://127.0.0.1:9/v1/health'
|
|
);
|
|
}
|
|
|
|
// ─── HV-3: summarizeHealth ──────────────────────────────────────────
|
|
{
|
|
const full = summarizeHealth({
|
|
stale: false,
|
|
status: 'ok',
|
|
version: '1.0.0-beta.24',
|
|
indexed_block: 1000,
|
|
chain_head_block: 1002,
|
|
lag_blocks: 2,
|
|
uptime_sec: 3661,
|
|
rpc_endpoints_healthy: 3,
|
|
rpc_endpoints_total: 4,
|
|
web_push: true,
|
|
blurt_balance: '9000.000 BLURT',
|
|
price_feed: {
|
|
enabled: true,
|
|
blurt_fiat: 0.0013,
|
|
denomination_fiat: 'USD',
|
|
source: 'coingecko',
|
|
stale: false
|
|
}
|
|
});
|
|
expect(
|
|
'HV-3a full healthy body parsed',
|
|
full.synced &&
|
|
full.status === 'ok' &&
|
|
full.version === '1.0.0-beta.24' &&
|
|
full.indexedBlock === 1000 &&
|
|
full.chainHeadBlock === 1002 &&
|
|
full.lagBlocks === 2 &&
|
|
full.uptimeSec === 3661 &&
|
|
full.rpcHealthy === 3 &&
|
|
full.rpcTotal === 4 &&
|
|
full.webPush === true &&
|
|
full.relayBalance === '9000.000 BLURT' &&
|
|
full.priceFeed !== null &&
|
|
full.priceFeed.enabled &&
|
|
full.priceFeed.blurtFiat === 0.0013 &&
|
|
full.priceFeed.source === 'coingecko' &&
|
|
!full.priceFeed.stale &&
|
|
!full.rpcAllDown,
|
|
JSON.stringify(full)
|
|
);
|
|
expect('HV-3b stale:true → not synced', !summarizeHealth({ stale: true }).synced);
|
|
expect("HV-3c status 'degraded' → not synced", !summarizeHealth({ status: 'degraded' }).synced);
|
|
const allDown = summarizeHealth({ rpc_endpoints_healthy: 0, rpc_endpoints_total: 4 });
|
|
expect('HV-3d rpcAllDown true when 0/total reachable', allDown.rpcAllDown);
|
|
const someUp = summarizeHealth({ rpc_endpoints_healthy: 1, rpc_endpoints_total: 4 });
|
|
expect('HV-3e rpcAllDown false when some reachable', !someUp.rpcAllDown);
|
|
const missing = summarizeHealth({});
|
|
expect(
|
|
'HV-3f missing fields become null (no crash), synced defaults true',
|
|
missing.indexedBlock === null &&
|
|
missing.chainHeadBlock === null &&
|
|
missing.lagBlocks === null &&
|
|
missing.uptimeSec === null &&
|
|
missing.rpcHealthy === null &&
|
|
missing.rpcTotal === null &&
|
|
missing.webPush === null &&
|
|
missing.relayBalance === null &&
|
|
missing.priceFeed === null &&
|
|
!missing.rpcAllDown &&
|
|
missing.synced,
|
|
JSON.stringify(missing)
|
|
);
|
|
const lowBal = summarizeHealth({ blurt_balance: '5.000 BLURT' });
|
|
expect(
|
|
'HV-3h relay balance parsed from health body',
|
|
lowBal.relayBalance === '5.000 BLURT'
|
|
);
|
|
expect(
|
|
'HV-3g non-object body tolerated',
|
|
summarizeHealth(null).status === 'unknown' && summarizeHealth(42).synced
|
|
);
|
|
// cp365 — parsePriceFeed: tolerant of every shape the indexer (or an
|
|
// older build) might send for the non-verbose `price_feed`.
|
|
const pfOn = parsePriceFeed({
|
|
enabled: true,
|
|
blurt_fiat: 0.0013,
|
|
denomination_fiat: 'USD',
|
|
source: 'coingecko',
|
|
stale: false
|
|
});
|
|
expect(
|
|
'HV-3i parsePriceFeed: live feed parsed',
|
|
pfOn !== null &&
|
|
pfOn.enabled &&
|
|
pfOn.blurtFiat === 0.0013 &&
|
|
pfOn.denomination === 'USD' &&
|
|
pfOn.source === 'coingecko' &&
|
|
!pfOn.stale,
|
|
JSON.stringify(pfOn)
|
|
);
|
|
const pfOff = parsePriceFeed({ enabled: false });
|
|
expect(
|
|
'HV-3j parsePriceFeed: disabled feed → enabled:false, no price',
|
|
pfOff !== null && !pfOff.enabled && pfOff.blurtFiat === null && pfOff.stale === false
|
|
);
|
|
const pfStale = parsePriceFeed({ enabled: true, blurt_fiat: 0.0015, source: 'static_floor', stale: true });
|
|
expect(
|
|
'HV-3k parsePriceFeed: stale upstream flagged',
|
|
pfStale !== null && pfStale.enabled && pfStale.stale && pfStale.source === 'static_floor'
|
|
);
|
|
expect(
|
|
'HV-3l parsePriceFeed: absent/non-object → null (pre-field indexer, relay health)',
|
|
parsePriceFeed(undefined) === null &&
|
|
parsePriceFeed(null) === null &&
|
|
parsePriceFeed(42) === null &&
|
|
parsePriceFeed({ enabled: 'yes' }) === null
|
|
);
|
|
}
|
|
|
|
// ─── HV-4: classifyHealthResult → outcome + exit code ───────────────
|
|
{
|
|
const unreachable = classifyHealthResult({
|
|
fetchError: 'ECONNREFUSED',
|
|
httpStatus: null,
|
|
jsonOk: false,
|
|
body: null
|
|
});
|
|
expect(
|
|
'HV-4a fetch error → unreachable, exit 2',
|
|
unreachable.kind === 'unreachable' && unreachable.exitCode === 2 && unreachable.summary === null
|
|
);
|
|
|
|
const http404 = classifyHealthResult({ fetchError: null, httpStatus: 404, jsonOk: false, body: null });
|
|
expect('HV-4b HTTP 404 → http-error, exit 2', http404.kind === 'http-error' && http404.exitCode === 2);
|
|
|
|
const http500 = classifyHealthResult({ fetchError: null, httpStatus: 500, jsonOk: false, body: null });
|
|
expect('HV-4c HTTP 500 → http-error, exit 2', http500.kind === 'http-error' && http500.exitCode === 2);
|
|
|
|
const notJson = classifyHealthResult({ fetchError: null, httpStatus: 200, jsonOk: false, body: null });
|
|
expect(
|
|
'HV-4d 200 but non-JSON (proxy HTML) → not-indexer, exit 2',
|
|
notJson.kind === 'not-indexer' && notJson.exitCode === 2
|
|
);
|
|
|
|
const synced = classifyHealthResult({
|
|
fetchError: null,
|
|
httpStatus: 200,
|
|
jsonOk: true,
|
|
body: { stale: false, indexed_block: 10, chain_head_block: 10, lag_blocks: 0 }
|
|
});
|
|
expect(
|
|
'HV-4e synced body → synced, exit 0, summary present',
|
|
synced.kind === 'synced' && synced.exitCode === 0 && synced.summary !== null && synced.summary.synced
|
|
);
|
|
|
|
const behind = classifyHealthResult({
|
|
fetchError: null,
|
|
httpStatus: 200,
|
|
jsonOk: true,
|
|
body: { stale: true, indexed_block: 5, chain_head_block: 10, lag_blocks: 5 }
|
|
});
|
|
expect(
|
|
'HV-4f behind body → behind, exit 1',
|
|
behind.kind === 'behind' && behind.exitCode === 1 && behind.summary !== null && !behind.summary.synced
|
|
);
|
|
|
|
const behindRpc = classifyHealthResult({
|
|
fetchError: null,
|
|
httpStatus: 200,
|
|
jsonOk: true,
|
|
body: { stale: true, rpc_endpoints_healthy: 0, rpc_endpoints_total: 4 }
|
|
});
|
|
expect(
|
|
'HV-4g behind + all RPC down → behind exit 1, message blames RPC',
|
|
behindRpc.kind === 'behind' &&
|
|
behindRpc.exitCode === 1 &&
|
|
/RPC/.test(behindRpc.message) &&
|
|
behindRpc.summary !== null &&
|
|
behindRpc.summary.rpcAllDown
|
|
);
|
|
|
|
// A 2xx other than 200 (e.g. 204) with JSON should still classify as
|
|
// synced/behind, not http-error — the guard is <200 || >=300.
|
|
const http204 = classifyHealthResult({
|
|
fetchError: null,
|
|
httpStatus: 299,
|
|
jsonOk: true,
|
|
body: { stale: false }
|
|
});
|
|
expect('HV-4h 2xx boundary (299) with JSON is not an http-error', http204.kind === 'synced');
|
|
}
|
|
|
|
// ─── HV-5: structural wiring in main.ts ─────────────────────────────
|
|
{
|
|
const main = readFileSync(
|
|
join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main.ts'),
|
|
'utf8'
|
|
);
|
|
expect('HV-5a main imports runHealth', /import \{ runHealth \} from '\.\/commands\/health\.ts'/.test(main));
|
|
expect("HV-5b main dispatches the 'health' subcommand", /args\.subcommand === 'health'/.test(main));
|
|
expect('HV-5c the health dispatch calls runHealth', /return await runHealth\(\{/.test(main));
|
|
expect(
|
|
'HV-5d url/host/port are value-taking flags',
|
|
/VALUE_FLAGS = new Set\(\[[^\]]*'url'[^\]]*'port'[^\]]*'host'[^\]]*\]\)/.test(main)
|
|
);
|
|
// health must dispatch BEFORE config/DB load (so it can't EACCES). We
|
|
// assert the health dispatch appears before the "Now load config" marker.
|
|
const healthIdx = main.indexOf("args.subcommand === 'health'");
|
|
const loadIdx = main.indexOf('Now load config');
|
|
expect(
|
|
'HV-5e health dispatches in the pre-config/DB group',
|
|
healthIdx !== -1 && loadIdx !== -1 && healthIdx < loadIdx,
|
|
`healthIdx=${healthIdx} loadIdx=${loadIdx}`
|
|
);
|
|
// help text advertises the command + the endpoint.
|
|
expect('HV-5f help text documents `health` and /v1/health', /health \[--url=URL\]/.test(main) || /\/v1\/health/.test(main));
|
|
}
|
|
|
|
// ─── HV-6: bridge-gateway auto-probe (the #13 fix) ──────────────────
|
|
{
|
|
// bridgeGatewayHosts: non-internal IPv4 only (the docker bridge
|
|
// gateway), skipping loopback + IPv6 + internal interfaces.
|
|
const fakeIfaces = {
|
|
lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }],
|
|
eth0: [{ address: '203.0.113.7', family: 'IPv4', internal: false }],
|
|
docker0: [
|
|
{ address: '172.18.0.1', family: 'IPv4', internal: false },
|
|
{ address: 'fe80::1', family: 'IPv6', internal: false }
|
|
]
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const hosts = bridgeGatewayHosts(fakeIfaces as any);
|
|
expect(
|
|
'HV-6a bridgeGatewayHosts: non-internal IPv4 only (incl. docker bridge)',
|
|
hosts.includes('172.18.0.1') &&
|
|
hosts.includes('203.0.113.7') &&
|
|
!hosts.includes('127.0.0.1') &&
|
|
!hosts.includes('fe80::1')
|
|
);
|
|
|
|
// candidateHealthUrls: primary first, then gateways at the port; deduped.
|
|
const urls = candidateHealthUrls(
|
|
'http://127.0.0.1:8081/v1/health',
|
|
false,
|
|
'8081',
|
|
['172.18.0.1', '127.0.0.1']
|
|
);
|
|
expect(
|
|
'HV-6b candidateHealthUrls: primary first, bridge gateways appended, deduped',
|
|
urls[0] === 'http://127.0.0.1:8081/v1/health' &&
|
|
urls.includes('http://172.18.0.1:8081/v1/health') &&
|
|
urls.length === 2
|
|
);
|
|
|
|
// explicit target → no fallbacks (respect the operator's --url/--host).
|
|
const explicitUrls = candidateHealthUrls(
|
|
'http://10.0.0.5:8081/v1/health',
|
|
true,
|
|
'8081',
|
|
['172.18.0.1']
|
|
);
|
|
expect(
|
|
'HV-6c candidateHealthUrls: explicit target skips auto-probe',
|
|
explicitUrls.length === 1 && explicitUrls[0] === 'http://10.0.0.5:8081/v1/health'
|
|
);
|
|
|
|
expect('HV-6d hasExplicitTarget: --url set → true', hasExplicitTarget({ url: 'http://x:8081' }, {}) === true);
|
|
expect(
|
|
'HV-6e hasExplicitTarget: MORPHIT_INDEXER_LISTEN_HOST set → true',
|
|
hasExplicitTarget({}, { MORPHIT_INDEXER_LISTEN_HOST: '172.18.0.1' }) === true
|
|
);
|
|
expect('HV-6f hasExplicitTarget: nothing set → false', hasExplicitTarget({}, {}) === false);
|
|
}
|
|
|
|
// ─── HV-7: relay URL resolution ─────────────────────────────────────
|
|
{
|
|
expect(
|
|
'HV-7a resolveRelayHealthUrl: defaults to loopback:8080',
|
|
resolveRelayHealthUrl({}) === `http://${DEFAULT_INDEXER_HOST}:${DEFAULT_RELAY_PORT}/v1/health`
|
|
);
|
|
expect(
|
|
'HV-7b resolveRelayHealthUrl: honors MORPHIT_RELAY_LISTEN_HOST/_PORT',
|
|
resolveRelayHealthUrl({ MORPHIT_RELAY_LISTEN_HOST: '172.18.0.1', MORPHIT_RELAY_LISTEN_PORT: '9090' }) ===
|
|
'http://172.18.0.1:9090/v1/health'
|
|
);
|
|
}
|
|
|
|
// ─── HV-8: canary freshness ─────────────────────────────────────────
|
|
{
|
|
const dir = mkdtempSync(join(tmpdir(), 'canary-smoke-'));
|
|
const now = new Date('2026-06-11T00:00:00Z');
|
|
try {
|
|
const fresh = join(dir, 'fresh.txt');
|
|
writeFileSync(fresh, 'Generated: 2026-06-13T03:14:00Z\nValid through: 2026-06-20T03:14:00Z\n');
|
|
const f = checkCanary(fresh, now);
|
|
expect(
|
|
'HV-8a checkCanary: comfortable future "Valid through" → fresh',
|
|
f.state === 'fresh' && f.validThrough === '2026-06-20T03:14:00Z'
|
|
);
|
|
|
|
const stale = join(dir, 'stale.txt');
|
|
writeFileSync(stale, 'Generated: 2026-05-25T03:14:00Z\nValid through: 2026-06-01T03:14:00Z\n');
|
|
expect('HV-8b checkCanary: past "Valid through" → overdue', checkCanary(stale, now).state === 'overdue');
|
|
|
|
expect('HV-8c checkCanary: missing file → missing', checkCanary(join(dir, 'nope.txt'), now).state === 'missing');
|
|
|
|
const tmpl = join(dir, 'tmpl.txt');
|
|
writeFileSync(tmpl, 'Generated: {{GENERATED_AT_ISO}}\nValid through: {{VALID_THROUGH_ISO}}\n');
|
|
expect('HV-8d checkCanary: un-substituted template → unparsable', checkCanary(tmpl, now).state === 'unparsable');
|
|
|
|
// cp442 — the generator now writes Ken's sitewide stamp
|
|
// ("15 June, 2026 @ 03:14:00 UTC"). `new Date(str)` on a non-ISO string is
|
|
// implementation-defined, so health parses it explicitly. If that ever
|
|
// regresses, a perfectly good canary reports as `unparsable` and the
|
|
// operator goes hunting for a problem that doesn't exist.
|
|
const humanFresh = join(dir, 'human-fresh.txt');
|
|
writeFileSync(
|
|
humanFresh,
|
|
'Generated: 8 June, 2026 @ 03:14:00 UTC\nValid through: 20 June, 2026 @ 03:14:00 UTC\n'
|
|
);
|
|
expect(
|
|
'HV-8e checkCanary: human stamp, comfortable deadline → fresh',
|
|
checkCanary(humanFresh, now).state === 'fresh'
|
|
);
|
|
|
|
const humanStale = join(dir, 'human-stale.txt');
|
|
writeFileSync(
|
|
humanStale,
|
|
'Generated: 25 May, 2026 @ 03:14:00 UTC\nValid through: 1 June, 2026 @ 03:14:00 UTC\n'
|
|
);
|
|
expect(
|
|
'HV-8f checkCanary: human stamp, past deadline → overdue',
|
|
checkCanary(humanStale, now).state === 'overdue'
|
|
);
|
|
|
|
// A local-time-looking stamp must NOT be silently accepted (it would skew
|
|
// the staleness window by the operator's UTC offset).
|
|
const ambiguous = join(dir, 'ambiguous.txt');
|
|
writeFileSync(ambiguous, 'Generated: 2026-06-08 03:14:00\nValid through: 2026-06-15 03:14:00\n');
|
|
expect(
|
|
'HV-8g checkCanary: ambiguous local-time stamp → unparsable, not guessed',
|
|
checkCanary(ambiguous, now).state === 'unparsable'
|
|
);
|
|
|
|
const badMonth = join(dir, 'bad-month.txt');
|
|
writeFileSync(badMonth, 'Generated: 8 June, 2026 @ 03:14:00 UTC\nValid through: 15 Junius, 2026 @ 03:14:00 UTC\n');
|
|
expect(
|
|
'HV-8h checkCanary: bogus month → unparsable, not treated as fresh',
|
|
checkCanary(badMonth, now).state === 'unparsable'
|
|
);
|
|
|
|
// cp622 — still valid but LOW on remaining validity → stale (aging): the
|
|
// weekly refresh has likely stalled, so warn WHILE it's still valid rather
|
|
// than waiting for it to expire and readers to see a false tamper signal.
|
|
const staleAging = join(dir, 'stale-aging.txt');
|
|
writeFileSync(staleAging, 'Generated: 2026-05-31T00:00:00Z\nValid through: 2026-06-14T00:00:00Z\n');
|
|
const sa = checkCanary(staleAging, now);
|
|
expect(
|
|
'HV-8i checkCanary: valid but < 5 days left → stale (aging)',
|
|
sa.state === 'stale' && /expires in 3 days/.test(sa.detail) && /update-canary/.test(sa.detail)
|
|
);
|
|
|
|
const humanStaleAging = join(dir, 'human-stale-aging.txt');
|
|
writeFileSync(
|
|
humanStaleAging,
|
|
'Generated: 31 May, 2026 @ 00:00:00 UTC\nValid through: 14 June, 2026 @ 00:00:00 UTC\n'
|
|
);
|
|
expect(
|
|
'HV-8j checkCanary: human stamp, low validity → stale',
|
|
checkCanary(humanStaleAging, now).state === 'stale'
|
|
);
|
|
|
|
// Just OUTSIDE the window (6 days left) stays fresh — the warning must not
|
|
// over-fire during normal weekly operation.
|
|
const barelyFresh = join(dir, 'barely-fresh.txt');
|
|
writeFileSync(barelyFresh, 'Generated: 2026-06-10T00:00:00Z\nValid through: 2026-06-17T00:00:00Z\n');
|
|
expect(
|
|
'HV-8k checkCanary: 6 days left (> 5-day window) → still fresh',
|
|
checkCanary(barelyFresh, now).state === 'fresh'
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
// ─── HV-9: parsePriceFeedsHealth (cp372 multi-source feed health;
|
|
// cp381 top-level operator-only block + per-source price) ───
|
|
{
|
|
// cp381: the parser now takes the top-level `price_feeds` block
|
|
// directly (it moved out of the gated `diagnostics`), and each
|
|
// crypto source row carries the price it reported.
|
|
const block = {
|
|
fx: {
|
|
enabled: true,
|
|
source: 'frankfurter+er_api',
|
|
stale: false,
|
|
live_currency_count: 44,
|
|
outlier_rejected: true,
|
|
contributing: 2,
|
|
sources: [
|
|
{ name: 'frankfurter', ok: true, last_ok_age_s: 12, price: null },
|
|
{ name: 'er_api', ok: true, last_ok_age_s: 12, price: null },
|
|
{ name: 'currency_api', ok: false, last_ok_age_s: 600, price: null }
|
|
]
|
|
},
|
|
crypto: {
|
|
BLURT: { source: 'external_avg', stale: false, outlier_rejected: false, sources: [
|
|
{ name: 'coingecko', ok: true, last_ok_age_s: 30, price: 0.0013047 },
|
|
{ name: 'coinpaprika', ok: true, last_ok_age_s: 30, price: 0.00130526 }
|
|
] },
|
|
BTC: { source: 'external_avg', stale: false, outlier_rejected: true, sources: [
|
|
{ name: 'coingecko', ok: true, last_ok_age_s: 30, price: 67000 },
|
|
{ name: 'kraken', ok: false, last_ok_age_s: null, price: null }
|
|
] },
|
|
XMR: { source: 'morphit_native', stale: true, outlier_rejected: false, sources: [
|
|
{ name: 'coingecko', ok: false, last_ok_age_s: 9000, price: null }
|
|
] }
|
|
}
|
|
};
|
|
const p = parsePriceFeedsHealth(block);
|
|
expect('HV-9a price_feeds parsed', p !== null && p.fxEnabled === true);
|
|
if (p) {
|
|
expect('HV-9b 4 feeds in FX,BLURT,BTC,XMR order', p.feeds.length === 4 && p.feeds[0]!.label.startsWith('FX') && p.feeds[1]!.label === 'BLURT' && p.feeds[2]!.label === 'BTC' && p.feeds[3]!.label === 'XMR');
|
|
const fx = p.feeds[0]!;
|
|
expect('HV-9c FX up/total counted (2/3)', fx.up === 2 && fx.total === 3);
|
|
expect('HV-9d FX outlier flag carried', fx.outlierRejected === true);
|
|
expect('HV-9d2 FX tagged not-crypto', fx.isCrypto === false);
|
|
const blurt = p.feeds.find((f) => f.label === 'BLURT')!;
|
|
expect('HV-9d3 BLURT tagged crypto + per-source price carried', blurt.isCrypto === true && blurt.sources.find((s) => s.name === 'coinpaprika')!.price === 0.00130526);
|
|
const btc = p.feeds.find((f) => f.label === 'BTC')!;
|
|
expect('HV-9e BTC down source counted (1/2)', btc.up === 1 && btc.total === 2);
|
|
expect('HV-9e2 BTC down source price null', btc.sources.find((s) => s.name === 'kraken')!.price === null);
|
|
const xmr = p.feeds.find((f) => f.label === 'XMR')!;
|
|
expect('HV-9f XMR stale + native source', xmr.stale === true && xmr.source === 'morphit_native');
|
|
}
|
|
expect('HV-9g absent block → null', parsePriceFeedsHealth(undefined) === null && parsePriceFeedsHealth(null) === null);
|
|
expect('HV-9h block without fx/crypto → null', parsePriceFeedsHealth({ explorers: [] }) === null);
|
|
// FX disabled + no crypto → null (nothing to show).
|
|
expect('HV-9i fx disabled + empty crypto → null', parsePriceFeedsHealth({ fx: { enabled: false }, crypto: {} }) === null);
|
|
// FX disabled but crypto present → parses crypto, fxEnabled false.
|
|
const cryptoOnly = parsePriceFeedsHealth({ fx: { enabled: false }, crypto: { BTC: { source: 'external_avg', stale: false, outlier_rejected: false, sources: [{ name: 'coingecko', ok: true, last_ok_age_s: 5, price: 67000 }] } } });
|
|
expect('HV-9j fx off + crypto present → crypto rendered, fxEnabled false', cryptoOnly !== null && cryptoOnly.fxEnabled === false && cryptoOnly.feeds.length === 1);
|
|
// Malformed source rows are skipped, not crashed.
|
|
const messy = parsePriceFeedsHealth({ fx: { enabled: false }, crypto: { BTC: { source: 'external_avg', stale: false, outlier_rejected: false, sources: [null, { ok: true }, { name: 'coingecko', ok: true, last_ok_age_s: 5, price: 67000 }] } } });
|
|
expect('HV-9k malformed source rows skipped (only valid named one kept)', messy !== null && messy.feeds[0]!.total === 1);
|
|
}
|
|
|
|
// ── HV-10: system-resource pure helpers (CPU / memory / disk math) ──
|
|
{
|
|
// bytesToGB — 1 GiB → 1.0, rounds to one decimal, 0 → 0.
|
|
expect('HV-10a bytesToGB(1 GiB) === 1', bytesToGB(1024 * 1024 * 1024) === 1);
|
|
expect('HV-10b bytesToGB rounds to 1 decimal', bytesToGB(1.55 * 1024 ** 3) === 1.6);
|
|
expect('HV-10c bytesToGB(0) === 0', bytesToGB(0) === 0);
|
|
|
|
// clampPct — integer 0..100, NaN-safe.
|
|
expect('HV-10d clampPct(-5) === 0', clampPct(-5) === 0);
|
|
expect('HV-10e clampPct(150) === 100', clampPct(150) === 100);
|
|
expect('HV-10f clampPct(49.6) rounds to 50', clampPct(49.6) === 50);
|
|
expect('HV-10g clampPct(NaN) === 0', clampPct(NaN) === 0);
|
|
|
|
// cpuTimesTotals — aggregate idle + total jiffies across cores.
|
|
const totals = cpuTimesTotals([
|
|
{ times: { user: 10, nice: 0, sys: 5, idle: 80, irq: 5 } },
|
|
{ times: { user: 20, nice: 0, sys: 10, idle: 60, irq: 10 } }
|
|
]);
|
|
expect('HV-10h cpuTimesTotals sums idle', totals.idle === 140);
|
|
expect('HV-10i cpuTimesTotals sums total', totals.total === 200);
|
|
|
|
// cpuBusyPct — busy fraction between two snapshots.
|
|
expect(
|
|
'HV-10j cpuBusyPct 50% busy',
|
|
cpuBusyPct({ idle: 100, total: 200 }, { idle: 150, total: 300 }) === 50
|
|
);
|
|
expect(
|
|
'HV-10k cpuBusyPct zero delta → null',
|
|
cpuBusyPct({ idle: 100, total: 200 }, { idle: 100, total: 200 }) === null
|
|
);
|
|
expect(
|
|
'HV-10l cpuBusyPct fully busy (no idle gain)',
|
|
cpuBusyPct({ idle: 100, total: 200 }, { idle: 100, total: 400 }) === 100
|
|
);
|
|
|
|
// parseMeminfo — MemTotal/MemAvailable kB → bytes; null on missing.
|
|
const mi = parseMeminfo('MemTotal: 4096 kB\nMemFree: 512 kB\nMemAvailable: 1024 kB\n');
|
|
expect(
|
|
'HV-10m parseMeminfo total/avail → bytes',
|
|
mi !== null && mi.totalBytes === 4096 * 1024 && mi.availBytes === 1024 * 1024
|
|
);
|
|
expect('HV-10n parseMeminfo garbage → null', parseMeminfo('not meminfo') === null);
|
|
expect(
|
|
'HV-10o parseMeminfo missing MemAvailable → null',
|
|
parseMeminfo('MemTotal: 4096 kB\nMemFree: 512 kB\n') === null
|
|
);
|
|
}
|
|
|
|
// ── HV-11: readSystemResources shape (impure; samples THIS host) ──
|
|
{
|
|
const sys = await readSystemResources();
|
|
const keys = [
|
|
'cpuPct',
|
|
'memUsedGB',
|
|
'memTotalGB',
|
|
'memPct',
|
|
'diskUsedGB',
|
|
'diskAvailGB',
|
|
'diskTotalGB',
|
|
'diskPct'
|
|
];
|
|
const pctOk = (v: number | null) => v === null || (v >= 0 && v <= 100);
|
|
const gbOk = (v: number | null) => v === null || v >= 0;
|
|
expect('HV-11a readSystemResources returns all 8 keys', keys.every((k) => k in sys));
|
|
expect('HV-11b cpuPct in 0..100 or null', pctOk(sys.cpuPct));
|
|
expect('HV-11c memPct in 0..100 or null', pctOk(sys.memPct));
|
|
expect('HV-11d diskPct in 0..100 or null', pctOk(sys.diskPct));
|
|
expect('HV-11e memTotalGB ≥ 0 or null', gbOk(sys.memTotalGB));
|
|
expect('HV-11f diskTotalGB ≥ 0 or null', gbOk(sys.diskTotalGB));
|
|
// Memory has an os.totalmem() fallback that never throws, so on any
|
|
// real host memTotalGB must resolve to a positive number.
|
|
expect(
|
|
'HV-11g memTotalGB is a positive number on this host',
|
|
typeof sys.memTotalGB === 'number' && (sys.memTotalGB as number) > 0
|
|
);
|
|
}
|
|
|
|
// ── HV-12: head-block fast-path status (v1.7.0, ADR-0051) ──
|
|
{
|
|
// parseFastPath — tolerant parse of the operator-only top-level `fastpath`
|
|
// block (camelCase keys, forwarded verbatim from the indexer's
|
|
// HeadTailerStatus).
|
|
//
|
|
// v1.7.0 — the `enabled` cases here are GONE with the field. They asserted a
|
|
// value that could only ever be true, so HV-12b ("disabled parsed") was
|
|
// pinning a state no deployment could reach. What replaced them is the
|
|
// property that can actually vary and that an operator actually needs:
|
|
// whether the tailer is keeping up with the chain head.
|
|
const on = parseFastPath({
|
|
running: true,
|
|
scannedHead: 59500000,
|
|
emitted: 42,
|
|
lastError: null,
|
|
lastErrorAt: null
|
|
});
|
|
expect(
|
|
'HV-12a fastpath running parsed',
|
|
on !== null && on.running && on.scannedHead === 59500000 && on.emitted === 42
|
|
);
|
|
const starting = parseFastPath({ running: false, scannedHead: 0, emitted: 0, lastError: null });
|
|
expect('HV-12b not-yet-tailing parsed', starting !== null && !starting.running);
|
|
expect(
|
|
'HV-12c absent/null block → null',
|
|
parseFastPath(undefined) === null && parseFastPath(null) === null
|
|
);
|
|
const err = parseFastPath({ running: false, lastError: 'rpc getBlock 502' });
|
|
expect(
|
|
'HV-12d lastError surfaced, missing numeric fields → null (tolerant)',
|
|
err !== null && err.lastError === 'rpc getBlock 502' && err.scannedHead === null && err.emitted === null
|
|
);
|
|
expect(
|
|
'HV-12e non-object → null',
|
|
parseFastPath(42) === null && parseFastPath('on') === null
|
|
);
|
|
// The threshold the renderer judges "keeping up" by must stay tight enough to
|
|
// mean something: blocks are ~3s and the scanner polls every ~2s, so a
|
|
// tailer more than a few blocks back is not delivering the ≤6s the fast path
|
|
// exists for. A large value here would let a broken tailer read as healthy.
|
|
expect(
|
|
'HV-12f healthy-lag threshold is tight (<= 6 blocks ≈ 18s)',
|
|
FASTPATH_HEALTHY_LAG_BLOCKS > 0 && FASTPATH_HEALTHY_LAG_BLOCKS <= 6
|
|
);
|
|
|
|
// Render source-assertion: the node-health view prints a "Fast path:" line.
|
|
//
|
|
// v1.7.0 — the old assertions pinned an on/off/disabled line ("messages
|
|
// appear once irreversible"). Those states no longer exist (ADR-0051), and a
|
|
// smoke that still demanded them would have forced the dead branch to stay.
|
|
// What's pinned now is that the line reports LAG: a tailer that is running
|
|
// but far behind head is broken, and the old line called that one "on".
|
|
const healthSrc = readFileSync(
|
|
join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'commands', 'health.ts'),
|
|
'utf-8'
|
|
);
|
|
expect('HV-12g render has a "Fast path:" label', healthSrc.includes('Fast path:'));
|
|
expect(
|
|
'HV-12h render reports lag against head, not an on/off boolean',
|
|
healthSrc.includes('behind head') && healthSrc.includes('FASTPATH_HEALTHY_LAG_BLOCKS')
|
|
);
|
|
expect(
|
|
'HV-12i render still shows a delivered count + keeping-up state',
|
|
healthSrc.includes('delivered') && healthSrc.includes('keeping up')
|
|
);
|
|
expect(
|
|
'HV-12j render has the older-build fallback',
|
|
healthSrc.includes('Fast path: status unavailable (older indexer build)')
|
|
);
|
|
expect(
|
|
'HV-12k render no longer offers a disabled state',
|
|
!healthSrc.includes('messages appear once irreversible')
|
|
);
|
|
|
|
// Indexer-side: `fastpath` must be in the OPERATOR-ONLY top-level block (the
|
|
// `localDiag` / X-Morphit-Local-Health gate the ops-cli uses) — NOT only in
|
|
// the ?verbose=1 diagnostics block, or the node-health view (which doesn't
|
|
// send verbose=1) would never see it.
|
|
const idxHealthSrc = readFileSync(
|
|
join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'indexer', 'src', 'api', 'health.ts'),
|
|
'utf-8'
|
|
);
|
|
const localDiagIdx = idxHealthSrc.indexOf('if (localDiag) {');
|
|
const fastPathIdx = idxHealthSrc.indexOf('body.fastpath');
|
|
const verboseIdx = idxHealthSrc.indexOf('if (verbose) {');
|
|
expect('HV-12l indexer emits body.fastpath', fastPathIdx >= 0);
|
|
expect(
|
|
'HV-12m fastpath is inside the localDiag (operator-only) block, before the verbose block',
|
|
localDiagIdx >= 0 && fastPathIdx > localDiagIdx && (verboseIdx < 0 || fastPathIdx < verboseIdx)
|
|
);
|
|
}
|
|
|
|
console.log('');
|
|
if (fail > 0) {
|
|
console.log('\u2717 health-view smoke FAILED');
|
|
process.exit(1);
|
|
}
|
|
console.log('\u2713 morphit-ops health resolves its URL, classifies sync state, and exits 0/1/2 correctly');
|
|
console.log(`\u2713 all ${pass} health-view scenarios passed`);
|