Files
Codeman/test/tui/tui-client.test.ts
T
Codeman maintainer 737c2ed7f8 fix(tui): escape the separator in the switch binding, closing the sizing leak
The loose end from 777f974, now explained. Sessions came back from a detach on
`window-size latest` instead of `manual`, and the restore primitive round-tripped
correctly in isolation, so the corruption had to be upstream of it. It was: the
snapshot was taken from state this code had already broken.

`bindSwitchKey` passed a bare `;` between the two commands it wanted in one
binding. That is a command separator to tmux's OWN parser, not an argument: it
ended the `bind-key` and executed what followed immediately. So the binding kept
only `switch-client`, and `set-window-option ... window-size latest` RAN against
every switchable session at attach time — before the sizing snapshot was taken.
Every session was therefore snapshotted as `latest` and faithfully restored to
`latest`.

Proven against real tmux both ways before fixing: a bare `;` leaves the session
on `latest` and stores a one-command binding, while `\;` leaves it `manual` and
stores both commands.

Verified end to end: 7 sessions manual before, 1 latest + 6 manual during the
attach (the attached one follows the terminal, the rest are pre-sized), no dot
padding on a switch, and all 7 back to 120x40 manual after the detach.

This also means the "follow the terminal after switching" half of 777f974 never
actually worked — it was never in the binding.
2026-08-22 14:13:58 +02:00

926 lines
37 KiB
TypeScript

