lifecycle.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /**
  2. * Tests for the brainstorm server's lifecycle (idle timeout + shutdown).
  3. *
  4. * - The idle timeout is configurable (default 4h) and reported in server-info.
  5. * - Idle shutdown must close any open WebSocket so the process actually exits,
  6. * not hang on a lingering connection.
  7. * - start-server.sh exposes the timeout via --idle-timeout-minutes.
  8. *
  9. * Uses the `ws` npm package as a test client (test-only dependency).
  10. */
  11. const { spawn, execFileSync } = require('child_process');
  12. const WebSocket = require('ws');
  13. const fs = require('fs');
  14. const path = require('path');
  15. const assert = require('assert');
  16. const SERVER = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
  17. const START = path.join(__dirname, '../../skills/brainstorming/scripts/start-server.sh');
  18. const STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
  19. const sleep = ms => new Promise(r => setTimeout(r, ms));
  20. function waitForExit(child, timeoutMs = 2000) {
  21. return new Promise(resolve => {
  22. let settled = false;
  23. const finish = (exited) => {
  24. if (settled) return;
  25. settled = true;
  26. resolve(exited);
  27. };
  28. child.once('exit', () => finish(true));
  29. setTimeout(() => finish(false), timeoutMs);
  30. });
  31. }
  32. function firstServerStarted(out) {
  33. return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
  34. }
  35. async function runTests() {
  36. let passed = 0, failed = 0;
  37. async function test(name, fn) {
  38. try { await fn(); console.log(` PASS: ${name}`); passed++; }
  39. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  40. }
  41. await test('server-info reports the configured idle_timeout_ms', async () => {
  42. const dir = fs.mkdtempSync('/tmp/bs-life-');
  43. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3401, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 1234567 } });
  44. let out = ''; srv.stdout.on('data', d => out += d.toString());
  45. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  46. try {
  47. const info = firstServerStarted(out);
  48. assert.strictEqual(info.idle_timeout_ms, 1234567, 'idle_timeout_ms should reflect the env override');
  49. } finally {
  50. srv.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  51. }
  52. });
  53. await test('idle shutdown closes an open WebSocket and the process exits', async () => {
  54. const dir = fs.mkdtempSync('/tmp/bs-life-');
  55. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3402, BRAINSTORM_DIR: dir, BRAINSTORM_TOKEN: 'lifetoken', BRAINSTORM_IDLE_TIMEOUT_MS: 200, BRAINSTORM_LIFECYCLE_CHECK_MS: 100 } });
  56. let out = ''; srv.stdout.on('data', d => out += d.toString());
  57. let exited = false, code = null; srv.on('exit', c => { exited = true; code = c; });
  58. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  59. const ws = new WebSocket('ws://localhost:3402/?key=lifetoken');
  60. await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
  61. // 200ms idle, checked every 100ms — should shut down and exit well within 4s,
  62. // *despite* the open WS, only if shutdown() closes client sockets.
  63. for (let i = 0; i < 40 && !exited; i++) await sleep(100);
  64. try {
  65. assert(exited, 'process must exit after idle shutdown even with an open WebSocket');
  66. assert.strictEqual(code, 0, 'should exit cleanly (0)');
  67. assert(fs.existsSync(path.join(dir, 'state', 'server-stopped')), 'should write server-stopped');
  68. } finally {
  69. try { ws.close(); } catch (e) {}
  70. if (!exited) srv.kill();
  71. fs.rmSync(dir, { recursive: true, force: true });
  72. }
  73. });
  74. await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
  75. const dir = fs.mkdtempSync('/tmp/bs-life-');
  76. let info;
  77. const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });
  78. info = firstServerStarted(out);
  79. try {
  80. assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
  81. } finally {
  82. execFileSync('bash', [STOP, path.dirname(info.state_dir)], { stdio: 'ignore' });
  83. fs.rmSync(dir, { recursive: true, force: true });
  84. }
  85. });
  86. await test('persists the bound port AND key, and restores both on restart', async () => {
  87. const dir = fs.mkdtempSync('/tmp/bs-port-');
  88. const portFile = path.join(dir, '.last-port');
  89. const tokenFile = path.join(dir, '.last-token');
  90. const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
  91. const a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
  92. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  93. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  94. const infoA = firstServerStarted(outA);
  95. const keyA = new URL(infoA.url).searchParams.get('key');
  96. assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
  97. const exitedA = waitForExit(a);
  98. a.kill();
  99. assert(await exitedA, 'first server should exit before restart binds its port');
  100. const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
  101. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  102. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  103. const infoB = firstServerStarted(outB);
  104. const keyB = new URL(infoB.url).searchParams.get('key');
  105. b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  106. assert.strictEqual(infoB.port, infoA.port, 'restart should reuse the same port');
  107. // Same key too — otherwise the open tab's cookie would 403 against the restart.
  108. assert.strictEqual(keyB, keyA, 'restart should reuse the same session key');
  109. });
  110. await test('stored key can authenticate WebSocket after same-port restart', async () => {
  111. const dir = fs.mkdtempSync('/tmp/bs-reconnect-');
  112. const portFile = path.join(dir, '.last-port');
  113. const tokenFile = path.join(dir, '.last-token');
  114. const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
  115. let a = null, b = null, ws = null;
  116. try {
  117. a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
  118. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  119. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  120. const infoA = firstServerStarted(outA);
  121. const keyA = new URL(infoA.url).searchParams.get('key');
  122. const exitedA = waitForExit(a);
  123. a.kill();
  124. assert(await exitedA, 'first server should exit before restart binds its port');
  125. a = null;
  126. b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
  127. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  128. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  129. const infoB = firstServerStarted(outB);
  130. ws = new WebSocket(`ws://localhost:${infoB.port}/?key=${keyA}`, {
  131. headers: { Origin: `http://localhost:${infoB.port}` }
  132. });
  133. const opened = await new Promise(resolve => {
  134. ws.on('open', () => resolve(true));
  135. ws.on('error', () => resolve(false));
  136. setTimeout(() => resolve(false), 1500);
  137. });
  138. assert.strictEqual(infoB.port, infoA.port, 'restart should reuse same port');
  139. assert(opened, 'stored key should authenticate WS after restart');
  140. } finally {
  141. try { if (ws) ws.close(); } catch (e) {}
  142. try { if (a) a.kill(); } catch (e) {}
  143. try { if (b) b.kill(); } catch (e) {}
  144. await sleep(100);
  145. fs.rmSync(dir, { recursive: true, force: true });
  146. }
  147. });
  148. await test('falls back to a random port when the preferred port is taken', async () => {
  149. const dir = fs.mkdtempSync('/tmp/bs-port-');
  150. const portFile = path.join(dir, '.last-port');
  151. const a = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'a'), BRAINSTORM_PORT: 3415, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  152. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  153. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  154. fs.writeFileSync(portFile, '3415'); // preferred port, but it's taken by A
  155. const b = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'b'), BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  156. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  157. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  158. const portB = firstServerStarted(outB).port;
  159. const persisted = fs.readFileSync(portFile, 'utf8').trim();
  160. a.kill(); b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  161. assert.notStrictEqual(portB, 3415, 'must not bind the already-taken port');
  162. assert(portB >= 49152, 'should fall back to a random high port');
  163. // The fallback must NOT clobber the shared port file — A still owns 3415 and
  164. // its open tab must keep reconnecting there.
  165. assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port');
  166. });
  167. await test('auto-opens the browser once, on the first screen', async () => {
  168. const dir = fs.mkdtempSync('/tmp/bs-open-');
  169. const marker = path.join(dir, 'opened.log');
  170. const openCmd = `sh -c 'echo "$0" >> ${marker}'`; // capture the launch instead of opening a browser
  171. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3417, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN: '1', BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  172. let out = ''; srv.stdout.on('data', d => out += d.toString());
  173. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  174. // First screen, with no browser connected -> should auto-open.
  175. fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
  176. await sleep(700);
  177. // Second screen -> must NOT open again.
  178. fs.writeFileSync(path.join(dir, 'content', 'second.html'), '<h2>Second</h2>');
  179. await sleep(700);
  180. const lines = fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean) : [];
  181. // The opened URL must carry the key AND be reachable — a keyless URL hits 403.
  182. let status = 0;
  183. if (lines[0]) {
  184. status = await new Promise(r => require('http').get(lines[0], res => { res.resume(); r(res.statusCode); }).on('error', () => r(0)));
  185. }
  186. srv.kill(); await sleep(100);
  187. fs.rmSync(dir, { recursive: true, force: true });
  188. assert.strictEqual(lines.length, 1, 'should open exactly once');
  189. assert(lines[0].includes('3417'), `should open the server URL, got: ${lines[0]}`);
  190. assert(/[?&]key=/.test(lines[0]), `opened URL must carry the session key, got: ${lines[0]}`);
  191. assert.strictEqual(status, 200, 'the opened URL must be reachable (valid key), not the 403 page');
  192. });
  193. await test('does NOT auto-open unless approved (BRAINSTORM_OPEN unset)', async () => {
  194. const dir = fs.mkdtempSync('/tmp/bs-open-');
  195. const marker = path.join(dir, 'opened.log');
  196. const openCmd = `sh -c 'echo "$0" >> ${marker}'`;
  197. // BRAINSTORM_OPEN intentionally NOT set — auto-open must stay off.
  198. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3418, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  199. let out = ''; srv.stdout.on('data', d => out += d.toString());
  200. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  201. fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
  202. await sleep(700);
  203. srv.kill(); await sleep(100);
  204. const opened = fs.existsSync(marker);
  205. fs.rmSync(dir, { recursive: true, force: true });
  206. assert(!opened, 'must not open the browser without explicit approval');
  207. });
  208. await test('unauthenticated requests do not defeat the idle timeout', async () => {
  209. const dir = fs.mkdtempSync('/tmp/bs-life-');
  210. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3419, BRAINSTORM_DIR: dir, BRAINSTORM_TOKEN: 'authtok', BRAINSTORM_IDLE_TIMEOUT_MS: 400, BRAINSTORM_LIFECYCLE_CHECK_MS: 100 } });
  211. let out = ''; srv.stdout.on('data', d => out += d.toString());
  212. let exited = false; srv.on('exit', () => { exited = true; });
  213. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  214. // Flood with UNAUTHENTICATED (keyless → 403) requests. These must NOT count
  215. // as activity, so the idle timeout still fires and the process exits.
  216. const hammer = setInterval(() => { require('http').get('http://localhost:3419/', r => r.resume()).on('error', () => {}); }, 60);
  217. for (let i = 0; i < 40 && !exited; i++) await sleep(100);
  218. clearInterval(hammer);
  219. if (!exited) srv.kill();
  220. fs.rmSync(dir, { recursive: true, force: true });
  221. assert(exited, 'idle shutdown must still fire despite a flood of unauthenticated requests');
  222. });
  223. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  224. if (failed > 0) process.exit(1);
  225. }
  226. runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });