@ordojs/cli
Version:
Command-line interface for OrdoJS framework
322 lines (250 loc) • 10.1 kB
text/typescript
/**
* @fileoverview Tests for FileWatcher
*/
import { EventEmitter } from 'events';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileChangeEvent, FileChangeType, FileWatcher } from './file-watcher.js';
// Mock fs module
vi.mock('fs', () => ({
watch: vi.fn(() => ({
close: vi.fn(),
on: vi.fn()
}))
}));
// Mock utils
vi.mock('../utils/fs.js', () => ({
fileExists: vi.fn(() => Promise.resolve(true)),
readFile: vi.fn(() => Promise.resolve(''))
}));
vi.mock('../utils/index.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
success: vi.fn()
}
}));
describe('FileWatcher', () => {
let fileWatcher: FileWatcher;
let mockWatch: any;
beforeEach(() => {
vi.clearAllMocks();
mockWatch = vi.fn(() => ({
close: vi.fn(),
on: vi.fn()
}));
vi.doMock('fs', () => ({ watch: mockWatch }));
});
afterEach(async () => {
if (fileWatcher) {
await fileWatcher.stop();
}
});
describe('constructor', () => {
it('should create FileWatcher with default options', () => {
fileWatcher = new FileWatcher({ watchDir: '/test' });
expect(fileWatcher).toBeInstanceOf(FileWatcher);
expect(fileWatcher).toBeInstanceOf(EventEmitter);
});
it('should merge provided options with defaults', () => {
const options = {
watchDir: '/custom',
patterns: ['*.custom'],
debounceMs: 200
};
fileWatcher = new FileWatcher(options);
expect(fileWatcher).toBeInstanceOf(FileWatcher);
});
});
describe('start', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({ watchDir: '/test' });
});
it('should start watching successfully', async () => {
await fileWatcher.start();
expect(mockWatch).toHaveBeenCalled();
});
it('should not start if already watching', async () => {
await fileWatcher.start();
await fileWatcher.start(); // Second call should be ignored
expect(mockWatch).toHaveBeenCalledTimes(1);
});
it('should throw error if watch directory does not exist', async () => {
const { fileExists } = await import('../utils/fs.js');
vi.mocked(fileExists).mockResolvedValueOnce(false);
await expect(fileWatcher.start()).rejects.toThrow('Watch directory does not exist');
});
});
describe('stop', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({ watchDir: '/test' });
});
it('should stop watching successfully', async () => {
const mockWatcher = {
close: vi.fn(),
on: vi.fn()
};
mockWatch.mockReturnValue(mockWatcher);
await fileWatcher.start();
await fileWatcher.stop();
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should not stop if not watching', async () => {
await fileWatcher.stop(); // Should not throw
});
});
describe('dependency tracking', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({ watchDir: '/test' });
});
it('should add dependency relationships', () => {
const dependent = '/test/component.ordo';
const dependency = '/test/utils.ts';
fileWatcher.addDependency(dependent, dependency);
const depInfo = fileWatcher.getDependencyInfo(dependency);
expect(depInfo?.dependents.has(path.resolve(dependent))).toBe(true);
const dependentInfo = fileWatcher.getDependencyInfo(dependent);
expect(dependentInfo?.dependencies.has(path.resolve(dependency))).toBe(true);
});
it('should remove dependency relationships', () => {
const dependent = '/test/component.ordo';
const dependency = '/test/utils.ts';
fileWatcher.addDependency(dependent, dependency);
fileWatcher.removeDependency(dependent, dependency);
const depInfo = fileWatcher.getDependencyInfo(dependency);
expect(depInfo?.dependents.has(path.resolve(dependent))).toBe(false);
const dependentInfo = fileWatcher.getDependencyInfo(dependent);
expect(dependentInfo?.dependencies.has(path.resolve(dependency))).toBe(false);
});
it('should get affected files including dependents', () => {
const file1 = '/test/utils.ts';
const file2 = '/test/component.ordo';
const file3 = '/test/page.ordo';
// Set up dependency chain: file1 <- file2 <- file3
fileWatcher.addDependency(file2, file1);
fileWatcher.addDependency(file3, file2);
const affected = fileWatcher.getAffectedFiles(file1);
expect(affected).toContain(path.resolve(file1));
expect(affected).toContain(path.resolve(file2));
expect(affected).toContain(path.resolve(file3));
});
});
describe('file pattern matching', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({
watchDir: '/test',
patterns: ['**/*.ordo', '**/*.ts'],
ignorePatterns: ['**/node_modules/**', '**/dist/**']
});
});
it('should match included patterns', () => {
// Access private method for testing
const shouldWatch = (fileWatcher as any).shouldWatchFile.bind(fileWatcher);
expect(shouldWatch('/test/component.ordo')).toBe(true);
expect(shouldWatch('/test/utils.ts')).toBe(true);
expect(shouldWatch('/test/nested/component.ordo')).toBe(true);
});
it('should ignore excluded patterns', () => {
const shouldWatch = (fileWatcher as any).shouldWatchFile.bind(fileWatcher);
expect(shouldWatch('/test/node_modules/package.json')).toBe(false);
expect(shouldWatch('/test/dist/bundle.js')).toBe(false);
});
it('should not match non-included patterns', () => {
const shouldWatch = (fileWatcher as any).shouldWatchFile.bind(fileWatcher);
expect(shouldWatch('/test/readme.md')).toBe(false);
expect(shouldWatch('/test/image.png')).toBe(false);
});
});
describe('change event handling', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({ watchDir: '/test', debounceMs: 10 });
});
it('should emit change events for file modifications', async () => {
const changePromise = new Promise<FileChangeEvent>((resolve) => {
fileWatcher.once('change', resolve);
});
// Simulate file change
const handleChange = (fileWatcher as any).handleFileChange.bind(fileWatcher);
handleChange('change', '/test/component.ordo');
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 20));
const changeEvent = await changePromise;
expect(changeEvent.type).toBe(FileChangeType.CHANGED);
expect(changeEvent.filePath).toBe(path.resolve('/test/component.ordo'));
expect(changeEvent.extension).toBe('.ordo');
});
it('should debounce rapid file changes', async () => {
const changes: FileChangeEvent[] = [];
fileWatcher.on('change', (event) => changes.push(event));
const handleChange = (fileWatcher as any).handleFileChange.bind(fileWatcher);
// Simulate rapid changes
handleChange('change', '/test/component.ordo');
handleChange('change', '/test/component.ordo');
handleChange('change', '/test/component.ordo');
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 20));
// Should only emit one change event
expect(changes).toHaveLength(1);
});
it('should handle file deletion events', async () => {
const { fileExists } = await import('../utils/fs.js');
vi.mocked(fileExists).mockResolvedValueOnce(false);
const changePromise = new Promise<FileChangeEvent>((resolve) => {
fileWatcher.once('change', resolve);
});
const handleChange = (fileWatcher as any).handleFileChange.bind(fileWatcher);
handleChange('rename', '/test/component.ordo');
await new Promise(resolve => setTimeout(resolve, 20));
const changeEvent = await changePromise;
expect(changeEvent.type).toBe(FileChangeType.DELETED);
});
it('should handle file addition events', async () => {
const { fileExists } = await import('../utils/fs.js');
vi.mocked(fileExists).mockResolvedValueOnce(true);
const changePromise = new Promise<FileChangeEvent>((resolve) => {
fileWatcher.once('change', resolve);
});
const handleChange = (fileWatcher as any).handleFileChange.bind(fileWatcher);
handleChange('rename', '/test/new-component.ordo');
await new Promise(resolve => setTimeout(resolve, 20));
const changeEvent = await changePromise;
expect(changeEvent.type).toBe(FileChangeType.ADDED);
});
});
describe('error handling', () => {
beforeEach(() => {
fileWatcher = new FileWatcher({ watchDir: '/test' });
});
it('should emit error events from watcher', async () => {
const mockWatcher = {
close: vi.fn(),
on: vi.fn()
};
mockWatch.mockReturnValue(mockWatcher);
await fileWatcher.start();
const errorPromise = new Promise<Error>((resolve) => {
fileWatcher.once('error', resolve);
});
// Simulate watcher error
const errorCallback = mockWatcher.on.mock.calls.find(call => call[0] === 'error')?.[1];
const testError = new Error('Watcher error');
errorCallback?.(testError);
const error = await errorPromise;
expect(error).toBe(testError);
});
it('should handle errors during file change processing', async () => {
const { fileExists } = await import('../utils/fs.js');
vi.mocked(fileExists).mockRejectedValueOnce(new Error('File system error'));
const errorPromise = new Promise<Error>((resolve) => {
fileWatcher.once('error', resolve);
});
const handleChange = (fileWatcher as any).handleFileChange.bind(fileWatcher);
handleChange('change', '/test/component.ordo');
await new Promise(resolve => setTimeout(resolve, 20));
const error = await errorPromise;
expect(error).toBeInstanceOf(Error);
});
});
});