feat: Added /copy command for copying output to clipboard with new Command Service approach (#3706)

Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
Devansh Sharma
2025-07-20 20:57:41 +02:00
committed by GitHub
parent a01b1219a3
commit 8f85ac7de0
7 changed files with 771 additions and 1 deletions

View File

@@ -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@domain.com')).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);
});
});
});
});

View File

@@ -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}`);
}
};