Files
Codeman/test/terminal-tile-input.test.ts
T
Codeman maintainer 330203c08b fix(tiles): final checkup review follow-ups
- docs/tile-grid-plan.md: the As built bullet on tile loads said a refresh
  clears the screen at its turn in the queue. Since the fetch-first refresh it
  fetches at its turn, keeps the last frame through the wait and its own round
  trip, and resets with the queued in-stream \x1bc only once the capture is in
  hand; a failed, aborted or empty fetch writes nothing and resets nothing.
- docs/architecture-invariants.md: the tile grid's One load queue paragraph
  gets the same correction, and its list of captures that go through the
  TileLoadQueue now names the server {t:'c'} refresh and the dropped-output
  recovery refresh.
- test/terminal-tile-input.test.ts: destroy() cancelling a pending recovery is
  now pinned on the timer itself (armed before destroy(), null right after it,
  read before any timer runs), since the recovery callback's own destroyed
  guard made the fetch check pass either way; a second test pins that
  destroy() starts the live-output count over, so a write callback xterm still
  owed counts nothing. Both fail with the _resetLiveFlow() call removed from
  destroy().

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

1479 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview TerminalTile input rides the app's exactly-once queue, and only
* what a human typed is queued.
*
* Before, the split pane's second terminal sent every xterm `onData` chunk as a
* bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down,
* never acknowledged an idle alert. It now goes through `app._sendInputAsync`,
* over the pane's own socket registered in the app's input-socket map. That
* queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on
* its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane
* drops it, and a focus or mouse report goes out once, ephemeral.
*
* The socket's lifecycle is pinned here too: a transient drop reconnects on the
* primary pane's backoff and refreshes the buffer without leaving a stale
* "disconnected" marker under a healthy pane; codes that cannot get better stop
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
* destroy() cancels a pending reconnect.
*
* The last blocks pin the soft-keyboard controller every tile wires
* (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an
* Android autocorrect is sent as an edit, not a duplicated line, a character
* committed in the same task as Enter goes out ahead of the \r, and the
* controller is bound to THIS tile's textarea, composition helper and session.
*
* Real code under test: constants.js + terminal-keycode229-recovery.js +
* app.js (the queue) + terminal-ui.js (the shared input predicates) +
* terminal-tile.js, in one `vm` context. xterm, the fit addon and WebSocket are
* fakes (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeFit, FakeSocket, FakeTerminal } from './mocks/terminal-tile-fakes.js';
const fetchMock = vi.fn();
function loadContext() {
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
const windowStub: Record<string, unknown> = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
CodemanBase: { base: '' },
// The keyCode-229 controller defaults its timers to window's. Late-bound,
// like the context's own, so vi.useFakeTimers() reaches it; without them
// its create() throws into the tile's catch and every controller test
// would run against no controller at all.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
};
const context = vm.createContext({
console: { ...console, log: vi.fn(), debug: vi.fn() },
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
// running inside this context.
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: FakeSocket,
Terminal: FakeTerminal,
FitAddon: { FitAddon: FakeFit },
fetch: (...args: unknown[]) => fetchMock(...args),
location: { protocol: 'http:', host: 'codeman.test' },
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: {
isTouchDevice: () => false,
isHandheldDevice: () => false,
getDeviceType: () => 'desktop',
},
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` +
`${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
return {
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
windowStub,
};
}
const { CodemanApp, windowStub } = loadContext();
type App = Record<string, unknown> & {
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
markIdleAlertSeen: ReturnType<typeof vi.fn>;
};
function makeApp(): App {
const app = Object.create(CodemanApp.prototype) as App;
app._clientId = 'c-test';
app._wsTabNonce = 'nonce-1';
app._seqCounters = new Map();
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._extraInputSockets = new Map();
app._persistReliableState = vi.fn();
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.markIdleAlertSeen = vi.fn();
app.showToast = vi.fn();
// The key handler's chord gates read the shortcut registry, which reads these.
app.loadAppSettingsFromStorage = () => ({});
app._ws = null;
app._wsSessionId = null;
app._estimateReplayRows = (text: string) => text.split('\n').length;
return app;
}
type Tile = {
connect(): Promise<void>;
destroy(): void;
reconnectNow(): void;
fit(opts?: { force?: boolean }): boolean;
detachedSessions?: Set<string>;
ws: FakeSocket | null;
_reconnectAttempts: number;
};
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
/**
* Every tile a test creates, destroyed after it. A tile closed with real timers
* schedules a real reconnect, and one firing during a LATER test opens a socket
* there (FakeSocket.instances is shared), which flaked under full-suite load.
*/
const liveTiles: Tile[] = [];
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
windowStub.app = app;
const tile = new TerminalTile(
's-tile',
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
{ mode: 'claude', ...opts }
);
liveTiles.push(tile);
await tile.connect();
const ws = FakeSocket.instances.at(-1)!;
return { tile, ws, term: FakeTerminal.last! };
}
afterEach(() => {
for (const tile of liveTiles.splice(0)) tile.destroy();
vi.useRealTimers();
});
beforeEach(() => {
FakeTerminal.coreFactory = null;
FakeFit.proposed = { cols: 80, rows: 24 };
FakeSocket.instances = [];
fetchMock.mockReset();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: '' } }),
}));
});
describe('TerminalTile socket identity', () => {
it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => {
const { ws } = await connectTile(makeApp());
const cid = new URL(ws.url).searchParams.get('cid');
expect(cid).toBe('c-test:nonce-1:tile');
});
});
describe('TerminalTile input through the exactly-once queue', () => {
it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('h');
term.type('i');
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([
['h', 1, 'c-test'],
['i', 2, 'c-test'],
]);
});
it('drops a record on its ACK and acknowledges the session idle alert', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('x');
expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1);
ws.receive({ t: 'ia', seq: 1 });
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile');
});
it('flushes input typed before the socket opened, in order, once it does', async () => {
const app = makeApp();
// POSTs fail, as they would while the server restarts, so the input waits.
const { ws, term } = await connectTile(app);
fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) }));
term.type('a');
term.type('b');
await new Promise((r) => setTimeout(r, 0));
expect(ws.inputFrames()).toEqual([]);
ws.open();
expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([
['a', 1],
['b', 2],
]);
});
it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
const posts: Array<{ input: string; seq: number }> = [];
fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => {
if (init?.body) posts.push(JSON.parse(init.body));
return { ok: true, status: 200, json: async () => ({}) };
});
term.type('z');
await new Promise((r) => setTimeout(r, 0));
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
expect(ws.inputFrames()).toEqual([]);
});
});
describe('what xterm generates never enters the durable queue', () => {
it('drops a DA query reply entirely', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[?1;2c');
expect(ws.inputFrames()).toEqual([]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('sends a mouse report once, without a seq, and never persists it', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[<0;10;5M');
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
it('sends a focus report once, without a seq', async () => {
const app = makeApp();
const { ws, term } = await connectTile(app);
ws.open();
term.type('\x1b[I');
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
});
});
describe('TerminalTile leaves the input-socket map', () => {
it('on close, so a later keystroke cannot be written into a dead socket', async () => {
const app = makeApp();
const { ws } = await connectTile(app);
ws.open();
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(true);
ws.onclose?.({ code: 1006 });
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
});
it('on destroy, while pending input stays queued for the HTTP sweep', async () => {
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
ws.open();
term.type('q');
tile.destroy();
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']);
});
});
const isMarker = (data: string) => data.includes('[disconnected');
/**
* Lets the async buffer refresh settle: the fetch, the body read, the chunked
* write AND the load's finally block, which is where a stale marker would be
* stamped. Too few turns here and that assertion passes vacuously.
*/
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
describe('TerminalTile reconnects after a transient drop', () => {
it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => {
vi.useFakeTimers();
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
ws.open();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'fresh screen' } }),
}));
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]);
expect(FakeSocket.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(300);
expect(FakeSocket.instances).toHaveLength(2);
const ws2 = FakeSocket.instances[1];
expect(ws2.url).toBe(ws.url);
ws2.open();
await settle();
// The refresh reset the pane in-stream (never xterm's clear()) and replayed
// the current screen, and nothing after that reset is a marker: the pane is
// healthy again.
expect(term.writes).not.toContain('<CLEAR>');
const lastReset = term.writes.lastIndexOf('\x1bc');
expect(lastReset).toBeGreaterThan(-1);
expect(term.writes.slice(lastReset)).toEqual(['\x1bc', 'fresh screen']);
expect(term.writes.slice(lastReset).some(isMarker)).toBe(false);
expect(tile._reconnectAttempts).toBe(0);
expect(tile.ws).toBe(ws2);
});
it('counts failed attempts and resets the count only on a successful open', async () => {
vi.useFakeTimers();
const { tile, ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too
expect(tile._reconnectAttempts).toBe(2);
await vi.advanceTimersByTimeAsync(1000);
FakeSocket.instances[2].open();
expect(tile._reconnectAttempts).toBe(0);
});
it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => {
vi.useFakeTimers();
const { ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1005 });
await vi.advanceTimersByTimeAsync(300);
expect(FakeSocket.instances).toHaveLength(2);
});
it('reconnectNow() skips the backoff, but never replaces an open socket', async () => {
vi.useFakeTimers();
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(1);
ws.readyState = 3;
ws.onclose?.({ code: 1006 });
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(2);
// The backoff timer it pre-empted must not open a third socket later.
await vi.advanceTimersByTimeAsync(20_000);
expect(FakeSocket.instances).toHaveLength(2);
});
});
describe("the server's {t:'c'} frame refreshes the tile (a Claude pane's first prompt)", () => {
// Session.startInteractive (session.ts) sends it once a fresh Claude pane
// shows its prompt, meaning "refresh after startup"; the primary pane refetches
// and replays (_onSessionClearTerminal). A tile used to run a bare xterm
// clear(), which kept only the cursor's row: banner and transcript gone, and an
// idle Claude never repaints them.
it('refetches the capture and replays it, never a bare clear', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'Claude Code banner\r\n❯ ' } }),
}));
ws.receive({ t: 'o', d: 'banner painted live' });
ws.receive({ t: 'c' });
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.at(-1)).toBe('Claude Code banner\r\n❯ ');
});
it('two clear frames during one refresh fetch once more, not twice', async () => {
const { ws } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let release!: () => void;
fetchMock.mockImplementationOnce(
() =>
new Promise((resolveFetch) => {
release = () =>
resolveFetch({ ok: true, status: 200, json: async () => ({ data: { terminalBuffer: 'a' } }) });
})
);
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
ws.receive({ t: 'c' });
expect(fetchMock).toHaveBeenCalledTimes(1);
release();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('TerminalTile stops for good on codes that cannot get better', () => {
it.each([
[4004, 'the session ended'],
[4009, 'the session ended'],
[4003, 'the server refused this connection'],
[4010, 'another connection took over this pane'],
])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => {
vi.useFakeTimers();
const onExit = vi.fn();
const { tile, ws, term } = await connectTile(makeApp(), { onExit });
ws.open();
ws.onclose?.({ code });
await vi.advanceTimersByTimeAsync(30_000);
tile.reconnectNow();
expect(FakeSocket.instances).toHaveLength(1);
expect(onExit).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledWith(code);
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]);
});
});
describe('TerminalTile ignores a socket it already replaced', () => {
it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => {
vi.useFakeTimers();
const onExit = vi.fn();
const app = makeApp();
const { ws, term } = await connectTile(app, { onExit });
ws.open();
const lateClose = ws.onclose!;
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances[1];
ws2.open();
// The server supersedes the old socket by cid; its close arrives late.
lateClose({ code: 4010 });
expect(onExit).not.toHaveBeenCalled();
term.type('still typing');
expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']);
});
});
describe('TerminalTile destroy()', () => {
it('cancels a pending reconnect and never reports an exit afterwards', async () => {
vi.useFakeTimers();
const onExit = vi.fn();
const { tile, ws } = await connectTile(makeApp(), { onExit });
ws.open();
ws.onclose?.({ code: 1006 });
tile.destroy();
await vi.advanceTimersByTimeAsync(30_000);
expect(FakeSocket.instances).toHaveLength(1);
expect(onExit).not.toHaveBeenCalled();
});
});
describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => {
const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z');
it('announces its size as a desktop viewer when the socket opens', async () => {
const { ws } = await connectTile(makeApp());
ws.open();
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('does not resend an unchanged size, sends a changed one, and force resends', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
expect(tile.fit()).toBe(false); // unchanged: nothing sent
expect(resizeFrames(ws)).toHaveLength(1);
FakeFit.proposed = { cols: 100, rows: 30 };
expect(tile.fit()).toBe(true);
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
expect(tile.fit({ force: true })).toBe(true);
expect(resizeFrames(ws)).toHaveLength(3);
// The primary pane's forced resize flag: without it Session.resize skips a
// size equal to the one it last applied, and the forced resend did nothing.
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop', f: true });
});
it('force on a closed socket sends nothing and says so', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
ws.drop(1006);
expect(tile.fit({ force: true })).toBe(false);
expect(resizeFrames(ws)).toHaveLength(1); // only the open's own announcement
});
describe('Redraw (Ctrl+Shift+R, the header button) on a focused tile', () => {
type RedrawApp = App & {
restoreTerminalSize(): Promise<void>;
_noteFocusedTile(tile: unknown): void;
detachedSessions?: Set<string>;
};
it('forces the resize through to the server and reports the size it sent', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
await app.restoreTerminalSize();
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 80, r: 24, v: 'desktop', f: true });
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 80x24', 'success');
});
it('with the socket down, sends nothing and does not report a success', async () => {
const app = makeApp() as RedrawApp;
const { tile, ws } = await connectTile(app);
ws.open();
app._noteFocusedTile(tile);
ws.drop(1006);
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith(
'Terminal not connected: its size is sent when it reconnects',
'warning'
);
});
it('for a session popped out to its own window, says that window sizes it', async () => {
const app = makeApp() as RedrawApp;
const detached = new Set<string>();
app.detachedSessions = detached;
const { tile, ws } = await connectTile(app, { detachedSessions: detached });
ws.open();
app._noteFocusedTile(tile);
detached.add('s-tile'); // popped out after the tile opened
await app.restoreTerminalSize();
expect(resizeFrames(ws)).toHaveLength(1);
expect(app.showToast).not.toHaveBeenCalledWith(expect.stringContaining('restored'), 'success');
expect(app.showToast).toHaveBeenCalledWith('This session is sized by its own window', 'warning');
});
});
it('re-announces an unchanged size on a reconnected socket', async () => {
vi.useFakeTimers();
const { ws } = await connectTile(makeApp());
ws.open();
ws.onclose?.({ code: 1006 });
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances[1];
ws2.open();
expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => {
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: 28, rows: 30 };
tile.fit();
expect(term.cols).toBe(28);
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
});
it('paneStarted() resends an unchanged size: the first one went out before there was a PTY', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
tile.paneStarted();
expect(resizeFrames(ws)).toEqual([
{ t: 'z', c: 80, r: 24, v: 'desktop' },
{ t: 'z', c: 80, r: 24, v: 'desktop' },
]);
// Only once: the size is recorded again, so a plain fit does not repeat it.
tile.fit();
expect(resizeFrames(ws)).toHaveLength(2);
});
it('paneStarted() on a hidden tile sends nothing, and its next fit sends the size', async () => {
const { tile, ws } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: NaN, rows: NaN };
tile.paneStarted();
expect(resizeFrames(ws)).toHaveLength(1);
// Shown again (a zoom ends) at the same size it had: still sent.
FakeFit.proposed = { cols: 80, rows: 24 };
tile.fit();
expect(resizeFrames(ws)).toHaveLength(2);
});
it('paneStarted() before the socket opens sends nothing; the open sends the size once', async () => {
const { tile, ws } = await connectTile(makeApp());
tile.paneStarted();
expect(resizeFrames(ws)).toHaveLength(0);
ws.open();
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
});
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
FakeFit.proposed = { cols: NaN, rows: NaN };
tile.fit();
expect(resizeFrames(ws)).toHaveLength(1);
expect([term.cols, term.rows]).toEqual([80, 24]);
});
it('stands aside for a session detached into its own window', async () => {
const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) });
ws.open();
FakeFit.proposed = { cols: 120, rows: 40 };
expect(tile.fit()).toBe(false);
expect(resizeFrames(ws)).toEqual([]);
});
it('adopts the column count the PTY reports, keeping its own rows', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
ws.receive({ t: 'zc', c: 132, r: 50 });
expect([term.cols, term.rows]).toEqual([132, 24]);
});
it('leaves the pane alone when the PTY agrees on width', async () => {
const { ws, term } = await connectTile(makeApp());
ws.open();
const before = term.resizes.length;
ws.receive({ t: 'zc', c: 80, r: 60 });
expect(term.resizes.length).toBe(before);
});
});
describe('TerminalTile links and paste follow THIS pane', () => {
it('registers the shared link provider on its own terminal, resolving its own session', async () => {
const app = makeApp();
const register = vi.fn();
app.registerFilePathLinkProvider = register;
const { term } = await connectTile(app);
expect(register).toHaveBeenCalledTimes(1);
const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string };
expect(target.terminal).toBe(term);
expect(target.getSessionId()).toBe('s-tile');
});
it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => {
const app = makeApp();
const paste = vi.fn();
app._handleImagePaste = paste;
const { term } = await connectTile(app);
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
expect(handled).toBe(false);
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
});
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {
const app = makeApp();
const paste = vi.fn();
app._handleImagePaste = paste;
const { term } = await connectTile(app);
term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' });
expect(paste).not.toHaveBeenCalled();
});
});
describe("TerminalTile Ctrl+C copies through the primary pane's copy helpers", () => {
const ctrlC = (extra: Record<string, unknown> = {}) => ({
type: 'keydown',
key: 'c',
code: 'KeyC',
ctrlKey: true,
preventDefault: vi.fn(),
...extra,
});
it("copies THIS pane's selection; a failed write keeps it and focus returns to this pane", async () => {
const app = makeApp();
const copyText = vi.fn(async () => false);
app._copyText = copyText;
const { term } = await connectTile(app);
term.selection = 'npm run build';
const ev = ctrlC();
expect(term.keyHandler!(ev)).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(ev.preventDefault).toHaveBeenCalled();
expect(copyText).toHaveBeenCalledWith('npm run build');
expect(app.showToast).toHaveBeenCalledWith('Failed to copy', 'error');
// As in the primary pane: nothing was copied, so the selection stays for a retry.
expect(term.clearSelection).not.toHaveBeenCalled();
expect(term.selection).toBe('npm run build');
// The execCommand fallback focuses a temporary textarea; the keyboard comes back here.
expect(term.focus).toHaveBeenCalled();
});
it('a successful write clears the selection (a second Ctrl+C interrupts) and refocuses this pane', async () => {
const app = makeApp();
app._copyText = vi.fn(async () => true);
const { term } = await connectTile(app);
term.selection = 'npm run build';
expect(term.keyHandler!(ctrlC())).toBe(false);
await new Promise((r) => setTimeout(r, 0));
expect(app.showToast).toHaveBeenCalledWith('Copied to clipboard', 'success');
expect(term.clearSelection).toHaveBeenCalled();
expect(term.focus).toHaveBeenCalled();
});
it('with nothing selected, Ctrl+C reaches the PTY and Ctrl+Shift+C does not', async () => {
const app = makeApp();
const copyText = vi.fn(async () => true);
app._copyText = copyText;
const { term } = await connectTile(app);
const plain = ctrlC();
expect(term.keyHandler!(plain)).toBe(true);
expect(plain.preventDefault).not.toHaveBeenCalled();
const shifted = ctrlC({ key: 'C', shiftKey: true });
expect(term.keyHandler!(shifted)).toBe(false);
expect(shifted.preventDefault).toHaveBeenCalled();
expect(copyText).not.toHaveBeenCalled();
});
});
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
const app = makeApp();
const { tile, term } = await connectTile(app);
expect(app._focusedTile ?? null).toBeNull();
term.focusTextarea();
expect(app._focusedTile).toBe(tile);
tile.destroy();
expect(app._focusedTile).toBeNull();
expect(term.focusListeners).toEqual([]);
});
});
describe('TerminalTile live-output flow control: a flood cannot pile up in xterm', () => {
// The server applies no backpressure, and a tile used to write every live
// frame straight into xterm: a flood it could not parse as fast piled up in
// xterm's own queue without bound, until xterm's WriteBuffer threw past 50M
// code units and onmessage's catch silently lost the frames. Now unparsed
// (and held) output is counted per tile; past the budget a frame is dropped,
// the tile stops writing onto the hole, and one debounced, bounded refresh
// recaptures the screen (the primary pane's _scheduleDroppedOutputRecovery).
const TileStatics = TerminalTile as unknown as { LIVE_BACKLOG_BUDGET: number };
const defaultBudget = TileStatics.LIVE_BACKLOG_BUDGET;
afterEach(() => {
TileStatics.LIVE_BACKLOG_BUDGET = defaultBudget;
delete windowStub.AbortController;
});
const out = (ws: FakeSocket, d: string) => ws.receive({ t: 'o', d });
const inFlight = (tile: Tile) => (tile as unknown as { _liveInFlight: number })._liveInFlight;
function serve(terminalBuffer: string) {
fetchMock.mockClear();
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer } }),
}));
}
it("the budget is a few MB, not the primary pane's 128 KB: a 1 MiB burst xterm has not parsed yet is still written", async () => {
expect(defaultBudget).toBeGreaterThanOrEqual(2 * 1024 * 1024);
expect(defaultBudget).toBeLessThanOrEqual(16 * 1024 * 1024);
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.holdParse = true;
out(ws, 'z'.repeat(1024 * 1024));
expect(term.writes.at(-1)).toHaveLength(1024 * 1024);
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('counts output until xterm has parsed it, so parsed bytes no longer hold the budget', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(40));
out(ws, 'b'.repeat(40));
expect(inFlight(tile)).toBe(80);
term.parse();
expect(inFlight(tile)).toBe(0);
out(ws, 'c'.repeat(90));
expect(term.writes.slice(-3)).toEqual(['a'.repeat(40), 'b'.repeat(40), 'c'.repeat(90)]);
});
it('stops writing past the budget, and ONE debounced refresh recaptures the screen', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.holdParse = true; // xterm falls behind
out(ws, 'a'.repeat(60));
out(ws, 'b'.repeat(60)); // 120 unparsed: past the budget, dropped
out(ws, 'c');
term.parse(); // xterm catches up, but the stream already has a hole:
out(ws, 'd'); // nothing more is written onto it
expect(term.writes.filter((w) => /^[abcd]/.test(w))).toEqual(['a'.repeat(60)]);
expect(fetchMock).not.toHaveBeenCalled();
term.holdParse = false;
await vi.advanceTimersByTimeAsync(1999);
expect(fetchMock).not.toHaveBeenCalled(); // debounced, like the primary pane's
await vi.advanceTimersByTimeAsync(1);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe('/api/sessions/s-tile/terminal?full=1');
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
await vi.advanceTimersByTimeAsync(10_000);
expect(fetchMock).toHaveBeenCalledTimes(1); // one recovery for the whole burst
});
it("a write xterm refuses (its 50M throw) is a drop that schedules the same recovery, never a swallowed 'malformed frame'", async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('recovered screen');
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'after the hole');
expect(term.writes).not.toContain('after the hole');
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'recovered screen']);
});
it('frames held behind a replay count against the budget too, and a hole there is recovered by another refresh', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
let answer!: (body: string) => void;
fetchMock.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: () =>
new Promise((resolveBody) => {
answer = (terminalBuffer) => resolveBody({ data: { terminalBuffer } });
}),
}));
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({ data: { terminalBuffer: 'second capture' } }),
}));
ws.receive({ t: 'r' });
await settle(); // the headers landed: from here frames are held
out(ws, 'x'.repeat(60));
out(ws, 'y'.repeat(60)); // the held queue would pass the budget: dropped
answer('first capture');
await settle();
// The capture predates the hole, so nothing after it is written onto it.
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'first capture']);
expect(term.writes).not.toContain('y'.repeat(60));
await vi.advanceTimersByTimeAsync(2000);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(term.writes.slice(-2)).toEqual(['\x1bc', 'second capture']);
out(ws, 'live again');
expect(term.writes.at(-1)).toBe('live again');
});
it('a recovery that keeps failing is retried a bounded number of times, then lets live output through again', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(async () => {
throw new Error('offline');
});
term.throwOnWrite = 'boom';
out(ws, 'boom');
out(ws, 'held back');
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(2000);
await settle();
}
const { DROP_RECOVERY_MAX_ATTEMPTS } = windowStub.CodemanDroppedOutput as { DROP_RECOVERY_MAX_ATTEMPTS: number };
expect(fetchMock).toHaveBeenCalledTimes(DROP_RECOVERY_MAX_ATTEMPTS);
expect(term.writes).not.toContain('held back');
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing'); // never left frozen
});
it('a recovery cut off at its deadline is not retried (a stalled link), and lets live output through again', async () => {
windowStub.AbortController = AbortController;
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
fetchMock.mockImplementation(
(_url: string, init?: { signal?: AbortSignal }) =>
new Promise((_resolveFetch, rejectFetch) => {
init?.signal?.addEventListener('abort', () => rejectFetch(new Error('aborted')));
})
);
term.throwOnWrite = 'boom';
out(ws, 'boom');
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(45_000); // the full-capture budget runs out
await settle();
out(ws, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('a reconnect starts the count over, and callbacks from before it count nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
FakeSocket.instances.at(-1)!.open(); // reconnected: its refresh replaces the screen
expect(inFlight(tile)).toBe(0);
term.parse(); // the old write's callback lands after the reset
expect(inFlight(tile)).toBe(0);
});
it('a reconnect drops a pending recovery: its own refresh replaces the screen', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
serve('');
term.throwOnWrite = 'boom';
out(ws, 'boom');
ws.drop(1006);
await vi.advanceTimersByTimeAsync(300);
const ws2 = FakeSocket.instances.at(-1)!;
ws2.open();
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1); // the reconnect's refresh
out(ws2, 'flowing');
expect(term.writes.at(-1)).toBe('flowing');
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('destroy() cancels a pending recovery', async () => {
vi.useFakeTimers();
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
fetchMock.mockClear();
term.throwOnWrite = 'boom';
out(ws, 'boom');
const flow = tile as unknown as { _dropRecoveryTimer: unknown; _liveDropped: boolean };
expect(flow._dropRecoveryTimer).not.toBeNull(); // the drop armed one recovery
tile.destroy();
// Cleared by destroy() itself, read before any timer runs: the timer's own
// callback nulls the field and its destroyed guard skips the fetch, so the
// fetch check below alone cannot tell a cleared timer from a leaked one.
expect(flow._dropRecoveryTimer).toBeNull();
expect(flow._liveDropped).toBe(false);
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).not.toHaveBeenCalled();
});
it('destroy() starts the count over: a write callback xterm still owed counts nothing', async () => {
TileStatics.LIVE_BACKLOG_BUDGET = 100;
const { tile, ws, term } = await connectTile(makeApp());
ws.open();
term.holdParse = true;
out(ws, 'a'.repeat(60));
expect(inFlight(tile)).toBe(60);
tile.destroy();
expect(inFlight(tile)).toBe(0);
term.parse(); // a callback from before destroy() carries the old epoch
expect(inFlight(tile)).toBe(0);
});
});
describe('the server coming back kicks Pane B', () => {
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
// handleInit needs a whole app to run, so the wiring is pinned by source;
// reconnectNow() itself is exercised above.
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId');
const branch = appSource.slice(start, appSource.indexOf('} else {', start));
expect(start).toBeGreaterThan(-1);
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
});
});
/**
* xterm's CompositionHelper, reduced to what the keyCode-229 controller touches.
* Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped
* (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a
* control without the controller reproduces the device-log duplicate, and its
* `triggerDataEvent` feeds the tile's onData, as xterm's core service does.
*/
type Helper = {
_isComposing: boolean;
_isSendingComposition: boolean;
_dataAlreadySent: string;
_coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void };
_handleAnyTextareaChanges: () => void;
};
/**
* Gives every FakeTerminal created from now on a composition helper. Returns
* them in creation order, with xterm's own diff each one started with.
*/
function withCompositionHelpers() {
const helpers: Helper[] = [];
const originals: Array<Helper['_handleAnyTextareaChanges']> = [];
FakeTerminal.coreFactory = (term) => {
const helper: Helper = {
_isComposing: false,
_isSendingComposition: false,
_dataAlreadySent: '',
_coreService: { triggerDataEvent: (data: string) => term.type(data) },
_handleAnyTextareaChanges(this: Helper) {
const oldValue = term.textarea.value;
setTimeout(() => {
if (this._isComposing) return;
const newValue = term.textarea.value;
const diff = newValue.replace(oldValue, '');
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true);
else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true);
else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true);
}, 0);
},
};
helpers.push(helper);
originals.push(helper._handleAnyTextareaChanges);
return { _compositionHelper: helper };
};
return Object.assign(helpers, { originals });
}
/**
* Drives a tile the way an Android soft keyboard drives xterm. The fake xterm
* runs no CompositionHelper.keydown of its own, so `key229()` does what xterm
* does, in xterm's order: the custom key handler first, then (keyCode 229, no
* composition) the helper's `_handleAnyTextareaChanges()`, read off the helper
* at call time so the controller's patch is what runs.
*/
function softKeyboard(term: FakeTerminal, helper: Helper) {
const textarea = term.textarea;
const key229 = () => {
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 });
helper._handleAnyTextareaChanges();
};
return {
key229,
/** One appended character, settled on its own timer before the next key. */
typeKeys(text: string) {
for (const ch of text) {
key229();
textarea.value += ch;
vi.advanceTimersByTime(1);
}
},
/** The textarea now reads `value` (what the keyboard's input event left there). */
edit(value: string) {
textarea.value = value;
},
/** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */
enter() {
const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 });
if (passed) term.type('\r');
textarea.value = '';
},
};
}
/** Every byte the tile sent as input, in order. */
const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join('');
const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames());
/** The line a shell ends up with: every DEL erases the character before it. */
function lineOf(frames: Array<{ d?: string }>) {
const out: string[] = [];
for (const ch of joinFrames(frames)) {
if (ch === '\x7f') out.pop();
else out.push(ch);
}
return out.join('');
}
type ControllerTile = Tile & { _keyCode229Recovery: unknown };
describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => {
it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => {
const helpers = withCompositionHelpers();
const { tile, term } = await connectTile(makeApp());
const helper = helpers[0];
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
// The controller patched this tile's helper (xterm's diff is no longer the one that runs) and
// listens on this tile's textarea in the CAPTURE phase (see the module's measured table).
expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]);
const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type);
expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']);
tile.destroy();
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]);
expect(term.textareaListeners).toEqual([]);
});
it('an autocorrect on space is sent as an edit, not a duplicated line', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const app = makeApp();
app.activeSessionId = 'some-other-session';
const { tile, ws, term } = await connectTile(app);
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
// The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `,
// both before any timer runs.
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(lineOf(frames)).toBe('testing the prompt ');
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
// Every byte went to THIS tile's session through the exactly-once queue, never the active one.
expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d));
expect(app._pendingDeliveries.has('some-other-session')).toBe(false);
});
it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const saved = windowStub.CodemanKeyCode229Recovery;
delete windowStub.CodemanKeyCode229Recovery;
try {
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
vi.advanceTimersByTime(1);
expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt ');
} finally {
windowStub.CodemanKeyCode229Recovery = saved;
}
});
it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { ws, term } = await connectTile(makeApp());
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('hell');
// One task: the last character's keydown and edit, then Enter, no timer in between.
kb.key229();
kb.edit('hello');
kb.enter();
// Settled synchronously at the Enter keydown, not by its timer.
expect(wireOf(ws)).toBe('hello\r');
vi.advanceTimersByTime(1);
const wire = wireOf(ws);
expect(wire).toBe('hello\r');
expect(wire).not.toContain('\x7f');
});
it('an autocorrect plus Enter in one task submits the corrected line', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { ws, term } = await connectTile(makeApp());
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.typeKeys('testing the peompt');
kb.key229();
kb.edit('testing the p');
kb.key229();
kb.edit('testing the prompt ');
kb.enter();
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(lineOf(frames)).toBe('testing the prompt \r');
expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5);
});
it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => {
vi.useFakeTimers();
const helpers = withCompositionHelpers();
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
const kb = softKeyboard(term, helpers[0]);
kb.key229();
kb.edit('y');
term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false });
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']);
});
});
describe("the tile's onData tells the controller only about what a human typed", () => {
/** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */
const orphan = (term: FakeTerminal, data: string) => {
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false });
};
it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => {
vi.useFakeTimers();
const app = makeApp();
const { tile, ws, term } = await connectTile(app);
expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull();
ws.open();
orphan(term, 'x');
term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke"
term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either
vi.advanceTimersByTime(1);
const frames = ws.inputFrames();
expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']);
const recovered = frames.find((f) => f.d === 'x')!;
expect(Number.isInteger(recovered.seq)).toBe(true);
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']);
});
it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => {
// Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the
// recovered bytes also go through. Counted there, the first recovery would read as "xterm
// spoke" for the second candidate, which shares its keydown snapshot, and drop it.
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 });
term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false });
term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false });
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']);
});
it('stands down when xterm really did deliver the keystroke', async () => {
vi.useFakeTimers();
const { ws, term } = await connectTile(makeApp());
ws.open();
orphan(term, 'x');
term.type('x'); // xterm's own canonical emission for this keystroke
vi.advanceTimersByTime(1);
expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']);
});
});
describe('the controller can never break a tile', () => {
type FakeController = {
handleKeyEvent: ReturnType<typeof vi.fn>;
notifyCanonicalData: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
};
let saved: unknown;
beforeEach(() => {
saved = windowStub.CodemanKeyCode229Recovery;
});
afterEach(() => {
windowStub.CodemanKeyCode229Recovery = saved;
});
const fakeController = (overrides: Partial<FakeController> = {}): FakeController => ({
handleKeyEvent: vi.fn(),
notifyCanonicalData: vi.fn(),
destroy: vi.fn(),
...overrides,
});
it('a create() that throws leaves the tile connected and typing', async () => {
windowStub.CodemanKeyCode229Recovery = {
create: () => {
throw new Error('broken');
},
};
const { tile, ws, term } = await connectTile(makeApp());
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
ws.open();
term.type('a');
expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']);
});
it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => {
const controller = fakeController({
handleKeyEvent: vi.fn(() => {
throw new Error('broken');
}),
});
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
const { term } = await connectTile(makeApp());
expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false);
expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false);
expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true);
expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true);
// It still ran first, for every one of them.
expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4);
});
it('a destroy() that throws still lets the tile dispose its xterm', async () => {
const controller = fakeController({
destroy: vi.fn(() => {
throw new Error('broken');
}),
});
windowStub.CodemanKeyCode229Recovery = { create: () => controller };
const { tile, term } = await connectTile(makeApp());
const dispose = vi.spyOn(term, 'dispose');
tile.destroy();
expect(controller.destroy).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
expect((tile as ControllerTile)._keyCode229Recovery).toBeNull();
});
it('each tile gets its own controller on its own textarea, and destroys only its own', async () => {
const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = [];
windowStub.CodemanKeyCode229Recovery = {
create: (options: { textarea: unknown }) => {
const controller = fakeController();
made.push({ options, controller });
return controller;
},
};
const app = makeApp();
const a = await connectTile(app);
const b = await connectTile(app);
expect(made).toHaveLength(2);
expect(made[0].options.textarea).toBe(a.term.textarea);
expect(made[1].options.textarea).toBe(b.term.textarea);
a.tile.destroy();
expect(made[0].controller.destroy).toHaveBeenCalledTimes(1);
expect(made[1].controller.destroy).not.toHaveBeenCalled();
});
});
describe('terminal-tile.js keeps the controller call where it works (source pin)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => {
const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)');
const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true");
expect(call).toBeGreaterThan(-1);
expect(earlyReturn).toBeGreaterThan(-1);
expect(call).toBeLessThan(earlyReturn);
});
it("hands the controller this tile's own composition helper", () => {
expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/);
});
});