// Watcher Unit Tests — exercises WorkspaceWatcher against a temp dir with // real chokidar. We use real FS because mocking it would test the mock, // not the actual behavior under inotify. import { describe, it, before, after, beforeEach } from "node:test"; import * as assert from "node:assert/strict"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts"; import { pathToUri } from "../../src/root.ts"; // Wait For - Polls a predicate up to timeoutMs. Returns true if it became // true, false if it timed out. Used to wait on async chokidar events // without arbitrary sleeps. async function waitFor( predicate: () => boolean, timeoutMs = 2000, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return true; await new Promise((r) => setTimeout(r, 25)); } return predicate(); } // Wait Quiet - Waits for the debounce window to elapse so any pending // events flush. Slightly longer than DEBOUNCE_MAX_WAIT_MS. const FLUSH_WAIT_MS = 700; describe("WorkspaceWatcher", () => { let tmpDir: string; let received: FileEvent[][] = []; let watcher: WorkspaceWatcher | null = null; before(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-watcher-")); }); after(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); beforeEach(async () => { if (watcher) { await watcher.dispose(); watcher = null; } received = []; // Reset Dir - Wipe contents between tests so file lists are predictable. for (const entry of fs.readdirSync(tmpDir)) { fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true }); } }); it("emits Created event for new file matching pattern", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); // Settle - chokidar's `ready` is implicit; give it a moment. await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x"); const ok = await waitFor(() => received.length > 0); assert.ok(ok, "Expected at least one batch"); const all = received.flat(); const match = all.find((e) => e.uri === pathToUri(path.join(tmpDir, "foo.ts"))); assert.ok(match, `Expected event for foo.ts, got ${JSON.stringify(all)}`); assert.strictEqual(match.type, 1); }); it("emits Deleted event when file removed", async () => { const file = path.join(tmpDir, "del.ts"); fs.writeFileSync(file, "x"); watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); fs.unlinkSync(file); const ok = await waitFor(() => received.flat().some((e) => e.type === 3 && e.uri === pathToUri(file)), ); assert.ok(ok, `Expected delete event, got ${JSON.stringify(received)}`); }); it("emits Changed event when file content changes", async () => { const file = path.join(tmpDir, "chg.ts"); fs.writeFileSync(file, "x"); watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(file, "y"); const ok = await waitFor(() => received.flat().some((e) => e.type === 2 && e.uri === pathToUri(file)), ); assert.ok(ok, `Expected change event, got ${JSON.stringify(received)}`); }); it("skips files not matching the pattern", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); assert.strictEqual( all.length, 0, `Expected no events for .txt, got ${JSON.stringify(all)}`, ); }); it("honors .gitignore at root", async () => { fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n"); fs.mkdirSync(path.join(tmpDir, "ignored-dir")); watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x"); fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); const uris = all.map((e) => e.uri); assert.ok( uris.includes(pathToUri(path.join(tmpDir, "watched.ts"))), "watched.ts should fire", ); assert.ok( !uris.some((u) => u.includes("ignored-dir")), `ignored-dir/x.ts should be filtered, got ${JSON.stringify(uris)}`, ); }); it("respects WatchKind to filter event types", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); // Kind 1 = Create only - delete events should be suppressed. watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]); await new Promise((r) => setTimeout(r, 200)); const file = path.join(tmpDir, "only-create.ts"); fs.writeFileSync(file, "x"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); fs.unlinkSync(file); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); assert.ok( all.some((e) => e.type === 1), "Expected create event", ); assert.ok( !all.some((e) => e.type === 3), `Did not expect delete events, got ${JSON.stringify(all)}`, ); }); it("batches multiple rapid events into one onEvents call", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); // Write Several Files Quickly - within the 50ms debounce window they // should all land in one batch, capped at 500ms max wait. for (let i = 0; i < 5; i++) { fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x"); } await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`); // The batches should be << 5 (debounce coalesces). Allow up to 2 in // case the loop straddled a flush boundary. assert.ok( received.length <= 2, `Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`, ); }); it("ignores .git/ even when not in gitignore", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*" }]); await new Promise((r) => setTimeout(r, 200)); fs.mkdirSync(path.join(tmpDir, ".git")); fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); assert.ok( !all.some((e) => e.uri.includes("/.git/")), `.git contents should be ignored, got ${JSON.stringify(all)}`, ); }); it("stops emitting after dispose", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([{ globPattern: "**/*.ts" }]); await new Promise((r) => setTimeout(r, 200)); await watcher.dispose(); watcher = null; fs.writeFileSync(path.join(tmpDir, "post-dispose.ts"), "x"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); const all = received.flat(); assert.strictEqual(all.length, 0, `Expected no events after dispose`); }); it("matches absolute-path glob patterns (gopls-style)", async () => { // Regression - gopls registers e.g. `/tmp/.../**/*.{go,mod}` rather // than a bare relative `**/*.go`. The matcher must accept both. watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([ { globPattern: `${tmpDir}/**/*.{go,mod}` }, ]); await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n"); const ok = await waitFor(() => received .flat() .some((e) => e.uri === pathToUri(path.join(tmpDir, "helper.go"))), ); assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`); }); it("empty pattern set means no watching", async () => { watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs)); watcher.setPatterns([]); await new Promise((r) => setTimeout(r, 200)); fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x"); await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS)); assert.strictEqual(received.length, 0); }); });