294 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|