lifecycle.test.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  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 firstServerStarted(out) {
  21. return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
  22. }
  23. async function runTests() {
  24. let passed = 0, failed = 0;
  25. async function test(name, fn) {
  26. try { await fn(); console.log(` PASS: ${name}`); passed++; }
  27. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  28. }
  29. await test('server-info reports the configured idle_timeout_ms', async () => {
  30. const dir = fs.mkdtempSync('/tmp/bs-life-');
  31. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3401, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 1234567 } });
  32. let out = ''; srv.stdout.on('data', d => out += d.toString());
  33. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  34. try {
  35. const info = firstServerStarted(out);
  36. assert.strictEqual(info.idle_timeout_ms, 1234567, 'idle_timeout_ms should reflect the env override');
  37. } finally {
  38. srv.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  39. }
  40. });
  41. await test('idle shutdown closes an open WebSocket and the process exits', async () => {
  42. const dir = fs.mkdtempSync('/tmp/bs-life-');
  43. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3402, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 200, BRAINSTORM_LIFECYCLE_CHECK_MS: 100 } });
  44. let out = ''; srv.stdout.on('data', d => out += d.toString());
  45. let exited = false, code = null; srv.on('exit', c => { exited = true; code = c; });
  46. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  47. const ws = new WebSocket('ws://localhost:3402');
  48. await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
  49. // 200ms idle, checked every 100ms — should shut down and exit well within 4s,
  50. // *despite* the open WS, only if shutdown() closes client sockets.
  51. for (let i = 0; i < 40 && !exited; i++) await sleep(100);
  52. try {
  53. assert(exited, 'process must exit after idle shutdown even with an open WebSocket');
  54. assert.strictEqual(code, 0, 'should exit cleanly (0)');
  55. assert(fs.existsSync(path.join(dir, 'state', 'server-stopped')), 'should write server-stopped');
  56. } finally {
  57. try { ws.close(); } catch (e) {}
  58. if (!exited) srv.kill();
  59. fs.rmSync(dir, { recursive: true, force: true });
  60. }
  61. });
  62. await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
  63. const dir = fs.mkdtempSync('/tmp/bs-life-');
  64. let info;
  65. const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5'], { encoding: 'utf8' });
  66. info = firstServerStarted(out);
  67. try {
  68. assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
  69. } finally {
  70. execFileSync('bash', [STOP, path.dirname(info.state_dir)], { stdio: 'ignore' });
  71. fs.rmSync(dir, { recursive: true, force: true });
  72. }
  73. });
  74. await test('persists the bound port and restores it on restart', async () => {
  75. const dir = fs.mkdtempSync('/tmp/bs-port-');
  76. const portFile = path.join(dir, '.last-port');
  77. const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
  78. const a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
  79. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  80. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  81. const portA = firstServerStarted(outA).port;
  82. assert(fs.existsSync(portFile), 'should write the port file');
  83. assert.strictEqual(Number(fs.readFileSync(portFile, 'utf8').trim()), portA, 'port file holds the bound port');
  84. a.kill(); await sleep(400); // free the port
  85. const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
  86. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  87. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  88. const portB = firstServerStarted(outB).port;
  89. b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  90. assert.strictEqual(portB, portA, 'restart should reuse the same port');
  91. });
  92. await test('falls back to a random port when the preferred port is taken', async () => {
  93. const dir = fs.mkdtempSync('/tmp/bs-port-');
  94. const portFile = path.join(dir, '.last-port');
  95. const a = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'a'), BRAINSTORM_PORT: 3415, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  96. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  97. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  98. fs.writeFileSync(portFile, '3415'); // preferred port, but it's taken by A
  99. const b = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'b'), BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  100. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  101. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  102. const portB = firstServerStarted(outB).port;
  103. a.kill(); b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
  104. assert.notStrictEqual(portB, 3415, 'must not bind the already-taken port');
  105. assert(portB >= 49152, 'should fall back to a random high port');
  106. });
  107. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  108. if (failed > 0) process.exit(1);
  109. }
  110. runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });