diff --git a/browse/test/pair-agent-e2e.test.ts b/browse/test/pair-agent-e2e.test.ts index 3891c3c11..a9e978c98 100644 --- a/browse/test/pair-agent-e2e.test.ts +++ b/browse/test/pair-agent-e2e.test.ts @@ -34,36 +34,46 @@ interface DaemonHandle { stateFile: string; tempDir: string; baseUrl: string; + output: Promise<[string, string]>; } -async function waitForReady(baseUrl: string, timeoutMs = 15_000): Promise { +async function waitForReady( + proc: ReturnType, stateFile: string, timeoutMs = 15_000, +): Promise<{ port: number; token: string }> { const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { + let lastError = ''; + while (Date.now() < deadline && proc.exitCode === null) { try { - const resp = await fetch(`${baseUrl}/health`, { + // Only this daemon's published state can identify its selected port. + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (state.pid !== proc.pid || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535 || + typeof state.token !== 'string' || !state.token) { + throw new Error('State does not identify this test daemon'); + } + const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { signal: AbortSignal.timeout(1000), }); - if (resp.ok) return; - } catch { - // not ready yet + await resp.arrayBuffer(); + if (resp.ok) return state; + lastError = `Health returned HTTP ${resp.status}`; + } catch (error) { + lastError = String(error); } await new Promise(r => setTimeout(r, 200)); } - throw new Error(`Daemon did not become ready within ${timeoutMs}ms`); + throw new Error(`Daemon did not become ready within ${timeoutMs}ms (exit=${proc.exitCode}): ${lastError}`); } async function spawnDaemon(): Promise { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pair-agent-e2e-')); const stateFile = path.join(tempDir, 'browse.json'); - // Pick a high ephemeral port - const port = 20000 + Math.floor(Math.random() * 20000); const proc = Bun.spawn(['bun', 'run', SERVER_ENTRY], { cwd: ROOT, env: { ...process.env, BROWSE_HEADLESS_SKIP: '1', - BROWSE_PORT: String(port), + BROWSE_PORT: '0', // Use the daemon's checked allocator and discover its port from state. BROWSE_STATE_FILE: stateFile, BROWSE_PARENT_PID: '0', BROWSE_IDLE_TIMEOUT: '600000', @@ -71,17 +81,35 @@ async function spawnDaemon(): Promise { stdio: ['ignore', 'pipe', 'pipe'], }); - const baseUrl = `http://127.0.0.1:${port}`; - await waitForReady(baseUrl); - - // Read the token from the state file that the daemon wrote - const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - return { proc, port, token: state.token, stateFile, tempDir, baseUrl }; + const output = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + try { + const state = await waitForReady(proc, stateFile); + const port = state.port; + const baseUrl = `http://127.0.0.1:${port}`; + return { proc, port, token: state.token, stateFile, tempDir, baseUrl, output }; + } catch (error) { + // beforeAll cannot pass a handle to afterAll when startup fails. + try { proc.kill('SIGKILL'); } catch {} + try { + await proc.exited; + const [stdout, stderr] = await output; + const errorFile = path.join(tempDir, 'browse-startup-error.log'); + const startupError = fs.existsSync(errorFile) ? fs.readFileSync(errorFile, 'utf-8') : ''; + throw new Error(`${error}\n${startupError}\n${stderr}\n${stdout}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } } -function killDaemon(handle: DaemonHandle): void { +async function killDaemon(handle: DaemonHandle): Promise { try { handle.proc.kill('SIGKILL'); } catch {} - try { fs.rmSync(handle.tempDir, { recursive: true, force: true }); } catch {} + try { + await handle.proc.exited; + await handle.output; + } finally { + fs.rmSync(handle.tempDir, { recursive: true, force: true }); + } } describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => { @@ -91,8 +119,8 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => { daemon = await spawnDaemon(); }, 20_000); - afterAll(() => { - if (daemon) killDaemon(daemon); + afterAll(async () => { + if (daemon) await killDaemon(daemon); }); test('GET /health returns daemon status and NEVER includes a token (even for chrome-extension origins)', async () => { diff --git a/browse/test/pair-agent-tunnel-eval.test.ts b/browse/test/pair-agent-tunnel-eval.test.ts index ffb432193..f922afebe 100644 --- a/browse/test/pair-agent-tunnel-eval.test.ts +++ b/browse/test/pair-agent-tunnel-eval.test.ts @@ -39,36 +39,51 @@ interface DaemonHandle { localUrl: string; tunnelUrl: string; attemptsLogPath: string; + output: Promise<[string, string]>; } -async function waitForReady(baseUrl: string, timeoutMs = 20_000): Promise { +async function waitForReady( + proc: ReturnType, stateFile: string, timeoutMs = 20_000, +): Promise<{ port: number; token: string }> { const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { + let lastError = ''; + while (Date.now() < deadline && proc.exitCode === null) { try { - const resp = await fetch(`${baseUrl}/health`, { + // Only this daemon's published state identifies its bound listener. + const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (state.pid !== proc.pid || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535 || + typeof state.token !== 'string' || !state.token) { + throw new Error('State does not identify this test daemon'); + } + const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { signal: AbortSignal.timeout(1000), }); - if (resp.ok) return; - } catch { - // not ready yet + await resp.arrayBuffer(); + if (resp.ok) return state; + lastError = `Health returned HTTP ${resp.status}`; + } catch (error) { + lastError = String(error); } await new Promise(r => setTimeout(r, 200)); } - throw new Error(`Daemon did not become ready within ${timeoutMs}ms at ${baseUrl}`); + throw new Error(`Daemon did not become ready within ${timeoutMs}ms (exit=${proc.exitCode}): ${lastError}`); } -async function waitForTunnelPort(stateFile: string, timeoutMs = 20_000): Promise { +async function waitForTunnelPort( + proc: ReturnType, stateFile: string, timeoutMs = 20_000, +): Promise { const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { + while (Date.now() < deadline && proc.exitCode === null) { try { const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (typeof state.tunnelLocalPort === 'number') return state.tunnelLocalPort; + if (state.pid === proc.pid && Number.isInteger(state.tunnelLocalPort) && + state.tunnelLocalPort > 0 && state.tunnelLocalPort <= 65535) return state.tunnelLocalPort; } catch { // state file not written yet } await new Promise(r => setTimeout(r, 200)); } - throw new Error(`Tunnel local port did not appear in ${stateFile} within ${timeoutMs}ms`); + throw new Error(`Tunnel local port did not appear in ${stateFile} within ${timeoutMs}ms (exit=${proc.exitCode})`); } async function spawnDaemonWithTunnel(): Promise { @@ -78,7 +93,6 @@ async function spawnDaemonWithTunnel(): Promise { const stateFile = path.join(tempDir, 'browse.json'); const fakeHome = path.join(tempDir, 'home'); fs.mkdirSync(fakeHome, { recursive: true }); - const localPort = 30000 + Math.floor(Math.random() * 30000); const attemptsLogPath = path.join(fakeHome, '.gstack', 'security', 'attempts.jsonl'); const proc = Bun.spawn(['bun', 'run', SERVER_ENTRY], { @@ -88,7 +102,7 @@ async function spawnDaemonWithTunnel(): Promise { HOME: fakeHome, BROWSE_HEADLESS_SKIP: '1', BROWSE_TUNNEL_LOCAL_ONLY: '1', - BROWSE_PORT: String(localPort), + BROWSE_PORT: '0', // Use the daemon's checked allocator, then discover its actual port. BROWSE_STATE_FILE: stateFile, BROWSE_PARENT_PID: '0', BROWSE_IDLE_TIMEOUT: '600000', @@ -96,37 +110,56 @@ async function spawnDaemonWithTunnel(): Promise { stdio: ['ignore', 'pipe', 'pipe'], }); - const localUrl = `http://127.0.0.1:${localPort}`; - await waitForReady(localUrl); - const tunnelPort = await waitForTunnelPort(stateFile); - const tunnelUrl = `http://127.0.0.1:${tunnelPort}`; + const output = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + try { + const state = await waitForReady(proc, stateFile); + const localPort = state.port; + const localUrl = `http://127.0.0.1:${localPort}`; + const tunnelPort = await waitForTunnelPort(proc, stateFile); + const tunnelUrl = `http://127.0.0.1:${tunnelPort}`; - // Read the root token, then exchange it for a scoped token via /pair → /connect. - const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - const rootToken = state.token; + // Exchange this daemon's root token for a scoped token via /pair → /connect. + const rootToken = state.token; + const pairResp = await fetch(`${localUrl}/pair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` }, + body: JSON.stringify({ clientId: 'tunnel-eval' }), + }); + if (!pairResp.ok) throw new Error(`/pair failed: ${pairResp.status}`); + const { setup_key } = await pairResp.json() as any; - const pairResp = await fetch(`${localUrl}/pair`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${rootToken}` }, - body: JSON.stringify({ clientId: 'tunnel-eval' }), - }); - if (!pairResp.ok) throw new Error(`/pair failed: ${pairResp.status}`); - const { setup_key } = await pairResp.json() as any; + const connectResp = await fetch(`${localUrl}/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ setup_key }), + }); + if (!connectResp.ok) throw new Error(`/connect failed: ${connectResp.status}`); + const { token: scopedToken } = await connectResp.json() as any; - const connectResp = await fetch(`${localUrl}/connect`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ setup_key }), - }); - if (!connectResp.ok) throw new Error(`/connect failed: ${connectResp.status}`); - const { token: scopedToken } = await connectResp.json() as any; - - return { proc, localPort, tunnelPort, rootToken, scopedToken, stateFile, tempDir, localUrl, tunnelUrl, attemptsLogPath }; + return { proc, localPort, tunnelPort, rootToken, scopedToken, stateFile, tempDir, localUrl, tunnelUrl, attemptsLogPath, output }; + } catch (error) { + // A failed beforeAll never hands its daemon to afterAll for cleanup. + try { + try { proc.kill('SIGKILL'); } catch {} + await proc.exited; + const [stdout, stderr] = await output; + const errorFile = path.join(tempDir, 'browse-startup-error.log'); + const startupError = fs.existsSync(errorFile) ? fs.readFileSync(errorFile, 'utf-8') : ''; + throw new Error(`${error}\n${startupError}\n${stderr}\n${stdout}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } } -function killDaemon(handle: DaemonHandle): void { +async function killDaemon(handle: DaemonHandle): Promise { try { handle.proc.kill('SIGKILL'); } catch {} - try { fs.rmSync(handle.tempDir, { recursive: true, force: true }); } catch {} + try { + await handle.proc.exited; + await handle.output; + } finally { + fs.rmSync(handle.tempDir, { recursive: true, force: true }); + } } async function postCommand(baseUrl: string, token: string, body: any): Promise<{ status: number; bodyText: string }> { @@ -145,8 +178,8 @@ describe('pair-agent over tunnel surface — gate fires on the right surface onl daemon = await spawnDaemonWithTunnel(); }, 30_000); - afterAll(() => { - if (daemon) killDaemon(daemon); + afterAll(async () => { + if (daemon) await killDaemon(daemon); }); test('newtab on tunnel surface passes the allowlist gate (not 403 disallowed_command)', async () => { diff --git a/browse/test/watchdog.test.ts b/browse/test/watchdog.test.ts index ff4cfb84f..66ead77cd 100644 --- a/browse/test/watchdog.test.ts +++ b/browse/test/watchdog.test.ts @@ -50,13 +50,13 @@ afterEach(async () => { serverProc = null; }); -function spawnServer(env: Record, port: number): Subprocess { +function spawnServer(env: Record): Subprocess { const stateFile = path.join(tmpDir, 'browse-state.json'); return spawn(['bun', 'run', SERVER_SCRIPT], { env: { ...process.env, BROWSE_STATE_FILE: stateFile, - BROWSE_PORT: String(port), + BROWSE_PORT: '0', // Use the existing available-port allocator; fixed ports can collide across shards. ...env, }, stdio: ['ignore', 'pipe', 'pipe'], @@ -101,7 +101,7 @@ async function readStdoutUntil( describe('parent-process watchdog (v0.18.1.0)', () => { test('BROWSE_PARENT_PID=0 disables the watchdog', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'watchdog-pid0-')); - serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }, 34901); + serverProc = spawnServer({ BROWSE_PARENT_PID: '0' }); const out = await readStdoutUntil( serverProc, @@ -121,7 +121,6 @@ describe('parent-process watchdog (v0.18.1.0)', () => { // this PID and eventually fire on the "dead parent." serverProc = spawnServer( { BROWSE_HEADED: '1', BROWSE_PARENT_PID: '999999' }, - 34902, ); const out = await readStdoutUntil( @@ -146,7 +145,7 @@ describe('parent-process watchdog (v0.18.1.0)', () => { serverProc = spawnServer({ BROWSE_PARENT_PID: String(parentPid), BROWSE_PARENT_WATCHDOG_INTERVAL_MS: '250', - }, 34903); + }); const serverPid = serverProc.pid!; // Startup barrier: poll stdout for the listen line instead of a fixed 2s