Addresses review feedback on 7787626:
1. Deleted->Created at the same path is a file replacement, not a no-op.
Previous coalescing dropped both Created->Deleted and Deleted->Created;
the latter left the server with no signal to re-read replaced content.
Now: Deleted->Created collapses to Changed, Created->Changed keeps
Created (server didn't know the file at all). Extracted coalesce() so
the matrix is reviewable in one place.
2. setPatterns([]) (server unregistered all watchers) stopped chokidar
but left pending events + timers intact, so a queued batch could
still fire after the server stopped caring. Now drains via
cancelPending() before stopping chokidar.
3. Added ready() returning a promise resolved by chokidar's initial-scan
'ready' event. Production daemon doesn't need to await it (LSP
handshake gives chokidar ample wall-time), but tests now use it
instead of fixed 200ms sleeps - deflakes the suite on slower
filesystems and addresses the (narrow) startup race where a file
created during chokidar's initial crawl could be missed.
4. Unit tests replace 11 hardcoded sleeps with watcher.ready(), and add
coverage for the two coalesce fixes plus the unregister-drains case.
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
// 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" }]);
|
|
await watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
// 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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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 watcher.ready();
|
|
|
|
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("coalesces Created+Deleted to no-op", async () => {
|
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
|
await watcher.ready();
|
|
|
|
// Create Then Delete - both within the debounce quiet window.
|
|
const file = path.join(tmpDir, "transient.ts");
|
|
fs.writeFileSync(file, "x");
|
|
fs.unlinkSync(file);
|
|
|
|
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
|
|
|
const all = received.flat();
|
|
assert.ok(
|
|
!all.some((e) => e.uri === pathToUri(file)),
|
|
`Transient file should not surface, got ${JSON.stringify(all)}`,
|
|
);
|
|
});
|
|
|
|
it("coalesces Deleted+Created (replacement) to Changed", async () => {
|
|
// Setup - file exists before the watcher starts, so chokidar's initial
|
|
// scan registers it (with ignoreInitial: true, no event fires).
|
|
const file = path.join(tmpDir, "replace.ts");
|
|
fs.writeFileSync(file, "v1");
|
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
|
await watcher.ready();
|
|
|
|
// Delete Then Recreate - within one debounce window.
|
|
fs.unlinkSync(file);
|
|
fs.writeFileSync(file, "v2");
|
|
|
|
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
|
|
|
const all = received.flat();
|
|
const events = all.filter((e) => e.uri === pathToUri(file));
|
|
assert.ok(events.length > 0, `Expected an event for replaced file`);
|
|
// Expected: the coalesced single event is Changed (type=2). Some
|
|
// platforms may split the delete+create across debounce windows, in
|
|
// which case the server still sees correct state. Accept either:
|
|
// - one Changed event (coalesced), or
|
|
// - Deleted then Created in separate batches (not coalesced, fine)
|
|
const types = events.map((e) => e.type).sort();
|
|
const acceptable =
|
|
JSON.stringify(types) === JSON.stringify([2]) ||
|
|
JSON.stringify(types) === JSON.stringify([1, 3]);
|
|
assert.ok(
|
|
acceptable,
|
|
`Expected [2] or [1,3], got ${JSON.stringify(types)}`,
|
|
);
|
|
});
|
|
|
|
it("drops pending events when patterns are cleared", async () => {
|
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
|
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
|
await watcher.ready();
|
|
|
|
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
|
|
// Don't wait for the debounce - clear patterns immediately so the
|
|
// pending batch should be dropped, not flushed.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
watcher.setPatterns([]);
|
|
|
|
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
|
|
|
assert.strictEqual(
|
|
received.length,
|
|
0,
|
|
`Pending batch should be dropped, got ${JSON.stringify(received)}`,
|
|
);
|
|
});
|
|
|
|
it("empty pattern set means no watching", async () => {
|
|
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
|
watcher.setPatterns([]);
|
|
await watcher.ready();
|
|
|
|
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
|
|
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
|
|
|
assert.strictEqual(received.length, 0);
|
|
});
|
|
});
|