/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach, afterEach, type Mock, } from 'vitest'; import * as fs from 'node:fs/promises'; import { createWriteStream, existsSync, statSync, type Stats, type WriteStream, } from 'node:fs'; import { spawn, execSync, type ChildProcess } from 'node:child_process'; import EventEmitter from 'node:events'; import { Stream } from 'node:stream'; import * as path from 'node:path'; // Mock dependencies BEFORE imports vi.mock('node:fs/promises'); vi.mock('node:fs', () => ({ createWriteStream: vi.fn(), existsSync: vi.fn(), statSync: vi.fn(), })); vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawn: vi.fn(), execSync: vi.fn(), }; }); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, spawnAsync: vi.fn(), debugLogger: { debug: vi.fn(), warn: vi.fn(), }, Storage: class { getProjectTempDir = vi.fn(() => '/tmp/global'); initialize = vi.fn(() => Promise.resolve(undefined)); }, }; }); import { spawnAsync } from '@google/gemini-cli-core'; // Keep static imports for stateless functions import { cleanupOldClipboardImages, splitEscapedPaths, parsePastedPaths, } from './clipboardUtils.js'; // 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; const MOCK_FILE_STATS = { isFile: () => true, size: 100, mtimeMs: Date.now(), } as unknown as Stats; beforeEach(async () => { vi.resetAllMocks(); originalPlatform = process.platform; originalEnv = process.env; process.env = { ...originalEnv }; // Reset modules to clear internal state (linuxClipboardTool variable) vi.resetModules(); // Dynamically import the module to get a fresh instance for each test clipboardUtils = await import('./clipboardUtils.js'); }); afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, }); process.env = originalEnv; 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'); process.env['XDG_SESSION_TYPE'] = 'wayland'; vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: 'image/png\ntext/plain', stderr: '', }); const result = await clipboardUtils.clipboardHasImage(); expect(result).toBe(true); expect(execSync).toHaveBeenCalledWith( expect.stringContaining('wl-paste'), expect.anything(), ); expect(spawnAsync).toHaveBeenCalledWith('wl-paste', ['--list-types']); }); it('should return true when xclip shows image type (X11)', async () => { setPlatform('linux'); process.env['XDG_SESSION_TYPE'] = 'x11'; vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: 'image/png\nTARGETS', stderr: '', }); const result = await clipboardUtils.clipboardHasImage(); expect(result).toBe(true); expect(execSync).toHaveBeenCalledWith( expect.stringContaining('xclip'), expect.anything(), ); expect(spawnAsync).toHaveBeenCalledWith('xclip', [ '-selection', 'clipboard', '-t', 'TARGETS', '-o', ]); }); it('should return false if tool fails', async () => { setPlatform('linux'); process.env['XDG_SESSION_TYPE'] = 'wayland'; vi.mocked(execSync).mockReturnValue(Buffer.from('')); vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('wl-paste failed')); const result = await clipboardUtils.clipboardHasImage(); expect(result).toBe(false); }); it('should return false if no image type is found', async () => { setPlatform('linux'); process.env['XDG_SESSION_TYPE'] = 'wayland'; vi.mocked(execSync).mockReturnValue(Buffer.from('')); vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: 'text/plain', stderr: '', }); const result = await clipboardUtils.clipboardHasImage(); expect(result).toBe(false); }); it('should return false if tool not found', async () => { setPlatform('linux'); process.env['XDG_SESSION_TYPE'] = 'wayland'; vi.mocked(execSync).mockImplementation(() => { throw new Error('Command not found'); }); const result = await clipboardUtils.clipboardHasImage(); expect(result).toBe(false); }); }); describe('saveClipboardImage (Linux)', () => { const mockTargetDir = '/tmp/target'; const mockTempDir = path.join('/tmp/global', 'images'); beforeEach(() => { setPlatform('linux'); vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.unlink).mockResolvedValue(undefined); }); const createMockChildProcess = ( shouldSucceed: boolean, exitCode: number = 0, ) => { const child = new EventEmitter() as EventEmitter & { stdout: Stream & { pipe: Mock }; }; child.stdout = new Stream() as Stream & { pipe: Mock }; // Dummy stream child.stdout.pipe = vi.fn(); // Simulate process execution setTimeout(() => { if (!shouldSucceed) { child.emit('error', new Error('Spawn failed')); } else { child.emit('close', exitCode); } }, 10); return child; }; // Helper to prime the internal linuxClipboardTool state const primeClipboardTool = async ( type: 'wayland' | 'x11', hasImage = true, ) => { process.env['XDG_SESSION_TYPE'] = type; vi.mocked(execSync).mockReturnValue(Buffer.from('')); vi.mocked(spawnAsync).mockResolvedValueOnce({ stdout: hasImage ? 'image/png' : 'text/plain', stderr: '', }); await clipboardUtils.clipboardHasImage(); vi.mocked(spawnAsync).mockClear(); vi.mocked(execSync).mockClear(); }; it('should save image using wl-paste if detected', async () => { await primeClipboardTool('wayland'); // Mock fs.stat to return size > 0 vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); // Mock spawn to return a successful process for wl-paste const mockChild = createMockChildProcess(true, 0); vi.mocked(spawn).mockReturnValueOnce( mockChild as unknown as ChildProcess, ); // Mock createWriteStream const mockStream = new EventEmitter() as EventEmitter & { writableFinished: boolean; }; mockStream.writableFinished = false; vi.mocked(createWriteStream).mockReturnValue( mockStream as unknown as WriteStream, ); // Use dynamic instance const promise = clipboardUtils.saveClipboardImage(mockTargetDir); // Simulate stream finishing successfully BEFORE process closes mockStream.writableFinished = true; mockStream.emit('finish'); const result = await promise; expect(result).toContain(mockTempDir); expect(result).toMatch(/clipboard-\d+\.png$/); expect(spawn).toHaveBeenCalledWith('wl-paste', expect.any(Array)); expect(fs.mkdir).toHaveBeenCalledWith(mockTempDir, { recursive: true }); }); it('should return null if wl-paste fails', async () => { await primeClipboardTool('wayland'); // Mock fs.stat to return size > 0 vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); // wl-paste fails (non-zero exit code) const child1 = createMockChildProcess(true, 1); vi.mocked(spawn).mockReturnValueOnce(child1 as unknown as ChildProcess); const mockStream1 = new EventEmitter() as EventEmitter & { writableFinished: boolean; }; vi.mocked(createWriteStream).mockReturnValueOnce( mockStream1 as unknown as WriteStream, ); const promise = clipboardUtils.saveClipboardImage(mockTargetDir); mockStream1.writableFinished = true; mockStream1.emit('finish'); const result = await promise; expect(result).toBe(null); // Should NOT try xclip expect(spawn).toHaveBeenCalledTimes(1); }); it('should save image using xclip if detected', async () => { await primeClipboardTool('x11'); // Mock fs.stat to return size > 0 vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS); // Mock spawn to return a successful process for xclip const mockChild = createMockChildProcess(true, 0); vi.mocked(spawn).mockReturnValueOnce( mockChild as unknown as ChildProcess, ); // Mock createWriteStream const mockStream = new EventEmitter() as EventEmitter & { writableFinished: boolean; }; mockStream.writableFinished = false; vi.mocked(createWriteStream).mockReturnValue( mockStream as unknown as WriteStream, ); const promise = clipboardUtils.saveClipboardImage(mockTargetDir); mockStream.writableFinished = true; mockStream.emit('finish'); const result = await promise; expect(result).toMatch(/clipboard-\d+\.png$/); expect(spawn).toHaveBeenCalledWith('xclip', expect.any(Array)); }); it('should return null if tool is not yet detected', async () => { // Unset session type to ensure no tool is detected automatically delete process.env['XDG_SESSION_TYPE']; // Don't prime the tool const result = await clipboardUtils.saveClipboardImage(mockTargetDir); expect(result).toBe(null); expect(spawn).not.toHaveBeenCalled(); }); }); // Stateless functions continue to use static imports describe('cleanupOldClipboardImages', () => { const mockTargetDir = '/tmp/target'; it('should not throw errors', async () => { // Should handle missing directories gracefully await expect( cleanupOldClipboardImages(mockTargetDir), ).resolves.not.toThrow(); }); it('should complete without errors on valid directory', async () => { await expect( cleanupOldClipboardImages(mockTargetDir), ).resolves.not.toThrow(); }); }); describe('splitEscapedPaths', () => { it('should return single path when no spaces', () => { expect(splitEscapedPaths('/path/to/image.png')).toEqual([ '/path/to/image.png', ]); }); it('should split simple space-separated paths', () => { expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([ '/img1.png', '/img2.png', ]); }); 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([]); }); }); describe('parsePastedPaths', () => { it('should return null for empty string', () => { const result = parsePastedPaths(''); expect(result).toBe(null); }); it('should add @ prefix to single valid path', () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS); const result = parsePastedPaths('/path/to/file.txt'); expect(result).toBe('@/path/to/file.txt '); }); it('should return null for single invalid path', () => { vi.mocked(existsSync).mockReturnValue(false); const result = parsePastedPaths('/path/to/file.txt'); expect(result).toBe(null); }); it('should add @ prefix to all valid paths', () => { const validPaths = new Set(['/path/to/file1.txt', '/path/to/file2.txt']); vi.mocked(existsSync).mockImplementation((p) => validPaths.has(p as string), ); vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS); const result = parsePastedPaths('/path/to/file1.txt /path/to/file2.txt'); expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt '); }); it('should only add @ prefix to valid paths', () => { 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 '); }); it('should return null if no paths are valid', () => { vi.mocked(existsSync).mockReturnValue(false); const result = parsePastedPaths('/path/to/file1.txt /path/to/file2.txt'); 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); }); 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 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 '); }); 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:\\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 '); }); }); });