Files
meshcore-analyzer/test-live.js
Kpa-clawbot 7e0b904d09 fix: refresh live feed relative timestamps every 10s (#709)
## Summary

Fixes #701 — Live feed timestamps showed stale relative times (e.g. "2s
ago" never updated to "5m ago").

## Root Cause

`formatLiveTimestampHtml()` was called once when each feed item was
created and never refreshed. The dedup path (when a duplicate hash moves
an item to the top) also didn't update the timestamp.

## Changes

### `public/live.js`
- **`data-ts` attribute on `.feed-time` spans**: All three feed item
creation paths (VCR replay, `addFeedItemDOM`, `addFeedItem`) now store
the packet timestamp as `data-ts` on the `.feed-time` span element
- **10-second refresh interval**: A `setInterval` queries all
`.feed-time[data-ts]` elements and re-renders their content via
`formatLiveTimestampHtml()`, keeping relative times accurate
- **Dedup path timestamp update**: When a duplicate hash observation
moves an existing feed item to the top, the `.feed-time` span is updated
with the new observation's timestamp
- **Cleanup**: The interval is cleared on page teardown alongside other
intervals

### `test-live.js`
- 3 new tests: formatting idempotency, numeric timestamp acceptance,
`data-ts` round-trip correctness

## Performance

- The refresh interval runs every 10s, iterating over at most 25
`.feed-time` DOM elements (feed is capped at 25 items via `while
(feed.children.length > 25)`). Negligible overhead.
- Uses `querySelectorAll` with attribute selector — O(n) where n ≤ 25.

## Testing

- All 3 new tests pass
- All pre-existing test suites pass (70 live.js tests, 62 packet-filter,
501 frontend-helpers)
- 8 pre-existing failures in `test-live.js` are unrelated
(`getParsedDecoded` missing from sandbox)

Co-authored-by: you <you@example.com>
2026-04-11 21:30:38 -07:00

941 lines
36 KiB
JavaScript

/* Unit tests for live.js functions (tested via VM sandbox)
* Part of #344 — live.js coverage
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
const pendingTests = [];
function test(name, fn) {
try {
const out = fn();
if (out && typeof out.then === 'function') {
pendingTests.push(
out.then(() => { passed++; console.log(`${name}`); })
.catch((e) => { failed++; console.log(`${name}: ${e.message}`); })
);
return;
}
passed++; console.log(`${name}`);
} catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// --- Browser-like sandbox ---
function makeSandbox() {
const ctx = {
window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 },
document: {
readyState: 'complete',
createElement: (tag) => ({
tagName: tag, id: '', textContent: '', innerHTML: '', style: {},
classList: { add() {}, remove() {}, contains() { return false; } },
setAttribute() {}, getAttribute() { return null; },
addEventListener() {}, focus() {},
getContext: () => ({
clearRect() {}, fillRect() {}, beginPath() {}, arc() {}, fill() {},
scale() {}, fillStyle: '', font: '', fillText() {},
}),
offsetWidth: 200, offsetHeight: 40, width: 0, height: 0,
}),
head: { appendChild: () => {} },
getElementById: () => null,
addEventListener: () => {},
querySelectorAll: () => [],
querySelector: () => null,
createElementNS: () => ({
tagName: 'svg', id: '', textContent: '', innerHTML: '', style: {},
setAttribute() {}, getAttribute() { return null; },
}),
documentElement: { getAttribute: () => null, setAttribute: () => {} },
body: { appendChild: () => {}, removeChild: () => {}, contains: () => false },
hidden: false,
},
console,
Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
Error, TypeError, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite,
encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout: () => {},
setInterval: () => 0, clearInterval: () => {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
requestAnimationFrame: (cb) => setTimeout(cb, 0),
cancelAnimationFrame: () => {},
localStorage: (() => {
const store = {};
return {
getItem: k => store[k] !== undefined ? store[k] : null,
setItem: (k, v) => { store[k] = String(v); },
removeItem: k => { delete store[k]; },
};
})(),
location: { hash: '', protocol: 'https:', host: 'localhost' },
CustomEvent: class CustomEvent {},
addEventListener: () => {},
dispatchEvent: () => {},
getComputedStyle: () => ({ getPropertyValue: () => '' }),
matchMedia: () => ({ matches: false, addEventListener: () => {} }),
navigator: {},
visualViewport: null,
MutationObserver: function() { this.observe = () => {}; this.disconnect = () => {}; },
WebSocket: function() { this.close = () => {}; },
IATA_COORDS_GEO: {},
};
vm.createContext(ctx);
return ctx;
}
function loadInCtx(ctx, file) {
vm.runInContext(fs.readFileSync(file, 'utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
function makeLeafletMock() {
return {
circleMarker: () => {
const m = {
addTo() { return m; }, bindTooltip() { return m; }, on() { return m; },
setRadius() {}, setStyle() {}, setLatLng() {},
getLatLng() { return { lat: 0, lng: 0 }; },
_baseColor: '', _baseSize: 5, _glowMarker: null, remove() {},
};
return m;
},
polyline: () => { const p = { addTo() { return p; }, setStyle() {}, remove() {} }; return p; },
polygon: () => { const p = { addTo() { return p; }, remove() {} }; return p; },
map: () => {
const m = {
setView() { return m; }, addLayer() { return m; }, on() { return m; },
getZoom() { return 11; }, getCenter() { return { lat: 37, lng: -122 }; },
getBounds() { return { contains: () => true }; }, fitBounds() { return m; },
invalidateSize() {}, remove() {}, hasLayer() { return false; }, removeLayer() {},
};
return m;
},
layerGroup: () => {
const g = {
addTo() { return g; }, addLayer() {}, removeLayer() {},
clearLayers() {}, hasLayer() { return true; }, eachLayer() {},
};
return g;
},
tileLayer: () => ({ addTo() { return this; } }),
control: { attribution: () => ({ addTo() {} }) },
DomUtil: { addClass() {}, removeClass() {} },
};
}
function addLiveGlobals(ctx) {
ctx.L = makeLeafletMock();
ctx.registerPage = () => {};
ctx.onWS = () => {};
ctx.offWS = () => {};
ctx.connectWS = () => {};
ctx.api = () => Promise.resolve([]);
ctx.invalidateApiCache = () => {};
ctx.favStar = () => '';
ctx.bindFavStars = () => {};
ctx.getFavorites = () => [];
ctx.isFavorite = () => false;
ctx.HopResolver = { init() {}, resolve: () => ({}), ready: () => false };
ctx.MeshAudio = null;
ctx.RegionFilter = { init() {}, getSelected: () => null, onRegionChange: () => {} };
}
function makeLiveSandbox({ withAppJs = false } = {}) {
const ctx = makeSandbox();
addLiveGlobals(ctx);
loadInCtx(ctx, 'public/roles.js');
if (withAppJs) loadInCtx(ctx, 'public/app.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
console.error('live.js load error:', e.message);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
return ctx;
}
// ===== dbPacketToLive =====
console.log('\n=== live.js: dbPacketToLive ===');
{
const ctx = makeLiveSandbox();
const dbPacketToLive = ctx.window._liveDbPacketToLive;
assert.ok(dbPacketToLive, '_liveDbPacketToLive must be exposed');
test('converts basic DB packet to live format', () => {
const pkt = {
id: 42, hash: 'abc123',
raw_hex: 'deadbeef',
path_json: '["hop1","hop2"]',
decoded_json: '{"type":"GRP_TXT","text":"hello"}',
timestamp: '2024-06-15T12:00:00Z',
snr: 7.5, rssi: -85, observer_name: 'ObsA',
};
const result = dbPacketToLive(pkt);
assert.strictEqual(result.id, 42);
assert.strictEqual(result.hash, 'abc123');
assert.strictEqual(result.raw, 'deadbeef');
assert.strictEqual(result.snr, 7.5);
assert.strictEqual(result.rssi, -85);
assert.strictEqual(result.observer, 'ObsA');
assert.strictEqual(result.decoded.header.payloadTypeName, 'GRP_TXT');
assert.strictEqual(result.decoded.payload.text, 'hello');
assert.deepStrictEqual(result.decoded.path.hops, ['hop1', 'hop2']);
assert.strictEqual(result._ts, new Date('2024-06-15T12:00:00Z').getTime());
});
test('handles null decoded_json', () => {
const pkt = { id: 1, hash: 'x', decoded_json: null, path_json: null, timestamp: '2024-01-01T00:00:00Z' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result.decoded.header.payloadTypeName, 'UNKNOWN');
assert.deepStrictEqual(result.decoded.path.hops, []);
});
test('uses payload_type_name as fallback', () => {
const pkt = { id: 2, hash: 'y', decoded_json: '{}', path_json: '[]', timestamp: '2024-01-01T00:00:00Z', payload_type_name: 'ADVERT' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result.decoded.header.payloadTypeName, 'ADVERT');
});
test('uses created_at as timestamp fallback', () => {
const pkt = { id: 3, hash: 'z', decoded_json: '{}', path_json: '[]', created_at: '2024-03-01T06:00:00Z' };
const result = dbPacketToLive(pkt);
assert.strictEqual(result._ts, new Date('2024-03-01T06:00:00Z').getTime());
});
}
// ===== expandToBufferEntries =====
console.log('\n=== live.js: expandToBufferEntries ===');
{
const ctx = makeLiveSandbox();
const expand = ctx.window._liveExpandToBufferEntries;
assert.ok(expand, '_liveExpandToBufferEntries must be exposed');
test('single packet without observations returns one entry', () => {
const pkts = [{
id: 1, hash: 'h1', timestamp: '2024-06-15T12:00:00Z',
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 1);
assert.strictEqual(entries[0].pkt.id, 1);
assert.strictEqual(entries[0].ts, new Date('2024-06-15T12:00:00Z').getTime());
});
test('packet with observations expands to one entry per observation', () => {
const pkts = [{
id: 10, hash: 'h10', timestamp: '2024-06-15T12:00:00Z',
decoded_json: '{"type":"ADVERT"}', path_json: '[]', raw_hex: 'ff',
observations: [
{ timestamp: '2024-06-15T12:00:01Z', snr: 5, observer_name: 'O1' },
{ timestamp: '2024-06-15T12:00:02Z', snr: 8, observer_name: 'O2' },
{ timestamp: '2024-06-15T12:00:03Z', snr: 3, observer_name: 'O3' },
],
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 3);
assert.strictEqual(entries[0].pkt.observer, 'O1');
assert.strictEqual(entries[1].pkt.observer, 'O2');
assert.strictEqual(entries[2].pkt.observer, 'O3');
// All should share the same hash
assert.strictEqual(entries[0].pkt.hash, 'h10');
assert.strictEqual(entries[2].pkt.hash, 'h10');
// Entries should be in chronological order
assert.ok(entries[0].ts < entries[1].ts, 'entry 0 should be before entry 1');
assert.ok(entries[1].ts < entries[2].ts, 'entry 1 should be before entry 2');
});
test('empty observations array treated as no observations', () => {
const pkts = [{
id: 5, hash: 'h5', timestamp: '2024-01-01T00:00:00Z',
decoded_json: '{}', path_json: '[]', observations: [],
}];
const entries = expand(pkts);
assert.strictEqual(entries.length, 1);
});
test('multiple packets expand independently', () => {
const pkts = [
{ id: 1, hash: 'h1', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]' },
{
id: 2, hash: 'h2', timestamp: '2024-01-01T00:00:00Z', decoded_json: '{}', path_json: '[]', raw_hex: 'aa',
observations: [
{ timestamp: '2024-01-01T00:00:01Z', observer_name: 'X' },
{ timestamp: '2024-01-01T00:00:02Z', observer_name: 'Y' },
],
},
];
const entries = expand(pkts);
assert.strictEqual(entries.length, 3);
});
}
// ===== expandToBufferEntriesAsync (chunked, non-blocking) =====
console.log('\n=== live.js: expandToBufferEntriesAsync ===');
{
// Build a sandbox with packet-helpers loaded so expandToBufferEntries can call dbPacketToLive
const ctx = makeSandbox();
addLiveGlobals(ctx);
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/packet-helpers.js');
try { loadInCtx(ctx, 'public/live.js'); } catch (e) {
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
}
const expandSync = ctx.window._liveExpandToBufferEntries;
const expandAsync = ctx.window._liveExpandToBufferEntriesAsync;
assert.ok(expandAsync, '_liveExpandToBufferEntriesAsync must be exposed');
const pkts = [];
for (let i = 0; i < 500; i++) {
pkts.push({
id: i, hash: 'h' + i, timestamp: new Date(1700000000000 + i * 1000).toISOString(),
decoded_json: '{"type":"GRP_TXT"}', path_json: '[]',
observations: [
{ timestamp: new Date(1700000000000 + i * 1000 + 100).toISOString(), snr: 5, observer_name: 'O1' },
{ timestamp: new Date(1700000000000 + i * 1000 + 200).toISOString(), snr: 8, observer_name: 'O2' },
],
});
}
test('sync expand handles 500 packets (1000 entries) correctly', () => {
const result = expandSync(pkts);
assert.strictEqual(result.length, 1000, '500 packets * 2 observations = 1000 entries');
assert.strictEqual(result[0].pkt.hash, 'h0');
assert.strictEqual(result[999].pkt.hash, 'h499');
});
test('VCR_CHUNK_SIZE is defined and async function yields via setTimeout', () => {
const src = fs.readFileSync(__dirname + '/public/live.js', 'utf8');
assert.ok(src.includes('VCR_CHUNK_SIZE'), 'VCR_CHUNK_SIZE constant must exist');
assert.ok(src.includes('expandToBufferEntriesAsync'), 'async version must exist');
assert.ok(src.includes('setTimeout(processChunk, 0)'), 'must yield via setTimeout between chunks');
});
}
// ===== SEG_MAP (7-segment display) =====
console.log('\n=== live.js: SEG_MAP ===');
{
const ctx = makeLiveSandbox();
const SEG_MAP = ctx.window._liveSEG_MAP;
assert.ok(SEG_MAP, '_liveSEG_MAP must be exposed');
test('all digits 0-9 are mapped', () => {
for (let i = 0; i <= 9; i++) {
assert.ok(SEG_MAP[String(i)] !== undefined, `digit ${i} must be in SEG_MAP`);
assert.ok(SEG_MAP[String(i)] > 0, `digit ${i} must have non-zero segments`);
}
});
test('digit 8 lights all 7 segments and no others', () => {
// 0x7F = 0b01111111 — all 7 segment bits on, MSB (colon) off
const val = SEG_MAP['8'];
assert.strictEqual(val & 0x7F, 0x7F, 'all 7 segment bits should be set');
assert.strictEqual(val & 0x80, 0, 'colon bit should not be set for a digit');
});
test('colon only sets the MSB (dot/colon indicator)', () => {
const val = SEG_MAP[':'];
assert.strictEqual(val & 0x80, 0x80, 'MSB (colon bit) should be set');
assert.strictEqual(val & 0x7F, 0, 'no segment bits should be set for colon');
});
test('space lights no segments', () => {
assert.strictEqual(SEG_MAP[' '], 0x00, 'space should have no bits set');
});
test('digit 1 lights fewer segments than digit 8', () => {
// Behavioral: 1 has fewer segments lit than 8
const ones = (n) => { let c = 0; while (n) { c += n & 1; n >>= 1; } return c; };
assert.ok(ones(SEG_MAP['1']) < ones(SEG_MAP['8']),
'digit 1 should have fewer segment bits than digit 8');
});
test('VCR mode letters are mapped with non-zero segments', () => {
for (const ch of ['P', 'A', 'U', 'S', 'E', 'L', 'I', 'V']) {
assert.ok(SEG_MAP[ch] !== undefined, `${ch} must be in SEG_MAP`);
assert.ok(SEG_MAP[ch] > 0, `${ch} must have non-zero segments`);
}
});
}
// ===== VCR state machine =====
console.log('\n=== live.js: VCR state machine ===');
{
const ctx = makeLiveSandbox();
const VCR = ctx.window._liveVCR;
const vcrSetMode = ctx.window._liveVcrSetMode;
const vcrPause = ctx.window._liveVcrPause;
const vcrSpeedCycle = ctx.window._liveVcrSpeedCycle;
assert.ok(VCR, '_liveVCR must be exposed');
test('VCR initial mode is LIVE', () => {
assert.strictEqual(VCR().mode, 'LIVE');
});
test('vcrSetMode changes mode', () => {
vcrSetMode('PAUSED');
assert.strictEqual(VCR().mode, 'PAUSED');
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when not LIVE');
});
test('vcrSetMode LIVE clears frozenNow', () => {
vcrSetMode('LIVE');
assert.strictEqual(VCR().mode, 'LIVE');
assert.strictEqual(VCR().frozenNow, null);
});
test('vcrPause stops replay and sets PAUSED', () => {
vcrSetMode('LIVE');
vcrPause();
assert.strictEqual(VCR().mode, 'PAUSED');
assert.strictEqual(VCR().missedCount, 0);
});
test('vcrPause is idempotent', () => {
vcrPause();
const frozen1 = VCR().frozenNow;
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should be PAUSED after first call');
vcrPause();
assert.strictEqual(VCR().frozenNow, frozen1);
assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call');
});
test('vcrSpeedCycle cycles through 1,2,4,8', () => {
vcrSetMode('LIVE');
VCR().speed = 1;
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 2);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 4);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 8);
vcrSpeedCycle();
assert.strictEqual(VCR().speed, 1); // wraps around
});
const vcrResumeLive = ctx.window._liveVcrResumeLive;
assert.ok(vcrResumeLive, '_liveVcrResumeLive must be exposed');
test('vcrResumeLive transitions from PAUSED to LIVE', () => {
vcrPause();
assert.strictEqual(VCR().mode, 'PAUSED');
assert.ok(VCR().frozenNow != null, 'frozenNow should be set when paused');
vcrResumeLive();
assert.strictEqual(VCR().mode, 'LIVE');
assert.strictEqual(VCR().frozenNow, null, 'frozenNow should be cleared');
assert.strictEqual(VCR().playhead, -1, 'playhead should reset to -1');
assert.strictEqual(VCR().speed, 1, 'speed should reset to 1');
assert.strictEqual(VCR().missedCount, 0, 'missedCount should be 0');
});
}
// ===== getFavoritePubkeys =====
console.log('\n=== live.js: getFavoritePubkeys ===');
{
const ctx = makeLiveSandbox();
const getFavPubkeys = ctx.window._liveGetFavoritePubkeys;
assert.ok(getFavPubkeys, '_liveGetFavoritePubkeys must be exposed');
test('returns empty array when no favorites stored', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
test('reads from meshcore-favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(result.includes('pk1'));
assert.ok(result.includes('pk2'));
});
test('reads from meshcore-my-nodes pubkeys', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mynode1"},{"pubkey":"mynode2"}]');
const result = getFavPubkeys();
assert.ok(result.includes('mynode1'));
assert.ok(result.includes('mynode2'));
});
test('merges both sources', () => {
ctx.localStorage.setItem('meshcore-favorites', '["fav1"]');
ctx.localStorage.setItem('meshcore-my-nodes', '[{"pubkey":"mine1"}]');
const result = getFavPubkeys();
assert.ok(result.includes('fav1'));
assert.ok(result.includes('mine1'));
assert.strictEqual(result.length, 2);
});
test('handles corrupt localStorage gracefully', () => {
ctx.localStorage.setItem('meshcore-favorites', 'not json');
ctx.localStorage.setItem('meshcore-my-nodes', '{bad}');
const result = getFavPubkeys();
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0, 'corrupt data should yield empty array');
});
test('filters out falsy values', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1",null,"",false,"pk2"]');
ctx.localStorage.removeItem('meshcore-my-nodes');
const result = getFavPubkeys();
assert.ok(!result.includes(null));
assert.ok(!result.includes(''));
assert.strictEqual(result.length, 2);
});
}
// ===== packetInvolvesFavorite =====
console.log('\n=== live.js: packetInvolvesFavorite ===');
{
const ctx = makeLiveSandbox();
// Clean localStorage to avoid leakage from prior test sections
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const involves = ctx.window._livePacketInvolvesFavorite;
assert.ok(involves, '_livePacketInvolvesFavorite must be exposed');
test('returns false when no favorites', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const pkt = { decoded: { header: {}, payload: { pubKey: 'abc' } } };
assert.strictEqual(involves(pkt), false);
});
test('matches sender pubKey', () => {
ctx.localStorage.setItem('meshcore-favorites', '["sender123"]');
const pkt = { decoded: { header: {}, payload: { pubKey: 'sender123' } } };
assert.strictEqual(involves(pkt), true);
});
test('matches hop prefix', () => {
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['abcd'] } } };
assert.strictEqual(involves(pkt), true);
});
test('does not match unrelated hop', () => {
ctx.localStorage.setItem('meshcore-favorites', '["abcdef1234567890"]');
const pkt = { decoded: { header: {}, payload: {}, path: { hops: ['ffff'] } } };
assert.strictEqual(involves(pkt), false);
});
test('handles missing decoded fields gracefully', () => {
ctx.localStorage.setItem('meshcore-favorites', '["xyz"]');
const pkt = {};
assert.strictEqual(involves(pkt), false);
});
}
// ===== isNodeFavorited =====
console.log('\n=== live.js: isNodeFavorited ===');
{
const ctx = makeLiveSandbox();
// Clean localStorage to avoid leakage from prior test sections
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
const isFav = ctx.window._liveIsNodeFavorited;
assert.ok(isFav, '_liveIsNodeFavorited must be exposed');
test('returns true when pubkey is in favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1","pk2"]');
assert.strictEqual(isFav('pk1'), true);
});
test('returns false when pubkey not in favorites', () => {
ctx.localStorage.setItem('meshcore-favorites', '["pk1"]');
assert.strictEqual(isFav('pk99'), false);
});
test('returns false with empty favorites', () => {
ctx.localStorage.removeItem('meshcore-favorites');
ctx.localStorage.removeItem('meshcore-my-nodes');
assert.strictEqual(isFav('pk1'), false);
});
}
// ===== formatLiveTimestampHtml =====
console.log('\n=== live.js: formatLiveTimestampHtml ===');
{
const ctx = makeLiveSandbox({ withAppJs: true });
const fmt = ctx.window._liveFormatLiveTimestampHtml;
assert.ok(fmt, '_liveFormatLiveTimestampHtml must be exposed');
test('formats a recent ISO timestamp', () => {
const iso = new Date(Date.now() - 30000).toISOString();
const html = fmt(iso);
assert.ok(html.includes('timestamp-text'), 'should contain timestamp-text span');
assert.ok(html.includes('title='), 'should have tooltip');
});
test('handles null input', () => {
const html = fmt(null);
assert.ok(typeof html === 'string');
assert.ok(html.includes('—'), 'null input should render em-dash fallback');
});
test('handles numeric timestamp', () => {
const html = fmt(Date.now() - 60000);
assert.ok(typeof html === 'string');
assert.ok(html.includes('timestamp-text'), 'numeric timestamp should produce timestamp-text span');
assert.ok(html.includes('title='), 'numeric timestamp should have tooltip');
});
test('future timestamp shows warning icon', () => {
const future = new Date(Date.now() + 120000).toISOString();
const html = fmt(future);
assert.ok(html.includes('timestamp-future-icon'), 'should show future warning');
});
}
// ===== Feed timestamp refresh — data-ts attribute and selector (#701) =====
console.log('\n=== live.js: feed timestamp refresh (#701) ===');
{
const ctx = makeLiveSandbox({ withAppJs: true });
const fmt = ctx.window._liveFormatLiveTimestampHtml;
test('formatLiveTimestampHtml returns different text for different ages', () => {
const recent = fmt(Date.now() - 5000);
const older = fmt(Date.now() - 120000);
// Both should produce valid HTML
assert.ok(recent.includes('timestamp-text'), 'recent should have timestamp-text');
assert.ok(older.includes('timestamp-text'), 'older should have timestamp-text');
});
test('formatLiveTimestampHtml accepts numeric ms timestamp', () => {
const ts = Date.now() - 45000;
const html = fmt(ts);
assert.ok(html.includes('timestamp-text'), 'numeric ms timestamp should render');
// Re-calling with same ts should produce same result (idempotent refresh)
const html2 = fmt(ts);
assert.strictEqual(html, html2, 'same input should produce same output');
});
test('feed-time template with data-ts round-trips correctly', () => {
// Verify that Number(dataset.ts) fed back to fmt produces valid output
const ts = Date.now() - 30000;
const tsStr = String(ts);
const reparsed = Number(tsStr);
assert.strictEqual(reparsed, ts, 'data-ts round-trip should preserve value');
const html = fmt(reparsed);
assert.ok(html.includes('timestamp-text'), 'round-tripped timestamp should render');
});
}
// ===== resolveHopPositions =====
console.log('\n=== live.js: resolveHopPositions ===');
{
const ctx = makeLiveSandbox();
const resolve = ctx.window._liveResolveHopPositions;
const nodeData = ctx.window._liveNodeData();
const nodeMarkers = ctx.window._liveNodeMarkers();
assert.ok(resolve, '_liveResolveHopPositions must be exposed');
test('returns empty array for empty hops', () => {
const result = resolve([], {});
assert.deepStrictEqual(result, []);
});
test('returns sender position when payload has pubKey + coords', () => {
const payload = { pubKey: 'sender1', name: 'Sender', lat: 37.5, lon: -122.0 };
// No nodes in nodeData, so hops won't resolve
const result = resolve([], payload);
// With empty hops, the function still adds the sender as an anchor point.
assert.ok(Array.isArray(result), 'should return an array');
assert.strictEqual(result.length, 1, 'sender coords should produce one anchor position');
assert.strictEqual(result[0].pos[0], 37.5, 'anchor should use sender lat');
assert.strictEqual(result[0].pos[1], -122.0, 'anchor should use sender lon');
assert.strictEqual(result[0].name, 'Sender', 'anchor should use sender name');
assert.strictEqual(result[0].known, true, 'sender with coords should be marked as known');
});
test('resolves known node from nodeData', () => {
// Add a node to nodeData
nodeData['nodeA_pubkey'] = { public_key: 'nodeA_pubkey', name: 'NodeA', lat: 37.3, lon: -122.0 };
nodeData['nodeB_pubkey'] = { public_key: 'nodeB_pubkey', name: 'NodeB', lat: 38.0, lon: -121.0 };
// Need HopResolver to resolve the hop prefix — set on both ctx and window
const mockResolver = {
init() {},
ready() { return true; },
resolve(hops) {
const map = {};
for (const h of hops) {
if (h === 'nodeA') map[h] = { name: 'NodeA', pubkey: 'nodeA_pubkey' };
else if (h === 'nodeB') map[h] = { name: 'NodeB', pubkey: 'nodeB_pubkey' };
else map[h] = { name: null, pubkey: null };
}
return map;
},
};
ctx.HopResolver = mockResolver;
ctx.window.HopResolver = mockResolver;
// Need at least 2 known nodes for ghost mode to not filter down
const result = resolve(['nodeA', 'nodeB'], {});
assert.ok(result.length >= 2, `expected >= 2 positions, got ${result.length}`);
const foundA = result.find(r => r.key === 'nodeA_pubkey');
assert.ok(foundA, 'should resolve nodeA to nodeA_pubkey');
assert.strictEqual(foundA.pos[0], 37.3);
assert.strictEqual(foundA.pos[1], -122.0);
assert.strictEqual(foundA.known, true);
delete nodeData['nodeA_pubkey'];
delete nodeData['nodeB_pubkey'];
});
test('ghost hops get interpolated positions between known nodes', () => {
// Set up: two known nodes, one unknown hop between them
nodeData['n1'] = { public_key: 'n1', name: 'N1', lat: 37.0, lon: -122.0 };
nodeData['n2'] = { public_key: 'n2', name: 'N2', lat: 38.0, lon: -121.0 };
const mockResolver = {
init() {},
ready() { return true; },
resolve(hops) {
const map = {};
for (const h of hops) {
if (h === 'h1') map[h] = { name: 'N1', pubkey: 'n1' };
else if (h === 'h3') map[h] = { name: 'N2', pubkey: 'n2' };
else map[h] = { name: null, pubkey: null };
}
return map;
},
};
ctx.HopResolver = mockResolver;
ctx.window.HopResolver = mockResolver;
const result = resolve(['h1', 'h2', 'h3'], {});
assert.ok(result.length >= 2, `should have at least 2 positions, got ${result.length}`);
// Check that the ghost hop got an interpolated position
const ghost = result.find(r => r.ghost);
assert.ok(ghost, 'ghost hop should be present in resolved positions — if missing, interpolation logic changed');
assert.ok(ghost.pos[0] > 37.0 && ghost.pos[0] < 38.0, 'ghost lat should be interpolated');
assert.ok(ghost.pos[1] > -122.0 && ghost.pos[1] < -121.0, 'ghost lon should be interpolated');
delete nodeData['n1'];
delete nodeData['n2'];
});
}
// ===== bufferPacket and VCR buffer management =====
console.log('\n=== live.js: bufferPacket / VCR buffer ===');
{
const ctx = makeLiveSandbox();
const bufferPacket = ctx.window._liveBufferPacket;
const VCR = ctx.window._liveVCR;
assert.ok(bufferPacket, '_liveBufferPacket must be exposed');
test('bufferPacket adds entry to VCR buffer', () => {
const initialLen = VCR().buffer.length;
const pkt = { hash: 'test1', decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().buffer.length, initialLen + 1);
const last = VCR().buffer[VCR().buffer.length - 1];
assert.strictEqual(last.pkt.hash, 'test1');
assert.ok(last.ts > 0);
});
test('bufferPacket sets _ts on packet', () => {
const pkt = { hash: 'test2', decoded: { header: {}, payload: {} } };
const before = Date.now();
bufferPacket(pkt);
const after = Date.now();
assert.ok(pkt._ts >= before && pkt._ts <= after, `_ts should be between ${before} and ${after}, got ${pkt._ts}`);
});
test('VCR buffer caps at ~2000 entries', () => {
// Fill buffer past 2000
VCR().buffer.length = 0;
for (let i = 0; i < 2100; i++) {
VCR().buffer.push({ ts: Date.now(), pkt: { hash: 'fill' + i } });
}
// Next bufferPacket triggers trim: 2100+1=2101 > 2000 → splice(0, 500) → 1601
const pkt = { hash: 'overflow', decoded: { header: {}, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().buffer.length, 1601, `buffer should be 2101 - 500 = 1601, got ${VCR().buffer.length}`);
});
test('bufferPacket increments missedCount when PAUSED', () => {
ctx.window._liveVcrSetMode('PAUSED');
VCR().missedCount = 0;
const pkt = { hash: 'missed1', decoded: { header: {}, payload: {} } };
bufferPacket(pkt);
assert.strictEqual(VCR().missedCount, 1);
bufferPacket({ hash: 'missed2', decoded: { header: {}, payload: {} } });
assert.strictEqual(VCR().missedCount, 2);
ctx.window._liveVcrSetMode('LIVE');
});
test('bufferPacket handles malformed packet without decoded field', () => {
const before = VCR().buffer.length;
// Packet with no decoded field at all — should not throw, and should still be buffered
bufferPacket({ hash: 'malformed1' });
assert.strictEqual(VCR().buffer.length, before + 1, 'malformed packet should still be added to buffer');
});
test('bufferPacket handles packet with null decoded', () => {
const before = VCR().buffer.length;
bufferPacket({ hash: 'malformed2', decoded: null });
assert.strictEqual(VCR().buffer.length, before + 1, 'packet with null decoded should still be added to buffer');
});
}
// ===== VCR frozenNow behavior =====
console.log('\n=== live.js: VCR frozenNow ===');
{
const ctx = makeLiveSandbox();
const VCR = ctx.window._liveVCR;
const setMode = ctx.window._liveVcrSetMode;
test('frozenNow is set on first non-LIVE mode', () => {
setMode('LIVE');
assert.strictEqual(VCR().frozenNow, null);
setMode('PAUSED');
const t1 = VCR().frozenNow;
assert.ok(t1 > 0);
// Should NOT change on subsequent non-LIVE mode changes
setMode('REPLAY');
assert.strictEqual(VCR().frozenNow, t1, 'frozenNow should not change if already set');
});
test('frozenNow cleared on LIVE', () => {
setMode('PAUSED');
assert.ok(VCR().frozenNow != null);
setMode('LIVE');
assert.strictEqual(VCR().frozenNow, null);
});
}
// ===== Source-level checks for live.js safety guards =====
// NOTE: These src.includes() checks are intentionally brittle — they verify that specific
// safety guards exist in the source code TODAY. They will break on whitespace/rename refactors,
// which is an acceptable tradeoff: a failing test forces the developer to verify the guard
// still exists in its new form. For critical guards (animation limits, null checks), prefer
// behavioral tests where feasible (see bufferPacket and VCR sections above).
console.log('\n=== live.js: source-level safety checks ===');
{
const src = fs.readFileSync('public/live.js', 'utf8');
test('renderPacketTree null-checks packets array', () => {
assert.ok(src.includes('if (!packets || !packets.length) return;'),
'renderPacketTree must guard null/empty packets');
});
test('animatePath guards MAX_CONCURRENT_ANIMS', () => {
assert.ok(src.includes('if (activeAnims >= MAX_CONCURRENT_ANIMS) return;'),
'animatePath must respect concurrent animation limit');
});
test('animatePath guards null animLayer/pathsLayer', () => {
assert.ok(src.includes('if (!animLayer || !pathsLayer) return;'),
'animatePath must guard null layers');
});
test('pulseNode guards null animLayer/nodesLayer', () => {
assert.ok(src.includes('if (!animLayer || !nodesLayer) return;'),
'pulseNode must guard null layers');
});
test('nextHop guards null animLayer', () => {
assert.ok(src.includes('if (!animLayer) return;'),
'nextHop must guard null animLayer before drawing');
});
test('VCR buffer trim adjusts playhead', () => {
assert.ok(src.includes('VCR.playhead = Math.max(0, VCR.playhead - trimCount)'),
'buffer trim must adjust playhead to prevent stale indices');
});
test('tab hidden skips animations', () => {
assert.ok(src.includes('if (_tabHidden)'),
'bufferPacket should skip animation when tab is hidden');
});
test('visibility change clears propagation buffer', () => {
assert.ok(src.includes('propagationBuffer.clear()'),
'tab restore should clear propagation buffer');
});
test('connectWS has reconnect on close', () => {
assert.ok(src.includes('ws.onclose = () => setTimeout(connectWS, WS_RECONNECT_MS)'),
'WebSocket should auto-reconnect on close');
});
test('addNodeMarker avoids duplicates', () => {
assert.ok(src.includes('if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key]'),
'addNodeMarker should return existing marker if already exists');
});
test('matrix mode saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-matrix-mode'"),
'matrix toggle should persist to localStorage');
});
test('matrix rain saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-matrix-rain'"),
'matrix rain toggle should persist to localStorage');
});
test('realistic propagation saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-realistic-propagation'"),
'realistic propagation toggle should persist to localStorage');
});
test('favorites filter saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-favorites-only'"),
'favorites filter toggle should persist to localStorage');
});
test('ghost hops saves toggle to localStorage', () => {
assert.ok(src.includes("localStorage.setItem('live-ghost-hops'"),
'ghost hops toggle should persist to localStorage');
});
test('clearNodeMarkers resets HopResolver', () => {
assert.ok(src.includes('if (window.HopResolver) HopResolver.init([])'),
'clearNodeMarkers should reset HopResolver');
});
test('rescaleMarkers reads zoom from map', () => {
assert.ok(src.includes('const zoom = map.getZoom()'),
'rescaleMarkers should read current zoom level');
});
test('startReplay pre-aggregates by hash', () => {
assert.ok(src.includes('const hashGroups = new Map()'),
'startReplay should group buffer entries by hash');
});
test('orientation change retries resize with delays', () => {
assert.ok(src.includes('[50, 200, 500, 1000, 2000].forEach'),
'orientation change handler should retry resize at multiple intervals');
});
test('VCR rewind deduplicates buffer entries by ID', () => {
assert.ok(src.includes('const existingIds = new Set(VCR.buffer.map(b => b.pkt.id)'),
'vcrRewind should dedup by packet ID');
});
test('feed items include transport badge', () => {
const count = (src.match(/transportBadge\(pkt\.route_type\)/g) || []).length;
assert.ok(count >= 3,
`feed rendering should call transportBadge(pkt.route_type) in at least 3 places (found ${count})`);
});
test('node detail recent packets include transport badge', () => {
assert.ok(src.includes('transportBadge(p.route_type)'),
'node detail recent packets should call transportBadge(p.route_type)');
});
}
// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
console.log(` live.js tests: ${passed} passed, ${failed} failed`);
console.log(`${'═'.repeat(40)}\n`);
if (failed > 0) process.exit(1);
}).catch((e) => {
console.error('Failed waiting for async tests:', e);
process.exit(1);
});