Files
openchamber/packages/ui/src/lib/ghostty/core.test.ts
T
Bohdan Triapitsyn 39fa8c1917 feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch,
and every rendering bug (recycled rows, duplicated reflow fragments, prompt
artifacts) had to be worked around from outside. The emulator now is the
official libghostty-vt C ABI compiled to WebAssembly, driven by a browser
adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in
packages/ui/src/lib/ghostty. The artifact is reproducible with
scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on
macOS 27 SDKs.

On top of the port: one WASM instance per page with every tab kept mounted
and hidden tabs paused; history replayed at the PTY size it was drawn for;
shells spawned only after the first fitted grid so zsh never prints the
PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn
procedurally to the exact cell so TUI borders and block logos have no gaps
between rows; a software-rasterized canvas so Gecko renders every tab's text
with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN
fetch; touch selection and scrolling driven through the surface API; a copy
button in the tab strip for touch hosts; localized aria labels.

Testing: bun tests run the real WASM (reflow, palette, replay isolation,
recycled rows, box glyph geometry); viewport and view tests use a surface
double; verified in Chromium and Zen (windowed and headless) for crisp text,
new tabs, panel reopen, resize and box glyph rendering; package type-check,
oxlint/eslint on new files, web build.
2026-09-07 12:18:22 +03:00

159 lines
6.3 KiB
TypeScript

// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import { afterEach, describe, expect, test } from 'bun:test';
import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText, ghosttyPaletteBytes, type GhosttyColor } from './core';
import { loadGhosttyRuntime } from './runtime';
const WHITE: GhosttyColor = { r: 255, g: 255, b: 255 };
const BLACK: GhosttyColor = { r: 0, g: 0, b: 0 };
function codepointView(codepoints: ReadonlyArray<number>): DataView {
const view = new DataView(new ArrayBuffer(codepoints.length * 4));
codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true));
return view;
}
describe('ghosttyCellText', () => {
test('converts oversized grapheme clusters without hitting engine spread limits', () => {
const graphemeLength = 130_000;
const view = new DataView(new ArrayBuffer(graphemeLength * 4));
for (let index = 0; index < graphemeLength; index += 1) {
view.setUint32(index * 4, index === 0 ? 'a'.codePointAt(0)! : 0x301, true);
}
const text = ghosttyCellText(view, graphemeLength);
expect(text.length).toBe(graphemeLength);
expect(text.codePointAt(0)).toBe('a'.codePointAt(0));
expect(text.codePointAt(graphemeLength - 1)).toBe(0x301);
});
test('converts small clusters including astral codepoints', () => {
expect([...ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2)]).toEqual(['\u{1F642}', '\u{20E3}']);
expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe('🙂');
expect(ghosttyCellText(codepointView([]), 0)).toBe('');
});
});
describe('ghosttyPaletteBytes', () => {
test('places the theme ANSI colors first and keeps the xterm cube and gray ramp', () => {
const ansi = Array.from({ length: 16 }, (_, index) => ({ r: index, g: index * 2, b: index * 3 }));
const bytes = ghosttyPaletteBytes(ansi);
expect(bytes.length).toBe(768);
expect([...bytes.subarray(15 * 3, 16 * 3)]).toEqual([15, 30, 45]);
// Index 196 is pure red in the 6x6x6 cube; 232 is the darkest gray.
expect([...bytes.subarray(196 * 3, 197 * 3)]).toEqual([255, 0, 0]);
expect([...bytes.subarray(232 * 3, 233 * 3)]).toEqual([8, 8, 8]);
expect([...bytes.subarray(255 * 3, 256 * 3)]).toEqual([238, 238, 238]);
});
});
describe('GhosttyTerminalCore', () => {
const cores = new Set<GhosttyTerminalCore>();
async function createCore(onData: (data: string) => void = () => {}, palette?: GhosttyColor[]) {
const core = await GhosttyTerminalCore.create(12, 3, 8, 16, {
foreground: WHITE,
background: BLACK,
cursor: WHITE,
palette,
}, onData);
cores.add(core);
return core;
}
afterEach(() => {
for (const core of cores) core.dispose();
cores.clear();
});
test('preserves styles, wide cells, and selection after shared memory grows', async () => {
const core = await createCore();
const runtime = await loadGhosttyRuntime();
const grapheme = `e${'́'.repeat(64)}`;
core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`);
const cells = core.snapshot().rowData[0]!.cells;
expect(cells[0]).toEqual({
text: grapheme,
wide: 0,
foreground: { r: 123, g: 45, b: 67 },
background: { r: 9, g: 8, b: 7 },
bold: true,
italic: true,
invisible: true,
strikethrough: true,
overline: true,
underline: true,
selected: false,
});
expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([
{ text: '界', wide: 0 },
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
{ text: '🙂', wide: 0 },
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
]);
runtime.memory.grow(1);
core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 });
expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0]!, selected: true });
core.clearSelection();
expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]!);
});
test('renders ANSI colors from the theme palette', async () => {
const palette = Array.from({ length: 16 }, (_, index) => ({ r: 10 + index, g: 20, b: 30 }));
const core = await createCore(() => {}, palette);
core.write('\x1b[31mred\x1b[0m \x1b[94mblue');
const cells = core.snapshot().rowData[0]!.cells;
expect(cells[0]!.foreground).toEqual({ r: 11, g: 20, b: 30 });
expect(cells[4]!.foreground).toEqual({ r: 22, g: 20, b: 30 });
// Indices past the theme keep the standard table.
core.write('\x1b[38;5;196mX');
expect(core.snapshot().rowData[0]!.cells[8]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
});
test('answers device queries through the PTY writer but not during history replay', async () => {
const replies: string[] = [];
const core = await createCore((data) => replies.push(data));
core.write('\x1b[5n');
expect(replies).toEqual(['\x1b[0n']);
replies.length = 0;
core.resetAndWrite('history\x1b[5n');
expect(replies).toEqual([]);
expect(core.snapshot().rowData[0]!.text).toBe('history');
core.write('\x1b[5n');
expect(replies).toEqual(['\x1b[0n']);
});
test('a fresh terminal after disposing a scrolled one shows none of its rows', async () => {
const first = await createCore();
first.write(Array.from({ length: 200 }, (_, index) => `leak-${index}\r\n`).join(''));
first.snapshot();
first.dispose();
cores.delete(first);
const second = await createCore();
second.write('fresh\r\n'.repeat(4));
const rows = second.snapshot().rowData.map((row) => row.text);
expect(rows.some((text) => text.includes('leak-'))).toBe(false);
expect(rows[0]).toBe('fresh');
});
test('reflows history written at a wider size back to the fitted grid', async () => {
const core = await createCore();
core.resize(40, 3, 8, 16);
core.resetAndWrite(`${'x'.repeat(30)}\r\nprompt> `);
core.resize(12, 3, 8, 16);
// 30 columns wrap into 12 + 12 + 6; the first wrapped row scrolls out of a 3-row viewport.
expect(core.snapshot().rowData.map((row) => row.text)).toEqual(['x'.repeat(12), 'x'.repeat(6), 'prompt>']);
});
test('encodes bracketed paste only when the terminal asked for it', async () => {
const core = await createCore();
expect(core.encodePaste('hello')).toBe('hello');
core.write('\x1b[?2004h');
expect(core.encodePaste('hello')).toBe('\x1b[200~hello\x1b[201~');
});
});