Files
meshcore-analyzer/test-live-anims.js
Kpa-clawbot c9c473279e fix: add null-guards to rAF callbacks in live page animations (#506)
## Summary

Fixes #483 — navigating away from the live page while matrix/hop
animations are running throws `TypeError: Cannot read properties of null
(reading 'addLayer')`.

## Root Cause

`destroy()` sets `animLayer = null` and `pathsLayer = null`, but
in-flight `requestAnimationFrame` callbacks continue executing and
attempt to call `.addTo(animLayer)` or `.removeLayer()` on the now-null
references.

The entry guards at the top of `drawMatrixLine()` and
`drawAnimatedLine()` only protect the initial call — not the rAF
continuation loops inside `tick()`, `fadeOut()`, `animateLine()`, and
`animateFade()`.

## Fix

Added null-guards (`if (!animLayer || !pathsLayer) return`) at the top
of all four rAF callback functions in `live.js`:

1. **`tick()`** (line ~2203) — matrix animation main loop
2. **`fadeOut()`** (line ~2253) — matrix animation fade-out
3. **`animateLine()`** (line ~2302) — standard line animation main loop
4. **`animateFade()`** (line ~2337) — standard line fade-out

This pattern is already used elsewhere in the file (e.g., line 1873,
1886) for the same purpose.

## Testing

- All unit tests pass (`npm test` — 0 failures)
- Go server tests pass (`cmd/server` + `cmd/ingestor`)
- Change is defensive only (early return on null) — no behavioral change
when layers exist

---------

Co-authored-by: you <you@example.com>
2026-04-02 20:14:52 -07:00

129 lines
5.6 KiB
JavaScript

/* Unit tests for live.js animation system — verifies rAF migration and concurrency cap */
'use strict';
const fs = require('fs');
const assert = require('assert');
const src = fs.readFileSync('public/live.js', 'utf8');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
console.log('\n=== Animation interval elimination ===');
test('pulseNode does not use setInterval', () => {
// Extract pulseNode function body
const pulseStart = src.indexOf('function pulseNode(');
const nextFn = src.indexOf('\n function ', pulseStart + 1);
const body = src.substring(pulseStart, nextFn);
assert.ok(!body.includes('setInterval'), 'pulseNode still uses setInterval');
assert.ok(body.includes('requestAnimationFrame'), 'pulseNode should use requestAnimationFrame');
});
test('drawAnimatedLine does not use setInterval', () => {
const drawStart = src.indexOf('function drawAnimatedLine(');
const nextFn = src.indexOf('\n function ', drawStart + 1);
const body = src.substring(drawStart, nextFn);
assert.ok(!body.includes('setInterval'), 'drawAnimatedLine still uses setInterval');
assert.ok(body.includes('requestAnimationFrame'), 'drawAnimatedLine should use requestAnimationFrame');
});
test('ghost hop pulse does not use setInterval', () => {
// Ghost pulse is inside animatePath
const animStart = src.indexOf('function animatePath(');
const animEnd = src.indexOf('\n function ', animStart + 1);
const body = src.substring(animStart, animEnd);
assert.ok(!body.includes('setInterval'), 'animatePath still uses setInterval');
});
console.log('\n=== Concurrency cap ===');
test('MAX_CONCURRENT_ANIMS is defined', () => {
assert.ok(src.includes('MAX_CONCURRENT_ANIMS'), 'MAX_CONCURRENT_ANIMS constant not found');
});
test('MAX_CONCURRENT_ANIMS is set to 20', () => {
const match = src.match(/MAX_CONCURRENT_ANIMS\s*=\s*(\d+)/);
assert.ok(match, 'Could not parse MAX_CONCURRENT_ANIMS value');
assert.strictEqual(parseInt(match[1]), 20);
});
test('animatePath checks MAX_CONCURRENT_ANIMS before proceeding', () => {
const animStart = src.indexOf('function animatePath(');
// Check that within the first 200 chars of the function, we check the cap
const snippet = src.substring(animStart, animStart + 300);
assert.ok(snippet.includes('activeAnims >= MAX_CONCURRENT_ANIMS'), 'animatePath should check activeAnims against cap');
});
console.log('\n=== Safety: no stale setInterval in animation functions ===');
test('no setInterval remains in animation hot path', () => {
// The only acceptable setIntervals are the UI ones (timeline, clock, prune, rate counter)
// Count total setInterval occurrences
const matches = src.match(/setInterval\(/g) || [];
// Count known OK ones: _timelineRefreshInterval, _lcdClockInterval, _pruneInterval, _rateCounterInterval
const okPatterns = ['_timelineRefreshInterval', '_lcdClockInterval', '_pruneInterval', '_rateCounterInterval'];
let okCount = 0;
for (const p of okPatterns) {
if (src.includes(p + ' = setInterval') || src.includes(p + '= setInterval')) okCount++;
}
// Allow some non-animation setIntervals (the 4 UI ones above)
assert.ok(matches.length <= okCount + 1,
`Found ${matches.length} setInterval calls, expected at most ${okCount + 1} (non-animation). Some animation setIntervals may remain.`);
});
console.log(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
/* === Null-guard coverage for rAF callbacks === */
const src2 = fs.readFileSync('public/live.js', 'utf8');
let p2 = 0, f2 = 0;
function test2(name, fn) {
try { fn(); p2++; console.log(`${name}`); }
catch (e) { f2++; console.log(`${name}: ${e.message}`); }
}
console.log('\n=== Null guards on rAF animation callbacks ===');
test2('animatePath tick() has null guard', () => {
// tick is inside animatePath, after "function tick(now)"
const tickStart = src2.indexOf('function tick(now)');
const tickBody = src2.substring(tickStart, tickStart + 200);
assert.ok(tickBody.includes('!animLayer || !pathsLayer'), 'tick() missing animLayer/pathsLayer null guard');
});
test2('animatePath fadeOut() has null guard', () => {
const fadeOutStart = src2.indexOf('function fadeOut(now)');
const fadeOutBody = src2.substring(fadeOutStart, fadeOutStart + 200);
assert.ok(fadeOutBody.includes('!animLayer || !pathsLayer'), 'fadeOut() missing animLayer/pathsLayer null guard');
});
test2('drawAnimatedLine animateLine() has null guard', () => {
const lineStart = src2.indexOf('function animateLine(now)');
const lineBody = src2.substring(lineStart, lineStart + 200);
assert.ok(lineBody.includes('!animLayer || !pathsLayer'), 'animateLine() missing animLayer/pathsLayer null guard');
});
test2('drawAnimatedLine animateFade() has null guard', () => {
const fadeStart = src2.indexOf('function animateFade(now)');
const fadeBody = src2.substring(fadeStart, fadeStart + 200);
assert.ok(fadeBody.includes('!pathsLayer'), 'animateFade() missing pathsLayer null guard');
});
test2('pulseNode animatePulse() has null guard', () => {
const pulseStart = src2.indexOf('function animatePulse(now)');
const pulseBody = src2.substring(pulseStart, pulseStart + 200);
assert.ok(pulseBody.includes('!animLayer'), 'animatePulse() missing animLayer null guard');
});
test2('ghostPulse has null guard', () => {
const ghostStart = src2.indexOf('function ghostPulse(now)');
const ghostBody = src2.substring(ghostStart, ghostStart + 200);
assert.ok(ghostBody.includes('!animLayer'), 'ghostPulse() missing animLayer null guard');
});
console.log(`\n${p2} passed, ${f2} failed\n`);
if (f2 > 0) process.exit(1);