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'; import * as path from 'path'; // Skip in CI if (process.env.CI) { process.exit(0); } const TEST_DIR = './test/assets'; // Helper to delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper to wait for an event with timeout async function waitForEvent( observable: smartrx.rxjs.Observable, timeoutMs: number = 5000 ): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { subscription.unsubscribe(); reject(new Error(`Timeout waiting for event after ${timeoutMs}ms`)); }, timeoutMs); const subscription = observable.subscribe((value) => { clearTimeout(timeout); subscription.unsubscribe(); resolve(value); }); }); } let testSmartwatch: smartwatch.Smartwatch; // =========================================== // INODE CHANGE DETECTION TESTS // =========================================== tap.test('setup: start watcher', async () => { testSmartwatch = new smartwatch.Smartwatch([`${TEST_DIR}/**/*.txt`]); await testSmartwatch.start(); expect(testSmartwatch.status).toEqual('watching'); }); tap.test('should detect delete+recreate (inode change scenario)', async () => { // This simulates what many editors do: delete file, create new file const testFile = path.join(TEST_DIR, 'inode-test.txt'); // Create initial file await smartfile.memory.toFs('initial content', testFile); await delay(200); // 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); // Delete and recreate (this creates a new inode) await fs.promises.unlink(testFile); await smartfile.memory.toFs('recreated content', testFile); // Check inode changed const newStats = await fs.promises.stat(testFile); const newInode = newStats.ino; 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`); // Cleanup await fs.promises.unlink(testFile); }); tap.test('should detect atomic write pattern (temp file + rename)', async () => { // This simulates what Claude Code and many editors do: // 1. Write to temp file (file.txt.tmp.12345) // 2. Rename temp file to target file const testFile = path.join(TEST_DIR, 'atomic-test.txt'); const tempFile = path.join(TEST_DIR, 'atomic-test.txt.tmp.12345'); // Create initial file await smartfile.memory.toFs('initial content', testFile); await delay(200); const changeObservable = await testSmartwatch.getObservableFor('change'); const eventPromise = waitForEvent(changeObservable, 3000); // Atomic write: create temp file then rename await smartfile.memory.toFs('atomic content', tempFile); await fs.promises.rename(tempFile, testFile); // Should detect the change to the target file const [filePath] = await eventPromise; expect(filePath).toInclude('atomic-test.txt'); expect(filePath).not.toInclude('.tmp.'); // Cleanup await fs.promises.unlink(testFile); }); tap.test('teardown: stop watcher', async () => { await testSmartwatch.stop(); expect(testSmartwatch.status).toEqual('idle'); }); tap.test('cleanup: remove test files', async () => { const files = await fs.promises.readdir(TEST_DIR); for (const file of files) { if (file.startsWith('inode-') || file.startsWith('atomic-')) { try { await fs.promises.unlink(path.join(TEST_DIR, file)); } catch { // Ignore } } } }); export default tap.start();