From eceaecf076f3c10d7a85a778d3ea497abda5729b Mon Sep 17 00:00:00 2001 From: Randalix Date: Fri, 9 Oct 2026 16:29:01 +0200 Subject: [PATCH] test: raw test servers listen on port 0 (#440) http/Fastify servers in daemon-control, deepseek-status-shim, session-input-wait and the three tui tests listen on 0 and read address().port. The two "nothing listens here" ports (a fixed 3243, and the server's port + 1) come from closedPort(): bind 0, read the port, close. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/daemon-control.test.ts | 25 +++++++++---- test/deepseek-status-shim.test.ts | 11 +++--- test/routes/session-input-wait.test.ts | 5 ++- test/tui/tui-client-sse.test.ts | 27 +++++++------- test/tui/tui-client.test.ts | 50 +++++++++++++++++--------- test/tui/tui-e2e.test.ts | 9 ++--- 6 files changed, 81 insertions(+), 46 deletions(-) diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts index a5bb642f..54c19ff9 100644 --- a/test/daemon-control.test.ts +++ b/test/daemon-control.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { buildBaseUrl, buildStatusUrl, @@ -16,7 +17,17 @@ import { probeServer, } from '../src/daemon-control.js'; -const PORT = 3216; +/** + * A port nothing listens on: bind 0, read what the OS handed out, close. Free at the + * moment of use, unlike "the server's port + 1", which anything may hold. + */ +async function closedPort(): Promise { + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const { port: free } = probe.address() as AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return free; +} describe('buildWebArgs', () => { it('always passes host and port through explicitly', () => { @@ -143,6 +154,7 @@ describe('isProcessAlive', () => { describe('probeServer', () => { let server: http.Server; + let port: number; beforeAll(async () => { server = http.createServer((req, res) => { @@ -157,7 +169,8 @@ describe('probeServer', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } })); }); - await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; }); afterAll(async () => { @@ -165,23 +178,23 @@ describe('probeServer', () => { }); it('reports up and reads the version back', async () => { - const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`); + const result = await probeServer(`http://127.0.0.1:${port}/api/status`); expect(result.up).toBe(true); expect(result.version).toBe('9.9.9'); }); it('counts a 401 as up, because auth being active proves a server is there', async () => { - const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`); + const result = await probeServer(`http://127.0.0.1:${port}/unauthorized`); expect(result.up).toBe(true); }); it('does not mistake an unrelated service squatting on the port for Codeman', async () => { - const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`); + const result = await probeServer(`http://127.0.0.1:${port}/foreign`); expect(result.up).toBe(false); }); it('reports down when nothing is listening', async () => { - const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000); + const result = await probeServer(`http://127.0.0.1:${await closedPort()}/api/status`, 1000); expect(result.up).toBe(false); }); diff --git a/test/deepseek-status-shim.test.ts b/test/deepseek-status-shim.test.ts index 15b51c81..ec72cc5d 100644 --- a/test/deepseek-status-shim.test.ts +++ b/test/deepseek-status-shim.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest'; import { execFileSync, spawn } from 'node:child_process'; import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs'; import { dirname } from 'node:path'; import { @@ -25,8 +26,6 @@ import { DEEPSEEK_STATE_TO_HOOK_EVENT, } from '../src/deepseek-status-shim.js'; -const PORT = 3251; - describe('DeepSeek status shim: provisioning', () => { beforeEach(() => { resetDeepSeekStatusShimForTest(); @@ -73,6 +72,7 @@ describe('DeepSeek status shim: provisioning', () => { describe('DeepSeek status shim: the supervisor contract', () => { let server: Server | undefined; + let port: number; const received: Array<{ body: unknown; secret: string | undefined }> = []; let status = 200; @@ -96,7 +96,10 @@ describe('DeepSeek status shim: the supervisor contract', () => { res.end('{}'); }); }); - server.listen(PORT, '127.0.0.1', resolve); + server.listen(0, '127.0.0.1', () => { + port = (server!.address() as AddressInfo).port; + resolve(); + }); }); beforeAll(() => listen()); @@ -120,7 +123,7 @@ describe('DeepSeek status shim: the supervisor contract', () => { const child = spawn(process.execPath, [path, ...args], { env: { ...process.env, - CODEMAN_API_URL: `http://127.0.0.1:${PORT}`, + CODEMAN_API_URL: `http://127.0.0.1:${port}`, CODEMAN_SESSION_ID: 'sess-from-env', ...env, }, diff --git a/test/routes/session-input-wait.test.ts b/test/routes/session-input-wait.test.ts index 9d1bf965..95080459 100644 --- a/test/routes/session-input-wait.test.ts +++ b/test/routes/session-input-wait.test.ts @@ -451,13 +451,12 @@ describe('POST /api/sessions/:id/input: delivered reports the write, not just th * exactly why this regression needs a POST and a real socket to catch. */ describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => { - const PORT = 3181; - const base = `http://127.0.0.1:${PORT}`; + let base: string; let app: FastifyInstance; beforeAll(async () => { app = (await harness()).app; - await app.listen({ port: PORT, host: '127.0.0.1' }); + base = await app.listen({ port: 0, host: '127.0.0.1' }); }); afterAll(async () => { diff --git a/test/tui/tui-client-sse.test.ts b/test/tui/tui-client-sse.test.ts index a811b91c..5b4f383d 100644 --- a/test/tui/tui-client-sse.test.ts +++ b/test/tui/tui-client-sse.test.ts @@ -9,10 +9,11 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js'; -const PORT = 3242; -const BASE_URL = `http://127.0.0.1:${PORT}`; +let port: number; +let baseUrl: string; interface Connection { url: string; @@ -57,7 +58,9 @@ beforeAll(async () => { res.flushHeaders(); connections.push({ url: req.url, headers: req.headers, res }); }); - await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + baseUrl = `http://127.0.0.1:${port}`; }); afterAll(async () => { @@ -82,7 +85,7 @@ describe('subscribeEvents', () => { let planUsage: unknown = null; let init: unknown = null; - client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client = new TuiClient({ baseUrl, password: 's3cret' }); client.subscribeEvents({ onInit: (state) => { init = state; @@ -117,7 +120,7 @@ describe('subscribeEvents', () => { }); it('suppresses the terminal firehose by default and carries the auth header', async () => { - client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client = new TuiClient({ baseUrl, password: 's3cret' }); client.subscribeEvents({}); await until(() => connections.length === 1); expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal'); @@ -126,7 +129,7 @@ describe('subscribeEvents', () => { }); it('subscribes to the terminal stream of named sessions when asked', async () => { - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); client.subscribeEvents({}, { sessionIds: ['a', 'b'] }); await until(() => connections.length === 1); expect(connections[0].url).toBe('/api/events?sessions=a%2Cb'); @@ -134,7 +137,7 @@ describe('subscribeEvents', () => { it('reconnects when the stream ends', async () => { const statuses: Array<[string, TuiSseStatusDetail]> = []; - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); const stream = client.subscribeEvents( { onStatus: (status, detail) => statuses.push([status, detail]) }, { baseBackoffMs: 10, maxBackoffMs: 20 } @@ -148,7 +151,7 @@ describe('subscribeEvents', () => { }); it('reconnects when a live stream goes silent, which no socket error reports', async () => { - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 }); await until(() => connections.length === 1); @@ -161,7 +164,7 @@ describe('subscribeEvents', () => { it('recommends polling once connecting keeps failing', async () => { refuse = true; const details: TuiSseStatusDetail[] = []; - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); const stream = client.subscribeEvents( { onStatus: (_status, detail) => details.push(detail) }, { baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 } @@ -175,7 +178,7 @@ describe('subscribeEvents', () => { }); it('stops reconnecting after close, so the process can exit', async () => { - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 }); await until(() => connections.length === 1); @@ -187,7 +190,7 @@ describe('subscribeEvents', () => { }); it('closes every stream the client opened', async () => { - client = new TuiClient({ baseUrl: BASE_URL }); + client = new TuiClient({ baseUrl }); client.subscribeEvents({}); client.subscribeEvents({}); await until(() => connections.length === 2); @@ -200,7 +203,7 @@ describe('subscribeEvents', () => { }); it('refuses to subscribe before the client knows where the server is', () => { - const disconnected = new TuiClient({ port: 3999 }); + const disconnected = new TuiClient({ port }); expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/); }); }); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 907858d5..4a2eccc7 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -11,6 +11,7 @@ */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { dataPath } from '../../src/config/instance.js'; @@ -32,10 +33,22 @@ import { type TuiExecFile, } from '../../src/tui/tui-client.js'; -const PORT = 3241; -/** Nothing ever listens here: the "no server" path. */ -const DEAD_PORT = 3243; -const BASE_URL = `http://127.0.0.1:${PORT}`; +/** + * A port nothing listens on: bind 0, read what the OS handed out, close. Free at the + * moment of use, unlike "the server's port + 1", which anything may hold. + */ +async function closedPort(): Promise { + const probe = http.createServer(); + await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve)); + const { port: free } = probe.address() as AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return free; +} + +let port: number; +/** Nothing listens one above the bound port: the "no server" path. */ +let deadPort: number; +let baseUrl: string; interface Recorded { method: string; @@ -115,7 +128,7 @@ const defaultResponder: Responder = (req, res) => { }; function client(overrides: Record = {}): TuiClient { - return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides }); + return new TuiClient({ baseUrl, timeoutMs: 4000, ...overrides }); } let server: http.Server; @@ -140,7 +153,10 @@ beforeAll(async () => { (responder ?? defaultResponder)(req, res, body); }); }); - await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + deadPort = await closedPort(); + baseUrl = `http://127.0.0.1:${port}`; }); afterAll(async () => { @@ -391,8 +407,8 @@ describe('TuiClient remaining API surface', () => { describe('TuiClient.connect', () => { it('discovers the loopback server and reports its identity', async () => { - const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); - expect(info?.baseUrl).toBe(BASE_URL); + const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(baseUrl); expect(info?.version).toBe('9.9.9'); expect(info?.hostname).toBeTruthy(); expect(info?.authRequired).toBeUndefined(); @@ -403,17 +419,17 @@ describe('TuiClient.connect', () => { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' }); res.end('Unauthorized'); }; - const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); - expect(info?.baseUrl).toBe(BASE_URL); + const info = await new TuiClient({ port, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(baseUrl); expect(info?.authRequired).toBe(true); }); it('returns null when nothing answers', async () => { - await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull(); + await expect(new TuiClient({ port: deadPort, probeTimeoutMs: 500 }).connect()).resolves.toBeNull(); }); it('refuses to talk to an unconnected client', async () => { - await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/); + await expect(new TuiClient({ port: deadPort }).fetchApprovals()).rejects.toThrow(/not connected/); }); }); @@ -501,7 +517,7 @@ describe('degraded-mode tmux enumeration', () => { describe('attach window sizing', () => { /** A client that only ever needs its injected exec: none of this talks to a server. */ function sizingClient(exec: TuiExecFile): TuiClient { - return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + return new TuiClient({ baseUrl, socket: 'codeman-beta', exec }); } it('parses the sizing format, and rejects a window tmux could not measure', () => { @@ -615,7 +631,7 @@ describe('TuiClient.bindSwitchKey', () => { calls.push([...args]); return { stdout: '', stderr: '' }; }; - const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec }); return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => { const bind = calls.find((args) => args.includes('bind-key')); expect(bind).toBeDefined(); @@ -672,7 +688,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => { } return { stdout: '', stderr: '' }; }; - return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls }; + return { client: new TuiClient({ baseUrl, socket: 'codeman-beta', exec }), calls }; } it('takes down a bar a killed TUI left behind, and puts status back off', async () => { @@ -713,7 +729,7 @@ describe('TuiClient.clearLeakedAttachBanners', () => { const exec: TuiExecFile = async () => { throw new Error('no server running on /tmp/tmux-1000/codeman-beta'); }; - const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + const client = new TuiClient({ baseUrl, socket: 'codeman-beta', exec }); await expect(client.clearLeakedAttachBanners()).resolves.toBe(0); }); }); @@ -761,7 +777,7 @@ describe('parseDetachKey', () => { describe('attach status bar options', () => { function optionsClient(exec: TuiExecFile): TuiClient { - return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + return new TuiClient({ baseUrl, socket: 'codeman-beta', exec }); } const SHOW = [ diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 9163e366..6e93d011 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn } from 'node:child_process'; import http from 'node:http'; +import type { AddressInfo } from 'node:net'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -35,8 +36,7 @@ import type { SearchResponseData } from '../../src/types/search.js'; import type { ApprovalItem } from '../../src/web/approval-inbox.js'; import type { AwayDigestResponse } from '../../src/web/away-digest.js'; -const PORT = 3244; -const BASE_URL = `http://127.0.0.1:${PORT}`; +let baseUrl: string; const ROOT = resolve(import.meta.dirname, '..', '..'); const COLS = 100; const ROWS = 30; @@ -234,7 +234,7 @@ function childEnv(): Record { delete env.CODEMAN_PORT; return { ...env, - CODEMAN_API_URL: BASE_URL, + CODEMAN_API_URL: baseUrl, CODEMAN_DATA_DIR: dataDir, CODEMAN_TMUX_SOCKET: 'codeman-tui-e2e', CODEMAN_TUI_GLYPHS: 'ascii', @@ -347,7 +347,8 @@ beforeAll(async () => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' })); }); - await new Promise((done) => server.listen(PORT, '127.0.0.1', done)); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => {