feat: Add terminal copy/paste keyboard shortcuts for Windows/Linux (#786)
* feat: add terminal copy/paste keyboard shortcuts for Windows/Linux Implement smart copy/paste keyboard shortcuts in terminal emulator: - Smart CTRL+C: copies selected text or sends ^C interrupt if no selection - CTRL+V paste: pastes clipboard contents on Windows/Linux - Linux CTRL+SHIFT+C/V: alternative copy/paste shortcuts for Linux - Platform detection: correctly identifies Windows/Linux/macOS - Preserves all existing shortcuts (Ctrl+T, Ctrl+W, Ctrl+1-9, etc.) Implementation details: - Added platform detection constants (isMac, isWindows, isLinux) - Smart copy handler checks xterm.hasSelection() before copying - Uses xterm.paste() for proper encoding handling - Includes error handling for clipboard API failures - Handler ordering preserves all existing keyboard shortcuts Tests added: - Unit tests for keyboard event handlers (9/19 passing) - Integration tests for xterm.js + clipboard API - E2E tests for copy/paste flows (platform-specific) Fixes #38 - Terminal copy/paste not working on Windows 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve test failures for terminal copy/paste functionality - Fixed global XTerm mock setup to not interfere with test-specific mocks - Fixed 19 failing tests in useXterm.test.ts by adding proper DOM rendering - Fixed 7 failing tests in terminal-copy-paste.test.ts with same pattern - Added missing Mock type import for TypeScript compatibility Test Changes: - Replaced arrow functions with regular functions for mock constructors - Added ResizeObserver mock for browser API compatibility - Created wrapper components with proper DOM rendering - Used render() with act() instead of just renderHook() - Fixed type assertions (vi.Mock → Mock) All tests now pass (1297 passed, 6 skipped) Typecheck passes Lint passes (warnings only) * refactor: fix linting issues in terminal copy/paste test files E2E Test Changes (terminal-copy-paste.e2e.ts): - Removed unused imports (_android from Playwright, writeFileSync from fs) - Added global Navigator declaration for clipboard typing - Replaced (window as any) with typed navigator.clipboard calls - Removed dead helper functions (getCopyShortcutModifier, getPasteShortcutModifier) - Replaced relative Electron path with absolute path using __dirname - Renamed caught error 'e' to '_error' to satisfy lint rules Integration Test Changes (terminal-copy-paste.test.ts): - Removed unused renderHook import - Added process.platform restoration in afterEach cleanup - Fixed console error spy to safely coerce args[0] with String() Unit Test Changes (useXterm.test.ts): - Created reusable _createXTermMock factory function - Updated test to use TestWrapper pattern consistently - Added process.platform restoration in afterEach - Fixed test assertion (hasSelection: false) for Windows CTRL+SHIFT+C test All tests pass (1297 passed, 6 skipped) Typecheck passes Lint passes (warnings only) * fix: replace process.platform with navigator.platform for renderer compatibility Critical fix for runtime error: "process is not defined" in browser/renderer process. Core Changes (useXterm.ts): - Replaced process.platform (Node.js global) with navigator.platform (browser API) - Platform detection now uses: navigator.platform.toLowerCase() - isMac: navigatorPlatform.includes('mac') - isWindows: navigatorPlatform.includes('win') - isLinux: navigatorPlatform.includes('linux') Test Updates: - Integration tests: Updated to mock navigator.platform instead of process.platform - Added beforeEach/afterEach for proper cleanup - Removed redundant inline cleanup code - Added platform mocks where needed for Windows/Linux paste handler tests - Unit tests: Updated all process.platform references to navigator.platform - Changed originalPlatform to originalNavigatorPlatform - Updated afterEach to restore navigator.platform - Changed platform values: 'win32' → 'Win32', 'darwin' → 'MacIntel', 'linux' → 'Linux' - Removed unused _createXTermMock helper function E2E Test Improvements: - console.log → console.warn for clipboard accessibility message - Improved interrupt signal assertion: toMatch(/\^C|[$#>]\s*$/) All tests pass (1297 passed, 6 skipped) Typecheck passes Lint passes (warnings only) * fix: prevent double-paste by calling event.preventDefault() Fixed issue where pasted text appeared twice in the terminal. Root cause: When Ctrl+V was pressed: 1. Browser's default paste behavior was triggered 2. Our handler also called xterm.paste() Fix: Added event.preventDefault() to both paste handlers: - CTRL+V (Windows/Linux) - CTRL+SHIFT+V (Linux alternative) This prevents the browser's default paste behavior, ensuring only xterm.paste() handles the pasting operation once. Tests still pass (26 passed) * fix: resolve unreachable Linux handlers and improve test reliability Critical Fix (useXterm.ts): - Fixed unreachable CTRL+SHIFT+C/V handlers for Linux - Root cause: Regular CTRL+C/V handlers checked isMod && key, which matched even when SHIFT was pressed, preventing Linux-specific handlers from ever executing - Fix: Reordered checks to handle Linux shortcuts BEFORE regular shortcuts and added !event.shiftKey to regular copy/paste handlers E2E Test Improvements (terminal-copy-paste.e2e.ts): - Replaced fixed sleeps (waitForTimeout) with condition-based waits - Removed try/catch + test.skip anti-pattern, replaced with upfront precondition checks Unit Test Improvements (useXterm.test.ts): - Replaced trivial platform detection tests with comprehensive behavior tests - Added 4 new tests verifying platform-specific keyboard handling Test Results: 1298 passed, 6 skipped * refactor: extract XTerm mock setup into helper function Extract repeated XTerm mock setup code into a reusable setupMockXterm() helper function. This reduces test boilerplate from ~100 lines to ~20 lines per test while maintaining identical test coverage and behavior. Changes: - Added setupMockXterm() helper function that handles all mock initialization - Refactored all 20+ tests in useXterm.test.ts to use the helper - Significantly improved code readability and maintainability * fix(e2e): replace invalid toMatch() with toContainText() in terminal test Replace invalid Playwright locator assertion `toMatch()` with valid `toContainText()` assertion. The `toMatch()` method does not exist for Playwright locators; `toContainText()` is the correct matcher for checking text content with regex patterns. * refactor: extract copy/paste helpers and fix CTRL+SHIFT+C behavior Address PR review feedback: 1. [MEDIUM] Extract copy/paste helper functions - Added handleCopyToClipboard() helper to eliminate duplicate copy logic - Added handlePasteFromClipboard() helper to eliminate duplicate paste logic - Both handlers now use shared helper functions 2. [MEDIUM] Fix CTRL+SHIFT+C without selection on Linux - Changed from returning true (let event pass through) to returning false (consume event) - CTRL+SHIFT+C won't send proper interrupt signal, so consuming is correct behavior 3. [LOW] Add comment for isMac variable - Added comment explaining isMac is declared for documentation purposes Related: #038-terminal-copy-paste-is-not-working-on-windows * refactor: remove unused isMac variable Remove the unused isMac variable since it's not referenced in any conditional logic. The code already excludes macOS by only enabling custom paste handlers for Windows and Linux (isWindows || isLinux). Related: #038-terminal-copy-paste-is-not-working-on-windows * fix(e2e): remove non-existent electron.executablePath() API call Remove the executablePath parameter from electron.launch() to match the pattern used in other E2E tests (flows.e2e.ts, electron-helper.ts). * refactor(terminal): fix platform detection and clarify comments - Replace deprecated navigator.platform with navigator.userAgentData.platform with fallback to navigator.platform for older browsers - Add TypeScript type augmentation for NavigatorUAData interface - Fix misleading comment in handleCopyToClipboard to clarify return value semantics (true = copy attempted, false = no selection) - Add requestAnimationFrame mock to useXterm test for jsdom environment Fixes review findings for terminal copy/paste feature. --------- Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -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<string>;
|
||||
writeText(text: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
input?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
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<typeof vi.fn>;
|
||||
readText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user