Files
Codeman/test/terminal-font-settle.test.ts
T
Rounak DattaandClaude Opus 5 d9fa9ba1eb test(terminal): follow the existing suites to the one geometry owner
The gate caught fourteen failures the focused tests could not: every harness
that builds a partial app out of cherry-picked mixin methods, and every source
guard that named `fitAddon.fit()` by hand.

Most are wiring — `syncTerminalGeometry`, `_refitAfterCellSizeChange` and
`_resizeTerminalTo` added to the fakes so the real chain runs rather than a
stub of it. `file-browser-search` is the one that shows why it matters: without
the method on the fake, selectSession's unconditional call threw into its own
catch and every later assertion in the file measured a load that never
happened.

Two are not wiring.

`detached-session-pane-sizing` pinned the behaviour this change deliberately
reverses. It asserted the LOCAL fit still runs for a session owned by its own
window — "withhold the send, never the reflow" — so the assertion is restated
rather than patched, with the reason beside it and in the file's docblock: a
reflow the PTY is never told about leaves this xterm rendering a CLI's frames
against a shape that does not exist, and the popup that owns the pane is
drawing for its own width regardless. The old rule bought a garbled frame, not
a correct one.

`mobile-prompt-composer` sliced `_cleanupSessionData` as a fixed 1200-character
window, so the assertion depended on how much unrelated code sat above the line
it cared about. It reads the whole method now.

`terminal-scroll-intent` records `syncTerminalGeometry` rather than `fit`,
under its own name: recording a bare fit there would name the very thing the
subject was changed to stop doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:22:28 +05:30

215 lines
8.5 KiB
TypeScript

