- Updated notification configuration to separate settings for lead and user inbox messages, improving user control over notifications. - Enhanced the handling of inbox message notifications to respect individual inbox settings. - Introduced new IPC methods for managing watched directories, allowing for more granular file monitoring. - Improved task management by implementing work intervals for task status transitions, ensuring accurate tracking of task progress. - Added validation for task creation and configuration, ensuring proper input handling for project paths and task properties.
211 lines
6.7 KiB
TypeScript
211 lines
6.7 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;
|
|
const STARTUP_IGNORE_CHANGE_MS = 3000;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.resetAllMocks();
|
|
mockOn.mockReturnThis();
|
|
watcher = new EditorFileWatcher();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('start', () => {
|
|
it('creates chokidar watcher with correct options (open files only)', () => {
|
|
const onChange = vi.fn();
|
|
watcher.start('/Users/test/project', onChange);
|
|
|
|
// start() does not create a watcher until we provide watched files
|
|
expect(watch).not.toHaveBeenCalled();
|
|
|
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
|
|
|
expect(watch).toHaveBeenCalledWith(['/Users/test/project/src/index.ts'], {
|
|
ignoreInitial: true,
|
|
ignorePermissionErrors: true,
|
|
followSymlinks: false,
|
|
});
|
|
});
|
|
|
|
it('registers change, add, unlink, and error handlers', () => {
|
|
const onChange = vi.fn();
|
|
watcher.start('/Users/test/project', onChange);
|
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
|
|
|
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);
|
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
|
|
|
// Simulate chokidar 'change' event
|
|
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
|
// Startup grace period ignores 'change' events for first 3s
|
|
vi.advanceTimersByTime(STARTUP_IGNORE_CHANGE_MS);
|
|
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);
|
|
watcher.setWatchedFiles(['/Users/test/project/new-file.ts']);
|
|
|
|
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);
|
|
watcher.setWatchedFiles(['/Users/test/project/old-file.ts']);
|
|
|
|
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)', () => {
|
|
const onChange = vi.fn();
|
|
watcher.start('/Users/test/project', onChange);
|
|
watcher.setWatchedFiles(['/Users/test/project/src/index.ts']);
|
|
|
|
const changeHandler = mockOn.mock.calls.find((c) => c[0] === 'change')?.[1];
|
|
vi.advanceTimersByTime(STARTUP_IGNORE_CHANGE_MS);
|
|
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.setWatchedFiles(['/Users/test/project1/a.ts']);
|
|
watcher.start('/Users/test/project2', onChange);
|
|
|
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
|
expect(watch).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('stop', () => {
|
|
it('closes the watcher', () => {
|
|
const onChange = vi.fn();
|
|
watcher.start('/Users/test/project', onChange);
|
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
|
|
|
watcher.stop();
|
|
|
|
expect(mockClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('is safe to call multiple times', () => {
|
|
watcher.stop();
|
|
watcher.stop();
|
|
// No error thrown
|
|
});
|
|
});
|
|
|
|
describe('setWatchedFiles before start', () => {
|
|
it('returns silently when watcher not initialized', () => {
|
|
// Should NOT throw — graceful no-op when projectRoot is null
|
|
expect(() => watcher.setWatchedFiles(['/some/file.ts'])).not.toThrow();
|
|
expect(watch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('setWatchedDirs before start', () => {
|
|
it('returns silently when watcher not initialized', () => {
|
|
// Should NOT throw — graceful no-op when projectRoot is null
|
|
expect(() => watcher.setWatchedDirs(['/some/dir'])).not.toThrow();
|
|
expect(watch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('isWatching', () => {
|
|
it('returns false when not started', () => {
|
|
expect(watcher.isWatching()).toBe(false);
|
|
});
|
|
|
|
it('returns true after setWatchedFiles', () => {
|
|
watcher.start('/Users/test/project', vi.fn());
|
|
expect(watcher.isWatching()).toBe(false);
|
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
|
expect(watcher.isWatching()).toBe(true);
|
|
});
|
|
|
|
it('returns false after stop', () => {
|
|
watcher.start('/Users/test/project', vi.fn());
|
|
watcher.setWatchedFiles(['/Users/test/project/a.ts']);
|
|
watcher.stop();
|
|
expect(watcher.isWatching()).toBe(false);
|
|
});
|
|
});
|
|
});
|