refactor(web): extract reusable XtermSession from WorkspaceTerminal

This commit is contained in:
Gabriel Brown
2026-07-11 12:43:08 -04:00
parent 27019dc1d3
commit ea56b29453
3 changed files with 426 additions and 257 deletions
@@ -0,0 +1,130 @@
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { XtermSession } from '../../src/components/agent-workspace/xterm-session';
const { openSpy, wsInstances, fetchSpy } = vi.hoisted(() => ({
openSpy: vi.fn(),
wsInstances: [] as Array<{ url: string }>,
fetchSpy: vi.fn(),
}));
class FakeTerminal {
cols = 80;
rows = 24;
options: Record<string, unknown> = {};
open = openSpy;
loadAddon = vi.fn();
onData = vi.fn();
onResize = vi.fn();
onSelectionChange = vi.fn();
dispose = vi.fn();
refresh = vi.fn();
write = vi.fn();
focus = vi.fn();
constructor(opts: Record<string, unknown>) {
this.options = opts ?? {};
}
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }));
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit = vi.fn();
},
}));
vi.mock('@xterm/addon-web-links', () => ({
WebLinksAddon: class {},
}));
vi.mock('next-themes', () => ({
useTheme: () => ({ resolvedTheme: 'dark' }),
}));
class FakeWebSocket {
static OPEN = 1;
url: string;
readyState = 1;
binaryType = '';
onopen: (() => void) | null = null;
onmessage: ((event: unknown) => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
send = vi.fn();
close = vi.fn();
constructor(url: string) {
this.url = url;
wsInstances.push(this);
}
}
beforeEach(() => {
wsInstances.length = 0;
openSpy.mockClear();
fetchSpy.mockReset();
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ url: 'ws://x' }),
text: async () => '',
});
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.stubGlobal('fetch', fetchSpy);
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
unobserve() {}
},
);
Object.defineProperty(document, 'fonts', {
configurable: true,
value: {
ready: Promise.resolve(),
load: () => Promise.resolve([]),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('XtermSession', () => {
it('shows the waiting label and does not open a WebSocket while inactive', async () => {
render(
<XtermSession
active={false}
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
waitingLabel='Workspace is asleep.'
/>,
);
expect(screen.getByText('Workspace is asleep.')).toBeInTheDocument();
// Give any (unexpected) async connect a chance to run.
await Promise.resolve();
expect(fetchSpy).not.toHaveBeenCalled();
expect(wsInstances).toHaveLength(0);
});
it('fetches the token URL and opens a WebSocket to the returned url when active', async () => {
render(
<XtermSession
active
tokenUrl='/api/box/terminal-token'
sessionKey='box-1'
/>,
);
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/terminal-token',
expect.objectContaining({ signal: expect.anything() }),
),
);
await waitFor(() => expect(wsInstances).toHaveLength(1));
expect(wsInstances[0]?.url).toBe('ws://x');
expect(openSpy).toHaveBeenCalled();
});
});