/**
* @fileoverview A terminal is measured only once its own font can be measured.
*
* The first fit runs while the browser is still painting with a fallback font,
* whose character cell is a different size from the terminal font's. The grid
* that fit produces is therefore wrong, the pane is sized to it, and the
* correction arrives after the session's buffer has been replayed — so the CLI
* repaints for a shape that does not match what is on screen.
*
* Two properties carry the fix and both are pinned here:
*
* - The wait REQUESTS each measurable face and then forces xterm to re-measure.
* Waiting alone buys nothing: `FitAddon.proposeDimensions()` divides by a
* cached cell size that xterm refreshes only from `open()`, from a resize
* that changed the grid, and on a device-pixel-ratio change. Nothing in it
* listens for font loading, so a fit after the font arrives can still divide
* by the fallback cell and short-circuit.
* - The wait is BOUNDED. `FontFaceSet.ready` has no deadline, and `selectSession`
* awaits this before painting, so an unbounded wait would strand the session
* instead of merely mis-measuring it.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts), the same way terminal-buffer-flush
* extracts the real mixin methods from terminal-ui.js.
*/
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';
/** The mixin runs inside the vm context, so its `document` must live there. */
let currentDocument: unknown;
function loadTerminalMixin(): Record<string, unknown> {
const dir = resolve(import.meta.dirname, '../src/web/public');
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const context = vm.createContext({
console,
performance,
setTimeout,
clearTimeout,
setInterval: vi.fn(),
clearInterval: vi.fn(),
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
get document() {
return currentDocument;
},
});
// constants.js supplies TERMINAL_FONT_WAIT_MS and TERMINAL_FONT_UNMEASURED.
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const source = readFileSync(resolve(dir, 'terminal-ui.js'), 'utf8');
vm.runInContext(`${constants}\n${source}`, context);
return FakeCodemanApp.prototype;
}
const mixin = loadTerminalMixin();
type FontApp = {
_awaitTerminalFont: () => Promise<void>;
terminal: unknown;
};
function makeApp(fontFamily: string, opts: { measure?: () => void } = {}) {
const measure = vi.fn(opts.measure);
const app = {
_awaitTerminalFont: mixin._awaitTerminalFont,
terminal: {
options: { fontFamily, fontSize: 14 },
_core: { _charSizeService: { measure } },
},
} as unknown as FontApp & { terminal: { _core: { _charSizeService: { measure: typeof measure } } } };
return { app, measure };
}
/** A FontFaceSet stub recording what was asked for. */
function fontsStub(overrides: { load?: unknown; ready?: Promise<unknown> } = {}) {
const requested: string[] = [];
return {
requested,
fonts: {
load: overrides.load ?? ((spec: string) => (requested.push(spec), Promise.resolve([]))),
ready: overrides.ready ?? Promise.resolve(),
status: 'loaded',
},
};
}
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
currentDocument = undefined;
vi.useRealTimers();
});
describe('terminal font settle', () => {
it('requests every measurable family in the stack, unquoted', async () => {
const stub = fontsStub();
currentDocument = stub;
const { app } = makeApp('"Fira Code", "JetBrains Mono", monospace');
await app._awaitTerminalFont();
expect(stub.requested).toEqual(['14px "Fira Code"', '14px "JetBrains Mono"']);
});
it('does not wait on faces that cannot move the measured cell', async () => {
// The symbols face is ~1.2MB of private-use-area glyphs and xterm measures
// `W`, so awaiting it puts a megabyte in front of the first frame for
// nothing. The generics match no FontFace at all.
const stub = fontsStub();
currentDocument = stub;
const { app } = makeApp('"JetBrains Mono", "Symbols Nerd Font Mono", monospace, serif, system-ui');
await app._awaitTerminalFont();
expect(stub.requested).toEqual(['14px "JetBrains Mono"']);
});
it('forces xterm to re-measure, because loading a font does not', async () => {
// The property the whole change rests on. Without this the fit that follows
// still divides the container by the fallback cell.
currentDocument = fontsStub();
const { app, measure } = makeApp('"JetBrains Mono"');
await app._awaitTerminalFont();
expect(measure).toHaveBeenCalledTimes(1);
});
it('gives up on a font that never arrives, and still re-measures', async () => {
// FontFaceSet.ready has no deadline of its own, and selectSession awaits
// this before painting: unbounded here means a session that never renders.
currentDocument = fontsStub({ ready: new Promise(() => {}) });
const { app, measure } = makeApp('"JetBrains Mono"');
const started = Date.now();
await app._awaitTerminalFont();
expect(measure).toHaveBeenCalledTimes(1);
// Bounded by TERMINAL_FONT_WAIT_MS (2s), not left pending.
expect(Date.now() - started).toBeLessThan(4000);
}, 10_000);
it('survives a rejecting load and a browser with no font API', async () => {
currentDocument = fontsStub({ load: () => Promise.reject(new Error('network')) });
const { app: rejecting, measure: m1 } = makeApp('"JetBrains Mono"');
await expect(rejecting._awaitTerminalFont()).resolves.toBeUndefined();
expect(m1).toHaveBeenCalledTimes(1);
currentDocument = {};
const { app: noApi, measure: m2 } = makeApp('"JetBrains Mono"');
await expect(noApi._awaitTerminalFont()).resolves.toBeUndefined();
// No font API means nothing to wait for and nothing to re-measure against.
expect(m2).not.toHaveBeenCalled();
});
it('does not throw when the terminal was disposed mid-wait', async () => {
currentDocument = fontsStub();
const app = { _awaitTerminalFont: mixin._awaitTerminalFont, terminal: null } as unknown as FontApp;
await expect(app._awaitTerminalFont()).resolves.toBeUndefined();
});
});
describe('selectSession font gate', () => {
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const selectStart = appSource.indexOf('async selectSession(sessionId, options = {})');
const body = appSource.slice(
selectStart,
appSource.indexOf('\n // Shared cleanup for all session data', selectStart)
);
it('waits for the font before the first fit', () => {
const wait = body.indexOf('await this._terminalFontReady');
// `syncTerminalGeometry()` replaced the bare `fitAddon.fit()` here: it fits
// AND applies the floor it reports, so xterm and the PTY cannot disagree
// (#464). The gate this test guards is unchanged — the font must be
// measured before the terminal is.
const fit = body.indexOf('this.syncTerminalGeometry();');
expect(wait).toBeGreaterThan(-1);
expect(fit).toBeGreaterThan(-1);
expect(wait).toBeLessThan(fit);
});
it('waits BEFORE opening the buffer-load gate', () => {
// Inside the gate every live SSE event queues instead of painting, so a slow
// font would hold output back rather than only mis-measuring the grid.
const wait = body.indexOf('await this._terminalFontReady');
const gate = body.indexOf('this._beginBufferLoad(selectGen)');
expect(gate).toBeGreaterThan(-1);
expect(wait).toBeLessThan(gate);
});
it('keeps the synchronous focus ahead of the wait (iOS Safari)', () => {
// iOS honours programmatic focus only inside the user-gesture call stack,
// which the first await ends.
const focus = body.indexOf('if (shouldFocusTerminal && this.terminal) this.terminal.focus();');
const wait = body.indexOf('await this._terminalFontReady');
expect(focus).toBeGreaterThan(-1);
expect(focus).toBeLessThan(wait);
});
it('re-checks for a newer selection after the wait', () => {
const wait = body.indexOf('await this._terminalFontReady');
const guard = body.indexOf('this._isStaleSelect(selectGen)', wait);
expect(guard).toBeGreaterThan(-1);
expect(guard - wait).toBeLessThan(200);
});
});