helper.test.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * Tests for the injected browser client (helper.js).
  3. *
  4. * helper.js runs in the browser, so its DOM behaviour is exercised live; here we
  5. * unit-test the pure reconnect-backoff function it exports and assert that the
  6. * reconnect / status / tombstone wiring is present.
  7. */
  8. const assert = require('assert');
  9. const fs = require('fs');
  10. const path = require('path');
  11. const HELPER = path.join(__dirname, '../../skills/brainstorming/scripts/helper.js');
  12. const src = fs.readFileSync(HELPER, 'utf-8');
  13. // helper.js is browser code, and the repo is an ES module package, so a plain
  14. // require() won't surface its exports. Evaluate the source in a CommonJS sandbox
  15. // with no `window`, so only the exported pure helpers run (not the browser code).
  16. const moduleShim = { exports: {} };
  17. new Function('module', src)(moduleShim);
  18. const { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS } = moduleShim.exports;
  19. let passed = 0, failed = 0;
  20. function test(name, fn) {
  21. try { fn(); console.log(` PASS: ${name}`); passed++; }
  22. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  23. }
  24. console.log('\n--- Backoff (pure) ---');
  25. test('doubles the delay each call', () => {
  26. assert.strictEqual(nextReconnectDelay(500, 30000), 1000);
  27. assert.strictEqual(nextReconnectDelay(1000, 30000), 2000);
  28. assert.strictEqual(nextReconnectDelay(2000, 30000), 4000);
  29. });
  30. test('caps at the maximum', () => {
  31. assert.strictEqual(nextReconnectDelay(20000, 30000), 30000);
  32. assert.strictEqual(nextReconnectDelay(30000, 30000), 30000);
  33. });
  34. test('full progression from MIN caps at MAX and never exceeds it', () => {
  35. const seq = [MIN_RECONNECT_MS];
  36. let d = MIN_RECONNECT_MS;
  37. for (let i = 0; i < 10; i++) { d = nextReconnectDelay(d, MAX_RECONNECT_MS); seq.push(d); }
  38. assert.strictEqual(seq[0], 500);
  39. assert.deepStrictEqual(seq.slice(0, 7), [500, 1000, 2000, 4000, 8000, 16000, 30000]);
  40. assert(seq.every(v => v <= MAX_RECONNECT_MS), 'never exceeds max');
  41. assert.strictEqual(seq[seq.length - 1], 30000, 'settles at the cap');
  42. });
  43. test('exposes sane constants', () => {
  44. assert.strictEqual(MIN_RECONNECT_MS, 500);
  45. assert.strictEqual(MAX_RECONNECT_MS, 30000);
  46. assert(TOMBSTONE_AFTER_MS >= 5000, 'tombstone grace is at least a few seconds');
  47. });
  48. console.log('\n--- Wiring (source) ---');
  49. test('reflects all three connection states', () => {
  50. assert(/Connected/.test(src) && /Reconnecting/.test(src) && /Disconnected/.test(src),
  51. 'should set Connected / Reconnecting / Disconnected status');
  52. assert(src.includes("setProperty('--status-color'"), 'drives the status dot via --status-color');
  53. });
  54. test('renders a tombstone overlay when paused', () => {
  55. assert(src.includes('bs-tombstone'), 'creates the tombstone element');
  56. assert(/Companion paused/.test(src), 'tombstone explains the companion paused');
  57. });
  58. test('hardens reconnection (onerror, null socket, clears pending timer)', () => {
  59. assert(src.includes('onerror'), 'handles onerror');
  60. assert(/ws = null/.test(src), 'nulls the socket on close so sendEvent queues');
  61. assert(src.includes('clearTimeout'), 'clears a pending reconnect before scheduling another');
  62. assert(src.includes('nextReconnectDelay'), 'uses exponential backoff for reconnects');
  63. });
  64. test('reloads on recovery and on reload messages', () => {
  65. assert(/location\.reload\(\)/.test(src), 'reloads to pick up restarted/updated content');
  66. });
  67. console.log('\n--- Reconnect state machine (mocked browser) ---');
  68. // Drive helper.js's browser code against mocked DOM/WebSocket/timers/clock so we
  69. // can exercise the actual reconnect/status/tombstone behaviour, not just grep it.
  70. function makeEnv() {
  71. const state = { now: 1000, timers: [], reloads: 0, replacements: [], appended: [], sessionKey: 'stored-key-abc' };
  72. const sockets = [];
  73. const statusEl = { textContent: '', style: { setProperty() {} } };
  74. class FakeWS {
  75. constructor(url) { this.url = url; this.readyState = 0; this.onopen = this.onclose = this.onmessage = this.onerror = null; sockets.push(this); }
  76. send() {}
  77. close() { this.readyState = 3; if (this.onclose) this.onclose(); }
  78. open() { this.readyState = 1; if (this.onopen) this.onopen(); }
  79. }
  80. FakeWS.OPEN = 1;
  81. const env = {
  82. module: { exports: {} },
  83. window: {
  84. location: {
  85. host: 'localhost:7777',
  86. reload() { state.reloads++; },
  87. replace(url) { state.replacements.push(url); }
  88. },
  89. sessionStorage: { getItem: (key) => key === 'brainstorm-session-key' ? state.sessionKey : null }
  90. },
  91. document: {
  92. querySelector: (s) => s === '.status' ? statusEl : null,
  93. getElementById: () => null,
  94. createElement: () => ({ style: {}, id: '' }),
  95. addEventListener() {},
  96. body: { appendChild: (el) => state.appended.push(el) }
  97. },
  98. WebSocket: FakeWS,
  99. setTimeout: (fn, ms) => { state.timers.push({ fn, ms, fired: false, cleared: false }); return state.timers.length; },
  100. clearTimeout: (id) => { if (state.timers[id - 1]) state.timers[id - 1].cleared = true; },
  101. Date: { now: () => state.now },
  102. console
  103. };
  104. return {
  105. state, statusEl, sockets,
  106. boot() { new Function(...Object.keys(env), src)(...Object.values(env)); },
  107. advance(ms) { state.now += ms; },
  108. last() { return sockets[sockets.length - 1]; },
  109. fireReconnect() {
  110. const t = [...state.timers].reverse().find(x => !x.fired && !x.cleared);
  111. if (!t) throw new Error('no reconnect scheduled');
  112. t.fired = true; t.fn();
  113. }
  114. };
  115. }
  116. test('uses sessionStorage key in the WebSocket URL when present', () => {
  117. const e = makeEnv();
  118. e.state.sessionKey = 'stored-key-abc';
  119. e.boot();
  120. assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777/?key=stored-key-abc');
  121. });
  122. test('uses cookie-only WebSocket URL when no sessionStorage key is present', () => {
  123. const e = makeEnv();
  124. e.state.sessionKey = null;
  125. e.boot();
  126. assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777');
  127. });
  128. test('on disconnect shows Reconnecting and schedules a 500ms reconnect', () => {
  129. const e = makeEnv(); e.boot();
  130. e.last().open();
  131. assert.strictEqual(e.statusEl.textContent, 'Connected');
  132. e.last().close();
  133. assert.strictEqual(e.statusEl.textContent, 'Reconnecting…');
  134. assert.strictEqual(e.state.timers[e.state.timers.length - 1].ms, 500);
  135. });
  136. test('reconnect delay backs off 500 -> 1000 -> 2000', () => {
  137. const e = makeEnv(); e.boot();
  138. e.last().open(); e.last().close();
  139. e.fireReconnect(); e.last().close();
  140. e.fireReconnect(); e.last().close();
  141. assert.deepStrictEqual(e.state.timers.map(t => t.ms).slice(0, 3), [500, 1000, 2000]);
  142. });
  143. test('shows the tombstone and Disconnected after the grace period', () => {
  144. const e = makeEnv(); e.boot();
  145. e.last().open(); e.last().close();
  146. e.advance(20000); // past TOMBSTONE_AFTER_MS while still down
  147. e.fireReconnect(); e.last().close();
  148. assert.strictEqual(e.statusEl.textContent, 'Disconnected');
  149. assert.strictEqual(e.state.appended.length, 1, 'tombstone appended exactly once');
  150. });
  151. test('rebootstraps with stored key when a tombstoned connection comes back', () => {
  152. const e = makeEnv(); e.boot();
  153. e.last().open(); e.last().close();
  154. e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
  155. assert.deepStrictEqual(e.state.replacements, []);
  156. e.fireReconnect(); e.last().open(); // server back (e.g. same-port restart)
  157. assert.strictEqual(e.state.reloads, 0, 'stored-key recovery should not reload bare /');
  158. assert.deepStrictEqual(e.state.replacements, ['/?key=stored-key-abc']);
  159. });
  160. test('reloads to recover when tombstoned and no sessionStorage key is present', () => {
  161. const e = makeEnv();
  162. e.state.sessionKey = null;
  163. e.boot();
  164. e.last().open(); e.last().close();
  165. e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
  166. assert.strictEqual(e.state.reloads, 0);
  167. e.fireReconnect(); e.last().open(); // server back (e.g. cookie-only page)
  168. assert.strictEqual(e.state.reloads, 1, 'reloads once on recovery');
  169. assert.deepStrictEqual(e.state.replacements, []);
  170. });
  171. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  172. if (failed > 0) process.exit(1);