Files
Codeman/test/mocks/terminal-tile-fakes.ts
T
Codeman maintainer eb5d982c38 fix(tiles): refresh fetches first, then resets in-stream and replays
A tile's refresh (a {t:'r'} or {t:'c'} frame, every reconnect) wiped the
pane with a synchronous xterm clear() at the load's turn, BEFORE its fetch,
and wrote live frames straight through the fetch and the replay. That is
the replay clear CLAUDE.md "Terminal resilience" forbids: bytes still
queued in xterm are parsed after a synchronous clear and fuse into the
snapshot, and clear() keeps the cursor's row, column, SGR and margins, so
the capture (raw rows, no home) started wherever the cursor sat. A failed
or empty fetch left the tile blank.

The refresh now runs in the primary pane's order (_onSessionNeedsRefresh,
_resetTerminalForReplay):
- fetch first, so the tile keeps its last frame through the round trip and
  through a grid tile's wait in the load queue;
- from the response on, live frames are held in _liveQueue with their
  arrival time, as _pullHistory already did, and the body read of a bounded
  window (grid tile, shell) gets the pull's 10 s budget, while Pane B's
  unbounded full=1 keeps the request's own budget;
- then the queued in-stream \x1bc immediately before the replay;
- then the held frames that arrived after the response (_flushLiveQueue,
  now shared with _pullHistory), then the owed marker.
A failed, aborted or empty fetch writes nothing and resets nothing.

The _stampMarkerIfOwed guard for a pending trailing refresh stays (that
refresh settles the marker itself either way); only its rationale changed.
The fake xterm now treats an in-stream RIS like clear() in its row
emulation.

Tests: the ones that counted clear() calls on the refresh path now count
the in-stream reset instead, assert it sits right before the replay and
that clear() is never called (unit single-flight block, the marker
ordering tests, the reconnect test, the grid {t:'r'} and marker tests, and
the scroll test's server-clear overflow case, which now goes through a
refresh). New: the screen is untouched on a failed or empty fetch and on a
failed body read (held frames written in order), frames before the
response are written through and later ones held behind the replay, the
cutoff drops frames the capture covers, the body budgets, and a grid tile
keeps its last frame through its own capture's round trip.

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

203 lines
7.3 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 or an in-stream reset (RIS, `\x1bc`) 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) {
// A replay's reset (RIS) empties the buffer, as clear() does.
const reset = data.lastIndexOf('\x1bc');
if (reset !== -1) this.lineCount = 1;
this.lineCount += data.slice(reset === -1 ? 0 : reset + 2).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();
}