mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 17:59:41 +02:00
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:
co-authored by
Claude Opus 5.5
parent
6f87186deb
commit
eceaecf076
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user