Opening a terminal rebuilt the WASM terminal twice and rewrote the persisted session snapshot on every streamed output chunk, so the cost grew with each open terminal and could crash under load. - Move PTY scrollback to a standalone buffers map keyed by directory and tab id; output no longer touches sessions, so the tab strip, the project-action monitor and the persist projection stay referentially stable while chunks stream. - Memoize partialize and add a dedup storage adapter that skips writes when the persisted projection is unchanged. - Reuse the idle terminal WebSocket across tab switches (15s grace) instead of re-authenticating and replaying the snapshot on every attach. - Key the viewport by directory + tab only so createSession no longer tears down and rebuilds the Ghostty terminal. - Reset the terminal in place on replay discontinuities instead of bumping the renderer generation. - Scan the chunk array from the end (O(1) per write) instead of findIndex. Adds regression tests for the buffer map, socket reuse and viewport key, and documents the store invariants.
126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { useTerminalStore } from './useTerminalStore';
|
|
|
|
const setup = () => {
|
|
useTerminalStore.getState().clearAll();
|
|
useTerminalStore.getState().ensureDirectory('/repo');
|
|
return useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0].id;
|
|
};
|
|
|
|
const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo', tabId);
|
|
|
|
describe('terminal state reconciliation', () => {
|
|
afterEach(() => useTerminalStore.getState().clearAll());
|
|
|
|
test('applies snapshots atomically and deduplicates output by sequence', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
|
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' output', 5);
|
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, ' duplicate', 5);
|
|
expect(buffer(tabId).chunks.map((chunk) => chunk.data).join('')).toBe('prompt output');
|
|
expect(buffer(tabId).lastSequence).toBe(5);
|
|
});
|
|
|
|
test('keeps raw live bytes separate from replay-safe bytes', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'prompt\u001b[6n', 1, 'prompt');
|
|
const chunk = buffer(tabId).chunks[0];
|
|
expect(chunk.data).toBe('prompt\u001b[6n');
|
|
expect(chunk.replayData).toBe('prompt');
|
|
});
|
|
|
|
test('uses collision-resistant tab identities', () => {
|
|
const tabId = setup();
|
|
expect(/^tab-\d+$/.test(tabId)).toBe(false);
|
|
});
|
|
|
|
test('does not let stale snapshots replace newer output', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'new', 8);
|
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'stale', 7);
|
|
expect(buffer(tabId).chunks[0].data).toBe('new');
|
|
});
|
|
|
|
test('preserves buffer identity for an identical snapshot', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
|
|
const previous = buffer(tabId).chunks;
|
|
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 8);
|
|
expect(buffer(tabId).chunks).toBe(previous);
|
|
});
|
|
|
|
test('caps multibyte scrollback by UTF-8 bytes', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, '界'.repeat(200_000), 1);
|
|
expect(buffer(tabId).byteLength <= 512 * 1024).toBe(true);
|
|
expect(new TextEncoder().encode(buffer(tabId).chunks[0].data).byteLength).toBe(buffer(tabId).byteLength);
|
|
});
|
|
|
|
test('returns a stable empty buffer for tabs that produced no output', () => {
|
|
const tabId = setup();
|
|
expect(buffer(tabId).chunks.length).toBe(0);
|
|
expect(buffer(tabId)).toBe(useTerminalStore.getState().getBuffer('/repo', 'unknown-tab'));
|
|
});
|
|
|
|
// Scale guard: everything `partialize` reads must stay referentially unchanged
|
|
// while output streams, otherwise persistence and the tab strip go back to
|
|
// doing per-chunk work that grows with the number of open terminals.
|
|
test('streaming output keeps tab metadata and persisted inputs referentially stable', () => {
|
|
const first = setup();
|
|
const second = useTerminalStore.getState().createTab('/repo');
|
|
useTerminalStore.getState().ensureDirectory('/other');
|
|
const otherTab = useTerminalStore.getState().getDirectoryState('/other')!.tabs[0].id;
|
|
|
|
const sessionsBefore = useTerminalStore.getState().sessions;
|
|
const repoBefore = useTerminalStore.getState().getDirectoryState('/repo');
|
|
const otherBefore = useTerminalStore.getState().getDirectoryState('/other');
|
|
const nextTabIdBefore = useTerminalStore.getState().nextTabId;
|
|
|
|
const tabs: Array<[string, string]> = [['/repo', first], ['/repo', second], ['/other', otherTab]];
|
|
for (let index = 0; index < 90; index += 1) {
|
|
const [directory, tabId] = tabs[index % tabs.length];
|
|
useTerminalStore.getState().appendToBuffer(directory, tabId, `line ${index}\n`, index + 1);
|
|
}
|
|
|
|
expect(useTerminalStore.getState().sessions).toBe(sessionsBefore);
|
|
expect(useTerminalStore.getState().getDirectoryState('/repo')).toBe(repoBefore);
|
|
expect(useTerminalStore.getState().getDirectoryState('/other')).toBe(otherBefore);
|
|
expect(useTerminalStore.getState().nextTabId).toBe(nextTabIdBefore);
|
|
expect(buffer(first).chunks.length).toBe(30);
|
|
|
|
useTerminalStore.getState().removeDirectory('/other');
|
|
});
|
|
|
|
test('drops scrollback when a tab is closed or its directory is removed', () => {
|
|
const first = setup();
|
|
const second = useTerminalStore.getState().createTab('/repo');
|
|
useTerminalStore.getState().appendToBuffer('/repo', first, 'first', 1);
|
|
useTerminalStore.getState().appendToBuffer('/repo', second, 'second', 1);
|
|
expect(useTerminalStore.getState().buffers.size).toBe(2);
|
|
|
|
useTerminalStore.getState().closeTab('/repo', second);
|
|
expect(useTerminalStore.getState().buffers.size).toBe(1);
|
|
expect(buffer(first).chunks[0].data).toBe('first');
|
|
|
|
useTerminalStore.getState().removeDirectory('/repo');
|
|
expect(useTerminalStore.getState().buffers.size).toBe(0);
|
|
});
|
|
|
|
test('resets scrollback when a tab is bound to a different terminal session', () => {
|
|
const tabId = setup();
|
|
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'session-a');
|
|
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'from a', 1);
|
|
expect(buffer(tabId).chunks.length).toBe(1);
|
|
|
|
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'session-b');
|
|
expect(buffer(tabId)).toBe(useTerminalStore.getState().getBuffer('/repo', 'never-used'));
|
|
});
|
|
|
|
test('ignores output for tabs that no longer exist', () => {
|
|
setup();
|
|
useTerminalStore.getState().appendToBuffer('/repo', 'ghost-tab', 'output', 1);
|
|
useTerminalStore.getState().replaceBuffer('/repo', 'ghost-tab', 'snapshot', 1);
|
|
expect(useTerminalStore.getState().buffers.size).toBe(0);
|
|
});
|
|
});
|