agent-ecosystem/test/main/services/editor/EditorFileWatcher.test.ts
iliya e1585b4f71 feat: add binary file preview functionality and improve editor performance
- Introduced a new IPC handler for reading binary files as base64 for inline previews, enhancing the editor's capability to display images directly.
- Implemented a placeholder component for non-previewable binary files, providing users with file information and an option to open in the system viewer.
- Enhanced the EditorFileWatcher to ignore permission errors and adjusted debounce timing for better performance during file system events.
- Updated the ProjectFileService to include a method for reading binary previews with security checks and MIME type validation.
- Improved the EditorImagePreview component to handle loading states and display images with a checkerboard background for transparency.
- Refactored the EditorBinaryState component to utilize the new binary preview functionality, streamlining the display of binary files.
2026-03-01 23:51:51 +02:00

180 lines
5.1 KiB
TypeScript

/**
* Tests for EditorFileWatcher — start/stop, event filtering, path security.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock chokidar
const mockOn = vi.fn().mockReturnThis();
const mockClose = vi.fn().mockResolvedValue(undefined);
vi.mock('chokidar', () => ({
watch: vi.fn(() => ({
on: mockOn,
close: mockClose,
})),
}));
vi.mock('@main/utils/pathValidation', () => ({
isPathWithinRoot: vi.fn((filePath: string, root: string) => {
return filePath.startsWith(root);
}),
}));
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}));
import { watch } from 'chokidar';
import { isPathWithinRoot } from '../../../../src/main/utils/pathValidation';
import { EditorFileWatcher } from '../../../../src/main/services/editor/EditorFileWatcher';
// =============================================================================
// Tests
// =============================================================================
describe('EditorFileWatcher', () => {
let watcher: EditorFileWatcher;
const FLUSH_DEBOUNCE_MS = 350;
beforeEach(() => {
vi.useFakeTimers();
vi.resetAllMocks();
mockOn.mockReturnThis();
watcher = new EditorFileWatcher();
});
afterEach(() => {
vi.useRealTimers();
});
describe('start', () => {
it('creates chokidar watcher with correct options', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
expect(watch).toHaveBeenCalledWith('/Users/test/project', {
ignored: expect.any(RegExp),
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 20,
});
});
it('registers change, add, unlink, and error handlers', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
const registeredEvents = mockOn.mock.calls.map((c) => c[0]);
expect(registeredEvents).toContain('change');
expect(registeredEvents).toContain('add');
expect(registeredEvents).toContain('unlink');
expect(registeredEvents).toContain('error');
});
it('emits normalized events through onChange callback', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
// Simulate chokidar 'change' event
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
changeHandler?.('/Users/test/project/src/index.ts');
vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS);
expect(onChange).toHaveBeenCalledWith({
type: 'change',
path: '/Users/test/project/src/index.ts',
});
});
it('emits create event for add', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
const addHandler = mockOn.mock.calls.find((c) => c[0] === 'add')?.[1];
addHandler?.('/Users/test/project/new-file.ts');
vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS);
expect(onChange).toHaveBeenCalledWith({
type: 'create',
path: '/Users/test/project/new-file.ts',
});
});
it('emits delete event for unlink', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
const unlinkHandler = mockOn.mock.calls.find((c) => c[0] === 'unlink')?.[1];
unlinkHandler?.('/Users/test/project/old-file.ts');
vi.advanceTimersByTime(FLUSH_DEBOUNCE_MS);
expect(onChange).toHaveBeenCalledWith({
type: 'delete',
path: '/Users/test/project/old-file.ts',
});
});
it('ignores events outside project root (SEC-2)', () => {
vi.mocked(isPathWithinRoot).mockReturnValueOnce(false);
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
changeHandler?.('/etc/passwd');
expect(onChange).not.toHaveBeenCalled();
});
it('stops previous watcher on re-start (idempotent)', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project1', onChange);
watcher.start('/Users/test/project2', onChange);
expect(mockClose).toHaveBeenCalledTimes(1);
expect(watch).toHaveBeenCalledTimes(2);
});
});
describe('stop', () => {
it('closes the watcher', () => {
const onChange = vi.fn();
watcher.start('/Users/test/project', onChange);
watcher.stop();
expect(mockClose).toHaveBeenCalled();
});
it('is safe to call multiple times', () => {
watcher.stop();
watcher.stop();
// No error thrown
});
});
describe('isWatching', () => {
it('returns false when not started', () => {
expect(watcher.isWatching()).toBe(false);
});
it('returns true after start', () => {
watcher.start('/Users/test/project', vi.fn());
expect(watcher.isWatching()).toBe(true);
});
it('returns false after stop', () => {
watcher.start('/Users/test/project', vi.fn());
watcher.stop();
expect(watcher.isWatching()).toBe(false);
});
});
});