Files
Codeman/test/input-socket-map.test.ts
T
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:

- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
  exactly-once input queue, its ACK handling and the redelivery sweep now
  deliver over any registered socket bound to a session, not only
  this._ws. ACKs are routed by the receiving socket's session; silence is
  judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
  copyTerminalSelection and _handleImagePaste take an optional target
  terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
  (primary only, for now); _forEachTile() replaces the _splitPane special
  cases in the font, family, weight, skin and resize paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:55:05 +02:00

282 lines
11 KiB
TypeScript

/**
* @fileoverview The durable input layer delivers over ANY registered terminal
* socket, not only the primary one (`_inputSocketFor`, app.js).
*
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
* bound to another session (the split pane's Pane B, later a grid tile) had no
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
* and no idle-alert acknowledgement. These tests pin the seam: a registered
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
* registration can never evict the socket that replaced it, and the sweep
* judges a socket's silence by that socket's own last frame.
*
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
document: { addEventListener: vi.fn() },
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
type Frame = { t: string; d: string; seq?: number; cid?: string };
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
type Handle = { ws: FakeSocket; lastRecvAt: number };
type Rec = { seq: number; data: string; sentAt: number; tries: number };
type App = {
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
_sendInputEphemeral: (sessionId: string, input: string) => void;
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
_registerInputSocket: (sessionId: string, handle: Handle) => void;
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
_onWsReady: (sessionId: string) => void;
_redeliverSweep: () => void;
_pendingDeliveries: Map<string, Rec[]>;
_seqCounters: Map<string, number>;
_ws: FakeSocket | null;
_wsSessionId: string | null;
_wsLastRecvAt: number;
_reliableAckTimeoutMs: number;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
function makeApp(): App {
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
app._clientId = 'c-test';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.activeSessionId = 'primary';
app._ws = null;
app._wsSessionId = null;
app._wsLastRecvAt = 0;
app._reliableAckTimeoutMs = 4000;
return app as unknown as App;
}
function fakeSocket(frames: Frame[]): FakeSocket {
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
}
describe('a registered second socket joins the exactly-once queue', () => {
it('delivers seq-tagged frames over the registered socket for its session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
const tileFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputAsync('other', 'x');
app._sendInputAsync('other', 'y');
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
['x', 1, 'c-test'],
['y', 2, 'c-test'],
]);
// Nothing for the other session leaks onto the primary socket.
expect(primaryFrames).toEqual([]);
});
it('still prefers the primary socket for the primary session', () => {
const app = makeApp();
const primaryFrames: Frame[] = [];
app._ws = fakeSocket(primaryFrames);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'a');
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
});
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
const app = makeApp();
const posts: Array<{ input: string; seq: number }> = [];
global.fetch = vi.fn(async (_url, init) => {
posts.push(JSON.parse(String(init?.body)));
return new Response('{}', { status: 200 });
});
const closing = { ...fakeSocket([]), readyState: 3 };
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
expect(app._inputSocketFor('other')).toBeNull();
app._sendInputAsync('other', 'z');
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
});
it('routes ephemeral input over the registered socket too', () => {
const app = makeApp();
const tileFrames: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
});
});
describe('ACK routing', () => {
it('drops the record from the queue of the session the ACK arrived for', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
app._sendInputAsync('primary', 'p');
app._sendInputAsync('other', 'o');
// Both sessions issued seq 1. The ACK names no session, so the socket's own
// session decides which queue it drains; the primary's must be untouched.
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
expect(app._pendingDeliveries.get('other')).toBeUndefined();
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
});
it('defaults to the primary socket session when no session is passed', () => {
const app = makeApp();
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._sendInputAsync('primary', 'p');
app._onWsInputAck(1, { t: 'ia', seq: 1 });
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
});
it('re-sends everything pending over a freshly registered socket on ready', () => {
const app = makeApp();
const first: Frame[] = [];
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
app._registerInputSocket('other', firstHandle);
app._sendInputAsync('other', 'a');
expect(first.map((f) => f.d)).toEqual(['a']);
// The socket dies without an ACK; a replacement opens and asks for a flush.
firstHandle.ws.readyState = 3;
const second: Frame[] = [];
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
app._onWsReady('other');
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
});
});
describe('registration ownership', () => {
it('a stale handle cannot unregister the socket that replaced it', () => {
const app = makeApp();
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
const newFrames: Frame[] = [];
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
app._registerInputSocket('other', oldHandle);
app._registerInputSocket('other', newHandle);
// The old socket's close lands late and tries to clean up after itself.
app._unregisterInputSocket('other', oldHandle);
app._sendInputAsync('other', 'still-here');
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
});
it('the owning handle does unregister', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._unregisterInputSocket('other', handle);
expect(app._inputSocketFor('other')).toBeNull();
});
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
const before = Date.now();
app._registerInputSocket('other', handle);
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
});
});
describe('the redelivery sweep judges each socket by its own last frame', () => {
function staleRecord(app: App, sessionId: string) {
const rec = app._pendingDeliveries.get(sessionId)![0];
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
}
it('force-closes a silent registered socket with a stale record', () => {
const app = makeApp();
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
// The primary socket is chatty; that must not vouch for the other one.
app._ws = fakeSocket([]);
app._wsSessionId = 'primary';
app._wsLastRecvAt = Date.now();
app._redeliverSweep();
expect(handle.ws.close).toHaveBeenCalledTimes(1);
expect(app._ws.close).not.toHaveBeenCalled();
});
it('re-drives (does not close) a registered socket that is still receiving', () => {
const app = makeApp();
const frames: Frame[] = [];
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
app._registerInputSocket('other', handle);
app._sendInputAsync('other', 'a');
staleRecord(app, 'other');
handle.lastRecvAt = Date.now();
// A SILENT primary must not condemn the live registered socket either.
app._wsLastRecvAt = 0;
app._redeliverSweep();
expect(handle.ws.close).not.toHaveBeenCalled();
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
});
});