Wire copy-on-select in XtermSession (shared by the workspace terminal and the /machine terminal): a header checkbox toggles the behavior and persists to localStorage under spoon.terminal.copyOnSelect. On mount the stored value takes precedence over the copyOnSelect prop default. When enabled, onSelectionChange copies the current selection via navigator.clipboard.writeText — guarded (empty selections skipped; clipboard-unavailable/denied errors swallowed). The handler reads the toggle through a ref so live flips apply without reconnecting. WebLinksAddon remains loaded so printed URLs are clickable. Manual verification: in /machine and a workspace terminal, enabling copy-on-select and selecting text places it on the clipboard; a printed URL is clickable and opens in a new tab.
367 lines
9.8 KiB
TypeScript
367 lines
9.8 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 Array<{
|
|
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: 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(() => {
|
|
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();
|
|
});
|
|
});
|