/** * Tests for editor IPC handlers — validation, security, module-level state. */ import * as os from 'os'; import * as path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock electron vi.mock('electron', () => ({ app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }), BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); // Mock fs/promises vi.mock('fs/promises', () => ({ stat: vi.fn(), lstat: vi.fn(), readdir: vi.fn(), readFile: vi.fn(), realpath: vi.fn(), })); // Mock isbinaryfile vi.mock('isbinaryfile', () => ({ isBinaryFile: vi.fn(), })); // Mock IPC channels vi.mock('@preload/constants/ipcChannels', () => ({ EDITOR_OPEN: 'editor:open', EDITOR_CLOSE: 'editor:close', EDITOR_READ_DIR: 'editor:readDir', EDITOR_READ_FILE: 'editor:readFile', EDITOR_WRITE_FILE: 'editor:writeFile', EDITOR_CREATE_FILE: 'editor:createFile', EDITOR_CREATE_DIR: 'editor:createDir', EDITOR_DELETE_FILE: 'editor:deleteFile', EDITOR_MOVE_FILE: 'editor:moveFile', EDITOR_RENAME_FILE: 'editor:renameFile', EDITOR_SEARCH_IN_FILES: 'editor:searchInFiles', EDITOR_LIST_FILES: 'editor:listFiles', EDITOR_READ_BINARY_PREVIEW: 'editor:readBinaryPreview', EDITOR_GIT_STATUS: 'editor:gitStatus', EDITOR_WATCH_DIR: 'editor:watchDir', EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles', EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs', EDITOR_CHANGE: 'editor:change', PROJECT_LIST_FILES: 'project:listFiles', })); // Mock atomicWrite used by ProjectFileService vi.mock('@main/utils/atomicWrite', () => ({ atomicWriteAsync: vi.fn(), })); // Mock simple-git (used by GitStatusService) vi.mock('simple-git', () => { const mockGit = { status: vi.fn(), revparse: vi.fn(), env: vi.fn().mockReturnThis(), }; return { simpleGit: vi.fn(() => mockGit) }; }); // Mock chokidar (used by EditorFileWatcher) vi.mock('chokidar', () => ({ watch: vi.fn(() => ({ on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), })), })); // Mock logger vi.mock('@shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), }), })); // Mock pathDecoder vi.mock('@main/utils/pathDecoder', () => ({ getClaudeBasePath: () => path.join(os.homedir(), '.claude'), })); import * as fs from 'fs/promises'; import { cleanupEditorState, initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers, } from '../../../src/main/ipc/editor'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; // ============================================================================= // Helpers // ============================================================================= function createMockIpcMain() { const handlers = new Map unknown>(); return { handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { handlers.set(channel, handler); }), removeHandler: vi.fn((channel: string) => { handlers.delete(channel); }), invoke: async (channel: string, ...args: unknown[]) => { const handler = handlers.get(channel); if (!handler) throw new Error(`No handler for ${channel}`); return handler({} as IpcMainInvokeEvent, ...args); }, _handlers: handlers, }; } function createStats( overrides: Partial> = {} ): Awaited> { return { isFile: () => overrides.isFile ?? false, isDirectory: () => overrides.isDirectory ?? true, isSymbolicLink: () => overrides.isSymbolicLink ?? false, size: overrides.size ?? 1024, mtimeMs: overrides.mtimeMs ?? Date.now(), } as Awaited>; } function createFsError(code: string): NodeJS.ErrnoException { const error = new Error(code) as NodeJS.ErrnoException; error.code = code; return error; } // ============================================================================= // Tests // ============================================================================= describe('Editor IPC handlers', () => { let mockIpc: ReturnType; beforeEach(() => { vi.resetAllMocks(); mockIpc = createMockIpcMain(); initializeEditorHandlers(); registerEditorHandlers(mockIpc as unknown as IpcMain); // Always start with clean state cleanupEditorState(); }); describe('registration', () => { it('registers all 18 editor channels', () => { expect(mockIpc.handle).toHaveBeenCalledTimes(18); expect(mockIpc._handlers.has('editor:open')).toBe(true); expect(mockIpc._handlers.has('editor:close')).toBe(true); expect(mockIpc._handlers.has('editor:readDir')).toBe(true); expect(mockIpc._handlers.has('editor:readFile')).toBe(true); expect(mockIpc._handlers.has('editor:writeFile')).toBe(true); expect(mockIpc._handlers.has('editor:createFile')).toBe(true); expect(mockIpc._handlers.has('editor:createDir')).toBe(true); expect(mockIpc._handlers.has('editor:deleteFile')).toBe(true); expect(mockIpc._handlers.has('editor:moveFile')).toBe(true); expect(mockIpc._handlers.has('editor:renameFile')).toBe(true); expect(mockIpc._handlers.has('editor:searchInFiles')).toBe(true); expect(mockIpc._handlers.has('editor:listFiles')).toBe(true); expect(mockIpc._handlers.has('editor:readBinaryPreview')).toBe(true); expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true); expect(mockIpc._handlers.has('editor:watchDir')).toBe(true); expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true); expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true); expect(mockIpc._handlers.has('project:listFiles')).toBe(true); }); it('removeEditorHandlers clears all channels', () => { removeEditorHandlers(mockIpc as unknown as IpcMain); expect(mockIpc.removeHandler).toHaveBeenCalledTimes(18); }); }); describe('editor:open', () => { it('accepts valid absolute directory path', async () => { const projectPath = '/Users/test/my-project'; vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); const result = await mockIpc.invoke('editor:open', projectPath); expect(result).toEqual({ success: true, data: undefined }); }); it('rejects empty path', async () => { const result = await mockIpc.invoke('editor:open', ''); expect(result).toEqual({ success: false, error: expect.stringContaining('Invalid project path'), }); }); it('rejects relative path', async () => { const result = await mockIpc.invoke('editor:open', 'relative/path'); expect(result).toEqual({ success: false, error: expect.stringContaining('must be absolute'), }); }); it('rejects filesystem root (SEC-15)', async () => { const result = await mockIpc.invoke('editor:open', '/'); expect(result).toEqual({ success: false, error: expect.stringContaining('filesystem root'), }); }); it('rejects ~/.claude directory (SEC-15)', async () => { const claudeDir = path.join(os.homedir(), '.claude'); const result = await mockIpc.invoke('editor:open', claudeDir); expect(result).toEqual({ success: false, error: expect.stringContaining('Claude data directory'), }); }); it('rejects path to a file (not directory)', async () => { vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: false, isFile: true })); const result = await mockIpc.invoke('editor:open', '/Users/test/file.ts'); expect(result).toEqual({ success: false, error: expect.stringContaining('not a directory'), }); }); it('rejects non-existent path', async () => { vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); const result = await mockIpc.invoke('editor:open', '/nonexistent/path'); expect(result).toEqual({ success: false, error: expect.stringContaining('ENOENT'), }); }); }); describe('editor:close', () => { it('resets state successfully', async () => { // Open first vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); await mockIpc.invoke('editor:open', '/Users/test/project'); const result = await mockIpc.invoke('editor:close'); expect(result).toEqual({ success: true, data: undefined }); }); }); describe('editor:readDir', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:readDir', '/some/path'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); it('works after editor:open', async () => { // Open project vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); await mockIpc.invoke('editor:open', '/Users/test/project'); // Mock readDir vi.mocked(fs.lstat).mockResolvedValue(createStats({ isDirectory: true }) as never); vi.mocked(fs.readdir).mockResolvedValue([] as never); const result = await mockIpc.invoke('editor:readDir', '/Users/test/project'); expect(result).toEqual({ success: true, data: { entries: [], truncated: false }, }); }); }); describe('editor:readFile', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:readFile', '/some/file.ts'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:createFile', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:createFile', '/some/path', 'file.ts'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:createDir', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:createDir', '/some/path', 'new-dir'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:deleteFile', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:deleteFile', '/some/file.ts'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:moveFile', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:moveFile', '/some/file.ts', '/other/dir'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:searchInFiles', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:searchInFiles', { query: 'test' }); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('project:listFiles', () => { it('returns an empty list for deleted project paths', async () => { vi.mocked(fs.stat).mockRejectedValue(createFsError('ENOENT')); const result = await mockIpc.invoke('project:listFiles', '/tmp/deleted-project'); expect(result).toEqual({ success: true, data: [], }); }); it('returns an empty list for paths that are not directories', async () => { vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: false, isFile: true })); const result = await mockIpc.invoke('project:listFiles', '/tmp/project-file.txt'); expect(result).toEqual({ success: true, data: [], }); }); it('rejects empty explicit project paths', async () => { const result = await mockIpc.invoke('project:listFiles', ''); expect(result).toEqual({ success: false, error: expect.stringContaining('projectPath is required'), }); }); }); describe('editor:gitStatus', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:gitStatus'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('editor:watchDir', () => { it('rejects if editor not initialized', async () => { const result = await mockIpc.invoke('editor:watchDir', true); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); describe('cleanupEditorState', () => { it('resets state so readDir fails with not initialized', async () => { // Open project vi.mocked(fs.stat).mockResolvedValue(createStats({ isDirectory: true })); await mockIpc.invoke('editor:open', '/Users/test/project'); // Cleanup cleanupEditorState(); // Now readDir should fail const result = await mockIpc.invoke('editor:readDir', '/Users/test/project'); expect(result).toEqual({ success: false, error: expect.stringContaining('not initialized'), }); }); }); });