feat(watchers): add Rust-powered watcher backend with runtime fallback and cross-platform test coverage

This commit is contained in:
2026-03-23 14:15:31 +00:00
parent ca9a66e03e
commit 7def7020c6
26 changed files with 10383 additions and 2870 deletions

View File

@@ -1,6 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartwatch from '../ts/index.js';
import * as smartfile from '@push.rocks/smartfile';
import * as smartrx from '@push.rocks/smartrx';
import * as fs from 'fs';
@@ -86,7 +85,7 @@ tap.test('should detect ADD event for new files', async () => {
// Create a new file
const testFile = path.join(TEST_DIR, 'add-test.txt');
await smartfile.memory.toFs('test content', testFile);
await fs.promises.writeFile(testFile, 'test content');
const [filePath] = await eventPromise;
expect(filePath).toInclude('add-test.txt');
@@ -99,7 +98,7 @@ tap.test('should detect ADD event for new files', async () => {
tap.test('should detect CHANGE event for modified files', async () => {
// First create the file
const testFile = path.join(TEST_DIR, 'change-test.txt');
await smartfile.memory.toFs('initial content', testFile);
await fs.promises.writeFile(testFile, 'initial content');
// Wait for add event to complete
await delay(300);
@@ -108,7 +107,7 @@ tap.test('should detect CHANGE event for modified files', async () => {
const eventPromise = waitForFileEvent(changeObservable, 'change-test.txt');
// Modify the file
await smartfile.memory.toFs('modified content', testFile);
await fs.promises.writeFile(testFile, 'modified content');
const [filePath] = await eventPromise;
expect(filePath).toInclude('change-test.txt');
@@ -121,7 +120,7 @@ tap.test('should detect CHANGE event for modified files', async () => {
tap.test('should detect UNLINK event for deleted files', async () => {
// First create the file
const testFile = path.join(TEST_DIR, 'unlink-test.txt');
await smartfile.memory.toFs('to be deleted', testFile);
await fs.promises.writeFile(testFile, 'to be deleted');
// Wait for add event to complete
await delay(300);

View File

@@ -0,0 +1,86 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as chokidar from 'chokidar';
import * as fs from 'fs';
import * as path from 'path';
const TEST_DIR = './test/assets';
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Count active FSWatcher handles in the process
*/
function countFSWatcherHandles(): number {
const handles = (process as any)._getActiveHandles();
return handles.filter((h: any) => h?.constructor?.name === 'FSWatcher').length;
}
tap.test('should not leave lingering FSWatcher handles after chokidar close', async () => {
const handlesBefore = countFSWatcherHandles();
console.log(`FSWatcher handles before: ${handlesBefore}`);
// Start a chokidar watcher
const watcher = chokidar.watch(path.resolve(TEST_DIR), {
persistent: true,
ignoreInitial: false,
});
// Wait for ready
await new Promise<void>((resolve) => watcher.on('ready', resolve));
const handlesDuring = countFSWatcherHandles();
console.log(`FSWatcher handles during watch: ${handlesDuring}`);
expect(handlesDuring).toBeGreaterThan(handlesBefore);
// Close the watcher
await watcher.close();
console.log('chokidar.close() resolved');
// Check immediately after close
const handlesAfterClose = countFSWatcherHandles();
console.log(`FSWatcher handles immediately after close: ${handlesAfterClose}`);
// Wait a bit and check again to see if handles are cleaned up asynchronously
await delay(500);
const handlesAfterDelay500 = countFSWatcherHandles();
console.log(`FSWatcher handles after 500ms: ${handlesAfterDelay500}`);
await delay(1500);
const handlesAfterDelay2000 = countFSWatcherHandles();
console.log(`FSWatcher handles after 2000ms: ${handlesAfterDelay2000}`);
const lingeringHandles = handlesAfterDelay2000 - handlesBefore;
console.log(`Lingering FSWatcher handles: ${lingeringHandles}`);
if (lingeringHandles > 0) {
console.log('WARNING: chokidar left lingering FSWatcher handles after close()');
} else {
console.log('OK: all FSWatcher handles were cleaned up');
}
expect(lingeringHandles).toEqual(0);
});
tap.test('should not leave handles after multiple open/close cycles', async () => {
const handlesBefore = countFSWatcherHandles();
console.log(`\nMulti-cycle test - handles before: ${handlesBefore}`);
for (let i = 0; i < 3; i++) {
const watcher = chokidar.watch(path.resolve(TEST_DIR), {
persistent: true,
ignoreInitial: false,
});
await new Promise<void>((resolve) => watcher.on('ready', resolve));
const during = countFSWatcherHandles();
console.log(` Cycle ${i + 1} - handles during: ${during}`);
await watcher.close();
await delay(500);
}
const handlesAfter = countFSWatcherHandles();
const leaked = handlesAfter - handlesBefore;
console.log(`Handles after 3 cycles: ${handlesAfter} (leaked: ${leaked})`);
expect(leaked).toEqual(0);
});
export default tap.start();

View File

@@ -1,6 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartwatch from '../ts/index.js';
import * as smartfile from '@push.rocks/smartfile';
import * as smartrx from '@push.rocks/smartrx';
import * as fs from 'fs';
@@ -63,7 +62,7 @@ tap.test('should detect delete+recreate as change event (atomic handling)', asyn
await delay(100);
// Create initial file
await smartfile.memory.toFs('initial content', testFile);
await fs.promises.writeFile(testFile, 'initial content');
await delay(300);
// Get the initial inode
@@ -77,7 +76,7 @@ tap.test('should detect delete+recreate as change event (atomic handling)', asyn
// Delete and recreate (this creates a new inode)
await fs.promises.unlink(testFile);
await smartfile.memory.toFs('recreated content', testFile);
await fs.promises.writeFile(testFile, 'recreated content');
// Check inode changed
const newStats = await fs.promises.stat(testFile);
@@ -103,17 +102,24 @@ tap.test('should detect atomic write pattern (temp file + rename)', async () =>
const tempFile = path.join(TEST_DIR, 'atomic-test.txt.tmp.12345');
// Create initial file
await smartfile.memory.toFs('initial content', testFile);
await fs.promises.writeFile(testFile, 'initial content');
await delay(300);
// Listen for both change and add events — different watcher backends
// may report a rename-over-existing as either a change or an add
const changeObservable = await testSmartwatch.getObservableFor('change');
const eventPromise = waitForFileEvent(changeObservable, 'atomic-test.txt', 3000);
const addObservable = await testSmartwatch.getObservableFor('add');
const eventPromise = Promise.race([
waitForFileEvent(changeObservable, 'atomic-test.txt', 3000),
waitForFileEvent(addObservable, 'atomic-test.txt', 3000),
]);
// Atomic write: create temp file then rename
await smartfile.memory.toFs('atomic content', tempFile);
await fs.promises.writeFile(tempFile, 'atomic content');
await fs.promises.rename(tempFile, testFile);
// Should detect the change to the target file
// Should detect the event on the target file
const [filePath] = await eventPromise;
expect(filePath).toInclude('atomic-test.txt');
expect(filePath).not.toInclude('.tmp.');

115
test/test.platform.bun.ts Normal file
View File

@@ -0,0 +1,115 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { NodeWatcher } from '../ts/watchers/watcher.node.js';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'path';
import * as fs from 'fs';
// Bun uses NodeWatcher (Node.js compatibility layer).
// This test validates that the chokidar-based watcher works under Bun.
const isBun = typeof (globalThis as any).Bun !== 'undefined';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
let watcher: NodeWatcher;
tap.test('BunNodeWatcher: should create and start', async () => {
if (!isBun) { console.log('Skipping: not Bun runtime'); return; }
watcher = new NodeWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
await watcher.start();
expect(watcher.isWatching).toBeTrue();
await delay(500);
});
tap.test('BunNodeWatcher: should detect file creation', async () => {
if (!isBun) return;
const file = path.join(TEST_DIR, 'bun-add-test.txt');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('bun-add-test.txt'));
await fs.promises.writeFile(file, 'bun watcher test');
const event = await eventPromise;
expect(event.type).toEqual('add');
expect(event.path).toInclude('bun-add-test.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('BunNodeWatcher: should detect file modification', async () => {
if (!isBun) return;
const file = path.join(TEST_DIR, 'bun-change-test.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('bun-change-test.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('BunNodeWatcher: should detect file deletion', async () => {
if (!isBun) return;
const file = path.join(TEST_DIR, 'bun-unlink-test.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('bun-unlink-test.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('BunNodeWatcher: should detect directory creation', async () => {
if (!isBun) return;
const dir = path.join(TEST_DIR, 'bun-test-subdir');
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('bun-test-subdir'));
await fs.promises.mkdir(dir, { recursive: true });
const event = await addDirPromise;
expect(event.type).toEqual('addDir');
await delay(200);
await fs.promises.rmdir(dir);
await delay(200);
});
tap.test('BunNodeWatcher: should not be watching after stop', async () => {
if (!isBun) return;
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('BunNodeWatcher: cleanup', async () => {
if (!isBun) return;
for (const name of ['bun-add-test.txt', 'bun-change-test.txt', 'bun-unlink-test.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
try { await fs.promises.rmdir(path.join(TEST_DIR, 'bun-test-subdir')); } catch {}
});
export default tap.start();

119
test/test.platform.deno.ts Normal file
View File

@@ -0,0 +1,119 @@
// tstest:deno:allowAll
import { tap, expect } from '@git.zone/tstest/tapbundle';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
// This test requires the Deno runtime
const isDeno = typeof (globalThis as any).Deno !== 'undefined';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
let watcher: any;
tap.test('DenoWatcher: should create and start', async () => {
if (!isDeno) { console.log('Skipping: not Deno runtime'); return; }
const { DenoWatcher } = await import('../ts/watchers/watcher.deno.js');
watcher = new DenoWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
await watcher.start();
expect(watcher.isWatching).toBeTrue();
await delay(500);
});
tap.test('DenoWatcher: should detect file creation', async () => {
if (!isDeno) return;
const file = path.join(TEST_DIR, 'deno-add-test.txt');
// Deno.watchFs may report new files as 'create', 'any', or 'modify' depending on platform
const eventPromise = waitForEvent(
watcher,
(e) => (e.type === 'add' || e.type === 'change') && e.path.includes('deno-add-test.txt'),
10000,
);
await fs.promises.writeFile(file, 'deno watcher test');
const event = await eventPromise;
expect(event.path).toInclude('deno-add-test.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('DenoWatcher: should detect file modification', async () => {
if (!isDeno) return;
const file = path.join(TEST_DIR, 'deno-change-test.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('deno-change-test.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('DenoWatcher: should detect file deletion', async () => {
if (!isDeno) return;
const file = path.join(TEST_DIR, 'deno-unlink-test.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('deno-unlink-test.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('DenoWatcher: should detect directory creation', async () => {
if (!isDeno) return;
const dir = path.join(TEST_DIR, 'deno-test-subdir');
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('deno-test-subdir'));
await fs.promises.mkdir(dir, { recursive: true });
const event = await addDirPromise;
expect(event.type).toEqual('addDir');
await delay(200);
await fs.promises.rmdir(dir);
await delay(200);
});
tap.test('DenoWatcher: should not be watching after stop', async () => {
if (!isDeno) return;
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('DenoWatcher: cleanup', async () => {
if (!isDeno) return;
for (const name of ['deno-add-test.txt', 'deno-change-test.txt', 'deno-unlink-test.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
try { await fs.promises.rmdir(path.join(TEST_DIR, 'deno-test-subdir')); } catch {}
});
export default tap.start();

114
test/test.platform.node.ts Normal file
View File

@@ -0,0 +1,114 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { NodeWatcher } from '../ts/watchers/watcher.node.js';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'path';
import * as fs from 'fs';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
let watcher: NodeWatcher;
tap.test('NodeWatcher: should create and start', async () => {
watcher = new NodeWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
await watcher.start();
expect(watcher.isWatching).toBeTrue();
await delay(500);
});
tap.test('NodeWatcher: should emit ready event', async () => {
// Ready event fires during start, so we test isWatching as proxy
expect(watcher.isWatching).toBeTrue();
});
tap.test('NodeWatcher: should detect file creation', async () => {
const file = path.join(TEST_DIR, 'node-add-test.txt');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('node-add-test.txt'));
await fs.promises.writeFile(file, 'node watcher test');
const event = await eventPromise;
expect(event.type).toEqual('add');
expect(event.path).toInclude('node-add-test.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('NodeWatcher: should detect file modification', async () => {
const file = path.join(TEST_DIR, 'node-change-test.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('node-change-test.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('NodeWatcher: should detect file deletion', async () => {
const file = path.join(TEST_DIR, 'node-unlink-test.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('node-unlink-test.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('NodeWatcher: should detect directory creation and removal', async () => {
const dir = path.join(TEST_DIR, 'node-test-subdir');
const addDirPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('node-test-subdir'));
await fs.promises.mkdir(dir, { recursive: true });
const addEvent = await addDirPromise;
expect(addEvent.type).toEqual('addDir');
await delay(200);
const unlinkDirPromise = waitForEvent(watcher, (e) => e.type === 'unlinkDir' && e.path.includes('node-test-subdir'));
await fs.promises.rmdir(dir);
const unlinkEvent = await unlinkDirPromise;
expect(unlinkEvent.type).toEqual('unlinkDir');
});
tap.test('NodeWatcher: should not be watching after stop', async () => {
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('NodeWatcher: cleanup', async () => {
for (const name of ['node-add-test.txt', 'node-change-test.txt', 'node-unlink-test.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
try { await fs.promises.rmdir(path.join(TEST_DIR, 'node-test-subdir')); } catch {}
});
export default tap.start();

View File

@@ -0,0 +1,112 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'path';
import * as fs from 'fs';
// This test validates the Rust watcher running under the Bun runtime.
const isBun = typeof (globalThis as any).Bun !== 'undefined';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
let available = false;
tap.test('RustWatcher (Bun): check availability', async () => {
if (!isBun) { console.log('Skipping: not Bun runtime'); return; }
available = await RustWatcher.isAvailable();
console.log(`[test] Rust binary available: ${available}`);
if (!available) {
console.log('[test] Skipping Rust watcher tests — binary not found');
}
});
let watcher: RustWatcher;
tap.test('RustWatcher (Bun): should create and start', async () => {
if (!isBun || !available) return;
watcher = new RustWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
await watcher.start();
expect(watcher.isWatching).toBeTrue();
await delay(300);
});
tap.test('RustWatcher (Bun): should detect file creation', async () => {
if (!isBun || !available) return;
const file = path.join(TEST_DIR, 'rust-bun-add.txt');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-bun-add.txt'));
await fs.promises.writeFile(file, 'rust bun add test');
const event = await eventPromise;
expect(event.type).toEqual('add');
expect(event.path).toInclude('rust-bun-add.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Bun): should detect file modification', async () => {
if (!isBun || !available) return;
const file = path.join(TEST_DIR, 'rust-bun-change.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-bun-change.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Bun): should detect file deletion', async () => {
if (!isBun || !available) return;
const file = path.join(TEST_DIR, 'rust-bun-unlink.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-bun-unlink.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('RustWatcher (Bun): should not be watching after stop', async () => {
if (!isBun || !available) return;
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('RustWatcher (Bun): cleanup', async () => {
if (!isBun) return;
for (const name of ['rust-bun-add.txt', 'rust-bun-change.txt', 'rust-bun-unlink.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
});
export default tap.start();

View File

@@ -0,0 +1,122 @@
// tstest:deno:allowAll
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
// This test validates the Rust watcher running under the Deno runtime.
const isDeno = typeof (globalThis as any).Deno !== 'undefined';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
let available = false;
tap.test('RustWatcher (Deno): check availability', async () => {
if (!isDeno) { console.log('Skipping: not Deno runtime'); return; }
available = await RustWatcher.isAvailable();
console.log(`[test] Rust binary available: ${available}`);
if (!available) {
console.log('[test] Skipping Rust watcher tests — binary not found');
}
});
let watcher: RustWatcher;
let started = false;
tap.test('RustWatcher (Deno): should create and start', async () => {
if (!isDeno || !available) return;
watcher = new RustWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
try {
await watcher.start();
started = true;
expect(watcher.isWatching).toBeTrue();
await delay(300);
} catch (err) {
// Deno may block child_process.spawn without --allow-run permission
console.log(`[test] RustWatcher spawn failed (likely Deno permission): ${err}`);
available = false;
}
});
tap.test('RustWatcher (Deno): should detect file creation', async () => {
if (!isDeno || !available) return;
const file = path.join(TEST_DIR, 'rust-deno-add.txt');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-deno-add.txt'));
await fs.promises.writeFile(file, 'rust deno add test');
const event = await eventPromise;
expect(event.type).toEqual('add');
expect(event.path).toInclude('rust-deno-add.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Deno): should detect file modification', async () => {
if (!isDeno || !available) return;
const file = path.join(TEST_DIR, 'rust-deno-change.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-deno-change.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Deno): should detect file deletion', async () => {
if (!isDeno || !available) return;
const file = path.join(TEST_DIR, 'rust-deno-unlink.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-deno-unlink.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('RustWatcher (Deno): should not be watching after stop', async () => {
if (!isDeno || !available) return;
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('RustWatcher (Deno): cleanup', async () => {
if (!isDeno) return;
for (const name of ['rust-deno-add.txt', 'rust-deno-change.txt', 'rust-deno-unlink.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
});
export default tap.start();

View File

@@ -0,0 +1,157 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { RustWatcher } from '../ts/watchers/watcher.rust.js';
import type { IWatchEvent } from '../ts/watchers/interfaces.js';
import * as path from 'path';
import * as fs from 'fs';
const TEST_DIR = path.resolve('./test/assets');
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function waitForEvent(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
timeoutMs = 5000
): Promise<IWatchEvent> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
sub.unsubscribe();
reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`));
}, timeoutMs);
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) {
clearTimeout(timeout);
sub.unsubscribe();
resolve(event);
}
});
});
}
function collectEvents(
watcher: { events$: { subscribe: Function } },
filter: (e: IWatchEvent) => boolean,
durationMs: number
): Promise<IWatchEvent[]> {
return new Promise((resolve) => {
const events: IWatchEvent[] = [];
const sub = watcher.events$.subscribe((event: IWatchEvent) => {
if (filter(event)) events.push(event);
});
setTimeout(() => { sub.unsubscribe(); resolve(events); }, durationMs);
});
}
let available = false;
tap.test('RustWatcher (Node): check availability', async () => {
available = await RustWatcher.isAvailable();
console.log(`[test] Rust binary available: ${available}`);
if (!available) {
console.log('[test] Skipping Rust watcher tests — binary not found');
}
});
let watcher: RustWatcher;
tap.test('RustWatcher (Node): should create and start', async () => {
if (!available) return;
watcher = new RustWatcher({
basePaths: [TEST_DIR],
depth: 4,
followSymlinks: false,
debounceMs: 100,
});
expect(watcher.isWatching).toBeFalse();
await watcher.start();
expect(watcher.isWatching).toBeTrue();
await delay(300);
});
tap.test('RustWatcher (Node): should emit initial add events', async () => {
if (!available) return;
// The initial scan should have emitted add events for existing files.
// We verify by creating a file and checking it gets an add event
const file = path.join(TEST_DIR, 'rust-node-add.txt');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'add' && e.path.includes('rust-node-add.txt'));
await fs.promises.writeFile(file, 'rust node add test');
const event = await eventPromise;
expect(event.type).toEqual('add');
expect(event.path).toInclude('rust-node-add.txt');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Node): should detect file modification', async () => {
if (!available) return;
const file = path.join(TEST_DIR, 'rust-node-change.txt');
await fs.promises.writeFile(file, 'initial');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'change' && e.path.includes('rust-node-change.txt'));
await fs.promises.writeFile(file, 'modified');
const event = await eventPromise;
expect(event.type).toEqual('change');
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Node): should detect file deletion', async () => {
if (!available) return;
const file = path.join(TEST_DIR, 'rust-node-unlink.txt');
await fs.promises.writeFile(file, 'to delete');
await delay(300);
const eventPromise = waitForEvent(watcher, (e) => e.type === 'unlink' && e.path.includes('rust-node-unlink.txt'));
await fs.promises.unlink(file);
const event = await eventPromise;
expect(event.type).toEqual('unlink');
});
tap.test('RustWatcher (Node): should detect directory creation', async () => {
if (!available) return;
const dir = path.join(TEST_DIR, 'rust-node-subdir');
const eventPromise = waitForEvent(watcher, (e) => e.type === 'addDir' && e.path.includes('rust-node-subdir'));
await fs.promises.mkdir(dir, { recursive: true });
const event = await eventPromise;
expect(event.type).toEqual('addDir');
await delay(200);
await fs.promises.rmdir(dir);
await delay(200);
});
tap.test('RustWatcher (Node): should handle rapid modifications', async () => {
if (!available) return;
const file = path.join(TEST_DIR, 'rust-node-rapid.txt');
await fs.promises.writeFile(file, 'initial');
await delay(200);
const collector = collectEvents(watcher, (e) => e.type === 'change' && e.path.includes('rust-node-rapid.txt'), 3000);
for (let i = 0; i < 10; i++) {
await fs.promises.writeFile(file, `content ${i}`);
await delay(10);
}
const events = await collector;
console.log(`[test] Rapid mods: 10 writes, ${events.length} events received`);
expect(events.length).toBeGreaterThan(0);
await fs.promises.unlink(file);
await delay(200);
});
tap.test('RustWatcher (Node): should not be watching after stop', async () => {
if (!available) return;
await watcher.stop();
expect(watcher.isWatching).toBeFalse();
});
tap.test('RustWatcher (Node): cleanup', async () => {
for (const name of ['rust-node-add.txt', 'rust-node-change.txt', 'rust-node-unlink.txt', 'rust-node-rapid.txt']) {
try { await fs.promises.unlink(path.join(TEST_DIR, name)); } catch {}
}
try { await fs.promises.rmdir(path.join(TEST_DIR, 'rust-node-subdir')); } catch {}
});
export default tap.start();

View File

@@ -1,6 +1,5 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartwatch from '../ts/index.js';
import * as smartfile from '@push.rocks/smartfile';
import * as smartrx from '@push.rocks/smartrx';
import * as fs from 'fs';
@@ -52,7 +51,7 @@ tap.test('STRESS: rapid file modifications', async () => {
const testFile = path.join(TEST_DIR, 'stress-rapid.txt');
// Create initial file
await smartfile.memory.toFs('initial', testFile);
await fs.promises.writeFile(testFile, 'initial');
await delay(200);
const changeObservable = await testSmartwatch.getObservableFor('change');
@@ -62,7 +61,7 @@ tap.test('STRESS: rapid file modifications', async () => {
const eventCollector = collectEvents(changeObservable, 3000);
for (let i = 0; i < RAPID_CHANGES; i++) {
await smartfile.memory.toFs(`content ${i}`, testFile);
await fs.promises.writeFile(testFile, `content ${i}`);
await delay(10); // 10ms between writes
}
@@ -87,7 +86,7 @@ tap.test('STRESS: many files created rapidly', async () => {
for (let i = 0; i < FILE_COUNT; i++) {
const file = path.join(TEST_DIR, `stress-many-${i}.txt`);
files.push(file);
await smartfile.memory.toFs(`content ${i}`, file);
await fs.promises.writeFile(file, `content ${i}`);
await delay(20); // 20ms between creates
}
@@ -116,7 +115,7 @@ tap.test('STRESS: interleaved add/change/delete operations', async () => {
// Create initial files
for (const file of testFiles) {
await smartfile.memory.toFs('initial', file);
await fs.promises.writeFile(file, 'initial');
}
await delay(300);
@@ -129,13 +128,13 @@ tap.test('STRESS: interleaved add/change/delete operations', async () => {
const unlinkEvents = collectEvents(unlinkObservable, 3000);
// Interleaved operations
await smartfile.memory.toFs('changed 1', testFiles[0]); // change
await fs.promises.writeFile(testFiles[0], 'changed 1'); // change
await delay(50);
await fs.promises.unlink(testFiles[1]); // delete
await delay(50);
await smartfile.memory.toFs('recreated 1', testFiles[1]); // add (recreate)
await fs.promises.writeFile(testFiles[1], 'recreated 1'); // add (recreate)
await delay(50);
await smartfile.memory.toFs('changed 2', testFiles[2]); // change
await fs.promises.writeFile(testFiles[2], 'changed 2'); // change
await delay(50);
const [adds, changes, unlinks] = await Promise.all([addEvents, changeEvents, unlinkEvents]);