Files
Codeman/test/mocks/terminal-tile-fakes.ts
T
Codeman maintainer 312a8faa06 fix(tiles): wire the Android soft-keyboard controller into every tile (#541 parity)
#541 fixed Android autocorrect duplicating the typed line in the primary
pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on
space (delete a word, insert the corrected one) sent the whole line again.
The fix, an edit-based diff that sends one DEL per deleted code point and
then the inserted text, lives in terminal-keycode229-recovery.js together
with #441's next-keydown drain (a character committed in the same task as
Enter goes out ahead of the \r) and the original orphaned-insertText
recovery. Only the primary pane created that controller, so a grid tile or
the split's Pane B still ran xterm's stock behaviour. Both are gated on
width alone (1180 CSS px), which a wide Android tablet clears.

TerminalTile now creates its own controller in connect(), after the xterm
opens and before the first await, handed this tile's textarea, this tile's
CompositionHelper and _onTerminalData as the send path, so recovered bytes
go to the tile's own session through the exactly-once queue. As in the
primary pane, handleKeyEvent runs first in the custom key handler, above the
keyCode-229 early return, and notifyCanonicalData sits in the onData lambda,
gated on the same two CodemanTerminalInput predicates, never in
_onTerminalData, which the recovered bytes also take. destroy() tears the
controller down before disposing the xterm, which restores xterm's own diff
and removes the capture listeners. No mode or device gate, matching the
primary. The module itself is unchanged apart from its header; terminal-ui.js
gains only a comment naming the twin.

Tests: test/terminal-tile-input.test.ts now loads the real module into its
vm harness (with window timers, without which create() would silently throw
and every test would run against no controller) and drives a fake
CompositionHelper carrying xterm's own append-only diff. It covers install
and restore on the tile's own helper and textarea, autocorrect sent as an
edit (with a control reproducing the device-log duplicate), the last
character and an autocorrect each followed by Enter in one task, a
self-rescued 229 key delivered once, the onData gate ignoring query replies
and focus reports, two refused inserts after one keydown both recovered,
robustness when the controller throws, per-tile controllers, and a source pin
keeping the call above the early return. Removing the create, the
handleKeyEvent call, the notify, its gate, or the destroy each turns at least
one of them red, as does moving the notify into _onTerminalData. The browser
suite gains a TerminalTile block in
test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted
execCommand input, chunks asserted to address the tile's session, with a
destroyed-controller control).

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

199 lines
7.1 KiB
TypeScript

