feat(watchers): Integrate chokidar-based Node watcher, expose awaitWriteFinish options, and update docs/tests

This commit is contained in:
2025-12-11 21:04:42 +00:00
parent 696d454b00
commit 61a8222c9b
10 changed files with 185 additions and 960 deletions

View File

@@ -74,11 +74,15 @@ tap.test('should add paths and start watching', async () => {
testSmartwatch.add([`${TEST_DIR}/**/*.txt`]);
await testSmartwatch.start();
expect(testSmartwatch.status).toEqual('watching');
// Wait for chokidar to be ready
await delay(500);
});
tap.test('should detect ADD event for new files', async () => {
const addObservable = await testSmartwatch.getObservableFor('add');
const eventPromise = waitForEvent(addObservable);
// Subscribe FIRST, then create file
const eventPromise = waitForFileEvent(addObservable, 'add-test.txt');
// Create a new file
const testFile = path.join(TEST_DIR, 'add-test.txt');
@@ -87,9 +91,9 @@ tap.test('should detect ADD event for new files', async () => {
const [filePath] = await eventPromise;
expect(filePath).toInclude('add-test.txt');
// Cleanup - wait for atomic delay to complete (100ms debounce + 100ms atomic)
// Cleanup
await fs.promises.unlink(testFile);
await delay(250);
await delay(200);
});
tap.test('should detect CHANGE event for modified files', async () => {
@@ -98,10 +102,10 @@ tap.test('should detect CHANGE event for modified files', async () => {
await smartfile.memory.toFs('initial content', testFile);
// Wait for add event to complete
await delay(200);
await delay(300);
const changeObservable = await testSmartwatch.getObservableFor('change');
const eventPromise = waitForEvent(changeObservable);
const eventPromise = waitForFileEvent(changeObservable, 'change-test.txt');
// Modify the file
await smartfile.memory.toFs('modified content', testFile);
@@ -109,9 +113,9 @@ tap.test('should detect CHANGE event for modified files', async () => {
const [filePath] = await eventPromise;
expect(filePath).toInclude('change-test.txt');
// Cleanup - wait for atomic delay to complete
// Cleanup
await fs.promises.unlink(testFile);
await delay(250);
await delay(200);
});
tap.test('should detect UNLINK event for deleted files', async () => {
@@ -120,7 +124,7 @@ tap.test('should detect UNLINK event for deleted files', async () => {
await smartfile.memory.toFs('to be deleted', testFile);
// Wait for add event to complete
await delay(200);
await delay(300);
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');

View File

@@ -16,21 +16,25 @@ const TEST_DIR = './test/assets';
// Helper to delay
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
// Helper to wait for an event with timeout
async function waitForEvent<T>(
// Helper to wait for an event with timeout (filters by filename)
async function waitForFileEvent<T extends [string, ...any[]]>(
observable: smartrx.rxjs.Observable<T>,
expectedFile: string,
timeoutMs: number = 5000
): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
subscription.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
reject(new Error(`Timeout waiting for event on ${expectedFile} after ${timeoutMs}ms`));
}, timeoutMs);
const subscription = observable.subscribe((value) => {
clearTimeout(timeout);
subscription.unsubscribe();
resolve(value);
const [filePath] = value;
if (filePath.includes(expectedFile)) {
clearTimeout(timeout);
subscription.unsubscribe();
resolve(value);
}
});
});
}
@@ -45,27 +49,31 @@ tap.test('setup: start watcher', async () => {
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
await testSmartwatch.start();
expect(testSmartwatch.status).toEqual('watching');
// Wait for chokidar to be ready
await delay(500);
});
tap.test('should detect delete+recreate (inode change scenario)', async () => {
// This simulates what many editors do: delete file, create new file
tap.test('should detect delete+recreate as change event (atomic handling)', async () => {
// Chokidar with atomic: true handles delete+recreate as a single change event
// This is the expected behavior for editor save patterns
const testFile = path.join(TEST_DIR, 'inode-test.txt');
// Clean up any leftover file from previous runs
try { await fs.promises.unlink(testFile); } catch {}
await delay(100);
// Create initial file
await smartfile.memory.toFs('initial content', testFile);
await delay(200);
await delay(300);
// Get the initial inode
const initialStats = await fs.promises.stat(testFile);
const initialInode = initialStats.ino;
console.log(`[test] Initial inode: ${initialInode}`);
// With event sequence tracking, delete+recreate emits: unlink, then add
// This is more accurate than just emitting 'change'
const unlinkObservable = await testSmartwatch.getObservableFor('unlink');
const addObservable = await testSmartwatch.getObservableFor('add');
const unlinkPromise = waitForEvent(unlinkObservable, 3000);
const addPromise = waitForEvent(addObservable, 3000);
// Chokidar's atomic handling will emit a single 'change' event
const changeObservable = await testSmartwatch.getObservableFor('change');
const eventPromise = waitForFileEvent(changeObservable, 'inode-test.txt', 3000);
// Delete and recreate (this creates a new inode)
await fs.promises.unlink(testFile);
@@ -77,14 +85,14 @@ tap.test('should detect delete+recreate (inode change scenario)', async () => {
console.log(`[test] New inode: ${newInode}`);
expect(newInode).not.toEqual(initialInode);
// Should detect both unlink and add events for delete+recreate
const [[unlinkPath], [addPath]] = await Promise.all([unlinkPromise, addPromise]);
expect(unlinkPath).toInclude('inode-test.txt');
expect(addPath).toInclude('inode-test.txt');
console.log(`[test] Detected unlink + add events for delete+recreate`);
// Chokidar detects this as a change (atomic write pattern)
const [filePath] = await eventPromise;
expect(filePath).toInclude('inode-test.txt');
console.log(`[test] Detected change event for delete+recreate (atomic handling)`);
// Cleanup
await fs.promises.unlink(testFile);
await delay(200);
});
tap.test('should detect atomic write pattern (temp file + rename)', async () => {
@@ -96,10 +104,10 @@ tap.test('should detect atomic write pattern (temp file + rename)', async () =>
// Create initial file
await smartfile.memory.toFs('initial content', testFile);
await delay(200);
await delay(300);
const changeObservable = await testSmartwatch.getObservableFor('change');
const eventPromise = waitForEvent(changeObservable, 3000);
const eventPromise = waitForFileEvent(changeObservable, 'atomic-test.txt', 3000);
// Atomic write: create temp file then rename
await smartfile.memory.toFs('atomic content', tempFile);

View File

@@ -44,6 +44,8 @@ tap.test('setup: start watcher', async () => {
testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]);
await testSmartwatch.start();
expect(testSmartwatch.status).toEqual('watching');
// Wait for chokidar to be ready
await delay(500);
});
tap.test('STRESS: rapid file modifications', async () => {