mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
The server applies no WebSocket backpressure (16 KB / 8 ms batches, no bufferedAmount check), and a tile wrote every live frame straight into xterm. A flood a tile could not parse as fast (a shell tile running cat on a huge log) piled up in xterm's own write queue without bound, on a main thread up to six tiles share, until xterm's WriteBuffer threw past 50M code units; onmessage's empty catch then dropped every frame silently and nothing recaptured the screen. The primary pane caps its queues and drops then recaptures (_onSessionTerminal, _scheduleDroppedOutputRecovery). Each tile now writes live output through _writeLive: - unparsed code units are counted, each write's callback counting its own back down; frames held behind a replay (_liveQueue) count too; - the budget is TerminalTile.LIVE_BACKLOG_BUDGET, 4 MiB, deliberately not the primary pane's 128 KB: that caps its own rAF-paced queues, while xterm itself paces a tile, and a tight cap would trip on ordinary bursts and blank-and-reload the tile over and over; - past it a frame is dropped, the tile stops writing onto the hole, and one refresh is scheduled, debounced and bounded by the primary pane's own rule (CodemanDroppedOutput: 2 s, DROP_RECOVERY_MAX_ATTEMPTS, never retried after a deadline abort). It is an ordinary refresh, so single-flight, bounded by lines=/tail= and paced by the grid's TileLoadQueue. The flag clears once a capture taken after the last dropped frame has replayed; - a write that throws is the same drop, never a "malformed frame"; - past the bound the flag is released, so a tile is never left frozen; - a reconnect starts the accounting over (an epoch makes callbacks from before it count nothing) and drops a pending recovery, since its own refresh replaces the screen; destroy() cancels it. The live-queue flush after a pull or a refresh goes through the same path, so a throwing write there cannot skip the load's marker and trailing refresh either. Tests (input harness, real constants and fake timers): the default budget lets a 1 MiB unparsed burst through, parsed bytes stop counting, a trip stops writing and ONE debounced refresh recaptures, a write throw takes the same recovery, a hole in the held queue is recovered by another refresh, bounded retries then release, no retry after a deadline, a reconnect resets the count, and destroy cancels. The fake xterm can now hold and release parses and throw on a write. Live writes now carry a callback, so the unit tests match them on the data argument (a `.not.toHaveBeenCalledWith(data)` would otherwise pass for nothing). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1458 lines
54 KiB
TypeScript
1458 lines
54 KiB
TypeScript
/**
|
||
* @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');
|
||
|
||
tile.destroy();
|
||
await vi.advanceTimersByTimeAsync(5000);
|
||
|
||
expect(fetchMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
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/);
|
||
});
|
||
});
|