diff options
Diffstat (limited to 'packages/cli/src/ui/utils')
| -rw-r--r-- | packages/cli/src/ui/utils/commandUtils.test.ts | 345 | ||||
| -rw-r--r-- | packages/cli/src/ui/utils/commandUtils.ts | 56 |
2 files changed, 401 insertions, 0 deletions
diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts new file mode 100644 index 00000000..158a5f7a --- /dev/null +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -0,0 +1,345 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock } from 'vitest'; +import { spawn } from 'child_process'; +import { EventEmitter } from 'events'; +import { + isAtCommand, + isSlashCommand, + copyToClipboard, +} from './commandUtils.js'; + +// Mock child_process +vi.mock('child_process'); + +// Mock process.platform for platform-specific tests +const mockProcess = vi.hoisted(() => ({ + platform: 'darwin', +})); + +vi.stubGlobal('process', { + ...process, + get platform() { + return mockProcess.platform; + }, +}); + +interface MockChildProcess extends EventEmitter { + stdin: EventEmitter & { + write: Mock; + end: Mock; + }; + stderr: EventEmitter; +} + +describe('commandUtils', () => { + let mockSpawn: Mock; + let mockChild: MockChildProcess; + + beforeEach(async () => { + vi.clearAllMocks(); + // Dynamically import and set up spawn mock + const { spawn } = await import('child_process'); + mockSpawn = spawn as Mock; + + // Create mock child process with stdout/stderr emitters + mockChild = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }) as MockChildProcess; + + mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>); + }); + + describe('isAtCommand', () => { + it('should return true when query starts with @', () => { + expect(isAtCommand('@file')).toBe(true); + expect(isAtCommand('@path/to/file')).toBe(true); + expect(isAtCommand('@')).toBe(true); + }); + + it('should return true when query contains @ preceded by whitespace', () => { + expect(isAtCommand('hello @file')).toBe(true); + expect(isAtCommand('some text @path/to/file')).toBe(true); + expect(isAtCommand(' @file')).toBe(true); + }); + + it('should return false when query does not start with @ and has no spaced @', () => { + expect(isAtCommand('file')).toBe(false); + expect(isAtCommand('hello')).toBe(false); + expect(isAtCommand('')).toBe(false); + expect(isAtCommand('[email protected]')).toBe(false); + expect(isAtCommand('user@host')).toBe(false); + }); + + it('should return false when @ is not preceded by whitespace', () => { + expect(isAtCommand('hello@file')).toBe(false); + expect(isAtCommand('text@path')).toBe(false); + }); + }); + + describe('isSlashCommand', () => { + it('should return true when query starts with /', () => { + expect(isSlashCommand('/help')).toBe(true); + expect(isSlashCommand('/memory show')).toBe(true); + expect(isSlashCommand('/clear')).toBe(true); + expect(isSlashCommand('/')).toBe(true); + }); + + it('should return false when query does not start with /', () => { + expect(isSlashCommand('help')).toBe(false); + expect(isSlashCommand('memory show')).toBe(false); + expect(isSlashCommand('')).toBe(false); + expect(isSlashCommand('path/to/file')).toBe(false); + expect(isSlashCommand(' /help')).toBe(false); + }); + }); + + describe('copyToClipboard', () => { + describe('on macOS (darwin)', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + + it('should successfully copy text to clipboard using pbcopy', async () => { + const testText = 'Hello, world!'; + + // Simulate successful execution + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('pbcopy', []); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + + it('should handle pbcopy command failure', async () => { + const testText = 'Hello, world!'; + + // Simulate command failure + setTimeout(() => { + mockChild.stderr.emit('data', 'Command not found'); + mockChild.emit('close', 1); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow( + "'pbcopy' exited with code 1: Command not found", + ); + }); + + it('should handle spawn error', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('error', new Error('spawn error')); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow('spawn error'); + }); + + it('should handle stdin write error', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.stdin.emit('error', new Error('stdin error')); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow('stdin error'); + }); + }); + + describe('on Windows (win32)', () => { + beforeEach(() => { + mockProcess.platform = 'win32'; + }); + + it('should successfully copy text to clipboard using clip', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('clip', []); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + }); + + describe('on Linux', () => { + beforeEach(() => { + mockProcess.platform = 'linux'; + }); + + it('should successfully copy text to clipboard using xclip', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledWith('xclip', [ + '-selection', + 'clipboard', + ]); + expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); + expect(mockChild.stdin.end).toHaveBeenCalled(); + }); + + it('should fallback to xsel when xclip fails', async () => { + const testText = 'Hello, world!'; + let callCount = 0; + + mockSpawn.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }) as MockChildProcess; + + setTimeout(() => { + if (callCount === 0) { + // First call (xclip) fails + child.stderr.emit('data', 'xclip not found'); + child.emit('close', 1); + callCount++; + } else { + // Second call (xsel) succeeds + child.emit('close', 0); + } + }, 0); + + return child as unknown as ReturnType<typeof spawn>; + }); + + await copyToClipboard(testText); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [ + '-selection', + 'clipboard', + ]); + expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [ + '--clipboard', + '--input', + ]); + }); + + it('should throw error when both xclip and xsel fail', async () => { + const testText = 'Hello, world!'; + let callCount = 0; + + mockSpawn.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + }), + stderr: new EventEmitter(), + }); + + setTimeout(() => { + if (callCount === 0) { + // First call (xclip) fails + child.stderr.emit('data', 'xclip command not found'); + child.emit('close', 1); + callCount++; + } else { + // Second call (xsel) fails + child.stderr.emit('data', 'xsel command not found'); + child.emit('close', 1); + } + }, 0); + + return child as unknown as ReturnType<typeof spawn>; + }); + + await expect(copyToClipboard(testText)).rejects.toThrow( + /All copy commands failed/, + ); + + expect(mockSpawn).toHaveBeenCalledTimes(2); + }); + }); + + describe('on unsupported platform', () => { + beforeEach(() => { + mockProcess.platform = 'unsupported'; + }); + + it('should throw error for unsupported platform', async () => { + await expect(copyToClipboard('test')).rejects.toThrow( + 'Unsupported platform: unsupported', + ); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + + it('should handle command exit without stderr', async () => { + const testText = 'Hello, world!'; + + setTimeout(() => { + mockChild.emit('close', 1); + }, 0); + + await expect(copyToClipboard(testText)).rejects.toThrow( + "'pbcopy' exited with code 1", + ); + }); + + it('should handle empty text', async () => { + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(''); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(''); + }); + + it('should handle multiline text', async () => { + const multilineText = 'Line 1\nLine 2\nLine 3'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(multilineText); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(multilineText); + }); + + it('should handle special characters', async () => { + const specialText = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'; + + setTimeout(() => { + mockChild.emit('close', 0); + }, 0); + + await copyToClipboard(specialText); + + expect(mockChild.stdin.write).toHaveBeenCalledWith(specialText); + }); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index aadd035e..4280388f 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { spawn } from 'child_process'; + /** * Checks if a query string potentially represents an '@' command. * It triggers if the query starts with '@' or contains '@' preceded by whitespace @@ -24,3 +26,57 @@ export const isAtCommand = (query: string): boolean => * @returns True if the query looks like an '/' command, false otherwise. */ export const isSlashCommand = (query: string): boolean => query.startsWith('/'); + +//Copies a string snippet to the clipboard for different platforms +export const copyToClipboard = async (text: string): Promise<void> => { + const run = (cmd: string, args: string[]) => + new Promise<void>((resolve, reject) => { + const child = spawn(cmd, args); + let stderr = ''; + child.stderr.on('data', (chunk) => (stderr += chunk.toString())); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) return resolve(); + const errorMsg = stderr.trim(); + reject( + new Error( + `'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`, + ), + ); + }); + child.stdin.on('error', reject); + child.stdin.write(text); + child.stdin.end(); + }); + + switch (process.platform) { + case 'win32': + return run('clip', []); + case 'darwin': + return run('pbcopy', []); + case 'linux': + try { + await run('xclip', ['-selection', 'clipboard']); + } catch (primaryError) { + try { + // If xclip fails for any reason, try xsel as a fallback. + await run('xsel', ['--clipboard', '--input']); + } catch (fallbackError) { + const primaryMsg = + primaryError instanceof Error + ? primaryError.message + : String(primaryError); + const fallbackMsg = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + throw new Error( + `All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`, + ); + } + } + return; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +}; |
