lifecycle.test.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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. if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true);
  22. return new Promise(resolve => {
  23. let settled = false;
  24. const finish = (exited) => {
  25. if (settled) return;
  26. settled = true;
  27. resolve(exited);
  28. };
  29. child.once('exit', () => finish(true));
  30. setTimeout(() => finish(false), timeoutMs);
  31. });
  32. }
  33. async function killAndWait(child, timeoutMs = 2000) {
  34. if (!child || child.exitCode !== null || child.signalCode !== null) return true;
  35. const exited = waitForExit(child, timeoutMs);
  36. child.kill();
  37. if (await exited) return true;
  38. child.kill('SIGKILL');
  39. return waitForExit(child, 500);
  40. }
  41. async function waitForFile(file, timeoutMs = 3000) {
  42. const deadline = Date.now() + timeoutMs;
  43. while (Date.now() < deadline) {
  44. if (fs.existsSync(file)) return true;
  45. await sleep(50);
  46. }
  47. return fs.existsSync(file);
  48. }
  49. function firstServerStarted(out) {
  50. return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
  51. }
  52. function openCaptureCommand(dir, marker) {
  53. const scriptPath = path.resolve(dir, 'capture-open.cjs');
  54. const markerPath = path.resolve(marker);
  55. fs.writeFileSync(scriptPath,
  56. "const fs = require('fs');\n" +
  57. "fs.appendFileSync(process.argv[2], process.argv[3] + '\\n');\n");
  58. return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(markerPath)}`;
  59. }
  60. function httpStatus(port, key) {
  61. return new Promise(resolve => {
  62. const pathWithKey = key ? '/?key=' + encodeURIComponent(key) : '/';
  63. require('http')
  64. .get({ hostname: '127.0.0.1', port, path: pathWithKey }, res => {
  65. res.resume();
  66. resolve(res.statusCode);
  67. })
  68. .on('error', () => resolve(0));
  69. });
  70. }
  71. function isWindowsLikeShell() {
  72. return process.platform === 'win32' ||
  73. /^msys|^cygwin|^mingw/i.test(process.env.OSTYPE || '') ||
  74. !!process.env.MSYSTEM;
  75. }
  76. async function waitForStartedOutput(child, timeoutMs = 5000) {
  77. let stdout = '';
  78. let stderr = '';
  79. child.stdout.on('data', d => { stdout += d.toString(); });
  80. child.stderr.on('data', d => { stderr += d.toString(); });
  81. const deadline = Date.now() + timeoutMs;
  82. while (Date.now() < deadline && !stdout.includes('server-started') && child.exitCode === null) {
  83. await sleep(50);
  84. }
  85. if (!stdout.includes('server-started')) {
  86. throw new Error(`start-server.sh did not report server-started. exit=${child.exitCode} stdout=${stdout} stderr=${stderr}`);
  87. }
  88. return stdout;
  89. }
  90. function makeShellTempDir(prefix) {
  91. return execFileSync('bash', ['-lc', `mktemp -d "\${TMPDIR:-/tmp}/${prefix}-XXXXXX"`], { encoding: 'utf8' }).trim();
  92. }
  93. function removeShellPath(p) {
  94. execFileSync('bash', ['-lc', 'rm -rf "$1"', 'bash', p], { stdio: 'ignore' });
  95. }
  96. function newestSessionDir(projectDir) {
  97. const sessionDir = execFileSync('bash', [
  98. '-lc',
  99. 'find "$1/.superpowers/brainstorm" -mindepth 1 -maxdepth 1 -type d -print | sort | tail -1',
  100. 'bash',
  101. projectDir
  102. ], { encoding: 'utf8' }).trim();
  103. assert(sessionDir, `expected at least one session dir under ${projectDir}/.superpowers/brainstorm`);
  104. return sessionDir;
  105. }
  106. async function runTests() {
  107. let passed = 0, failed = 0;
  108. async function test(name, fn) {
  109. try { await fn(); console.log(` PASS: ${name}`); passed++; }
  110. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  111. }
  112. await test('server-info reports the configured idle_timeout_ms', async () => {
  113. const dir = fs.mkdtempSync('/tmp/bs-life-');
  114. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3401, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 1234567 } });
  115. let out = ''; srv.stdout.on('data', d => out += d.toString());
  116. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  117. try {
  118. const info = firstServerStarted(out);
  119. assert.strictEqual(info.idle_timeout_ms, 1234567, 'idle_timeout_ms should reflect the env override');
  120. } finally {
  121. await killAndWait(srv);
  122. fs.rmSync(dir, { recursive: true, force: true });
  123. }
  124. });
  125. await test('idle shutdown closes an open WebSocket and the process exits', async () => {
  126. const dir = fs.mkdtempSync('/tmp/bs-life-');
  127. 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 } });
  128. let out = ''; srv.stdout.on('data', d => out += d.toString());
  129. let exited = false, code = null; srv.on('exit', c => { exited = true; code = c; });
  130. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  131. const ws = new WebSocket('ws://localhost:3402/?key=lifetoken');
  132. await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
  133. // 200ms idle, checked every 100ms — should shut down and exit well within 4s,
  134. // *despite* the open WS, only if shutdown() closes client sockets.
  135. for (let i = 0; i < 40 && !exited; i++) await sleep(100);
  136. try {
  137. assert(exited, 'process must exit after idle shutdown even with an open WebSocket');
  138. assert.strictEqual(code, 0, 'should exit cleanly (0)');
  139. assert(fs.existsSync(path.join(dir, 'state', 'server-stopped')), 'should write server-stopped');
  140. } finally {
  141. try { ws.close(); } catch (e) {}
  142. if (!exited) await killAndWait(srv);
  143. fs.rmSync(dir, { recursive: true, force: true });
  144. }
  145. });
  146. await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
  147. const dir = makeShellTempDir('bs-life');
  148. let info = null;
  149. let startProcess = null;
  150. let sessionDir = null;
  151. try {
  152. if (isWindowsLikeShell()) {
  153. startProcess = spawn('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5']);
  154. info = firstServerStarted(await waitForStartedOutput(startProcess));
  155. } else {
  156. const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });
  157. info = firstServerStarted(out);
  158. }
  159. sessionDir = newestSessionDir(dir);
  160. assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
  161. } finally {
  162. if (sessionDir) execFileSync('bash', [STOP, sessionDir], { stdio: 'ignore' });
  163. if (startProcess && !await waitForExit(startProcess, 3000)) {
  164. await killAndWait(startProcess);
  165. }
  166. removeShellPath(dir);
  167. }
  168. });
  169. await test('server-started URL brackets IPv6 URL hosts', async () => {
  170. const dir = fs.mkdtempSync('/tmp/bs-ipv6-url-');
  171. const srv = spawn('node', [SERVER], {
  172. env: {
  173. ...process.env,
  174. BRAINSTORM_PORT: 3421,
  175. BRAINSTORM_HOST: '127.0.0.1',
  176. BRAINSTORM_URL_HOST: '::1',
  177. BRAINSTORM_TOKEN: 'ipv6token',
  178. BRAINSTORM_DIR: dir,
  179. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  180. }
  181. });
  182. let out = ''; srv.stdout.on('data', d => out += d.toString());
  183. try {
  184. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  185. const info = firstServerStarted(out);
  186. assert.strictEqual(info.url, 'http://[::1]:3421/?key=ipv6token');
  187. } finally {
  188. await killAndWait(srv);
  189. fs.rmSync(dir, { recursive: true, force: true });
  190. }
  191. });
  192. await test('persists the bound port AND key, and restores both on restart', async () => {
  193. const dir = fs.mkdtempSync('/tmp/bs-port-');
  194. const portFile = path.join(dir, '.last-port');
  195. const tokenFile = path.join(dir, '.last-token');
  196. const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
  197. const a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
  198. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  199. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  200. const infoA = firstServerStarted(outA);
  201. const keyA = new URL(infoA.url).searchParams.get('key');
  202. assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
  203. const exitedA = waitForExit(a);
  204. a.kill();
  205. assert(await exitedA, 'first server should exit before restart binds its port');
  206. const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
  207. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  208. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  209. const infoB = firstServerStarted(outB);
  210. const keyB = new URL(infoB.url).searchParams.get('key');
  211. await killAndWait(b);
  212. fs.rmSync(dir, { recursive: true, force: true });
  213. assert.strictEqual(infoB.port, infoA.port, 'restart should reuse the same port');
  214. // Same key too — otherwise the open tab's cookie would 403 against the restart.
  215. assert.strictEqual(keyB, keyA, 'restart should reuse the same session key');
  216. });
  217. await test('hardens existing persisted token file permissions', async () => {
  218. const dir = fs.mkdtempSync('/tmp/bs-token-mode-');
  219. const portFile = path.join(dir, '.last-port');
  220. const tokenFile = path.join(dir, '.last-token');
  221. const token = 'efefefefefefefefefefefefefefefef';
  222. let srv = null;
  223. try {
  224. fs.writeFileSync(tokenFile, token, { mode: 0o644 });
  225. fs.chmodSync(tokenFile, 0o644);
  226. srv = spawn('node', [SERVER], {
  227. env: {
  228. ...process.env,
  229. BRAINSTORM_DIR: path.join(dir, 's1'),
  230. BRAINSTORM_PORT_FILE: portFile,
  231. BRAINSTORM_TOKEN_FILE: tokenFile,
  232. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  233. }
  234. });
  235. let out = ''; srv.stdout.on('data', d => out += d.toString());
  236. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  237. assert(out.includes('server-started'), 'server should start with persisted token');
  238. if (process.platform !== 'win32') {
  239. const mode = fs.statSync(tokenFile).mode & 0o777;
  240. assert.strictEqual(mode, 0o600, `.last-token mode should be 0600, got ${mode.toString(8)}`);
  241. } else {
  242. assert(fs.existsSync(tokenFile), 'token file should remain present on Windows');
  243. }
  244. } finally {
  245. await killAndWait(srv);
  246. fs.rmSync(dir, { recursive: true, force: true });
  247. }
  248. });
  249. await test('stored key can authenticate WebSocket after same-port restart', async () => {
  250. const dir = fs.mkdtempSync('/tmp/bs-reconnect-');
  251. const portFile = path.join(dir, '.last-port');
  252. const tokenFile = path.join(dir, '.last-token');
  253. const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
  254. let a = null, b = null, ws = null;
  255. try {
  256. a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
  257. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  258. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  259. const infoA = firstServerStarted(outA);
  260. const keyA = new URL(infoA.url).searchParams.get('key');
  261. const exitedA = waitForExit(a);
  262. a.kill();
  263. assert(await exitedA, 'first server should exit before restart binds its port');
  264. a = null;
  265. b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
  266. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  267. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  268. const infoB = firstServerStarted(outB);
  269. ws = new WebSocket(`ws://localhost:${infoB.port}/?key=${keyA}`, {
  270. headers: { Origin: `http://localhost:${infoB.port}` }
  271. });
  272. const opened = await new Promise(resolve => {
  273. ws.on('open', () => resolve(true));
  274. ws.on('error', () => resolve(false));
  275. setTimeout(() => resolve(false), 1500);
  276. });
  277. assert.strictEqual(infoB.port, infoA.port, 'restart should reuse same port');
  278. assert(opened, 'stored key should authenticate WS after restart');
  279. } finally {
  280. try { if (ws) ws.close(); } catch (e) {}
  281. await killAndWait(a);
  282. await killAndWait(b);
  283. fs.rmSync(dir, { recursive: true, force: true });
  284. }
  285. });
  286. await test('falls back to a random port when the preferred port is taken', async () => {
  287. const dir = fs.mkdtempSync('/tmp/bs-port-');
  288. const portFile = path.join(dir, '.last-port');
  289. const a = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'a'), BRAINSTORM_PORT: 3415, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  290. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  291. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  292. fs.writeFileSync(portFile, '3415'); // preferred port, but it's taken by A
  293. const b = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'b'), BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  294. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  295. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  296. const portB = firstServerStarted(outB).port;
  297. const persisted = fs.readFileSync(portFile, 'utf8').trim();
  298. await killAndWait(a);
  299. await killAndWait(b);
  300. fs.rmSync(dir, { recursive: true, force: true });
  301. assert.notStrictEqual(portB, 3415, 'must not bind the already-taken port');
  302. assert(portB >= 49152, 'should fall back to a random high port');
  303. // The fallback must NOT clobber the shared port file — A still owns 3415 and
  304. // its open tab must keep reconnecting there.
  305. assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port');
  306. });
  307. await test('fallback with persisted token generates a fresh unpersisted key', async () => {
  308. const dir = fs.mkdtempSync('/tmp/bs-port-');
  309. const portFile = path.join(dir, '.last-port');
  310. const tokenFile = path.join(dir, '.last-token');
  311. const preferredToken = 'abababababababababababababababab';
  312. let a = null, b = null;
  313. try {
  314. a = spawn('node', [SERVER], {
  315. env: {
  316. ...process.env,
  317. BRAINSTORM_DIR: path.join(dir, 'a'),
  318. BRAINSTORM_PORT: 3422,
  319. BRAINSTORM_TOKEN: preferredToken,
  320. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  321. }
  322. });
  323. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  324. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  325. assert(outA.includes('server-started'), 'preferred-port server should start');
  326. fs.writeFileSync(portFile, '3422');
  327. fs.writeFileSync(tokenFile, preferredToken, { mode: 0o600 });
  328. b = spawn('node', [SERVER], {
  329. env: {
  330. ...process.env,
  331. BRAINSTORM_DIR: path.join(dir, 'b'),
  332. BRAINSTORM_PORT_FILE: portFile,
  333. BRAINSTORM_TOKEN_FILE: tokenFile,
  334. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  335. }
  336. });
  337. let outB = ''; b.stdout.on('data', d => outB += d.toString());
  338. for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
  339. const infoB = firstServerStarted(outB);
  340. const fallbackKey = new URL(infoB.url).searchParams.get('key');
  341. const persistedAfter = fs.readFileSync(tokenFile, 'utf8').trim();
  342. const originalStatus = await httpStatus(3422, fallbackKey);
  343. assert.notStrictEqual(infoB.port, 3422, 'fallback should use a different port');
  344. assert.notStrictEqual(fallbackKey, preferredToken, 'fallback must not reuse persisted key');
  345. assert.strictEqual(persistedAfter, preferredToken, 'fallback must not overwrite .last-token');
  346. assert.strictEqual(originalStatus, 403, 'fallback key must not authenticate to original server');
  347. } finally {
  348. await killAndWait(a);
  349. await killAndWait(b);
  350. fs.rmSync(dir, { recursive: true, force: true });
  351. }
  352. });
  353. await test('fallback with explicit BRAINSTORM_TOKEN fails closed', async () => {
  354. const dir = fs.mkdtempSync('/tmp/bs-port-');
  355. const portFile = path.join(dir, '.last-port');
  356. const explicitToken = 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd';
  357. let a = null, b = null;
  358. try {
  359. a = spawn('node', [SERVER], {
  360. env: {
  361. ...process.env,
  362. BRAINSTORM_DIR: path.join(dir, 'a'),
  363. BRAINSTORM_PORT: 3423,
  364. BRAINSTORM_TOKEN: explicitToken,
  365. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  366. }
  367. });
  368. let outA = ''; a.stdout.on('data', d => outA += d.toString());
  369. for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
  370. assert(outA.includes('server-started'), 'preferred-port server should start');
  371. fs.writeFileSync(portFile, '3423');
  372. b = spawn('node', [SERVER], {
  373. env: {
  374. ...process.env,
  375. BRAINSTORM_DIR: path.join(dir, 'b'),
  376. BRAINSTORM_PORT_FILE: portFile,
  377. BRAINSTORM_TOKEN: explicitToken,
  378. BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
  379. }
  380. });
  381. let outB = ''; let errB = '';
  382. b.stdout.on('data', d => outB += d.toString());
  383. b.stderr.on('data', d => errB += d.toString());
  384. for (let i = 0; i < 60 && !outB.includes('server-started') && b.exitCode === null; i++) await sleep(50);
  385. const exited = await waitForExit(b, 1500);
  386. assert(exited, 'explicit-token fallback process should exit');
  387. assert.notStrictEqual(b.exitCode, 0, 'explicit-token fallback should fail non-zero');
  388. assert(!outB.includes('server-started'), 'explicit-token fallback must not start on a random port');
  389. assert(/BRAINSTORM_TOKEN/.test(errB), `stderr should explain explicit token fallback refusal, got: ${errB}`);
  390. } finally {
  391. await killAndWait(a);
  392. await killAndWait(b);
  393. fs.rmSync(dir, { recursive: true, force: true });
  394. }
  395. });
  396. await test('auto-opens the browser once, on the first screen', async () => {
  397. const dir = fs.mkdtempSync('/tmp/bs-open-');
  398. const marker = path.join(dir, 'opened.log');
  399. const openCmd = openCaptureCommand(dir, marker); // capture the launch instead of opening a browser
  400. 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 } });
  401. let out = ''; srv.stdout.on('data', d => out += d.toString());
  402. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  403. // First screen, with no browser connected -> should auto-open.
  404. fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
  405. await waitForFile(marker);
  406. // Second screen -> must NOT open again.
  407. fs.writeFileSync(path.join(dir, 'content', 'second.html'), '<h2>Second</h2>');
  408. await sleep(700);
  409. const lines = fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean) : [];
  410. // The opened URL must carry the key AND be reachable — a keyless URL hits 403.
  411. let status = 0;
  412. if (lines[0]) {
  413. status = await new Promise(r => require('http').get(lines[0], res => { res.resume(); r(res.statusCode); }).on('error', () => r(0)));
  414. }
  415. await killAndWait(srv);
  416. fs.rmSync(dir, { recursive: true, force: true });
  417. assert.strictEqual(lines.length, 1, 'should open exactly once');
  418. assert(lines[0].includes('3417'), `should open the server URL, got: ${lines[0]}`);
  419. assert(/[?&]key=/.test(lines[0]), `opened URL must carry the session key, got: ${lines[0]}`);
  420. assert.strictEqual(status, 200, 'the opened URL must be reachable (valid key), not the 403 page');
  421. });
  422. await test('does NOT auto-open unless approved (BRAINSTORM_OPEN unset)', async () => {
  423. const dir = fs.mkdtempSync('/tmp/bs-open-');
  424. const marker = path.join(dir, 'opened.log');
  425. const openCmd = openCaptureCommand(dir, marker);
  426. // BRAINSTORM_OPEN intentionally NOT set — auto-open must stay off.
  427. const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3418, BRAINSTORM_DIR: dir, BRAINSTORM_OPEN_CMD: openCmd, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
  428. let out = ''; srv.stdout.on('data', d => out += d.toString());
  429. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  430. fs.writeFileSync(path.join(dir, 'content', 'first.html'), '<h2>First</h2>');
  431. await sleep(700);
  432. await killAndWait(srv);
  433. const opened = fs.existsSync(marker);
  434. fs.rmSync(dir, { recursive: true, force: true });
  435. assert(!opened, 'must not open the browser without explicit approval');
  436. });
  437. await test('unauthenticated requests do not defeat the idle timeout', async () => {
  438. const dir = fs.mkdtempSync('/tmp/bs-life-');
  439. 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 } });
  440. let out = ''; srv.stdout.on('data', d => out += d.toString());
  441. let exited = false; srv.on('exit', () => { exited = true; });
  442. for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
  443. // Flood with UNAUTHENTICATED (keyless → 403) requests. These must NOT count
  444. // as activity, so the idle timeout still fires and the process exits.
  445. const hammer = setInterval(() => { require('http').get('http://localhost:3419/', r => r.resume()).on('error', () => {}); }, 60);
  446. for (let i = 0; i < 40 && !exited; i++) await sleep(100);
  447. clearInterval(hammer);
  448. if (!exited) await killAndWait(srv);
  449. fs.rmSync(dir, { recursive: true, force: true });
  450. assert(exited, 'idle shutdown must still fire despite a flood of unauthenticated requests');
  451. });
  452. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  453. if (failed > 0) process.exit(1);
  454. }
  455. runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });