Files
pi-lsp/test/unit/watcher.test.ts

294 lines
9.5 KiB
TypeScript

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";
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();
}
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 = [];
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));
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();
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}`);
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 () => {
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();
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 () => {
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();
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`);
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");
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);
});
});