Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
19 kB · 682 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683/** * Terminal Scrolling Tests * * Test Isolation Pattern: * Uses createIsolatedTerminal() to ensure each test gets its own WASM instance. */
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';import type { Terminal } from './terminal';import { createIsolatedTerminal } from './test-helpers';
describe('Terminal Scrolling', () => { let terminal: Terminal; let container: HTMLElement;
beforeEach(async () => { container = document.createElement('div'); document.body.appendChild(container); terminal = await createIsolatedTerminal({ cols: 80, rows: 24 }); terminal.open(container); });
afterEach(() => { if (terminal) { terminal.dispose(); } if (container && document.body.contains(container)) { document.body.removeChild(container); } });
describe('Normal Screen Mode', () => { test('should scroll viewport on wheel event in normal mode', async () => { // Fill with enough lines to create scrollback for (let i = 0; i < 50; i++) { terminal.write(`Line ${i}\r\n`); }
// Initial viewport should be at bottom const initialViewportY = terminal.viewportY;
// Simulate wheel up (negative deltaY) const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Viewport should have scrolled up (viewportY increases away from 0) expect(terminal.viewportY).toBeGreaterThan(initialViewportY); });
test('should scroll down on positive deltaY', async () => { // Fill with scrollback for (let i = 0; i < 50; i++) { terminal.write(`Line ${i}\r\n`); }
// Scroll up first terminal.scrollLines(-10); const scrolledUpViewportY = terminal.viewportY;
// Simulate wheel down (positive deltaY) const wheelEvent = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Viewport should have scrolled down (viewportY decreases towards 0) expect(terminal.viewportY).toBeLessThan(scrolledUpViewportY); });
test('should not send data to application in normal mode', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Simulate wheel event const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// No data should be sent to application expect(dataSent).toEqual([]); }); });
describe('Alternate Screen Mode', () => { beforeEach(async () => { // Enter alternate screen mode (vim, less, htop, etc.) terminal.write('\x1B[?1049h'); });
test('should detect alternate screen mode', async () => { expect(terminal.wasmTerm?.isAlternateScreen()).toBe(true); });
test('should send arrow up sequences on wheel up in alternate screen', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Simulate wheel up (negative deltaY = -100, should send ~3 arrow ups) const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should send arrow up sequences (ESC[A) expect(dataSent.length).toBeGreaterThan(0); expect(dataSent.every((data) => data === '\x1B[A')).toBe(true); expect(dataSent.length).toBeCloseTo(3, 1); // ~3 arrows per click });
test('should send arrow down sequences on wheel down in alternate screen', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Simulate wheel down (positive deltaY = +100) const wheelEvent = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should send arrow down sequences (ESC[B) expect(dataSent.length).toBeGreaterThan(0); expect(dataSent.every((data) => data === '\x1B[B')).toBe(true); expect(dataSent.length).toBeCloseTo(3, 1); // ~3 arrows per click });
test('should not scroll viewport in alternate screen', async () => { const initialViewportY = terminal.viewportY;
// Simulate wheel event const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Viewport should not have changed expect(terminal.viewportY).toBe(initialViewportY); });
test('should cap arrow count at 5 per wheel event', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Simulate very large wheel delta const wheelEvent = new WheelEvent('wheel', { deltaY: -1000, // Very large delta bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should cap at 5 arrows expect(dataSent.length).toBeLessThanOrEqual(5); }); });
describe('Mode Transitions', () => { test('should switch behavior when entering alternate screen', async () => { // Start in normal mode for (let i = 0; i < 30; i++) { terminal.write(`Line ${i}\r\n`); }
// Scroll up in normal mode const wheelUpNormal = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelUpNormal); const normalModeViewportY = terminal.viewportY;
// Should have scrolled viewport expect(normalModeViewportY).toBeLessThan(terminal.rows);
// Enter alternate screen terminal.write('\x1B[?1049h');
const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Wheel should now send arrow keys const wheelUpAlt = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelUpAlt);
// Should have sent arrow keys, not scrolled expect(dataSent.length).toBeGreaterThan(0); expect(dataSent[0]).toBe('\x1B[A'); });
test('should switch back to viewport scrolling when exiting alternate screen', async () => { // Enter alternate screen terminal.write('\x1B[?1049h'); expect(terminal.wasmTerm?.isAlternateScreen()).toBe(true);
// Exit alternate screen terminal.write('\x1B[?1049l'); expect(terminal.wasmTerm?.isAlternateScreen()).toBe(false);
// Fill with lines for (let i = 0; i < 30; i++) { terminal.write(`Line ${i}\r\n`); }
const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
const initialViewportY = terminal.viewportY;
// Wheel should scroll viewport again const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should have scrolled up, not sent data expect(dataSent.length).toBe(0); expect(terminal.viewportY).toBeGreaterThan(initialViewportY); }); });
describe('Custom Wheel Handler', () => { test('should respect custom wheel handler in both modes', async () => { let customHandlerCalled = false; terminal.attachCustomWheelEventHandler(() => { customHandlerCalled = true; return true; // Override default behavior });
const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
expect(customHandlerCalled).toBe(true); });
test('custom handler can delegate to default behavior', async () => { terminal.attachCustomWheelEventHandler(() => { return false; // Don't override, use default });
// Enter alternate screen terminal.write('\x1B[?1049h');
const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should still send arrow keys expect(dataSent.length).toBeGreaterThan(0); expect(dataSent[0]).toBe('\x1B[A'); }); });
describe('Edge Cases', () => { test('should handle zero deltaY gracefully', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
const wheelEvent = new WheelEvent('wheel', { deltaY: 0, bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should not send any data or crash expect(dataSent.length).toBe(0); });
test('should handle very small deltaY values', async () => { const dataSent: string[] = []; terminal.onData((data) => dataSent.push(data));
// Enter alternate screen terminal.write('\x1B[?1049h');
const wheelEvent = new WheelEvent('wheel', { deltaY: -10, // Small delta, rounds to 0 bubbles: true, cancelable: true, }); container.dispatchEvent(wheelEvent);
// Should not send any arrows (count is 0) expect(dataSent.length).toBe(0); });
test('should handle terminal not yet opened', async () => { const closedTerminal = await createIsolatedTerminal({ cols: 80, rows: 24 });
// Should not crash when handleWheel is called without wasmTerm expect(() => { const wheelEvent = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true, }); // Can't dispatch without container, but we can test the internal state expect(closedTerminal.wasmTerm).toBeUndefined(); }).not.toThrow();
closedTerminal.dispose(); }); });});
/** * * Tests for scrolling methods and events (Phase 2) */describe('Scrolling Methods', () => { let term: Terminal; let container: HTMLDivElement;
beforeEach(async () => { container = document.createElement('div'); document.body.appendChild(container); term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); });
afterEach(() => { term.dispose(); document.body.removeChild(container); term = null!; container = null!; });
test('scrollLines() should scroll viewport up', async () => { // Write some content to create scrollback for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Scroll up 5 lines term.scrollLines(-5);
// Should be scrolled up expect((term as any).viewportY).toBe(5); });
test('scrollLines() should scroll viewport down', async () => { // Write content and scroll up first for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); } term.scrollLines(-10);
// Now scroll down 5 lines term.scrollLines(5);
// Should be at viewportY = 5 expect((term as any).viewportY).toBe(5); });
test('scrollLines() should not scroll beyond bounds', async () => { // Write limited content for (let i = 0; i < 10; i++) { term.write(`Line ${i}\r\n`); }
// Try to scroll way up term.scrollLines(-1000);
// Should be clamped to scrollback length const scrollbackLength = term.wasmTerm!.getScrollbackLength(); expect((term as any).viewportY).toBeLessThanOrEqual(scrollbackLength); });
test('scrollLines() should not scroll below bottom', async () => { // Write content and scroll up for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); } term.scrollLines(-10);
// Try to scroll way down term.scrollLines(1000);
// Should be at bottom (viewportY = 0) expect((term as any).viewportY).toBe(0); });
test('scrollPages() should scroll by page', async () => { // Write content for (let i = 0; i < 100; i++) { term.write(`Line ${i}\r\n`); }
// Scroll up 2 pages term.scrollPages(-2);
// Should be scrolled by 2 * rows lines expect((term as any).viewportY).toBe(2 * term.rows); });
test('scrollToTop() should scroll to top of buffer', async () => { // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Scroll to top term.scrollToTop();
// Should be at max scroll position const scrollbackLength = term.wasmTerm!.getScrollbackLength(); expect((term as any).viewportY).toBe(scrollbackLength); });
test('scrollToBottom() should scroll to bottom', async () => { // Write content and scroll up for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); } term.scrollLines(-10);
// Scroll to bottom term.scrollToBottom();
// Should be at bottom (viewportY = 0) expect((term as any).viewportY).toBe(0); });
test('scrollToLine() should scroll to specific line', async () => { // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Scroll to line 15 term.scrollToLine(15);
expect((term as any).viewportY).toBe(15); });
test('scrollToLine() should clamp to valid range', async () => { // Write limited content for (let i = 0; i < 10; i++) { term.write(`Line ${i}\r\n`); }
// Try to scroll beyond buffer term.scrollToLine(1000);
// Should be clamped const scrollbackLength = term.wasmTerm!.getScrollbackLength(); expect((term as any).viewportY).toBeLessThanOrEqual(scrollbackLength); });
test('scrollToLine() should handle negative values', async () => { // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Try negative line term.scrollToLine(-5);
// Should be clamped to 0 (bottom) expect((term as any).viewportY).toBe(0); });});
describe('Scroll Events', () => { let term: Terminal; let container: HTMLDivElement;
beforeEach(async () => { container = document.createElement('div'); document.body.appendChild(container); term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); });
afterEach(() => { term.dispose(); document.body.removeChild(container!); term = null!; container = null!; });
test('onScroll should fire when scrolling', async () => { let scrollPosition = -1; let fireCount = 0;
term.onScroll((position) => { scrollPosition = position; fireCount++; });
// Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Scroll up term.scrollLines(-5);
expect(fireCount).toBe(1); expect(scrollPosition).toBe(5); });
test('onScroll should not fire if position unchanged', async () => { let fireCount = 0;
term.onScroll(() => { fireCount++; });
// Try to scroll at bottom (already at 0) term.scrollToBottom();
expect(fireCount).toBe(0); });
test('onScroll should fire multiple times for multiple scrolls', async () => { const positions: number[] = [];
term.onScroll((position) => { positions.push(position); });
// Write content for (let i = 0; i < 100; i++) { term.write(`Line ${i}\r\n`); }
// Multiple scrolls term.scrollLines(-5); term.scrollLines(-3); term.scrollLines(2);
expect(positions.length).toBe(3); expect(positions[0]).toBe(5); expect(positions[1]).toBe(8); expect(positions[2]).toBe(6); });
// Note: onRender event implementation uses dirty tracking for performance // implementation. Firing it every frame causes performance issues.
test('onCursorMove should fire when cursor moves', async () => { let moveCount = 0;
term.onCursorMove(() => { moveCount++; });
// Write some lines (cursor moves) term.write('Line 1\r\n'); term.write('Line 2\r\n');
// Wait for render loop to detect cursor movement await new Promise((resolve) => setTimeout(resolve, 50));
// Should have fired at least once (cursor moved down) expect(moveCount).toBeGreaterThan(0); });});
describe('Custom Wheel Event Handler', () => { let term: Terminal; let container: HTMLDivElement;
beforeEach(async () => { container = document.createElement('div'); document.body.appendChild(container); term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 1000 }); term.open(container); });
afterEach(() => { term!.dispose(); document.body.removeChild(container!); term = null!; container = null!; });
test('attachCustomWheelEventHandler() should set handler', async () => { const handler = () => true; term.attachCustomWheelEventHandler(handler);
expect((term as any).customWheelEventHandler).toBe(handler); });
test('attachCustomWheelEventHandler() should allow clearing handler', async () => { const handler = () => true; term.attachCustomWheelEventHandler(handler); term.attachCustomWheelEventHandler(undefined);
expect((term as any).customWheelEventHandler).toBeUndefined(); });
test('custom wheel handler should block default scrolling when returning true', async () => { let handlerCalled = false;
term.attachCustomWheelEventHandler(() => { handlerCalled = true; return true; // Block default });
// Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Simulate wheel event const wheelEvent = new WheelEvent('wheel', { deltaY: 100 }); container.dispatchEvent(wheelEvent);
expect(handlerCalled).toBe(true); // Viewport should not have changed (blocked) expect((term as any).viewportY).toBe(0); });
test('custom wheel handler should allow default scrolling when returning false', async () => { let handlerCalled = false;
term.attachCustomWheelEventHandler(() => { handlerCalled = true; return false; // Allow default });
// Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Simulate wheel event (scroll down) const wheelEvent = new WheelEvent('wheel', { deltaY: 100 }); container.dispatchEvent(wheelEvent);
expect(handlerCalled).toBe(true); // Viewport should have changed (default behavior) // Note: Due to scrolling at bottom, it won't change. Let's scroll up first. });
test('wheel events should scroll terminal by default', async () => { // Write content for (let i = 0; i < 50; i++) { term.write(`Line ${i}\r\n`); }
// Simulate wheel up (negative deltaY = scroll up) const wheelEvent = new WheelEvent('wheel', { deltaY: -100 }); container.dispatchEvent(wheelEvent);
// Should have scrolled up expect((term as any).viewportY).toBeGreaterThan(0); });});