mirror of
https://github.com/obra/superpowers.git
synced 2026-06-11 05:09:05 +08:00
When the companion idle-shuts-down and the agent restarts it, a fresh random port meant the user's open browser tab pointed at a dead URL. Persist the bound port per project and prefer it on the next start, so the restarted server comes up on the same port and the open tab's reconnect just works. - start-server.sh exports BRAINSTORM_PORT_FILE=<project>/.superpowers/brainstorm/ .last-port for project sessions (not /tmp). - server.cjs prefers an explicit BRAINSTORM_PORT, else the recorded port, else random; writes the actually-bound port back; and on EADDRINUSE (preferred port still in use) falls back to a random port once instead of crashing. lifecycle.test.js: restart reuses the recorded port; a taken preferred port falls back to a random one without crashing. Refs #1237
132 lines
6.5 KiB
JavaScript
132 lines
6.5 KiB
JavaScript
/**
|
|
* Tests for the brainstorm server's lifecycle (idle timeout + shutdown).
|
|
*
|
|
* - The idle timeout is configurable (default 4h) and reported in server-info.
|
|
* - Idle shutdown must close any open WebSocket so the process actually exits,
|
|
* not hang on a lingering connection.
|
|
* - start-server.sh exposes the timeout via --idle-timeout-minutes.
|
|
*
|
|
* Uses the `ws` npm package as a test client (test-only dependency).
|
|
*/
|
|
|
|
const { spawn, execFileSync } = require('child_process');
|
|
const WebSocket = require('ws');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const assert = require('assert');
|
|
|
|
const SERVER = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
|
|
const START = path.join(__dirname, '../../skills/brainstorming/scripts/start-server.sh');
|
|
const STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
|
|
function firstServerStarted(out) {
|
|
return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
|
|
}
|
|
|
|
async function runTests() {
|
|
let passed = 0, failed = 0;
|
|
async function test(name, fn) {
|
|
try { await fn(); console.log(` PASS: ${name}`); passed++; }
|
|
catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
|
|
}
|
|
|
|
await test('server-info reports the configured idle_timeout_ms', async () => {
|
|
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
|
const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3401, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 1234567 } });
|
|
let out = ''; srv.stdout.on('data', d => out += d.toString());
|
|
for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
|
|
try {
|
|
const info = firstServerStarted(out);
|
|
assert.strictEqual(info.idle_timeout_ms, 1234567, 'idle_timeout_ms should reflect the env override');
|
|
} finally {
|
|
srv.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
await test('idle shutdown closes an open WebSocket and the process exits', async () => {
|
|
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
|
const srv = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_PORT: 3402, BRAINSTORM_DIR: dir, BRAINSTORM_IDLE_TIMEOUT_MS: 200, BRAINSTORM_LIFECYCLE_CHECK_MS: 100 } });
|
|
let out = ''; srv.stdout.on('data', d => out += d.toString());
|
|
let exited = false, code = null; srv.on('exit', c => { exited = true; code = c; });
|
|
for (let i = 0; i < 60 && !out.includes('server-started'); i++) await sleep(50);
|
|
|
|
const ws = new WebSocket('ws://localhost:3402');
|
|
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
|
|
|
|
// 200ms idle, checked every 100ms — should shut down and exit well within 4s,
|
|
// *despite* the open WS, only if shutdown() closes client sockets.
|
|
for (let i = 0; i < 40 && !exited; i++) await sleep(100);
|
|
|
|
try {
|
|
assert(exited, 'process must exit after idle shutdown even with an open WebSocket');
|
|
assert.strictEqual(code, 0, 'should exit cleanly (0)');
|
|
assert(fs.existsSync(path.join(dir, 'state', 'server-stopped')), 'should write server-stopped');
|
|
} finally {
|
|
try { ws.close(); } catch (e) {}
|
|
if (!exited) srv.kill();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
|
|
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
|
let info;
|
|
const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5'], { encoding: 'utf8' });
|
|
info = firstServerStarted(out);
|
|
try {
|
|
assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
|
|
} finally {
|
|
execFileSync('bash', [STOP, path.dirname(info.state_dir)], { stdio: 'ignore' });
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
await test('persists the bound port and restores it on restart', async () => {
|
|
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
|
const portFile = path.join(dir, '.last-port');
|
|
const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
|
|
|
|
const a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
|
|
let outA = ''; a.stdout.on('data', d => outA += d.toString());
|
|
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
|
|
const portA = firstServerStarted(outA).port;
|
|
assert(fs.existsSync(portFile), 'should write the port file');
|
|
assert.strictEqual(Number(fs.readFileSync(portFile, 'utf8').trim()), portA, 'port file holds the bound port');
|
|
a.kill(); await sleep(400); // free the port
|
|
|
|
const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
|
|
let outB = ''; b.stdout.on('data', d => outB += d.toString());
|
|
for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
|
|
const portB = firstServerStarted(outB).port;
|
|
b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
assert.strictEqual(portB, portA, 'restart should reuse the same port');
|
|
});
|
|
|
|
await test('falls back to a random port when the preferred port is taken', async () => {
|
|
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
|
const portFile = path.join(dir, '.last-port');
|
|
|
|
const a = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'a'), BRAINSTORM_PORT: 3415, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
|
|
let outA = ''; a.stdout.on('data', d => outA += d.toString());
|
|
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
|
|
|
|
fs.writeFileSync(portFile, '3415'); // preferred port, but it's taken by A
|
|
const b = spawn('node', [SERVER], { env: { ...process.env, BRAINSTORM_DIR: path.join(dir, 'b'), BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 } });
|
|
let outB = ''; b.stdout.on('data', d => outB += d.toString());
|
|
for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
|
|
const portB = firstServerStarted(outB).port;
|
|
|
|
a.kill(); b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true });
|
|
|
|
assert.notStrictEqual(portB, 3415, 'must not bind the already-taken port');
|
|
assert(portB >= 49152, 'should fall back to a random high port');
|
|
});
|
|
|
|
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });
|