fix(copyCommand): provide friendlier error messages for /copy command (#6723)

Co-authored-by: Ben Guo <hundunben@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Ben Guo
2025-08-23 00:58:35 +08:00
committed by GitHub
parent c4a788b7b2
commit 9c1490e985
5 changed files with 114 additions and 16 deletions

View File

@@ -227,7 +227,7 @@ describe('copyCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
});
});
@@ -242,14 +242,15 @@ describe('copyCommand', () => {
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
mockCopyToClipboard.mockRejectedValue('String error');
const rejectedValue = 'String error';
mockCopyToClipboard.mockRejectedValue(rejectedValue);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${rejectedValue}`,
});
});

View File

@@ -53,7 +53,7 @@ export const copyCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${message}`,
};
}
} else {

View File

@@ -224,7 +224,9 @@ describe('commandUtils', () => {
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip not found');
const error = new Error('spawn xclip ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
callCount++;
} else {
@@ -253,9 +255,12 @@ describe('commandUtils', () => {
);
});
it('should throw error when both xclip and xsel fail', async () => {
it('should throw error when both xclip and xsel are not found', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
mockSpawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
@@ -264,29 +269,99 @@ describe('commandUtils', () => {
end: vi.fn(),
}),
stderr: new EventEmitter(),
});
}) as MockChildProcess;
setTimeout(() => {
if (callCount === 0) {
// First call (xclip) fails
child.stderr.emit('data', 'xclip command not found');
// First call (xclip) fails with ENOENT
const error = new Error('spawn xclip ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
callCount++;
} else {
// Second call (xsel) fails
child.stderr.emit('data', 'xsel command not found');
// Second call (xsel) fails with ENOENT
const error = new Error('spawn xsel ENOENT');
(error as NodeJS.ErrnoException).code = 'ENOENT';
child.emit('error', error);
child.emit('close', 1);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
await expect(copyToClipboard(testText)).rejects.toThrow(
/All copy commands failed/,
'Please ensure xclip or xsel is installed and configured.',
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
it('should emit error when xclip or xsel fail with stderr output (command installed)', async () => {
const testText = 'Hello, world!';
let callCount = 0;
const linuxOptions: SpawnOptions = {
stdio: ['pipe', 'inherit', 'pipe'],
};
const errorMsg = "Error: Can't open display:";
const exitCode = 1;
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(() => {
// e.g., cannot connect to X server
if (callCount === 0) {
child.stderr.emit('data', errorMsg);
child.emit('close', exitCode);
callCount++;
} else {
child.stderr.emit('data', errorMsg);
child.emit('close', exitCode);
}
}, 0);
return child as unknown as ReturnType<typeof spawn>;
});
const xclipErrorMsg = `'xclip' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
const xselErrorMsg = `'xsel' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
await expect(copyToClipboard(testText)).rejects.toThrow(
`All copy commands failed. "${xclipErrorMsg}", "${xselErrorMsg}". `,
);
expect(mockSpawn).toHaveBeenCalledTimes(2);
expect(mockSpawn).toHaveBeenNthCalledWith(
1,
'xclip',
['-selection', 'clipboard'],
linuxOptions,
);
expect(mockSpawn).toHaveBeenNthCalledWith(
2,
'xsel',
['--clipboard', '--input'],
linuxOptions,
);
});
});

View File

@@ -74,16 +74,35 @@ export const copyToClipboard = async (text: string): Promise<void> => {
// If xclip fails for any reason, try xsel as a fallback.
await run('xsel', ['--clipboard', '--input'], linuxOptions);
} catch (fallbackError) {
const primaryMsg =
const xclipNotFound =
primaryError instanceof Error &&
(primaryError as NodeJS.ErrnoException).code === 'ENOENT';
const xselNotFound =
fallbackError instanceof Error &&
(fallbackError as NodeJS.ErrnoException).code === 'ENOENT';
if (xclipNotFound && xselNotFound) {
throw new Error(
'Please ensure xclip or xsel is installed and configured.',
);
}
let primaryMsg =
primaryError instanceof Error
? primaryError.message
: String(primaryError);
const fallbackMsg =
if (xclipNotFound) {
primaryMsg = `xclip not found`;
}
let fallbackMsg =
fallbackError instanceof Error
? fallbackError.message
: String(fallbackError);
if (xselNotFound) {
fallbackMsg = `xsel not found`;
}
throw new Error(
`All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
`All copy commands failed. "${primaryMsg}", "${fallbackMsg}". `,
);
}
}