mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
/**
|
||
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
|
||
*
|
||
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||
* colour change, and xterm substitutes a bright colour for bold only when the
|
||
* foreground is a palette index below 8, so nothing else distinguishes it. A
|
||
* family that ships only a regular and a bold face keeps that step small, and
|
||
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
|
||
* settable at all.
|
||
*
|
||
* Three things are pinned here because each fails silently:
|
||
*
|
||
* - A live save reaches the echo overlays and the Agent Teams panes. Both
|
||
* cache the weight (the overlays paint it into their spans, the panes read
|
||
* their options at construction), so without the propagation the characters
|
||
* being typed, or a pane left open across the save, keep the old weight
|
||
* beside a repainted terminal.
|
||
* - An unchanged save is a no-op, so opening and closing App Settings does not
|
||
* churn the terminal.
|
||
* - The bundled face is declared over its full axis. The `@font-face`
|
||
* descriptor, not the file, is what the browser synthesizes from: at
|
||
* `400 700` every weight below 400 renders identically to 400, so the
|
||
* setting would be inert for anyone without Fira Code or Cascadia Code
|
||
* installed.
|
||
*
|
||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { performance } from 'node:perf_hooks';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||
|
||
function loadTerminalMixin(): Record<string, unknown> {
|
||
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() },
|
||
document: undefined,
|
||
});
|
||
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
||
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
|
||
vm.runInContext(`${constants}\n${source}`, context);
|
||
// constants.js publishes CodemanTerminalFont onto the context's window, which
|
||
// is the one the mixin closes over.
|
||
return FakeCodemanApp.prototype;
|
||
}
|
||
|
||
const mixin = loadTerminalMixin();
|
||
|
||
function fakeTerminal(options: Record<string, unknown> = {}) {
|
||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||
}
|
||
|
||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
||
const fit = vi.fn();
|
||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||
for (let i = 0; i < (opts.teammates ?? 0); i++) {
|
||
const teammateFit = vi.fn();
|
||
teammateFits.push(teammateFit);
|
||
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
|
||
}
|
||
const app = {
|
||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||
// through the one function that floors the result and reports it (#464).
|
||
// Wiring the real methods keeps `fit` an assertion about what the terminal
|
||
// actually did rather than about which helper happened to be called.
|
||
_refitAfterCellSizeChange: mixin._refitAfterCellSizeChange,
|
||
syncTerminalGeometry: mixin.syncTerminalGeometry,
|
||
_resizeTerminalTo: mixin._resizeTerminalTo,
|
||
getTerminalDimensions: mixin.getTerminalDimensions,
|
||
// No session: `_refitAfterCellSizeChange` then refits locally and sends
|
||
// nothing, which is what these cases are about.
|
||
activeSessionId: null,
|
||
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
|
||
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
|
||
fitAddon: { fit },
|
||
teammateTerminals,
|
||
_localEchoOverlay: { refreshFont: vi.fn() },
|
||
_predictiveEcho: { refreshFont: vi.fn() },
|
||
_terminalFontReady: null as unknown,
|
||
};
|
||
return { app, fit, teammateFits, teammateTerminals };
|
||
}
|
||
|
||
describe('applyTerminalFontWeights', () => {
|
||
it('writes both slots to the live terminal', () => {
|
||
const { app, fit } = makeApp();
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: '300',
|
||
terminalFontWeightBold: '800',
|
||
});
|
||
|
||
expect(app.terminal?.options.fontWeight).toBe(300);
|
||
expect(app.terminal?.options.fontWeightBold).toBe(800);
|
||
expect(fit).toHaveBeenCalled();
|
||
});
|
||
|
||
it('refreshes the echo overlays, which cache the weight and paint it', () => {
|
||
// Without this the characters being typed keep the old weight while the
|
||
// rest of the screen changes — most visible on a phone, where local echo
|
||
// is on by default.
|
||
const { app } = makeApp();
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: '300',
|
||
});
|
||
|
||
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
|
||
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('reaches Agent Teams panes, which read their options at construction', () => {
|
||
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: '300',
|
||
terminalFontWeightBold: '800',
|
||
});
|
||
|
||
for (const [, entry] of teammateTerminals) {
|
||
expect(entry.terminal.options.fontWeight).toBe(300);
|
||
expect(entry.terminal.options.fontWeightBold).toBe(800);
|
||
}
|
||
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
|
||
});
|
||
|
||
it('restores xterm’s own defaults when the setting is cleared', () => {
|
||
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
|
||
|
||
expect(app.terminal?.options.fontWeight).toBe('normal');
|
||
expect(app.terminal?.options.fontWeightBold).toBe('bold');
|
||
});
|
||
|
||
it('does nothing when neither slot changed', () => {
|
||
// saveAppSettings runs on every close of the modal.
|
||
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: 300,
|
||
});
|
||
|
||
expect(fit).not.toHaveBeenCalled();
|
||
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
|
||
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
|
||
const { app } = makeApp();
|
||
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: '300',
|
||
});
|
||
|
||
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
|
||
expect(app._terminalFontReady).toBeInstanceOf(Promise);
|
||
});
|
||
|
||
it('survives a terminal that does not exist yet', () => {
|
||
const { app } = makeApp({ terminal: null });
|
||
|
||
expect(() =>
|
||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||
terminalFontWeight: '300',
|
||
})
|
||
).not.toThrow();
|
||
});
|
||
});
|
||
|
||
describe('bundled terminal face', () => {
|
||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||
|
||
it('is declared over its full weight axis, not xterm’s default span', () => {
|
||
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
|
||
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
|
||
// 400 and 800 identically to 700, so the settings above would be a no-op
|
||
// for every install without Fira Code or Cascadia Code.
|
||
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
|
||
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
|
||
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
|
||
expect(Number(declared![1])).toBeLessThanOrEqual(100);
|
||
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
|
||
});
|
||
});
|
||
|
||
describe('terminal font weight settings plumbing', () => {
|
||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
|
||
|
||
it('offers both selects with a Default entry and the 100-900 steps', () => {
|
||
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
|
||
const start = html.indexOf(`<select id="${id}"`);
|
||
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
|
||
const select = html.slice(start, html.indexOf('</select>', start));
|
||
expect(select).toContain('<option value="">');
|
||
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
|
||
}
|
||
});
|
||
|
||
it('treats both as per-device, which is TWO separate decisions', () => {
|
||
// Membership in displayKeys keeps one device from overwriting another's
|
||
// value; the strip before the PUT is what stops the .strict() schema from
|
||
// 400-ing the whole settings save.
|
||
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
|
||
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
|
||
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
|
||
for (const key of keys) {
|
||
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
|
||
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
|
||
`${key}: _`
|
||
);
|
||
}
|
||
});
|
||
|
||
it('applies the save to the live terminal', () => {
|
||
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
|
||
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
|
||
});
|
||
});
|