/**
* @fileoverview Unit tests for the TUI's IO layer: discovery, credentials, the
* typed API surface and degraded-mode tmux enumeration.
*
* The API calls run against a real loopback HTTP server that answers in the
* shapes the routes really produce (the `{success,data}` envelope, plus
* away-digest's legacy top-level `digest`), so an envelope change breaks these
* tests rather than the dashboard. tmux is never executed: the exec function is
* injected, and `test/setup.ts` gives this file its own HOME, so the state file
* it reads is a fixture of its own making.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import http from 'node:http';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../../src/config/instance.js';
import {
TuiApiError,
TuiClient,
basicAuthHeader,
enumerateTmuxSessions,
parseEnvFile,
arrayOptionBase,
parseSessionOptions,
parseDetachKey,
parsePrefixBinding,
ATTACH_BANNER_MARKER,
parseTmuxSessionList,
parseWindowSizing,
readCodemanCredentials,
tuiServerCandidates,
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}`;
interface Recorded {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: string;
}
const recorded: Recorded[] = [];
type Responder = (req: http.IncomingMessage, res: http.ServerResponse, body: string) => void;
/** Per-test override; falls back to `defaultResponder`. */
let responder: Responder | null = null;
function sendJson(res: http.ServerResponse, status: number, payload: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
}
const defaultResponder: Responder = (req, res) => {
const url = req.url ?? '';
if (url.startsWith('/api/status')) {
return sendJson(res, 200, {
success: true,
data: { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 32, resetAt: 1000 } } },
});
}
if (url.startsWith('/api/sessions/unified')) {
return sendJson(res, 200, {
success: true,
data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 },
});
}
if (url === '/api/sessions' || url.startsWith('/api/sessions?')) {
// The light state, plus the two rows the narrowing has to discard.
return sendJson(res, 200, {
success: true,
data: [
{ id: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100, status: 'busy' },
{ id: '', lastSubmitAt: 7000 },
{ id: 'zzz', lastSubmitAt: '5', inputTokens: null },
],
});
}
if (url.startsWith('/api/approvals')) {
return sendJson(res, 200, {
success: true,
data: { approvals: [{ id: 'abc:1', sessionId: 'abc', sessionName: 'w1', kind: 'permission', createdAt: 5 }] },
});
}
if (url.includes('/terminal')) {
return sendJson(res, 200, { success: true, data: { terminalBuffer: 'tail bytes', status: 'idle' } });
}
if (url.endsWith('/input')) {
return sendJson(res, 200, { success: true, data: {} });
}
if (url.startsWith('/api/quick-start')) {
return sendJson(res, 200, { success: true, data: { sessionId: 'new-1', casePath: '/cases/x', caseName: 'x' } });
}
if (url.startsWith('/api/cases')) {
return sendJson(res, 200, { success: true, data: [{ name: 'x', path: '/cases/x', location: 'local' }] });
}
if (url.startsWith('/api/search')) {
return sendJson(res, 200, {
success: true,
data: { query: 'foo', groups: [], totalResults: 0, truncated: false },
});
}
if (url.startsWith('/api/away-digest')) {
// Legacy shape: the payload sits at the TOP level, not under `data`.
return sendJson(res, 200, { success: true, digest: { totals: { activeSessions: 2 } } });
}
if (req.method === 'DELETE') {
return sendJson(res, 200, { success: true, data: {} });
}
return sendJson(res, 404, { success: false, error: 'no route', errorCode: 'NOT_FOUND' });
};
function client(overrides: Record<string, unknown> = {}): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides });
}
let server: http.Server;
const originalApiUrl = process.env.CODEMAN_API_URL;
const originalPort = process.env.CODEMAN_PORT;
beforeAll(async () => {
// This suite runs inside a Codeman-managed session, which exports
// CODEMAN_API_URL pointing at the LIVE server. Discovery consults it, so it
// has to be out of the way before any test calls connect().
delete process.env.CODEMAN_API_URL;
delete process.env.CODEMAN_PORT;
server = http.createServer((req, res) => {
let body = '';
req.setEncoding('utf-8');
req.on('data', (chunk: string) => {
body += chunk;
});
req.on('end', () => {
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
(responder ?? defaultResponder)(req, res, body);
});
});
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (originalApiUrl !== undefined) process.env.CODEMAN_API_URL = originalApiUrl;
if (originalPort !== undefined) process.env.CODEMAN_PORT = originalPort;
});
beforeEach(() => {
recorded.length = 0;
responder = null;
});
describe('parseEnvFile', () => {
it('reads plain assignments and skips comments and blanks', () => {
expect(parseEnvFile('# comment\n\nCODEMAN_USERNAME=bob\nCODEMAN_PASSWORD=hunter2\n')).toEqual({
CODEMAN_USERNAME: 'bob',
CODEMAN_PASSWORD: 'hunter2',
});
});
it('strips one layer of matching quotes', () => {
expect(parseEnvFile('A="quoted"\nB=\'single\'\nC="mismatched\'')).toEqual({
A: 'quoted',
B: 'single',
C: '"mismatched\'',
});
});
it('ignores lines that are not assignments', () => {
expect(parseEnvFile('not an assignment\n1BAD=x\nGOOD=y')).toEqual({ GOOD: 'y' });
});
});
describe('readCodemanCredentials', () => {
it('falls back to the data dir .env when the environment has nothing', () => {
const envPath = dataPath('.env');
mkdirSync(dirname(envPath), { recursive: true });
writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8');
expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'filepass' });
});
it('lets the environment win over the file', () => {
const envPath = dataPath('.env');
writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8');
process.env.CODEMAN_PASSWORD = 'envpass';
try {
expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'envpass' });
} finally {
delete process.env.CODEMAN_PASSWORD;
}
});
it('reports admin with no password when nothing is configured', () => {
expect(readCodemanCredentials('/nonexistent/codeman/.env')).toEqual({ username: 'admin' });
});
});
describe('basicAuthHeader', () => {
it('is absent without a password and base64 with one', () => {
expect(basicAuthHeader({ username: 'admin' })).toBeUndefined();
expect(basicAuthHeader({ username: 'admin', password: 's3cret' })).toBe(
`Basic ${Buffer.from('admin:s3cret').toString('base64')}`
);
});
});
describe('tuiServerCandidates', () => {
it('prefers an explicit API url and trims its trailing slash', () => {
expect(tuiServerCandidates({ apiUrl: 'https://box:8443/' })).toEqual(['https://box:8443']);
});
it('probes both schemes on loopback, https first', () => {
expect(tuiServerCandidates({ port: 5000 })).toEqual(['https://127.0.0.1:5000', 'http://127.0.0.1:5000']);
});
it('falls back to port 3000 for junk', () => {
expect(tuiServerCandidates({ port: 'not-a-port' })).toEqual(['https://127.0.0.1:3000', 'http://127.0.0.1:3000']);
});
});
describe('TuiClient envelope handling', () => {
it('unwraps the sessions list', async () => {
const sessions = await client().fetchUnifiedSessions(10);
expect(sessions).toEqual([{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }]);
expect(recorded[0].url).toBe('/api/sessions/unified?limit=10');
});
it('unwraps pending approvals', async () => {
const approvals = await client().fetchApprovals();
expect(approvals).toHaveLength(1);
expect(approvals[0].id).toBe('abc:1');
});
it('turns a success:false envelope into a typed error carrying the code', async () => {
responder = (_req, res) =>
sendJson(res, 404, { success: false, error: 'Session not found', errorCode: 'NOT_FOUND' });
await expect(client().fetchTerminalTail('gone', 1000)).rejects.toMatchObject({
name: 'TuiApiError',
status: 404,
errorCode: 'NOT_FOUND',
message: 'Session not found',
});
});
it('turns a non-JSON failure into a typed error too', async () => {
responder = (_req, res) => {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('bad gateway');
};
const err = await client()
.fetchApprovals()
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(TuiApiError);
expect((err as TuiApiError).status).toBe(502);
});
it('sends Basic auth when a password is configured, and none when it is not', async () => {
await client({ username: 'admin', password: 's3cret' }).fetchApprovals();
expect(recorded[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`);
recorded.length = 0;
await client({ envFilePath: '/nonexistent/codeman/.env' }).fetchApprovals();
expect(recorded[0].headers.authorization).toBeUndefined();
});
});
describe('TuiClient.answerApproval', () => {
it('reports success', async () => {
responder = (_req, res) =>
sendJson(res, 200, { success: true, data: { id: 'abc:1', sessionId: 'abc', action: 'approve' } });
await expect(client().answerApproval('abc:1', { action: 'approve' })).resolves.toEqual({
ok: true,
id: 'abc:1',
sessionId: 'abc',
action: 'approve',
});
});
it('reports a 409 as a typed "gone" result, not an exception', async () => {
responder = (_req, res) =>
sendJson(res, 409, { success: false, error: 'The dialog is no longer on screen', errorCode: 'CONFLICT' });
const result = await client().answerApproval('abc:1', { action: 'option', option: 2 });
expect(result).toEqual({ ok: false, reason: 'gone', message: 'The dialog is no longer on screen' });
});
it('separates "already resolved" from "digit rejected"', async () => {
responder = (_req, res) => sendJson(res, 404, { success: false, error: 'gone', errorCode: 'NOT_FOUND' });
expect((await client().answerApproval('x', { action: 'deny' })).ok).toBe(false);
expect(await client().answerApproval('x', { action: 'deny' })).toMatchObject({ reason: 'not-found' });
responder = (_req, res) => sendJson(res, 400, { success: false, error: 'bad option', errorCode: 'INVALID_INPUT' });
expect(await client().answerApproval('x', { action: 'option', option: 9 })).toMatchObject({ reason: 'rejected' });
});
it('posts the answer body verbatim', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { id: 'a', sessionId: 'b', action: 'text' } });
await client().answerApproval('a b/c', { action: 'text', text: 'yes please' });
expect(recorded[0].method).toBe('POST');
expect(recorded[0].url).toBe('/api/approvals/a%20b%2Fc/answer');
expect(JSON.parse(recorded[0].body)).toEqual({ action: 'text', text: 'yes please' });
});
});
describe('TuiClient.sendInput', () => {
it('always terminates with a carriage return and never sends a bare newline', async () => {
await client().sendInput('abc', 'hello world');
expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true });
});
it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => {
await client().sendInput('abc', 'echo A\necho B\r\nline three ');
expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r');
});
it('tags every send with a stable clientId and a monotonic seq', async () => {
const c = client();
await c.sendInput('abc', 'one');
await c.sendInput('abc', 'two');
await c.sendInput('def', 'three');
const bodies = recorded.map((entry) => JSON.parse(entry.body) as { clientId: string; seq: number });
expect(bodies.map((b) => b.seq)).toEqual([1, 2, 3]);
expect(new Set(bodies.map((b) => b.clientId)).size).toBe(1);
expect(bodies[0].clientId).toMatch(/^codeman-tui-\d+$/);
expect(c.lastInputSeq).toBe(3);
});
});
describe('TuiClient remaining API surface', () => {
it('fetches a terminal tail by byte count', async () => {
await expect(client().fetchTerminalTail('abc', 4096)).resolves.toBe('tail bytes');
expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096');
});
it('reads the turn stamp and token counters off the light session state', async () => {
const metrics = await client().fetchLiveSessionMetrics();
expect(recorded[0].url).toBe('/api/sessions');
// Narrowed to the three fields, keyed by id: a row with no usable id is
// dropped rather than folded in under an empty key, and a field of the
// wrong type is left absent rather than merged as a string.
expect(metrics).toEqual([
{ sessionId: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100 },
{ sessionId: 'zzz' },
]);
});
it('reports an unreadable session list as empty rather than throwing', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { sessions: 'not an array' } });
await expect(client().fetchLiveSessionMetrics()).resolves.toEqual([]);
});
it('starts sessions through quick-start', async () => {
const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' });
expect(result.sessionId).toBe('new-1');
expect(recorded[0].url).toBe('/api/quick-start');
expect(JSON.parse(recorded[0].body)).toEqual({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' });
});
it('lists cases', async () => {
await expect(client().fetchCases()).resolves.toEqual([{ name: 'x', path: '/cases/x', location: 'local' }]);
});
it('deletes a session by exact id', async () => {
await client().deleteSession('abc');
expect(recorded[0].method).toBe('DELETE');
expect(recorded[0].url).toBe('/api/sessions/abc');
});
it('searches with an encoded query', async () => {
await client().search('a b', 5);
expect(recorded[0].url).toBe('/api/search?q=a+b&limit=5');
});
it('reads the away digest from its legacy top-level shape', async () => {
await expect(client().fetchAwayDigest('24h')).resolves.toEqual({ totals: { activeSessions: 2 } });
expect(recorded[0].url).toBe('/api/away-digest?range=24h');
});
it('reads plan usage off the status snapshot', async () => {
await expect(client().fetchPlanUsage()).resolves.toEqual({ fiveHour: { usedPercentage: 32, resetAt: 1000 } });
});
it('reports a missing plan-usage snapshot as null rather than throwing', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { version: '1.0.0', planUsage: null } });
await expect(client().fetchPlanUsage()).resolves.toBeNull();
});
});
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);
expect(info?.version).toBe('9.9.9');
expect(info?.hostname).toBeTruthy();
expect(info?.authRequired).toBeUndefined();
});
it('reports a server that rejects our credentials instead of calling it down', async () => {
responder = (_req, res) => {
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);
expect(info?.authRequired).toBe(true);
});
it('returns null when nothing answers', async () => {
await expect(new TuiClient({ port: DEAD_PORT, 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/);
});
});
describe('degraded-mode tmux enumeration', () => {
const listing = [
'codeman-1a2b3c4d\t1\t1700000000\t1',
'codeman-deadbeef\t0\t1700000100\t2',
'claudeman-cafe0001\t0\t1700000200\t1',
'my-own-tmux-session\t1\t1700000300\t1',
'codeman-ssh-abc\t0\t1700000400\t1',
].join('\n');
it('parses the list format and keeps only Codeman-owned names', () => {
const rows = parseTmuxSessionList(listing);
expect(rows.map((row) => row.muxName)).toEqual(['codeman-1a2b3c4d', 'codeman-deadbeef', 'claudeman-cafe0001']);
expect(rows[0]).toMatchObject({ sessionIdPrefix: '1a2b3c4d', attached: true, createdAt: 1_700_000_000_000 });
expect(rows[1]).toMatchObject({ attached: false, windows: 2 });
});
it('never shells out: the tmux call is an argv array on the instance socket', async () => {
const calls: Array<{ file: string; args: readonly string[] }> = [];
const exec: TuiExecFile = async (file, args) => {
calls.push({ file, args });
return { stdout: listing, stderr: '' };
};
await enumerateTmuxSessions({ exec, socket: 'codeman-beta', statePath: '/nonexistent/state.json' });
expect(calls).toHaveLength(1);
expect(calls[0].file).toBe('tmux');
expect(calls[0].args.slice(0, 4)).toEqual(['-L', 'codeman-beta', 'list-sessions', '-F']);
});
it('decorates rows with names and dirs from state.json, matching on the id prefix', async () => {
const statePath = dataPath('state.json');
writeFileSync(
statePath,
JSON.stringify({
sessions: {
'1a2b3c4d-1111-2222-3333-444444444444': {
name: 'w1-codeman',
workingDir: '/home/dev/codeman',
mode: 'claude',
},
},
}),
'utf-8'
);
const exec: TuiExecFile = async () => ({ stdout: listing, stderr: '' });
const rows = await enumerateTmuxSessions({ exec, statePath });
expect(rows[0]).toMatchObject({
sessionId: '1a2b3c4d-1111-2222-3333-444444444444',
name: 'w1-codeman',
workingDir: '/home/dev/codeman',
mode: 'claude',
});
// No state entry: the row still exists, it just has no decoration.
expect(rows[1].sessionId).toBeUndefined();
expect(rows[1].name).toBeUndefined();
});
it('refuses to guess when two ids share a prefix', async () => {
const statePath = dataPath('ambiguous-state.json');
writeFileSync(
statePath,
JSON.stringify({
sessions: {
'1a2b3c4d-aaaa': { name: 'first' },
'1a2b3c4d-bbbb': { name: 'second' },
},
}),
'utf-8'
);
const exec: TuiExecFile = async () => ({ stdout: 'codeman-1a2b3c4d\t0\t1700000000\t1', stderr: '' });
const rows = await enumerateTmuxSessions({ exec, statePath });
expect(rows[0].name).toBeUndefined();
});
it('treats a dead tmux server as an empty list', async () => {
const exec: TuiExecFile = async () => {
throw new Error('no server running on /tmp/tmux-1000/codeman');
};
await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]);
});
});
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 });
}
it('parses the sizing format, and rejects a window tmux could not measure', () => {
expect(parseWindowSizing('183\t38\tmanual\n')).toEqual({ cols: 183, rows: 38, mode: 'manual' });
expect(parseWindowSizing('120\t40\tlatest')).toEqual({ cols: 120, rows: 40, mode: 'latest' });
// No mode reported (an ancient tmux) still yields a usable size.
expect(parseWindowSizing('120\t40\t')).toEqual({ cols: 120, rows: 40, mode: 'manual' });
expect(parseWindowSizing('')).toBeNull();
expect(parseWindowSizing("can't find window\n")).toBeNull();
});
it('reads the sizing with an argv array on the client socket', async () => {
const calls: Array<readonly string[]> = [];
const client = sizingClient(async (_file, args) => {
calls.push(args);
return { stdout: '120\t40\tmanual', stderr: '' };
});
await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toEqual({
cols: 120,
rows: 40,
mode: 'manual',
});
expect(calls[0].slice(0, 6)).toEqual(['-L', 'codeman-beta', 'display-message', '-p', '-t', 'codeman-1a2b3c4d']);
});
it('hands the window to the attaching client with window-size latest', async () => {
const calls: Array<readonly string[]> = [];
const client = sizingClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(true);
expect(calls[0]).toEqual([
'-L',
'codeman-beta',
'set-window-option',
'-t',
'codeman-1a2b3c4d',
'window-size',
'latest',
]);
});
it('restores a manual window with resize-window alone, which re-pins the mode itself', async () => {
const calls: Array<readonly string[]> = [];
const client = sizingClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' });
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual([
'-L',
'codeman-beta',
'resize-window',
'-t',
'codeman-1a2b3c4d',
'-x',
'120',
'-y',
'40',
]);
});
it('restores a non-manual window by putting its mode back', async () => {
const calls: Array<readonly string[]> = [];
const client = sizingClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'latest' });
expect(calls).toEqual([
['-L', 'codeman-beta', 'set-window-option', '-t', 'codeman-1a2b3c4d', 'window-size', 'latest'],
]);
});
it('never targets a name Codeman does not own', async () => {
const calls: Array<readonly string[]> = [];
const client = sizingClient(async (_file, args) => {
calls.push(args);
return { stdout: '120\t40\tmanual', stderr: '' };
});
await expect(client.readWindowSizing('codeman-ssh-prod')).resolves.toBeNull();
await expect(client.followAttachingClient('other-session')).resolves.toBe(false);
await client.restoreWindowSizing('codeman-dkr-box', { cols: 80, rows: 24, mode: 'manual' });
expect(calls).toEqual([]);
});
it('swallows a dead tmux: an attach must never fail over cosmetics', async () => {
const client = sizingClient(async () => {
throw new Error('no server running on /tmp/tmux-1000/codeman-beta');
});
await expect(client.readWindowSizing('codeman-1a2b3c4d')).resolves.toBeNull();
await expect(client.followAttachingClient('codeman-1a2b3c4d')).resolves.toBe(false);
await expect(
client.restoreWindowSizing('codeman-1a2b3c4d', { cols: 120, rows: 40, mode: 'manual' })
).resolves.toBeUndefined();
});
});
describe('TuiClient.bindSwitchKey', () => {
it('escapes the command separator, or tmux runs the second command instead of binding it', () => {
// ⚠️ A bare `;` argument is a command separator to tmux's OWN parser: it
// ends the bind-key and executes what follows immediately. That bound only
// `switch-client` and ran `window-size latest` against every session at
// attach time, which is why sessions were left on `latest` after a detach —
// the sizing snapshot was taken from already-corrupted state. Verified
// against real tmux both ways before this test was written.
const calls: string[][] = [];
const exec: TuiExecFile = async (_file, args) => {
calls.push([...args]);
return { stdout: '', stderr: '' };
};
const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
return client.bindSwitchKey('M-2', 'codeman-aaaa1111').then(() => {
const bind = calls.find((args) => args.includes('bind-key'));
expect(bind).toBeDefined();
expect(bind).toContain('\\;');
expect(bind).not.toContain(';');
// Both commands have to be in the ONE binding.
expect(bind?.join(' ')).toContain('switch-client -t codeman-aaaa1111');
expect(bind?.join(' ')).toContain('window-size latest');
});
});
});
describe('parsePrefixBinding', () => {
const REAL = [
'bind-key -T prefix d detach-client',
'bind-key -T prefix D choose-client -Z',
'bind-key -T prefix C-b send-prefix',
].join('\n');
it('reports what a key is bound to, verbatim', () => {
expect(parsePrefixBinding(REAL, 'd')).toBe('detach-client');
expect(parsePrefixBinding(REAL, 'D')).toBe('choose-client -Z');
});
it('is null for a key nothing claims, which is what makes it safe to claim', () => {
expect(parsePrefixBinding(REAL, 'C-d')).toBeNull();
});
it('is case-sensitive, like tmux itself', () => {
expect(parsePrefixBinding('bind-key -T prefix D choose-client', 'd')).toBeNull();
});
it('reads the prefix-less root table too, where the one-key exit lives', () => {
const root = ['bind-key -T root MouseDown1Pane select-pane -t =', 'bind-key -T root F12 detach-client'].join('\n');
expect(parsePrefixBinding(root, 'F12', 'root')).toBe('detach-client');
// Stock tmux has no F12 there, which is what makes it safe to claim.
expect(parsePrefixBinding('bind-key -T root MouseDown1Pane select-pane -t =', 'F12', 'root')).toBeNull();
// A prefix binding must not be mistaken for a root one.
expect(parsePrefixBinding('bind-key -T prefix F12 detach-client', 'F12', 'root')).toBeNull();
});
});
describe('TuiClient.clearLeakedAttachBanners', () => {
const OURS = `#[align=left] press #[bold]Ctrl+B then d#[nobold] to detach, ${ATTACH_BANNER_MARKER} #[default]`;
function sweeper(formats: Record<string, string>): { client: TuiClient; calls: string[][] } {
const calls: string[][] = [];
const exec: TuiExecFile = async (_file, args) => {
calls.push([...args]);
if (args.includes('list-sessions')) return { stdout: Object.keys(formats).join('\n'), stderr: '' };
if (args.includes('show-options')) {
const name = args[args.indexOf('-t') + 1] ?? '';
return { stdout: formats[name] ?? '', stderr: '' };
}
return { stdout: '', stderr: '' };
};
return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls };
}
it('takes down a bar a killed TUI left behind, and puts status back off', async () => {
// The case that produced this: the tester closed the terminal window while
// attached, so restore() never ran and the bar stayed pinned.
const { client, calls } = sweeper({ 'codeman-aaaa1111': OURS });
expect(await client.clearLeakedAttachBanners()).toBe(1);
const sets = calls.filter((args) => args.includes('set-option'));
// The array whole, never one index: unsetting `status-format[0]` alone
// leaves an EMPTY array, which renders as a blank bar.
expect(sets.some((args) => args.includes('-u') && args.includes('status-format'))).toBe(true);
expect(sets.some((args) => args.includes('-u') && args.includes('status-style'))).toBe(true);
// ⚠️ Every option the banner writes must be undone by the pass that
// recognises it. `status-position` was missing, so a sweep removed the
// marker and left the position behind — and with no marker the leftover
// stopped matching, making it permanently unsweepable.
expect(sets.some((args) => args.includes('-u') && args.includes('status-position'))).toBe(true);
expect(sets.some((args) => args.join(' ').endsWith('status off'))).toBe(true);
expect(sets.every((args) => !args.includes('status-format[0]'))).toBe(true);
});
it('never touches a status bar that is not ours', async () => {
const { client, calls } = sweeper({ 'codeman-bbbb2222': '#[align=right] my own bar ' });
expect(await client.clearLeakedAttachBanners()).toBe(0);
expect(calls.filter((args) => args.includes('set-option'))).toEqual([]);
});
it('sweeps only the sessions that leaked, leaving the rest alone', async () => {
const { client } = sweeper({
'codeman-aaaa1111': OURS,
'codeman-bbbb2222': '',
'codeman-cccc3333': OURS,
});
expect(await client.clearLeakedAttachBanners()).toBe(2);
});
it('reports nothing rather than throwing when there is no tmux server', async () => {
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 });
await expect(client.clearLeakedAttachBanners()).resolves.toBe(0);
});
});
describe('parseDetachKey', () => {
// Verbatim from `tmux -L codeman list-keys -T prefix` on tmux 3.4, trimmed to
// the two lines that matter. They differ only by case, which is the whole
// point: `D` is choose-client and advertising it leaves the tester attached.
const REAL_TMUX_34 = [
'bind-key -T prefix C-b send-prefix',
'bind-key -T prefix d detach-client',
'bind-key -T prefix D choose-client -Z',
'bind-key -T prefix x confirm-before -p "kill-pane #P? (y/n)" kill-pane',
].join('\n');
it('picks the lowercase detach-client binding out of real tmux output', () => {
expect(parseDetachKey(REAL_TMUX_34)).toBe('d');
});
it('follows a rebound detach key rather than assuming d', () => {
expect(parseDetachKey('bind-key -T prefix Q detach-client')).toBe('Q');
});
it('tolerates the -r repeat flag ahead of -T', () => {
expect(parseDetachKey('bind-key -r -T prefix d detach-client')).toBe('d');
});
it('ignores detach-client bindings that carry arguments', () => {
// `-a` detaches OTHER clients and `-P` kills the pane's process; neither is
// what the bar promises, so a socket with only those reports nothing.
expect(parseDetachKey('bind-key -T prefix X detach-client -a')).toBeNull();
expect(parseDetachKey('bind-key -T prefix Y detach-client -P')).toBeNull();
});
it('prefers a single-character binding over a named key', () => {
const both = 'bind-key -T prefix F1 detach-client\nbind-key -T prefix d detach-client';
expect(parseDetachKey(both)).toBe('d');
});
it('is null when nothing detaches, so the caller can fall back', () => {
expect(parseDetachKey('')).toBeNull();
expect(parseDetachKey('bind-key -T prefix D choose-client -Z')).toBeNull();
});
});
describe('attach status bar options', () => {
function optionsClient(exec: TuiExecFile): TuiClient {
return new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec });
}
const SHOW = [
'history-limit 100000',
'mouse off',
'status off',
'status-format[0] "#[reverse] left "',
'status-format[1] "#[align=right] second line "',
'status-left " plain #[bold]value\\" quoted "',
].join('\n');
it('reads session-level options, unquoting what tmux quoted', () => {
const parsed = parseSessionOptions(SHOW, ['status', 'status-left', 'status-right']);
expect(parsed.status).toBe('off');
expect(parsed['status-left']).toBe(' plain #[bold]value" quoted ');
// Not set on this session: restoring must UNSET it, not write a value back.
expect(parsed['status-right']).toBeNull();
});
it('captures a whole array when one index is asked for', () => {
const parsed = parseSessionOptions(SHOW, ['status-format[0]']);
expect(parsed['status-format[0]']).toBe('#[reverse] left ');
// The second status line the user configured comes along, or the restore
// would silently delete it.
expect(parsed['status-format[1]']).toBe('#[align=right] second line ');
});
it('knows an array element from a plain option', () => {
expect(arrayOptionBase('status-format[0]')).toBe('status-format');
expect(arrayOptionBase('status')).toBeNull();
});
it('reads the prefix from the session, falling back to the global one', () => {
const calls: Array<readonly string[]> = [];
const client = optionsClient(async (_file, args) => {
calls.push(args);
// Session level says nothing; the global answer is the real one.
return { stdout: args.includes('-gv') ? 'C-a\n' : '\n', stderr: '' };
});
return expect(client.readPrefixKey('codeman-1a2b3c4d'))
.resolves.toBe('C-a')
.then(() => {
expect(calls).toHaveLength(2);
expect(calls[1]).toContain('-gv');
});
});
it('restores an array by dropping it FIRST, then writing the captured indices', async () => {
const calls: Array<readonly string[]> = [];
const client = optionsClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await client.restoreSessionOptions('codeman-1a2b3c4d', {
status: 'off',
'status-format[0]': null,
'status-format[1]': '#[align=right] second ',
});
// Unsetting one index leaves an EMPTY array (a blank status bar), so the
// base option goes first and the survivors are written back on top.
expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status-format']);
expect(calls.map((args) => args.slice(2))).toContainEqual([
'set-option',
'-t',
'codeman-1a2b3c4d',
'status',
'off',
]);
expect(calls.map((args) => args.slice(2))).toContainEqual([
'set-option',
'-t',
'codeman-1a2b3c4d',
'status-format[1]',
'#[align=right] second ',
]);
// The null index is covered by the array drop; it never gets its own unset.
expect(calls.some((args) => args.includes('status-format[0]'))).toBe(false);
});
it('unsets a plain option that was not set on the session', async () => {
const calls: Array<readonly string[]> = [];
const client = optionsClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await client.restoreSessionOptions('codeman-1a2b3c4d', { status: null });
expect(calls[0].slice(2)).toEqual(['set-option', '-u', '-t', 'codeman-1a2b3c4d', 'status']);
});
it('writes the banner one option at a time, and never at a foreign session', async () => {
const calls: Array<readonly string[]> = [];
const client = optionsClient(async (_file, args) => {
calls.push(args);
return { stdout: '', stderr: '' };
});
await client.applySessionOptions('codeman-1a2b3c4d', { status: 'on', 'status-format[0]': 'x' });
expect(calls).toHaveLength(2);
expect(calls[0].slice(2)).toEqual(['set-option', '-t', 'codeman-1a2b3c4d', 'status', 'on']);
calls.length = 0;
await client.applySessionOptions('codeman-ssh-prod', { status: 'on' });
await client.restoreSessionOptions('codeman-dkr-box', { status: null });
await expect(client.readPrefixKey('other-thing')).resolves.toBeNull();
expect(calls).toEqual([]);
});
it('keeps going when tmux rejects one option', async () => {
let seen = 0;
const client = optionsClient(async (_file, args) => {
seen += 1;
if (args.includes('status-format[0]')) throw new Error('unknown option');
return { stdout: '', stderr: '' };
});
await client.applySessionOptions('codeman-1a2b3c4d', { 'status-format[0]': 'x', status: 'on' });
expect(seen).toBe(2);
});
});
describe('resumeSession', () => {
it('creates with resumeSessionId and then starts the pane', async () => {
recorded.length = 0;
responder = (req, res) => {
if (req.url === '/api/sessions') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ success: true, data: { session: { id: 'new-session-id' } } }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ success: true, data: {} }));
};
const id = await client().resumeSession({
workingDir: '/home/dev/codeman',
resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888',
sessionName: 'w7-codeman',
});
expect(id).toBe('new-session-id');
expect(recorded.map((entry) => `${entry.method} ${entry.url}`)).toEqual([
'POST /api/sessions',
// Creating a session gives it no pane; without this the resumed row would
// sit in the list unattachable.
'POST /api/sessions/new-session-id/interactive',
]);
expect(JSON.parse(recorded[0].body)).toMatchObject({
workingDir: '/home/dev/codeman',
resumeSessionId: 'bbbbbbbb-5555-6666-7777-888888888888',
mode: 'claude',
name: 'w7-codeman',
});
});
it('does not start a pane when creation answered without an id', async () => {
recorded.length = 0;
responder = (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ success: true, data: {} }));
};
await expect(client().resumeSession({ workingDir: '/home/dev', resumeSessionId: 'aaaa-bbbb' })).rejects.toThrow(
/no session id/
);
expect(recorded).toHaveLength(1);
});
});