mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
Fix drag and drop escaping (#18965)
This commit is contained in:
committed by
GitHub
parent
00f73b73bc
commit
d82f66973f
@@ -62,15 +62,25 @@ import { spawnAsync } from '@google/gemini-cli-core';
|
||||
// Keep static imports for stateless functions
|
||||
import {
|
||||
cleanupOldClipboardImages,
|
||||
splitEscapedPaths,
|
||||
splitDragAndDropPaths,
|
||||
parsePastedPaths,
|
||||
} from './clipboardUtils.js';
|
||||
|
||||
const mockPlatform = (platform: string) => {
|
||||
vi.stubGlobal(
|
||||
'process',
|
||||
Object.create(process, {
|
||||
platform: {
|
||||
get: () => platform,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Define the type for the module to use in tests
|
||||
type ClipboardUtilsModule = typeof import('./clipboardUtils.js');
|
||||
|
||||
describe('clipboardUtils', () => {
|
||||
let originalPlatform: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
// Dynamic module instance for stateful functions
|
||||
let clipboardUtils: ClipboardUtilsModule;
|
||||
@@ -83,7 +93,6 @@ describe('clipboardUtils', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
originalPlatform = process.platform;
|
||||
originalEnv = process.env;
|
||||
process.env = { ...originalEnv };
|
||||
|
||||
@@ -94,22 +103,13 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const setPlatform = (platform: string) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
};
|
||||
|
||||
describe('clipboardHasImage (Linux)', () => {
|
||||
it('should return true when wl-paste shows image type (Wayland)', async () => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
@@ -128,7 +128,7 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
|
||||
it('should return true when xclip shows image type (X11)', async () => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
process.env['XDG_SESSION_TYPE'] = 'x11';
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
@@ -153,7 +153,7 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
|
||||
it('should return false if tool fails', async () => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||
vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('wl-paste failed'));
|
||||
@@ -164,7 +164,7 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
|
||||
it('should return false if no image type is found', async () => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||
@@ -178,7 +178,7 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
|
||||
it('should return false if tool not found', async () => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
@@ -195,7 +195,7 @@ describe('clipboardUtils', () => {
|
||||
const mockTempDir = path.join('/tmp/global', 'images');
|
||||
|
||||
beforeEach(() => {
|
||||
setPlatform('linux');
|
||||
mockPlatform('linux');
|
||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -363,65 +363,86 @@ describe('clipboardUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitEscapedPaths', () => {
|
||||
it('should return single path when no spaces', () => {
|
||||
expect(splitEscapedPaths('/path/to/image.png')).toEqual([
|
||||
'/path/to/image.png',
|
||||
]);
|
||||
describe('splitDragAndDropPaths', () => {
|
||||
describe('in posix', () => {
|
||||
beforeEach(() => mockPlatform('linux'));
|
||||
|
||||
it.each([
|
||||
['empty string', '', []],
|
||||
['single path no spaces', '/path/to/image.png', ['/path/to/image.png']],
|
||||
[
|
||||
'simple space-separated paths',
|
||||
'/img1.png /img2.png',
|
||||
['/img1.png', '/img2.png'],
|
||||
],
|
||||
[
|
||||
'three paths',
|
||||
'/a.png /b.jpg /c.heic',
|
||||
['/a.png', '/b.jpg', '/c.heic'],
|
||||
],
|
||||
['escaped spaces', '/my\\ image.png', ['/my image.png']],
|
||||
[
|
||||
'multiple paths with escaped spaces',
|
||||
'/my\\ img1.png /my\\ img2.png',
|
||||
['/my img1.png', '/my img2.png'],
|
||||
],
|
||||
[
|
||||
'multiple escaped spaces',
|
||||
'/path/to/my\\ cool\\ image.png',
|
||||
['/path/to/my cool image.png'],
|
||||
],
|
||||
[
|
||||
'consecutive spaces',
|
||||
'/img1.png /img2.png',
|
||||
['/img1.png', '/img2.png'],
|
||||
],
|
||||
[
|
||||
'trailing/leading whitespace',
|
||||
' /img1.png /img2.png ',
|
||||
['/img1.png', '/img2.png'],
|
||||
],
|
||||
['whitespace only', ' ', []],
|
||||
['quoted path with spaces', '"/my image.png"', ['/my image.png']],
|
||||
[
|
||||
'mixed quoted and unquoted',
|
||||
'"/my img1.png" /my\\ img2.png',
|
||||
['/my img1.png', '/my img2.png'],
|
||||
],
|
||||
[
|
||||
'quoted with escaped quotes',
|
||||
"'/derp/my '\\''cool'\\'' image.png'",
|
||||
["/derp/my 'cool' image.png"],
|
||||
],
|
||||
])('should escape %s', (_, input, expected) => {
|
||||
expect([...splitDragAndDropPaths(input)]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should split simple space-separated paths', () => {
|
||||
expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([
|
||||
'/img1.png',
|
||||
'/img2.png',
|
||||
]);
|
||||
});
|
||||
describe('in windows', () => {
|
||||
beforeEach(() => mockPlatform('win32'));
|
||||
|
||||
it('should split three paths', () => {
|
||||
expect(splitEscapedPaths('/a.png /b.jpg /c.heic')).toEqual([
|
||||
'/a.png',
|
||||
'/b.jpg',
|
||||
'/c.heic',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve escaped spaces within filenames', () => {
|
||||
expect(splitEscapedPaths('/my\\ image.png')).toEqual(['/my\\ image.png']);
|
||||
});
|
||||
|
||||
it('should handle multiple paths with escaped spaces', () => {
|
||||
expect(splitEscapedPaths('/my\\ img1.png /my\\ img2.png')).toEqual([
|
||||
'/my\\ img1.png',
|
||||
'/my\\ img2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle path with multiple escaped spaces', () => {
|
||||
expect(splitEscapedPaths('/path/to/my\\ cool\\ image.png')).toEqual([
|
||||
'/path/to/my\\ cool\\ image.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive spaces between paths', () => {
|
||||
expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([
|
||||
'/img1.png',
|
||||
'/img2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle trailing and leading whitespace', () => {
|
||||
expect(splitEscapedPaths(' /img1.png /img2.png ')).toEqual([
|
||||
'/img1.png',
|
||||
'/img2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
expect(splitEscapedPaths('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for whitespace only', () => {
|
||||
expect(splitEscapedPaths(' ')).toEqual([]);
|
||||
it.each([
|
||||
['double quoted path', '"C:\\my image.png"', ['C:\\my image.png']],
|
||||
[
|
||||
'multiple double quoted paths',
|
||||
'"C:\\img 1.png" "D:\\img 2.png"',
|
||||
['C:\\img 1.png', 'D:\\img 2.png'],
|
||||
],
|
||||
['unquoted path', 'C:\\img.png', ['C:\\img.png']],
|
||||
[
|
||||
'mixed quoted and unquoted',
|
||||
'"C:\\img 1.png" D:\\img2.png',
|
||||
['C:\\img 1.png', 'D:\\img2.png'],
|
||||
],
|
||||
['single quoted path', "'C:\\my image.png'", ['C:\\my image.png']],
|
||||
[
|
||||
'mixed single and double quoted',
|
||||
'"C:\\img 1.png" \'D:\\img 2.png\'',
|
||||
['C:\\img 1.png', 'D:\\img 2.png'],
|
||||
],
|
||||
])('should split %s', (_, input, expected) => {
|
||||
expect([...splitDragAndDropPaths(input)]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -455,14 +476,14 @@ describe('clipboardUtils', () => {
|
||||
expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt ');
|
||||
});
|
||||
|
||||
it('should only add @ prefix to valid paths', () => {
|
||||
it('should return null if any path is invalid', () => {
|
||||
vi.mocked(existsSync).mockImplementation((p) =>
|
||||
(p as string).endsWith('.txt'),
|
||||
);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('/valid/file.txt /invalid/file.jpg');
|
||||
expect(result).toBe('@/valid/file.txt /invalid/file.jpg ');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should return null if no paths are valid', () => {
|
||||
@@ -471,76 +492,110 @@ describe('clipboardUtils', () => {
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle paths with escaped spaces', () => {
|
||||
const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']);
|
||||
vi.mocked(existsSync).mockImplementation((p) =>
|
||||
validPaths.has(p as string),
|
||||
);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('/path/to/my\\ file.txt /other/path.txt');
|
||||
expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt ');
|
||||
});
|
||||
|
||||
it('should unescape paths before validation', () => {
|
||||
const validPaths = new Set(['/my file.txt', '/other.txt']);
|
||||
const validatedPaths: string[] = [];
|
||||
vi.mocked(existsSync).mockImplementation((p) => {
|
||||
validatedPaths.push(p as string);
|
||||
return validPaths.has(p as string);
|
||||
describe('in posix', () => {
|
||||
beforeEach(() => {
|
||||
mockPlatform('linux');
|
||||
});
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
parsePastedPaths('/my\\ file.txt /other.txt');
|
||||
// First checks entire string, then individual unescaped segments
|
||||
expect(validatedPaths).toEqual([
|
||||
'/my\\ file.txt /other.txt',
|
||||
'/my file.txt',
|
||||
'/other.txt',
|
||||
]);
|
||||
it('should handle paths with escaped spaces', () => {
|
||||
const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']);
|
||||
vi.mocked(existsSync).mockImplementation((p) =>
|
||||
validPaths.has(p as string),
|
||||
);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths(
|
||||
'/path/to/my\\ file.txt /other/path.txt',
|
||||
);
|
||||
expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt ');
|
||||
});
|
||||
|
||||
it('should unescape paths before validation', () => {
|
||||
const validPaths = new Set(['/my file.txt', '/other.txt']);
|
||||
const validatedPaths: string[] = [];
|
||||
vi.mocked(existsSync).mockImplementation((p) => {
|
||||
validatedPaths.push(p as string);
|
||||
return validPaths.has(p as string);
|
||||
});
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
parsePastedPaths('/my\\ file.txt /other.txt');
|
||||
// First checks entire string, then individual unescaped segments
|
||||
expect(validatedPaths).toEqual([
|
||||
'/my\\ file.txt /other.txt',
|
||||
'/my file.txt',
|
||||
'/other.txt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle single path with unescaped spaces from copy-paste', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('/path/to/my file.txt');
|
||||
expect(result).toBe('@/path/to/my\\ file.txt ');
|
||||
});
|
||||
|
||||
it('should handle single-quoted with escaped quote', () => {
|
||||
const validPaths = new Set([
|
||||
"/usr/test/my file with 'single quotes'.txt",
|
||||
]);
|
||||
const validatedPaths: string[] = [];
|
||||
vi.mocked(existsSync).mockImplementation((p) => {
|
||||
validatedPaths.push(p as string);
|
||||
return validPaths.has(p as string);
|
||||
});
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths(
|
||||
"'/usr/test/my file with '\\''single quotes'\\''.txt'",
|
||||
);
|
||||
expect(result).toBe(
|
||||
"@/usr/test/my\\ file\\ with\\ \\'single\\ quotes\\'.txt ",
|
||||
);
|
||||
|
||||
expect(validatedPaths).toEqual([
|
||||
"/usr/test/my file with 'single quotes'.txt",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single path with unescaped spaces from copy-paste', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
describe('in windows', () => {
|
||||
beforeEach(() => mockPlatform('win32'));
|
||||
|
||||
const result = parsePastedPaths('/path/to/my file.txt');
|
||||
expect(result).toBe('@/path/to/my\\ file.txt ');
|
||||
});
|
||||
it('should handle Windows path', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
it('should handle Windows path', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
const result = parsePastedPaths('C:\\Users\\file.txt');
|
||||
expect(result).toBe('@C:\\Users\\file.txt ');
|
||||
});
|
||||
|
||||
const result = parsePastedPaths('C:\\Users\\file.txt');
|
||||
expect(result).toBe('@C:\\Users\\file.txt ');
|
||||
});
|
||||
it('should handle Windows path with unescaped spaces', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
it('should handle Windows path with unescaped spaces', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
const result = parsePastedPaths('C:\\My Documents\\file.txt');
|
||||
expect(result).toBe('@"C:\\My Documents\\file.txt" ');
|
||||
});
|
||||
it('should handle multiple Windows paths', () => {
|
||||
const validPaths = new Set(['C:\\file1.txt', 'D:\\file2.txt']);
|
||||
vi.mocked(existsSync).mockImplementation((p) =>
|
||||
validPaths.has(p as string),
|
||||
);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('C:\\My Documents\\file.txt');
|
||||
expect(result).toBe('@C:\\My\\ Documents\\file.txt ');
|
||||
});
|
||||
const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt');
|
||||
expect(result).toBe('@C:\\file1.txt @D:\\file2.txt ');
|
||||
});
|
||||
|
||||
it('should handle multiple Windows paths', () => {
|
||||
const validPaths = new Set(['C:\\file1.txt', 'D:\\file2.txt']);
|
||||
vi.mocked(existsSync).mockImplementation((p) =>
|
||||
validPaths.has(p as string),
|
||||
);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
it('should handle Windows UNC path', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt');
|
||||
expect(result).toBe('@C:\\file1.txt @D:\\file2.txt ');
|
||||
});
|
||||
|
||||
it('should handle Windows UNC path', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||
|
||||
const result = parsePastedPaths('\\\\server\\share\\file.txt');
|
||||
expect(result).toBe('@\\\\server\\share\\file.txt ');
|
||||
const result = parsePastedPaths('\\\\server\\share\\file.txt');
|
||||
expect(result).toBe('@\\\\server\\share\\file.txt ');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user