diff --git a/skills/brainstorming/scripts/frame-template.html b/skills/brainstorming/scripts/frame-template.html index 6325ef91..51575a1e 100644 --- a/skills/brainstorming/scripts/frame-template.html +++ b/skills/brainstorming/scripts/frame-template.html @@ -73,8 +73,8 @@ flex-shrink: 0; } .header h1 { font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); } - .header .status { font-size: 0.7rem; color: var(--success); display: flex; align-items: center; gap: 0.4rem; } - .header .status::before { content: ''; width: 6px; height: 6px; background: var(--success); border-radius: 50%; } + .header .status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; } + .header .status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; } .main { flex: 1; overflow-y: auto; } #frame-content { padding: 2rem; min-height: 100%; } diff --git a/skills/brainstorming/scripts/helper.js b/skills/brainstorming/scripts/helper.js index 111f97f5..456e9150 100644 --- a/skills/brainstorming/scripts/helper.js +++ b/skills/brainstorming/scripts/helper.js @@ -1,26 +1,98 @@ (function() { + const MIN_RECONNECT_MS = 500; + const MAX_RECONNECT_MS = 30000; + const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected + + // Pure: next backoff delay (doubles, capped). Exported for unit tests. + function nextReconnectDelay(current, max) { + return Math.min(current * 2, max); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS }; + } + + // Everything below is browser-only; bail out when loaded in Node (tests). + if (typeof window === 'undefined') return; + const WS_URL = 'ws://' + window.location.host; let ws = null; let eventQueue = []; + let reconnectDelay = MIN_RECONNECT_MS; + let reconnectTimer = null; + let disconnectedSince = null; + let everConnected = false; + let tombstoneShown = false; + + // Reflect connection state in the frame's status pill (absent on full-doc screens). + function setStatus(state) { + const el = document.querySelector('.status'); + if (!el) return; + const map = { + connected: ['Connected', 'var(--success)'], + reconnecting: ['Reconnecting…', 'var(--warning)'], + disconnected: ['Disconnected', 'var(--error)'] + }; + const [text, color] = map[state] || map.disconnected; + el.textContent = text; + el.style.setProperty('--status-color', color); + } + + // Self-styled so it works on framed and full-document screens alike. + function showTombstone() { + if (tombstoneShown) return; + tombstoneShown = true; + const el = document.createElement('div'); + el.id = 'bs-tombstone'; + el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' + + 'align-items:center;justify-content:center;padding:2rem;text-align:center;' + + 'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif'; + el.innerHTML = '
' + + '

Companion paused

' + + '

This brainstorm companion has stopped. ' + + 'Ask your coding agent to bring it back — this page reconnects automatically.

'; + if (document.body) document.body.appendChild(el); + } function connect() { + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + setStatus(everConnected ? 'reconnecting' : 'connected'); ws = new WebSocket(WS_URL); ws.onopen = () => { + const recovered = tombstoneShown; + everConnected = true; + disconnectedSince = null; + reconnectDelay = MIN_RECONNECT_MS; + tombstoneShown = false; + setStatus('connected'); eventQueue.forEach(e => ws.send(JSON.stringify(e))); eventQueue = []; + // Recovered from a tombstoned outage (e.g. the server restarted on the same + // port) — reload to pick up the restarted server's current screen. + if (recovered) window.location.reload(); }; ws.onmessage = (msg) => { - const data = JSON.parse(msg.data); - if (data.type === 'reload') { - window.location.reload(); - } + let data; + try { data = JSON.parse(msg.data); } catch (e) { return; } + if (data.type === 'reload') window.location.reload(); }; ws.onclose = () => { - setTimeout(connect, 1000); + ws = null; + if (disconnectedSince === null) disconnectedSince = Date.now(); + if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) { + setStatus('disconnected'); + showTombstone(); + } else { + setStatus('reconnecting'); + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS); }; + + // Let onclose own reconnection so we don't schedule it twice. + ws.onerror = () => { try { ws.close(); } catch (e) {} }; } function sendEvent(event) { diff --git a/tests/brainstorm-server/helper.test.js b/tests/brainstorm-server/helper.test.js new file mode 100644 index 00000000..e897afcd --- /dev/null +++ b/tests/brainstorm-server/helper.test.js @@ -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); diff --git a/tests/brainstorm-server/package.json b/tests/brainstorm-server/package.json index a4044701..52ff3aed 100644 --- a/tests/brainstorm-server/package.json +++ b/tests/brainstorm-server/package.json @@ -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"