Files
openchamber/packages/ui/src/lib/terminalApi.test.ts
T
Bohdan Triapitsyn d4a8c4d2e1 feat(terminal): refactor runtime and add mobile workspace (#2280)
Replace the legacy terminal flow with a shared authenticated WebSocket
runtime used across web, desktop, relay, and mobile surfaces.

- introduce the v3 terminal protocol with scoped attachments, snapshots,
  ordered output, bounded replay history, reconnects, and explicit lifecycle
- harden PTY creation, restart, resize, close, force-kill, idle cleanup,
  shell selection, login mode, environment sanitization, and appearance sync
- add runtime-aware terminal APIs with relay authentication and Electron parity
- add a fullscreen mobile terminal workspace with touch scrolling,
  long-press selection, safe-area controls, quick keys, and Ctrl/Alt input
- add terminal selection attachments, preview detection, project actions,
  shell settings, and localized UI
- harden Ghostty rendering, resize recovery, Unicode handling, block
  characters, line height, and stale-row behavior
- remove the obsolete terminal SSE path and update reverse-proxy guidance
- expand terminal runtime, transport, input, selection, and store coverage
- avoid duplicate web builds when preparing mobile assets in root CI builds
2026-07-17 13:17:21 +03:00

173 lines
7.2 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { TerminalTransport } from './terminalApi';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const frame = (message: Record<string, unknown>): Uint8Array => {
const body = encoder.encode(JSON.stringify(message));
const result = new Uint8Array(body.length + 1);
result[0] = 1;
result.set(body, 1);
return result;
};
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): Record<string, unknown> => {
const bytes = typeof value === 'string'
? encoder.encode(value)
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return JSON.parse(decoder.decode(bytes.subarray(1))) as Record<string, unknown>;
};
class FakeSocket implements RelayTunnelWebSocket {
readyState = 0;
binaryType: 'blob' | 'arraybuffer' = 'arraybuffer';
onopen: (() => void) | null = null;
onmessage: RelayTunnelWebSocket['onmessage'] = null;
onerror: (() => void) | null = null;
onclose: RelayTunnelWebSocket['onclose'] = null;
sent: Record<string, unknown>[] = [];
open(): void { this.readyState = 1; this.onopen?.(); }
emit(message: Record<string, unknown>): void {
const bytes = frame(message);
this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
}
send(data: string | ArrayBuffer | ArrayBufferView): void { this.sent.push(parseFrame(data)); }
close(): void { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }); }
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('terminal transport', () => {
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const firstEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => firstEvents.push(`${event.type}:${event.data ?? ''}`) });
await tick();
socket.open();
await tick();
expect(socket.sent.some((message) => message.t === 'attach' && message.s === 'term-1')).toBe(true);
expect(socket.sent.filter((message) => message.t === 'attach')).toHaveLength(1);
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running' });
await tick();
const secondEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => secondEvents.push(`${event.type}:${event.data ?? ''}`) });
expect(secondEvents).toEqual(['snapshot:prompt']);
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 2, d: ' next' });
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 2, d: ' duplicate' });
await tick();
expect(firstEvents).toEqual(['snapshot:prompt', 'data: next']);
expect(secondEvents).toEqual(['snapshot:prompt', 'data: next']);
const thirdEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => thirdEvents.push(`${event.type}:${event.data ?? ''}`) });
expect(thirdEvents).toEqual(['snapshot:prompt next']);
transport.dispose();
});
test('recovers when opening the first websocket fails', async () => {
if (typeof document !== 'undefined') Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
if (typeof navigator !== 'undefined') Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
const socket = new FakeSocket();
let attempts = 0;
const events: string[] = [];
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => {
attempts += 1;
if (attempts === 1) throw new Error('offline');
queueMicrotask(() => socket.open());
return socket;
},
});
transport.subscribe('term-1', { onEvent: (event) => events.push(event.type) });
await new Promise((resolve) => setTimeout(resolve, 550));
expect(attempts).toBe(2);
expect(events).toContain('reconnecting');
expect(socket.sent.some((message) => message.t === 'attach')).toBe(true);
transport.dispose();
});
test('releases replay projections when the last subscriber detaches', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
await tick();
socket.open();
await tick();
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'large replay', status: 'running' });
await tick();
unsubscribe();
const events: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => events.push(event.type) });
expect(events).toEqual([]);
transport.dispose();
});
test('uses replay-safe output for projections and preserves terminal error codes', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
let errorCode: string | undefined;
transport.subscribe('term-1', { onEvent: () => {}, onError: (error) => { errorCode = error.code; } });
await tick();
socket.open();
await tick();
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 0, history: '', status: 'running' });
socket.emit({ t: 'output', v: 3, s: 'term-1', q: 1, d: 'prompt\u001b[6n', r: 'prompt' });
await tick();
const replay: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => { if (event.type === 'snapshot') replay.push(event.data ?? ''); } });
expect(replay).toEqual(['prompt']);
socket.emit({ t: 'error', v: 3, s: 'term-1', code: 'SESSION_NOT_FOUND', message: 'missing', fatal: true });
await tick();
expect(errorCode).toBe('SESSION_NOT_FOUND');
transport.dispose();
});
test('does not reconnect after the last subscriber detaches', async () => {
let attempts = 0;
const transport = new TerminalTransport({
refreshAuth: async () => '',
openSocket: () => {
attempts += 1;
throw new Error('offline');
},
});
const unsubscribe = transport.subscribe('term-1', { onEvent: () => {} });
unsubscribe();
await new Promise((resolve) => setTimeout(resolve, 550));
expect(attempts).toBe(0);
transport.dispose();
});
test('single-flights auth and socket opening for an immediate first write', async () => {
const sockets: FakeSocket[] = [];
let authCalls = 0;
const transport = new TerminalTransport({
refreshAuth: async () => { authCalls += 1; await tick(); },
openSocket: () => {
const socket = new FakeSocket();
sockets.push(socket);
queueMicrotask(() => socket.open());
return socket;
},
});
transport.subscribe('term-1', { onEvent: () => {} });
await transport.write('term-1', 'bun run dev\r');
expect(authCalls).toBe(1);
expect(sockets).toHaveLength(1);
expect(sockets[0].sent.filter((message) => message.t === 'write')).toEqual([
{ t: 'write', v: 3, s: 'term-1', d: 'bun run dev\r' },
]);
transport.dispose();
});
});