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) <noreply@anthropic.com>
This commit is contained in:
Randalix
2026-10-09 16:29:01 +02:00
co-authored by Claude Opus 5.5
parent 6f87186deb
commit eceaecf076
6 changed files with 81 additions and 46 deletions
+19 -6
View File
@@ -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<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((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<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((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);
});
+7 -4
View File
@@ -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,
},
+2 -3
View File
@@ -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 () => {
+15 -12
View File
@@ -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<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((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\(\)/);
});
});
+33 -17
View File
@@ -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<number> {
const probe = http.createServer();
await new Promise<void>((resolve) => probe.listen(0, '127.0.0.1', resolve));
const { port: free } = probe.address() as AddressInfo;
await new Promise<void>((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<string, unknown> = {}): 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<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
await new Promise<void>((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 = [
+5 -4
View File
@@ -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<string, string> {
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<void>((done) => server.listen(PORT, '127.0.0.1', done));
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {