diff --git a/apps/frontend/e2e/terminal-copy-paste.e2e.ts b/apps/frontend/e2e/terminal-copy-paste.e2e.ts new file mode 100644 index 00000000..8902600e --- /dev/null +++ b/apps/frontend/e2e/terminal-copy-paste.e2e.ts @@ -0,0 +1,335 @@ +/** + * End-to-End tests for terminal copy/paste functionality + * Tests copy/paste keyboard shortcuts in the Electron app + * + * These tests require the Electron app to be built first. + * Run `npm run build` before running E2E tests. + * + * To run: npx playwright test terminal-copy-paste.e2e.ts --config=e2e/playwright.config.ts + */ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import { mkdirSync, rmSync, existsSync } from 'fs'; +import path from 'path'; +import * as os from 'os'; + +// Global Navigator declaration for clipboard +declare global { + interface Navigator { + clipboard: { + readText(): Promise; + writeText(text: string): Promise; + }; + } +} + +// Test data directory +const TEST_DATA_DIR = path.join(os.tmpdir(), 'auto-claude-terminal-e2e'); + +// Determine platform for platform-specific tests +const platform = process.platform; +const isMac = platform === 'darwin'; +const isWindows = platform === 'win32'; +const isLinux = platform === 'linux'; + +// Setup test environment +function setupTestEnvironment(): void { + if (existsSync(TEST_DATA_DIR)) { + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// Cleanup test environment +function cleanupTestEnvironment(): void { + if (existsSync(TEST_DATA_DIR)) { + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +} + +// Helper to get platform-specific copy shortcut +function getCopyShortcutKey(): string { + return isMac ? 'Meta' : 'Control'; +} + +// Helper to check if test should run on current platform +function shouldRunForPlatform(testPlatform: 'all' | 'windows' | 'linux' | 'mac'): boolean { + if (testPlatform === 'all') return true; + if (testPlatform === 'windows') return isWindows; + if (testPlatform === 'linux') return isLinux; + if (testPlatform === 'mac') return isMac; + return false; +} + +test.describe('Terminal Copy/Paste Flows', () => { + let app: ElectronApplication; + let window: Page; + let isAppReady = false; + + test.beforeAll(async () => { + setupTestEnvironment(); + }); + + test.afterAll(async () => { + cleanupTestEnvironment(); + }); + + test.beforeEach(async () => { + // Launch Electron app + const appPath = path.join(__dirname, '..'); + app = await electron.launch({ args: [appPath] }); + + window = await app.firstWindow({ + timeout: 15000 + }); + + // Wait for app to be ready + try { + await window.waitForSelector('body', { timeout: 10000 }); + isAppReady = true; + } catch (error) { + console.error('App failed to load:', error); + isAppReady = false; + } + }); + + test.afterEach(async () => { + if (app) { + await app.close(); + } + }); + + test.describe.configure({ mode: 'serial' }); + + test('should copy selected text to clipboard', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform'); + + // Look for terminal element - skip if not found + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + // Run a command to produce output + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Type echo command and press enter + await window.keyboard.type('echo "test output for copy"'); + await window.keyboard.press('Enter'); + + // Wait for output to appear in terminal + await expect(terminal).toContainText('test output for copy', { timeout: 5000 }); + + // Select text (triple click to select line) + await terminal.click({ clickCount: 3 }); + + // Wait for selection to be active + await window.waitForTimeout(100); + + // Press copy shortcut (Cmd+C on Mac, Ctrl+C on Windows/Linux) + const copyKey = getCopyShortcutKey(); + await window.keyboard.press(`${copyKey}+c`); + + // Wait briefly for clipboard operation + await window.waitForTimeout(100); + + // Verify clipboard contains selected text + const clipboardText = await window.evaluate(async () => { + return await navigator.clipboard.readText(); + }); + + expect(clipboardText).toContain('test output for copy'); + }); + + test('should send interrupt signal when no text selected', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Start a long-running process (sleep on Linux/Mac, timeout on Windows) + const sleepCommand = isWindows ? 'timeout 10' : 'sleep 10'; + await window.keyboard.type(sleepCommand); + await window.keyboard.press('Enter'); + + // Wait for process to start + await window.waitForTimeout(500); + + // Press Ctrl+C without selection (should send interrupt) + await window.keyboard.press('Control+c'); + + // Wait for interrupt to be processed - look for ^C or new prompt + await expect(terminal).toContainText(/\^C|[$#>]/, { timeout: 3000 }); + }); + + test('should paste clipboard text into terminal', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + // Set clipboard content + const testText = 'hello world from clipboard'; + await window.evaluate(async (text) => { + await navigator.clipboard.writeText(text); + }, testText); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Press paste shortcut + const pasteKey = isMac ? 'Meta' : 'Control'; + await window.keyboard.press(`${pasteKey}+v`); + + // Wait briefly for paste to complete + await window.waitForTimeout(100); + + // Press Enter to execute the pasted command + await window.keyboard.press('Enter'); + + // Verify text was pasted (terminal should show the pasted text or output) + await expect(terminal).toContainText(testText, { timeout: 5000 }); + }); + + test('should handle Linux CTRL+SHIFT+C copy shortcut', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Type command to generate output + await window.keyboard.type('echo "linux copy test"'); + await window.keyboard.press('Enter'); + + // Wait for output + await expect(terminal).toContainText('linux copy test', { timeout: 5000 }); + + // Select text + await terminal.click({ clickCount: 3 }); + await window.waitForTimeout(100); + + // Press CTRL+SHIFT+C (Linux copy shortcut) + await window.keyboard.down('Control'); + await window.keyboard.down('Shift'); + await window.keyboard.press('c'); + await window.keyboard.up('Shift'); + await window.keyboard.up('Control'); + + // Wait briefly for clipboard operation + await window.waitForTimeout(100); + + // Verify clipboard contains selected text + const clipboardText = await window.evaluate(async () => { + return await navigator.clipboard.readText(); + }); + + expect(clipboardText).toContain('linux copy test'); + }); + + test('should handle Linux CTRL+SHIFT+V paste shortcut', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + // Set clipboard content + const testText = 'pasted via ctrl+shift+v'; + await window.evaluate(async (text) => { + await navigator.clipboard.writeText(text); + }, testText); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Press CTRL+SHIFT+V (Linux paste shortcut) + await window.keyboard.down('Control'); + await window.keyboard.down('Shift'); + await window.keyboard.press('v'); + await window.keyboard.up('Shift'); + await window.keyboard.up('Control'); + + // Wait briefly for paste to complete + await window.waitForTimeout(100); + + // Press Enter to execute + await window.keyboard.press('Enter'); + + // Verify text was pasted + await expect(terminal).toContainText(testText, { timeout: 5000 }); + }); + + test('should verify existing shortcuts still work', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Test SHIFT+Enter (multi-line input) + await window.keyboard.type('echo "line 1"'); + await window.keyboard.down('Shift'); + await window.keyboard.press('Enter'); + await window.keyboard.up('Shift'); + await window.keyboard.type('echo "line 2"'); + await window.keyboard.press('Enter'); + + // Verify multi-line input worked (both commands should execute) + await expect(terminal).toContainText('line 1', { timeout: 5000 }); + await expect(terminal).toContainText('line 2', { timeout: 5000 }); + }); + + test('should handle clipboard errors gracefully', async () => { + test.skip(!isAppReady, 'App not ready'); + test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform'); + + const terminalSelector = '.xterm'; + const terminalExists = await window.locator(terminalSelector).count() > 0; + test.skip(!terminalExists, 'Terminal element not found'); + + // Mock clipboard permission denial by clearing clipboard + await window.evaluate(async () => { + // Try to read clipboard (may fail if permission denied) + try { + await navigator.clipboard.readText(); + } catch (_error) { + // Expected - clipboard may not be accessible in test environment + console.warn('Clipboard not accessible (expected in some environments)'); + } + }); + + const terminal = window.locator(terminalSelector).first(); + await terminal.click(); + + // Try to paste even if clipboard is not accessible + const pasteKey = isMac ? 'Meta' : 'Control'; + await window.keyboard.press(`${pasteKey}+v`); + + // Wait briefly to ensure terminal remains stable + await window.waitForTimeout(100); + + // Try typing to verify terminal still works + await window.keyboard.type('echo "terminal still works"'); + await window.keyboard.press('Enter'); + + // Verify terminal still functions after clipboard error + await expect(terminal).toContainText('terminal still works', { timeout: 5000 }); + }); +}); diff --git a/apps/frontend/src/__tests__/integration/terminal-copy-paste.test.ts b/apps/frontend/src/__tests__/integration/terminal-copy-paste.test.ts new file mode 100644 index 00000000..ea4cec57 --- /dev/null +++ b/apps/frontend/src/__tests__/integration/terminal-copy-paste.test.ts @@ -0,0 +1,728 @@ +/** + * @vitest-environment jsdom + */ + +/** + * Integration tests for terminal copy/paste functionality + * Tests xterm.js selection API integration with clipboard operations + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import React from 'react'; +import type { Mock } from 'vitest'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { SerializeAddon } from '@xterm/addon-serialize'; + +// Mock xterm.js and its addons +vi.mock('@xterm/xterm', () => ({ + Terminal: vi.fn().mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(), + hasSelection: vi.fn(function() { return false; }), + getSelection: vi.fn(function() { return ''; }), + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }) +})); + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: vi.fn().mockImplementation(function() { + return { + fit: vi.fn() + }; + }) +})); + +vi.mock('@xterm/addon-web-links', () => ({ + WebLinksAddon: vi.fn().mockImplementation(function() { + return {}; + }) +})); + +vi.mock('@xterm/addon-serialize', () => ({ + SerializeAddon: vi.fn().mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }) +})); + +describe('Terminal copy/paste integration', () => { + let mockClipboard: { + writeText: Mock; + readText: Mock; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock ResizeObserver + global.ResizeObserver = vi.fn().mockImplementation(function() { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + }; + }); + + // Mock navigator.clipboard + mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue('clipboard content') + }; + + Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, + writable: true + }); + + // Mock window.electronAPI + (window as unknown as { electronAPI: unknown }).electronAPI = { + sendTerminalInput: vi.fn() + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('xterm.js selection API integration with clipboard write', () => { + it('should integrate xterm.hasSelection() with clipboard write', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + const mockHasSelection = vi.fn(function() { return true; }); + const mockGetSelection = vi.fn(function() { return 'selected terminal text'; }); + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: mockHasSelection, + getSelection: mockGetSelection, + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + // Simulate copy operation + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(event); + // Wait for clipboard write + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Verify integration: hasSelection() called + expect(mockHasSelection).toHaveBeenCalled(); + + // Verify integration: getSelection() called when hasSelection returns true + expect(mockGetSelection).toHaveBeenCalled(); + + // Verify integration: clipboard.writeText() called with selection + expect(mockClipboard.writeText).toHaveBeenCalledWith('selected terminal text'); + }); + + it('should not call getSelection when hasSelection returns false', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + const mockHasSelection = vi.fn(function() { return false; }); + const mockGetSelection = vi.fn(function() { return ''; }); + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: mockHasSelection, + getSelection: mockGetSelection, + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(event); + } + }); + + // Verify hasSelection was called + expect(mockHasSelection).toHaveBeenCalled(); + + // Verify getSelection was NOT called (no selection) + expect(mockGetSelection).not.toHaveBeenCalled(); + + // Verify clipboard was NOT written to + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + }); + + describe('clipboard read with xterm paste integration', () => { + let originalNavigatorPlatform: string; + + beforeEach(() => { + // Capture original navigator.platform + originalNavigatorPlatform = navigator.platform; + }); + + afterEach(() => { + // Restore navigator.platform + Object.defineProperty(navigator, 'platform', { + value: originalNavigatorPlatform, + writable: true + }); + }); + + it('should integrate clipboard.readText() with xterm.paste()', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + // Mock Windows platform + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + const mockPaste = vi.fn(); + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: vi.fn(), + getSelection: vi.fn(), + paste: mockPaste, + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + mockClipboard.readText.mockResolvedValue('pasted text'); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(event); + // Wait for clipboard read and paste + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Verify integration: clipboard.readText() called + expect(mockClipboard.readText).toHaveBeenCalled(); + + // Verify integration: xterm.paste() called with clipboard content + expect(mockPaste).toHaveBeenCalledWith('pasted text'); + }); + + it('should not paste when clipboard is empty', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + // Mock Linux platform + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + const mockPaste = vi.fn(); + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: vi.fn(), + getSelection: vi.fn(), + paste: mockPaste, + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Mock empty clipboard + mockClipboard.readText.mockResolvedValue(''); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(event); + // Wait for clipboard read + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Verify clipboard was read + expect(mockClipboard.readText).toHaveBeenCalled(); + + // Verify paste was NOT called for empty clipboard + expect(mockPaste).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard event propagation', () => { + it('should prevent copy/paste events from interfering with other shortcuts', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + let eventCallOrder: string[] = []; + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: vi.fn(function() { return true; }), + getSelection: vi.fn(function() { return 'selection'; }), + paste: vi.fn(), + input: vi.fn(function(data: string) { + eventCallOrder.push(`input:${data}`); + }), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + // Test SHIFT+Enter (should work independently of copy/paste) + const shiftEnterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + shiftKey: true, + ctrlKey: false, + metaKey: false + }); + + if (keyEventHandler) { + keyEventHandler(shiftEnterEvent); + } + + // Verify SHIFT+Enter still works (sends newline) + expect(eventCallOrder.some(s => s.includes('\x1b\n'))).toBe(true); + + // Test CTRL+C with selection (should not interfere) + eventCallOrder = []; + const copyEvent = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(copyEvent); + // Wait for clipboard write + await new Promise(resolve => setTimeout(resolve, 0)); + } + + // Copy should not send input to terminal + expect(eventCallOrder).toHaveLength(0); + + // Test CTRL+V (should not interfere) + const pasteEvent = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(pasteEvent); + // Wait for clipboard read + await new Promise(resolve => setTimeout(resolve, 0)); + } + + // Paste should use xterm.paste(), not xterm.input() + // The input() should not be called directly + expect(eventCallOrder).toHaveLength(0); + }); + }); + + it('should maintain correct handler ordering for existing shortcuts', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + let handlerResults: { key: string; handled: boolean }[] = []; + const mockHasSelection = vi.fn(function() { return false; }); + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: mockHasSelection, + getSelection: vi.fn(), + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + // Helper to test key handling + const testKey = (key: string, ctrl: boolean, meta: boolean, shift: boolean) => { + const event = new KeyboardEvent('keydown', { + key, + ctrlKey: ctrl, + metaKey: meta, + shiftKey: shift + }); + + if (keyEventHandler) { + const handled = keyEventHandler(event); + handlerResults.push({ key, handled }); + } + }; + + await act(async () => { + // Test existing shortcuts (should return false to bubble up) + testKey('1', true, false, false); // Ctrl+1 + testKey('Tab', true, false, false); // Ctrl+Tab + testKey('t', true, false, false); // Ctrl+T + testKey('w', true, false, false); // Ctrl+W + + // Verify these return false (bubble to window handler) + expect(handlerResults.filter(r => !r.handled)).toHaveLength(4); + + // Test copy/paste WITHOUT selection (should pass through to send ^C) + handlerResults = []; + mockHasSelection.mockReturnValue(false); + testKey('c', true, false, false); // Ctrl+C without selection + + // Should return true (let ^C pass through to terminal for interrupt signal) + expect(handlerResults[0].handled).toBe(true); + }); + }); + }); + + describe('clipboard error handling without breaking terminal', () => { + it('should continue terminal operation after clipboard error', async () => { + const { useXterm } = await import('../../renderer/components/terminal/useXterm'); + + // Mock Windows platform to enable custom paste handler + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + const mockPaste = vi.fn(); + const mockInput = vi.fn(); + const mockSendTerminalInput = vi.fn(); + let onDataCallback: ((data: string) => void) | undefined; + let errorLogged = false; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(function(...args: unknown[]) { + if (String(args[0]).includes('[useXterm]')) { + errorLogged = true; + } + }); + + // Mock clipboard error + mockClipboard.readText = vi.fn().mockRejectedValue(new Error('Clipboard denied')); + + // Mock window.electronAPI with sendTerminalInput + (window as unknown as { electronAPI: { sendTerminalInput: Mock } }).electronAPI = { + sendTerminalInput: mockSendTerminalInput + }; + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) { + keyEventHandler = handler; + }), + hasSelection: vi.fn(), + getSelection: vi.fn(), + paste: mockPaste, + input: mockInput, + onData: vi.fn(function(callback: (data: string) => void) { + onDataCallback = callback; + }), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Need to also override the addon mocks to be constructable + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(function() { return ''; }), + dispose: vi.fn() + }; + }); + + // Create a test wrapper component that provides the DOM element + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + await act(async () => { + // Try to paste (will fail) + const pasteEvent = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + if (keyEventHandler) { + keyEventHandler(pasteEvent); + // Wait for clipboard error + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Verify error was logged + expect(errorLogged).toBe(true); + + // Verify terminal still works (can accept input through onData callback) + const inputData = 'test command'; + + if (onDataCallback) { + onDataCallback(inputData); + } + + // Verify input was sent to electronAPI (terminal still functional) + expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal', 'test command'); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts b/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts new file mode 100644 index 00000000..39e0917c --- /dev/null +++ b/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts @@ -0,0 +1,793 @@ +/** + * @vitest-environment jsdom + */ + +/** + * Unit tests for useXterm keyboard handlers + * Tests terminal copy/paste keyboard shortcuts and platform detection + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; +import { renderHook, act, render } from '@testing-library/react'; +import React from 'react'; +import { Terminal as XTerm } from '@xterm/xterm'; +import { useXterm } from '../useXterm'; + +// Mock xterm.js +vi.mock('@xterm/xterm', () => ({ + Terminal: vi.fn().mockImplementation(() => ({ + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(), + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => ''), + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + cols: 80, + rows: 24 + })) +})); + +// Mock xterm addons +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: vi.fn().mockImplementation(() => ({ + fit: vi.fn() + })) +})); + +vi.mock('@xterm/addon-web-links', () => ({ + WebLinksAddon: vi.fn() +})); + +vi.mock('@xterm/addon-serialize', () => ({ + SerializeAddon: vi.fn().mockImplementation(() => ({ + serialize: vi.fn(() => ''), + dispose: vi.fn() + })) +})); + +// Mock terminal buffer manager +vi.mock('../../../../lib/terminal-buffer-manager', () => ({ + terminalBufferManager: { + get: vi.fn(() => ''), + set: vi.fn(), + clear: vi.fn() + } +})); + +// Mock navigator.platform for platform detection +const originalNavigatorPlatform = navigator.platform; + +// Mock requestAnimationFrame for jsdom environment (not provided by default) +global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number); + +/** + * Helper function to set up XTerm mocks and render the hook + * Reduces test boilerplate from ~100 lines to ~20 lines per test + */ +async function setupMockXterm(overrides: { + hasSelection?: () => boolean; + getSelection?: () => string; + paste?: ReturnType; + input?: ReturnType; +} = {}) { + let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null; + + // Override XTerm mock to be constructable + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => { + keyEventHandler = handler; + }), + hasSelection: overrides.hasSelection ?? vi.fn(() => false), + getSelection: overrides.getSelection ?? vi.fn(() => ''), + paste: overrides.paste ?? vi.fn(), + input: overrides.input ?? vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + cols: 80, + rows: 24 + }; + }); + + // Setup addon mocks + const { FitAddon } = await import('@xterm/addon-fit'); + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn() }; + }); + + const { WebLinksAddon } = await import('@xterm/addon-web-links'); + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + const { SerializeAddon } = await import('@xterm/addon-serialize'); + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { + serialize: vi.fn(() => ''), + dispose: vi.fn() + }; + }); + + // Mock ResizeObserver + global.ResizeObserver = vi.fn().mockImplementation(function() { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() + }; + }); + + // Create and render test wrapper component + const TestWrapper = () => { + const { terminalRef } = useXterm({ terminalId: 'test-terminal' }); + return React.createElement('div', { ref: terminalRef }); + }; + + render(React.createElement(TestWrapper)); + + // After rendering, keyEventHandler is guaranteed to be set by attachCustomKeyEventHandler + // Use non-null assertion since we know the hook will set it + return { + keyEventHandler: keyEventHandler!, + mockInstance: { + hasSelection: overrides.hasSelection, + getSelection: overrides.getSelection, + paste: overrides.paste, + input: overrides.input + } + }; +} + +describe('useXterm keyboard handlers', () => { + let mockClipboard: { + writeText: ReturnType; + readText: ReturnType; + }; + + beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); + + // Ensure window and navigator exist in test environment + if (typeof window === 'undefined') { + (global as { window: unknown }).window = {}; + } + if (typeof navigator === 'undefined') { + (global as { navigator: unknown }).navigator = {}; + } + + // Mock navigator.clipboard + mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue('test clipboard content') + }; + + Object.defineProperty(global.navigator, 'clipboard', { + value: mockClipboard, + writable: true, + configurable: true + }); + + // Mock window.electronAPI + (window as unknown as { electronAPI: unknown }).electronAPI = { + sendTerminalInput: vi.fn() + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Reset navigator.platform to original value + Object.defineProperty(navigator, 'platform', { + value: originalNavigatorPlatform, + writable: true + }); + }); + + describe('Platform detection', () => { + it('should enable paste shortcuts on Windows (CTRL+V)', async () => { + const mockPaste = vi.fn(); + + // Mock Windows platform + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true, + shiftKey: false + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Windows should enable CTRL+V paste + expect(mockPaste).toHaveBeenCalledWith('test clipboard content'); + }); + + it('should enable paste shortcuts on Linux (both CTRL+V and CTRL+SHIFT+V)', async () => { + const mockPaste = vi.fn(); + + // Mock Linux platform + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + // Test CTRL+V + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true, + shiftKey: false + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockPaste).toHaveBeenCalledTimes(1); + + // Test CTRL+SHIFT+V (Linux-specific) + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'V', + ctrlKey: true, + shiftKey: true + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockPaste).toHaveBeenCalledTimes(2); + }); + + it('should enable copy shortcuts on Linux (both CTRL+C and CTRL+SHIFT+C)', async () => { + const mockHasSelection = vi.fn(() => true); + const mockGetSelection = vi.fn(() => 'selected text'); + + // Mock Linux platform + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + // Test CTRL+C (should copy) + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true, + shiftKey: false + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockClipboard.writeText).toHaveBeenCalledTimes(1); + + // Test CTRL+SHIFT+C (Linux-specific, should also copy) + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'C', + ctrlKey: true, + shiftKey: true + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockClipboard.writeText).toHaveBeenCalledTimes(2); + }); + + it('should NOT enable custom paste handler on macOS (uses system Cmd+V)', async () => { + const mockPaste = vi.fn(); + + // Mock macOS platform + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true, + shiftKey: false + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // macOS should NOT use custom CTRL+V handler (uses system Cmd+V instead) + expect(mockPaste).not.toHaveBeenCalled(); + }); + }); + + describe('Smart CTRL+C behavior', () => { + it('should copy to clipboard when text is selected', async () => { + // Create mock functions that will be shared between the mock instance and our assertions + const mockHasSelection = vi.fn(() => true); + const mockGetSelection = vi.fn(() => 'selected text'); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + await act(async () => { + // Simulate CTRL+C keydown event + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true, + metaKey: false + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); // Should prevent xterm handling + + // Wait for clipboard write + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Verify the xterm instance methods were called + expect(mockHasSelection).toHaveBeenCalled(); + expect(mockGetSelection).toHaveBeenCalled(); + + // Verify clipboard.writeText was called with selected text + expect(mockClipboard.writeText).toHaveBeenCalledWith('selected text'); + }); + + it('should send ^C interrupt when no text is selected', async () => { + const mockHasSelection = vi.fn(() => false); + const mockGetSelection = vi.fn(() => ''); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + await act(async () => { + // Simulate CTRL+C keydown event with no selection + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true, + metaKey: false + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(true); // Should let ^C pass through to terminal + }); + + // Verify clipboard.writeText was NOT called + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + + it('should handle both ctrlKey (Windows/Linux) and metaKey (Mac)', async () => { + const mockHasSelection = vi.fn(() => true); + const mockGetSelection = vi.fn(() => 'selected text'); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + // Test ctrlKey (Windows/Linux) + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true, + metaKey: false + }); + + if (keyEventHandler) { + keyEventHandler!(event); + // Wait for clipboard write + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Test metaKey (Mac) + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: false, + metaKey: true + }); + + if (keyEventHandler) { + keyEventHandler!(event); + // Wait for clipboard write + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Both should trigger clipboard write + expect(mockClipboard.writeText).toHaveBeenCalledTimes(2); + }); + }); + + describe('CTRL+V paste behavior', () => { + it('should paste clipboard content on Windows', async () => { + const mockPaste = vi.fn(); + + // Mock Windows platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + if (keyEventHandler) { + const handled = keyEventHandler!(event); + expect(handled).toBe(false); // Should prevent literal ^V + + // Wait for clipboard read and paste + await new Promise(resolve => setTimeout(resolve, 0)); + } + }); + + // Verify clipboard read and paste + expect(mockClipboard.readText).toHaveBeenCalled(); + expect(mockPaste).toHaveBeenCalledWith('test clipboard content'); + }); + + it('should paste clipboard content on Linux', async () => { + const mockPaste = vi.fn(); + + // Mock Linux platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockClipboard.readText).toHaveBeenCalled(); + expect(mockPaste).toHaveBeenCalledWith('test clipboard content'); + }); + + it('should NOT paste on macOS (Cmd+V should work through existing handlers)', async () => { + const mockPaste = vi.fn(); + + // Mock macOS platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + // On Mac, this would be Cmd+V which is metaKey + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true, // ctrlKey, not metaKey + metaKey: false + }); + + // On Mac, ctrlKey+V should NOT trigger paste (only Cmd+V works) + keyEventHandler(event); + }); + + // Should not paste for ctrlKey+V on Mac + expect(mockClipboard.readText).not.toHaveBeenCalled(); + expect(mockPaste).not.toHaveBeenCalled(); + }); + }); + + describe('Linux CTRL+SHIFT+C copy shortcut', () => { + it('should copy on Linux when CTRL+SHIFT+C is pressed', async () => { + const mockHasSelection = vi.fn(() => true); + const mockGetSelection = vi.fn(() => 'selected text'); + + // Mock Linux platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'C', + ctrlKey: true, + shiftKey: true + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockClipboard.writeText).toHaveBeenCalledWith('selected text'); + }); + + it('should not trigger CTRL+SHIFT+C on Windows', async () => { + // Mock Windows platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => '') + }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'C', + ctrlKey: true, + shiftKey: true + }); + + if (keyEventHandler) { + keyEventHandler!(event); + } + }); + + // Should not copy on Windows + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + }); + + describe('Linux CTRL+SHIFT+V paste shortcut', () => { + it('should paste on Linux when CTRL+SHIFT+V is pressed', async () => { + const mockPaste = vi.fn(); + + // Mock Linux platform (navigator) + Object.defineProperty(navigator, 'platform', { + value: 'Linux', + writable: true + }); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'V', + ctrlKey: true, + shiftKey: true + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); + + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(mockClipboard.readText).toHaveBeenCalled(); + expect(mockPaste).toHaveBeenCalledWith('test clipboard content'); + }); + }); + + describe('Clipboard error handling', () => { + it('should handle clipboard write errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockHasSelection = vi.fn(() => true); + const mockGetSelection = vi.fn(() => 'selected text'); + + // Mock clipboard write failure + mockClipboard.writeText = vi.fn().mockRejectedValue(new Error('Clipboard write failed')); + + const { keyEventHandler } = await setupMockXterm({ + hasSelection: mockHasSelection, + getSelection: mockGetSelection + }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'c', + ctrlKey: true + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Should log error but not throw + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[useXterm] Failed to copy selection:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle clipboard read errors gracefully', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockPaste = vi.fn(); + + // Mock Windows platform to enable custom paste handler + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + writable: true + }); + + // Mock clipboard read failure + mockClipboard.readText = vi.fn().mockRejectedValue(new Error('Clipboard read failed')); + + const { keyEventHandler } = await setupMockXterm({ paste: mockPaste }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'v', + ctrlKey: true + }); + + keyEventHandler(event); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Should log error but not throw + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[useXterm] Failed to read clipboard:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Existing shortcuts preservation', () => { + it('should let SHIFT+Enter pass through', async () => { + const mockInput = vi.fn(); + + const { keyEventHandler } = await setupMockXterm({ input: mockInput }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'Enter', + shiftKey: true, + ctrlKey: false, + metaKey: false + }); + + if (keyEventHandler) { + keyEventHandler!(event); + } + }); + + // Should send ESC+newline for multi-line input + expect(mockInput).toHaveBeenCalledWith('\x1b\n'); + }); + + it('should let Ctrl+Backspace pass through', async () => { + const mockInput = vi.fn(); + + const { keyEventHandler } = await setupMockXterm({ input: mockInput }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { + key: 'Backspace', + ctrlKey: true, + metaKey: false + }); + + if (keyEventHandler) { + keyEventHandler!(event); + } + }); + + // Should send Ctrl+U for delete line + expect(mockInput).toHaveBeenCalledWith('\x15'); + }); + + it('should let Ctrl+1-9 pass through for project tab switching', async () => { + const { keyEventHandler } = await setupMockXterm(); + + // Test all number keys 1-9 + for (let i = 1; i <= 9; i++) { + act(() => { + const event = new KeyboardEvent('keydown', { + key: i.toString(), + ctrlKey: true + }); + + if (keyEventHandler) { + const handled = keyEventHandler!(event); + expect(handled).toBe(false); // Should bubble to window handler + } + }); + } + }); + + it('should let Ctrl+T and Ctrl+W pass through', async () => { + const { keyEventHandler } = await setupMockXterm(); + + // Test Ctrl+T + act(() => { + const event = new KeyboardEvent('keydown', { + key: 't', + ctrlKey: true + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); + }); + + // Test Ctrl+W + act(() => { + const event = new KeyboardEvent('keydown', { + key: 'w', + ctrlKey: true + }); + + const handled = keyEventHandler(event); + expect(handled).toBe(false); + }); + }); + }); + + describe('Event type checking', () => { + it('should only handle keydown events, not keyup', async () => { + const { keyEventHandler } = await setupMockXterm({ + hasSelection: vi.fn(() => true), + getSelection: vi.fn(() => 'selected text') + }); + + act(() => { + // Test keyup event (should be ignored) + const keyupEvent = new KeyboardEvent('keyup', { + key: 'c', + ctrlKey: true + }); + + keyEventHandler(keyupEvent); + }); + + // Clipboard should not be called for keyup events + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index 87c0a7cd..790bdfd4 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -5,6 +5,16 @@ import { WebLinksAddon } from '@xterm/addon-web-links'; import { SerializeAddon } from '@xterm/addon-serialize'; import { terminalBufferManager } from '../../lib/terminal-buffer-manager'; +// Type augmentation for navigator.userAgentData (modern User-Agent Client Hints API) +interface NavigatorUAData { + platform: string; +} +declare global { + interface Navigator { + userAgentData?: NavigatorUAData; + } +} + interface UseXtermOptions { terminalId: string; onCommandEnter?: (command: string) => void; @@ -80,6 +90,49 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea xterm.open(terminalRef.current); + // Platform detection for copy/paste shortcuts + // macOS uses system Cmd+V, no custom handler needed + const getPlatform = (): string => { + // Prefer navigator.userAgentData.platform (modern, non-deprecated) + if (navigator.userAgentData?.platform) { + return navigator.userAgentData.platform.toLowerCase(); + } + // Fallback to navigator.platform (deprecated but widely supported) + return navigator.platform.toLowerCase(); + }; + const platform = getPlatform(); + const isWindows = platform.includes('win'); + const isLinux = platform.includes('linux'); + + // Helper function to handle copy to clipboard + // Returns true if selection exists and copy was attempted, false if no selection + // Note: return value does not reflect actual clipboard write success/failure + const handleCopyToClipboard = (): boolean => { + if (xterm.hasSelection()) { + const selection = xterm.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection).catch((err) => { + console.error('[useXterm] Failed to copy selection:', err); + }); + return true; // Copy attempted (has selection) + } + } + return false; // No selection or nothing to copy + }; + + // Helper function to handle paste from clipboard + const handlePasteFromClipboard = (): void => { + navigator.clipboard.readText() + .then((text) => { + if (text) { + xterm.paste(text); + } + }) + .catch((err) => { + console.error('[useXterm] Failed to read clipboard:', err); + }); + }; + // Allow certain key combinations to bubble up to window-level handlers // This enables global shortcuts like Cmd/Ctrl+1-9 for project switching xterm.attachCustomKeyEventHandler((event) => { @@ -117,6 +170,45 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea return false; } + // Handle CTRL+SHIFT+C copy (Linux only - alternative to CTRL+C) + // NOTE: Check Linux-specific shortcuts BEFORE regular shortcuts to prevent unreachable code + const isLinuxCopyShortcut = event.ctrlKey && event.shiftKey && (event.key === 'C' || event.key === 'c') && event.type === 'keydown'; + if (isLinuxCopyShortcut && isLinux) { + if (handleCopyToClipboard()) { + return false; // Prevent xterm from handling (copy performed) + } + // No selection - consume event (CTRL+SHIFT+C won't send proper interrupt signal) + return false; + } + + // Handle CTRL+SHIFT+V paste (Linux only - alternative to CTRL+V) + const isLinuxPasteShortcut = event.ctrlKey && event.shiftKey && (event.key === 'V' || event.key === 'v') && event.type === 'keydown'; + if (isLinuxPasteShortcut && isLinux) { + event.preventDefault(); // Prevent browser's default paste behavior + handlePasteFromClipboard(); + return false; // Prevent xterm from sending literal ^V + } + + // Handle CMD/Ctrl+C - Smart copy (copy if text selected, send ^C if not) + // NOTE: Only trigger when shiftKey is NOT pressed (Linux CTRL+SHIFT+C handled above) + const isCopyShortcut = isMod && !event.shiftKey && (event.key === 'c' || event.key === 'C') && event.type === 'keydown'; + if (isCopyShortcut) { + if (handleCopyToClipboard()) { + return false; // Prevent xterm from handling (copy performed) + } + // No selection - let ^C pass through to terminal (sends interrupt signal) + return true; + } + + // Handle CTRL+V paste (Windows and Linux only) + // NOTE: Only trigger when shiftKey is NOT pressed (Linux CTRL+SHIFT+V handled above) + const isPasteShortcut = event.ctrlKey && !event.shiftKey && (event.key === 'v' || event.key === 'V') && event.type === 'keydown'; + if (isPasteShortcut && (isWindows || isLinux)) { + event.preventDefault(); // Prevent browser's default paste behavior + handlePasteFromClipboard(); + return false; // Prevent xterm from sending literal ^V + } + // Handle all other keys in xterm return true; });