373 lines
9.9 KiB
TypeScript
373 lines
9.9 KiB
TypeScript
import { cleanup, fireEvent, 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,
|
|
fitSpy,
|
|
selectionHandlers,
|
|
getSelectionSpy,
|
|
writeTextSpy,
|
|
} = vi.hoisted(() => ({
|
|
openSpy: vi.fn(),
|
|
wsInstances: [] as {
|
|
url: string;
|
|
send: ReturnType<typeof vi.fn>;
|
|
close: ReturnType<typeof vi.fn>;
|
|
}[],
|
|
fetchSpy: vi.fn(),
|
|
fitSpy: vi.fn(),
|
|
selectionHandlers: [] as (() => void)[],
|
|
getSelectionSpy: vi.fn<() => string>(() => ''),
|
|
writeTextSpy: vi.fn<(text: string) => Promise<void>>(() =>
|
|
Promise.resolve(),
|
|
),
|
|
}));
|
|
|
|
class FakeTerminal {
|
|
cols = 80;
|
|
rows = 24;
|
|
options: Record<string, unknown> = {};
|
|
open = openSpy;
|
|
loadAddon = vi.fn();
|
|
onData = vi.fn();
|
|
onResize = vi.fn();
|
|
onSelectionChange = vi.fn((cb: () => void) => {
|
|
selectionHandlers.push(cb);
|
|
});
|
|
getSelection = getSelectionSpy;
|
|
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 = fitSpy;
|
|
},
|
|
}));
|
|
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;
|
|
selectionHandlers.length = 0;
|
|
openSpy.mockClear();
|
|
fitSpy.mockClear();
|
|
getSelectionSpy.mockReset();
|
|
getSelectionSpy.mockReturnValue('');
|
|
writeTextSpy.mockReset();
|
|
writeTextSpy.mockResolvedValue(undefined);
|
|
const store = new Map<string, string>();
|
|
const localStorageMock = {
|
|
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
|
setItem: (key: string, value: string) => store.set(key, String(value)),
|
|
removeItem: (key: string) => store.delete(key),
|
|
clear: () => store.clear(),
|
|
key: (index: number) => Array.from(store.keys())[index] ?? null,
|
|
get length() {
|
|
return store.size;
|
|
},
|
|
};
|
|
Object.defineProperty(window, 'localStorage', {
|
|
configurable: true,
|
|
value: localStorageMock,
|
|
});
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText: writeTextSpy },
|
|
});
|
|
fetchSpy.mockReset();
|
|
fetchSpy.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ url: 'ws://x' }),
|
|
text: () => Promise.resolve(''),
|
|
});
|
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
vi.stubGlobal('fetch', fetchSpy);
|
|
vi.stubGlobal(
|
|
'ResizeObserver',
|
|
class {
|
|
observe() {
|
|
/* noop */
|
|
}
|
|
disconnect() {
|
|
/* noop */
|
|
}
|
|
unobserve() {
|
|
/* noop */
|
|
}
|
|
},
|
|
);
|
|
Object.defineProperty(document, 'fonts', {
|
|
configurable: true,
|
|
value: {
|
|
ready: Promise.resolve(),
|
|
load: () => Promise.resolve([]),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
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();
|
|
});
|
|
|
|
it('refits and sends a resize frame when it becomes visible', async () => {
|
|
const { rerender } = render(
|
|
<XtermSession
|
|
active
|
|
visible={false}
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(wsInstances).toHaveLength(1));
|
|
const ws = wsInstances[0]!;
|
|
// Ignore fits/sends from the initial connect; only the visible toggle matters.
|
|
fitSpy.mockClear();
|
|
ws.send.mockClear();
|
|
|
|
rerender(
|
|
<XtermSession
|
|
active
|
|
visible
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
|
await waitFor(() =>
|
|
expect(ws.send).toHaveBeenCalledWith(
|
|
expect.stringContaining('"type":"resize"'),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('keeps the WebSocket open when hidden while still active', async () => {
|
|
const { rerender } = render(
|
|
<XtermSession
|
|
active
|
|
visible
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(wsInstances).toHaveLength(1));
|
|
const ws = wsInstances[0]!;
|
|
|
|
rerender(
|
|
<XtermSession
|
|
active
|
|
visible={false}
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(ws.close).not.toHaveBeenCalled();
|
|
expect(wsInstances).toHaveLength(1);
|
|
});
|
|
|
|
it('copies the selection to the clipboard when copy-on-select is enabled', async () => {
|
|
render(
|
|
<XtermSession
|
|
active
|
|
copyOnSelect
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
|
|
getSelectionSpy.mockReturnValue('hello world');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
|
|
await waitFor(() =>
|
|
expect(writeTextSpy).toHaveBeenCalledWith('hello world'),
|
|
);
|
|
});
|
|
|
|
it('does not copy when copy-on-select is disabled', async () => {
|
|
render(
|
|
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
|
|
);
|
|
|
|
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
|
|
getSelectionSpy.mockReturnValue('should not copy');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
await Promise.resolve();
|
|
|
|
expect(writeTextSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips empty selections even when enabled', async () => {
|
|
render(
|
|
<XtermSession
|
|
active
|
|
copyOnSelect
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
|
|
getSelectionSpy.mockReturnValue('');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
await Promise.resolve();
|
|
|
|
expect(writeTextSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('swallows clipboard errors without crashing', async () => {
|
|
writeTextSpy.mockRejectedValue(new Error('denied'));
|
|
render(
|
|
<XtermSession
|
|
active
|
|
copyOnSelect
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-1'
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
|
|
getSelectionSpy.mockReturnValue('boom');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
|
|
await waitFor(() => expect(writeTextSpy).toHaveBeenCalledWith('boom'));
|
|
// Rejection is caught; the component stays mounted.
|
|
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the header toggle and persists flips to localStorage', async () => {
|
|
render(
|
|
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
|
|
);
|
|
|
|
await waitFor(() => expect(selectionHandlers.length).toBeGreaterThan(0));
|
|
const checkbox = screen.getByRole('checkbox');
|
|
expect(checkbox).not.toBeChecked();
|
|
|
|
// Off: a selection is not copied.
|
|
getSelectionSpy.mockReturnValue('nope');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
await Promise.resolve();
|
|
expect(writeTextSpy).not.toHaveBeenCalled();
|
|
|
|
// Flip on: persists and now copies.
|
|
fireEvent.click(checkbox);
|
|
expect(checkbox).toBeChecked();
|
|
expect(
|
|
window.localStorage.getItem('spoon.terminal.copyOnSelect'),
|
|
).toBe('true');
|
|
|
|
getSelectionSpy.mockReturnValue('now copied');
|
|
selectionHandlers.forEach((cb) => cb());
|
|
await waitFor(() =>
|
|
expect(writeTextSpy).toHaveBeenCalledWith('now copied'),
|
|
);
|
|
|
|
// Flip off again: persists false.
|
|
fireEvent.click(checkbox);
|
|
expect(checkbox).not.toBeChecked();
|
|
expect(
|
|
window.localStorage.getItem('spoon.terminal.copyOnSelect'),
|
|
).toBe('false');
|
|
});
|
|
|
|
it('lets the persisted localStorage value override the prop default', () => {
|
|
window.localStorage.setItem('spoon.terminal.copyOnSelect', 'true');
|
|
render(
|
|
<XtermSession active tokenUrl='/api/box/terminal-token' sessionKey='box-1' />,
|
|
);
|
|
|
|
const checkbox = screen.getByRole('checkbox');
|
|
expect(checkbox).toBeChecked();
|
|
|
|
window.localStorage.setItem('spoon.terminal.copyOnSelect', 'false');
|
|
render(
|
|
<XtermSession
|
|
active
|
|
copyOnSelect
|
|
tokenUrl='/api/box/terminal-token'
|
|
sessionKey='box-2'
|
|
/>,
|
|
);
|
|
// The second instance's checkbox reads the persisted false over prop=true.
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
expect(checkboxes[checkboxes.length - 1]).not.toBeChecked();
|
|
});
|
|
});
|