feat(watcher): forward FS events as workspace/didChangeWatchedFiles
LSP servers maintain their own workspace index built at initialize time and rely on the client to push file-system events. Previously the daemon only synced the single file being queried, so externally created/changed files (codegen, build scripts, git checkout, the agent's own writes from the perspective of other open files) left the server's index stale until manual /lsp-destroy. Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch) that translates FS events into workspace/didChangeWatchedFiles batches. Patterns come from the server via client/registerCapability (no speculative watching). Ignores layer a tiny baseline (.git, .DS_Store) over the repo's root .gitignore, with a fallback list for non-git workspaces. Events debounce 50ms quiet / 500ms max wait. Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather than relative ones, so compileWatchers() matches each event against both relative and absolute path forms. Caught by the integration test; unit regression test added. Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation. - src/client.ts: honor register/unregisterCapability for workspace/didChangeWatchedFiles; advertise dynamicRegistration; expose getFileWatchers/onWatchersChanged/sendNotification - src/watcher.ts: new WorkspaceWatcher with layered ignores, debounce+batch, Created+Deleted coalescing, dual-form glob matching - src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS, LSP_DEBUG-gated pattern/event logging - test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir - test/integration/watcher-gopls.test.ts: end-to-end against gopls - AGENTS.md: new "Workspace File Watching" section - flake.nix: add go (required by gopls integration test)
This commit is contained in:
249
test/unit/watcher.test.ts
Normal file
249
test/unit/watcher.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// 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<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user