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; close: ReturnType; }[], fetchSpy: vi.fn(), fitSpy: vi.fn(), selectionHandlers: [] as (() => void)[], getSelectionSpy: vi.fn<() => string>(() => ''), writeTextSpy: vi.fn<(text: string) => Promise>(() => Promise.resolve(), ), })); class FakeTerminal { cols = 80; rows = 24; options: Record = {}; 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) { 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(); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); await waitFor(() => expect(wsInstances).toHaveLength(1)); const ws = wsInstances[0]!; rerender( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); const checkbox = screen.getByRole('checkbox'); expect(checkbox).toBeChecked(); window.localStorage.setItem('spoon.terminal.copyOnSelect', 'false'); render( , ); // The second instance's checkbox reads the persisted false over prop=true. const checkboxes = screen.getAllByRole('checkbox'); expect(checkboxes[checkboxes.length - 1]).not.toBeChecked(); }); });