feat(brainstorm-companion): resilient reconnect, live status, paused overlay

The injected client reconnected on a fixed 1s timer with no feedback: if the
laptop slept or the server restarted, the page showed 'Connected' over a dead
socket and silently queued events. And when the server stopped, the user got a
bare connection-refused with no explanation.

helper.js now:
- reconnects with exponential backoff (500ms, doubling, capped at 30s; reset on
  open), with an onerror->close handler, nulls the socket on close, and clears a
  pending timer before scheduling another;
- drives the frame status pill Connected/Reconnecting/Disconnected via a
  --status-color custom property (frame-template.html);
- after ~15s disconnected, shows a self-styled 'Companion paused' overlay
  (tombstone) explaining the companion stopped and will reconnect automatically;
- on recovery from a tombstoned outage (e.g. server restarted on the same port)
  reloads to pick up the restarted server's current screen.

The reconnect-backoff is an exported pure function; helper.test.js unit-tests it
(doubling + cap progression) and asserts the status/tombstone/reconnect wiring.
DOM behaviour is verified live.

Refs #856, #1237
This commit is contained in:
Jesse Vincent
2026-06-09 15:18:19 -07:00
parent f057b4a30b
commit e6cf11f68c
4 changed files with 164 additions and 8 deletions

View File

@@ -0,0 +1,84 @@
/**
* Tests for the injected browser client (helper.js).
*
* helper.js runs in the browser, so its DOM behaviour is exercised live; here we
* unit-test the pure reconnect-backoff function it exports and assert that the
* reconnect / status / tombstone wiring is present.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const HELPER = path.join(__dirname, '../../skills/brainstorming/scripts/helper.js');
const src = fs.readFileSync(HELPER, 'utf-8');
// helper.js is browser code, and the repo is an ES module package, so a plain
// require() won't surface its exports. Evaluate the source in a CommonJS sandbox
// with no `window`, so only the exported pure helpers run (not the browser code).
const moduleShim = { exports: {} };
new Function('module', src)(moduleShim);
const { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS } = moduleShim.exports;
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); console.log(` PASS: ${name}`); passed++; }
catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
}
console.log('\n--- Backoff (pure) ---');
test('doubles the delay each call', () => {
assert.strictEqual(nextReconnectDelay(500, 30000), 1000);
assert.strictEqual(nextReconnectDelay(1000, 30000), 2000);
assert.strictEqual(nextReconnectDelay(2000, 30000), 4000);
});
test('caps at the maximum', () => {
assert.strictEqual(nextReconnectDelay(20000, 30000), 30000);
assert.strictEqual(nextReconnectDelay(30000, 30000), 30000);
});
test('full progression from MIN caps at MAX and never exceeds it', () => {
const seq = [MIN_RECONNECT_MS];
let d = MIN_RECONNECT_MS;
for (let i = 0; i < 10; i++) { d = nextReconnectDelay(d, MAX_RECONNECT_MS); seq.push(d); }
assert.strictEqual(seq[0], 500);
assert.deepStrictEqual(seq.slice(0, 7), [500, 1000, 2000, 4000, 8000, 16000, 30000]);
assert(seq.every(v => v <= MAX_RECONNECT_MS), 'never exceeds max');
assert.strictEqual(seq[seq.length - 1], 30000, 'settles at the cap');
});
test('exposes sane constants', () => {
assert.strictEqual(MIN_RECONNECT_MS, 500);
assert.strictEqual(MAX_RECONNECT_MS, 30000);
assert(TOMBSTONE_AFTER_MS >= 5000, 'tombstone grace is at least a few seconds');
});
console.log('\n--- Wiring (source) ---');
test('reflects all three connection states', () => {
assert(/Connected/.test(src) && /Reconnecting/.test(src) && /Disconnected/.test(src),
'should set Connected / Reconnecting / Disconnected status');
assert(src.includes("setProperty('--status-color'"), 'drives the status dot via --status-color');
});
test('renders a tombstone overlay when paused', () => {
assert(src.includes('bs-tombstone'), 'creates the tombstone element');
assert(/Companion paused/.test(src), 'tombstone explains the companion paused');
});
test('hardens reconnection (onerror, null socket, clears pending timer)', () => {
assert(src.includes('onerror'), 'handles onerror');
assert(/ws = null/.test(src), 'nulls the socket on close so sendEvent queues');
assert(src.includes('clearTimeout'), 'clears a pending reconnect before scheduling another');
assert(src.includes('nextReconnectDelay'), 'uses exponential backoff for reconnects');
});
test('reloads on recovery and on reload messages', () => {
assert(/location\.reload\(\)/.test(src), 'reloads to pick up restarted/updated content');
});
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
if (failed > 0) process.exit(1);

View File

@@ -2,7 +2,7 @@
"name": "brainstorm-server-tests",
"version": "1.0.0",
"scripts": {
"test": "node ws-protocol.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
"test": "node ws-protocol.test.js && node helper.test.js && node server.test.js && node lifecycle.test.js && bash stop-server.test.sh"
},
"dependencies": {
"ws": "^8.19.0"