/**
* @fileoverview Fakes for driving a real TerminalTile (terminal-tile.js) in a
* `vm` context: its WebSocket, its xterm and the fit addon. A test puts them in
* the context as `WebSocket`, `Terminal` and `FitAddon.FitAddon`, and runs
* `connect()` and the socket handlers for real.
*/
import { vi } from 'vitest';
export type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
/** A WebSocket the test opens, feeds and closes by hand; `sent` holds every frame the tile sent. */
export class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: Frame[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev?: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(JSON.parse(data) as Frame);
}
close = vi.fn(() => {
this.readyState = 3;
});
open() {
this.readyState = 1;
this.onopen?.();
}
receive(msg: object) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
inputFrames() {
return this.sent.filter((f) => f.t === 'i');
}
/** The connection drops: closed, and the tile hears `code`. */
drop(code = 1006) {
this.readyState = 3;
this.onclose?.({ code });
}
}
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
export class FakeFit {
static proposed = { cols: 80, rows: 24 };
term: FakeTerminal | null = null;
fit() {
const { cols, rows } = FakeFit.proposed;
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
this.term?.resize(cols, rows);
}
proposeDimensions() {
return { ...FakeFit.proposed };
}
}
/** An xterm that records writes, resizes and its handlers; `type()` feeds onData like a keystroke. */
export class FakeTerminal {
static last: FakeTerminal | null = null;
/**
* Opt-in, set by a test BEFORE the tile connects: the buffer's rows follow
* what is written, as in xterm. Every `\n` adds a line, `baseY` is the lines
* beyond the screen, a clear leaves one line, and a resize recomputes it
* (a row-shrinking fit pushes rows above the screen, a growing one pulls them
* back). The viewport follows the bottom. Off, `baseY` stays where a test
* puts it.
*/
static emulateScroll = false;
/**
* Opt-in, set by a test BEFORE the tile connects (and reset after): extra
* fields merged into `_core`, e.g. xterm's `_compositionHelper` for the
* keyCode-229 controller, which reads it once when the tile creates it.
*/
static coreFactory: ((term: FakeTerminal) => Record<string, unknown>) | null = null;
options: Record<string, unknown>;
cols = 80;
rows = 24;
dataCb: ((data: string) => void) | null = null;
buffer = { active: { type: 'normal', viewportY: 0, baseY: 0, length: 24 } };
/** xterm's own mouse-tracking mode (DECSET 1000 and friends); 'none' while no app asked for the mouse. */
modes = { mouseTrackingMode: 'none' };
/** Lines in the buffer while emulating (the cursor line counts). */
lineCount = 1;
emulate = FakeTerminal.emulateScroll;
/** Called after an emulated resize, so a test can stand in for a reflow. */
afterResize: ((cols: number, rows: number) => void) | null = null;
/** Where the screen sits and how big a cell renders, for the click-to-cell math. */
screenRect = { left: 10, top: 20 };
element = {
querySelector: (sel: string) =>
sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null,
};
_core: Record<string, unknown> = {
_renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } },
...(FakeTerminal.coreFactory?.(this) ?? {}),
};
constructor(options: Record<string, unknown>) {
this.options = { ...options };
FakeTerminal.last = this;
}
/** Re-derives baseY (and a viewport following the bottom) from the emulated line count. */
settleRows() {
const active = this.buffer.active;
active.baseY = Math.max(0, this.lineCount - this.rows);
active.viewportY = active.baseY;
active.length = Math.max(this.lineCount, this.rows);
}
loadAddon(addon: FakeFit) {
addon.term = this;
}
open() {}
onData(cb: (data: string) => void) {
this.dataCb = cb;
}
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
focusListeners: Array<() => void> = [];
/** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */
textareaListeners: Array<{ type: string; fn: (ev: Record<string, unknown>) => void; capture: unknown }> = [];
textarea = {
/** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */
value: '',
addEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners.push(fn as () => void);
else this.textareaListeners.push({ type, fn, capture });
},
removeEventListener: (type: string, fn: (ev?: Record<string, unknown>) => void, capture?: unknown) => {
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
else {
this.textareaListeners = this.textareaListeners.filter(
(l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture))
);
}
},
/** Delivers `ev` to the textarea's listeners of `type`, in registration order. */
fire: (type: string, ev: Record<string, unknown> = {}) => {
for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev });
},
};
focusTextarea() {
for (const fn of this.focusListeners) fn();
}
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
this.keyHandler = fn;
}
registerLinkProvider() {}
writes: string[] = [];
/** Set by a test: write callbacks never run, as on a disposed xterm. */
holdParse = false;
write(data: string, cb?: () => void) {
// An empty write puts nothing on screen; the replay queues one only to hear
// (its callback) that everything before it has been parsed.
if (data) this.writes.push(data);
if (data && this.emulate) {
this.lineCount += data.split('\n').length - 1;
this.settleRows();
}
if (!this.holdParse) cb?.();
}
clear() {
this.writes.push('<CLEAR>');
if (this.emulate) {
this.lineCount = 1;
this.settleRows();
}
}
resizes: Array<[number, number]> = [];
resize(cols: number, rows: number) {
this.resizes.push([cols, rows]);
this.cols = cols;
this.rows = rows;
if (this.emulate) {
this.settleRows();
this.afterResize?.(cols, rows);
}
}
scrollToLine() {}
scrollToTop() {}
dispose() {}
type(data: string) {
this.dataCb?.(data);
}
/** What a drag selected; '' is no selection. */
selection = '';
hasSelection() {
return this.selection !== '';
}
getSelection() {
return this.selection;
}
clearSelection = vi.fn(() => {
this.selection = '';
});
focus = vi.fn();
}