Skip to content

Commit

Permalink
faster, more robust file watcher tests? (#316)
Browse files Browse the repository at this point in the history
* faster, more robust file watcher tests?

* ignore if temp.csv changed

* resolve array
  • Loading branch information
mbostock authored Dec 5, 2023
1 parent a2b22e8 commit 2b4190f
Showing 1 changed file with 61 additions and 96 deletions.
157 changes: 61 additions & 96 deletions test/fileWatchers-test.ts
Original file line number Diff line number Diff line change
@@ -1,190 +1,135 @@
import assert from "node:assert";
import {renameSync, unlinkSync, utimesSync, writeFileSync} from "node:fs";
import {InternSet, difference} from "d3-array";
import {FileWatchers} from "../src/fileWatchers.js";

describe("FileWatchers.of(root, path, names, callback)", () => {
const root = "test/input/build/files";
it("watches a file", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["file-top.csv"]));
assert.deepStrictEqual(await watches(), ["file-top.csv"]);
} finally {
watcher.close();
}
});
it("watches multiple files", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv", "subsection/file-sub.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv", "subsection/file-sub.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
touch("test/input/build/files/subsection/file-sub.csv");
await pause();
assert.deepStrictEqual(names, new Set(["file-top.csv", "subsection/file-sub.csv"]));
assert.deepStrictEqual(await watches(), ["file-top.csv", "subsection/file-sub.csv"]);
} finally {
watcher.close();
}
});
it("watches a file generated by a data loader", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of("test/input/build/simple", "simple.md", ["data.txt"], watch);
const [watcher, watches] = await useWatcher("test/input/build/simple", "simple.md", ["data.txt"]);
try {
await pause();
touch("test/input/build/simple/data.txt.sh");
await pause();
assert.deepStrictEqual(names, new Set(["data.txt"]));
assert.deepStrictEqual(await watches(), ["data.txt"]);
} finally {
watcher.close();
}
});
it("watches a file within a static archive", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of("test/input/build/archives", "zip.md", ["static/file.txt"], watch);
const [watcher, watches] = await useWatcher("test/input/build/archives", "zip.md", ["static/file.txt"]);
try {
await pause();
touch("test/input/build/archives/static.zip");
await pause();
assert.deepStrictEqual(names, new Set(["static/file.txt"]));
assert.deepStrictEqual(await watches(), ["static/file.txt"]);
} finally {
watcher.close();
}
});
it("watches a file within an archive created by a data loader", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of("test/input/build/archives", "zip.md", ["dynamic/file.txt"], watch);
const [watcher, watches] = await useWatcher("test/input/build/archives", "zip.md", ["dynamic/file.txt"]);
try {
await pause();
touch("test/input/build/archives/dynamic.zip.sh");
await pause();
assert.deepStrictEqual(names, new Set(["dynamic/file.txt"]));
assert.deepStrictEqual(await watches(), ["dynamic/file.txt"]);
} finally {
watcher.close();
}
});
it("deduplicates watched files", async () => {
const names: string[] = [];
const watch = (name: string) => names.push(name);
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv", "file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv", "file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, ["file-top.csv"]);
assert.deepStrictEqual(await watches(), ["file-top.csv"]);
} finally {
watcher.close();
}
});
it("deduplicates watched files based on name, not normalized path", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv", "./file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv", "./file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["file-top.csv", "./file-top.csv"]));
assert.deepStrictEqual(await watches(), ["./file-top.csv", "file-top.csv"]);
} finally {
watcher.close();
}
});
it("resolves relative paths", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "subsection/subfiles.md", ["./file-sub.csv", "../file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "subsection/subfiles.md", ["./file-sub.csv", "../file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["../file-top.csv"]));
assert.deepStrictEqual(await watches(), ["../file-top.csv"]);
} finally {
watcher.close();
}
});
it("resolves absolute paths", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "subsection/subfiles.md", ["/file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "subsection/subfiles.md", ["/file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["/file-top.csv"]));
assert.deepStrictEqual(await watches(), ["/file-top.csv"]);
} finally {
watcher.close();
}
});
it("ignores missing files", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const watcher = await FileWatchers.of(root, "files.md", ["does-not-exist.csv", "file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["does-not-exist.csv", "file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["file-top.csv"]));
assert.deepStrictEqual(await watches(), ["file-top.csv"]);
} finally {
watcher.close();
}
});
it("ignores changes that don’t affect the modification time", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const then = new Date();
touch("test/input/build/files/file-top.csv", then);
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv"]);
try {
await pause();
touch("test/input/build/files/file-top.csv", then);
await pause();
assert.deepStrictEqual(names, new Set());
assert.deepStrictEqual(await watches(10), []);
} finally {
watcher.close();
}
});
it("ignores changes to empty files", async () => {
const names = new Set<string>();
const watch = (name: string) => names.add(name);
const then = new Date();
const watcher = await FileWatchers.of("test/input", "comment.md", ["empty.js"], watch);
const [watcher, watches] = await useWatcher("test/input", "comment.md", ["empty.js"]);
try {
await pause();
touch("test/input/empty.js", then);
await pause();
assert.deepStrictEqual(names, new Set());
assert.deepStrictEqual(await watches(10), []);
} finally {
watcher.close();
}
});
it("handles a file being renamed", async () => {
let names: Set<string>;
const watch = (name: string) => names.add(name);
writeFileSync("test/input/build/files/temp.csv", "hello", "utf-8");
try {
writeFileSync("test/input/build/files/temp.csv", "hello", "utf-8");
const watcher = await FileWatchers.of(root, "files.md", ["temp.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["temp.csv"]);
try {
// First rename the file, while writing a new file to the same place.
names = new Set<string>();
await pause();
renameSync("test/input/build/files/temp.csv", "test/input/build/files/temp2.csv");
writeFileSync("test/input/build/files/temp.csv", "hello 2", "utf-8");
await pause(150); // avoid debounce
assert.deepStrictEqual(names, new Set(["temp.csv"]));
assert.deepStrictEqual(await watches(), ["temp.csv"]);

// Then test that writing to the original location watches the new file.
names = new Set<string>();
await pause();
writeFileSync("test/input/build/files/temp.csv", "hello 3", "utf-8");
await pause();
assert.deepStrictEqual(names, new Set(["temp.csv"]));
assert.deepStrictEqual(await watches(), ["temp.csv"]);
} finally {
watcher.close();
}
Expand All @@ -194,23 +139,17 @@ describe("FileWatchers.of(root, path, names, callback)", () => {
}
});
it("handles a file being renamed and removed", async () => {
let names: Set<string>;
const watch = (name: string) => names.add(name);
writeFileSync("test/input/build/files/temp.csv", "hello", "utf-8");
try {
writeFileSync("test/input/build/files/temp.csv", "hello", "utf-8");
const watcher = await FileWatchers.of(root, "files.md", ["file-top.csv", "temp.csv"], watch);
const [watcher, watches] = await useWatcher(root, "files.md", ["file-top.csv", "temp.csv"]);
try {
// First delete the temp file. We don’t care if this is reported as a change or not.
names = new Set<string>();
await pause();
unlinkSync("test/input/build/files/temp.csv");
await pause(150);
await pause();

// Then touch a different file to make sure the watcher is still alive.
names = new Set<string>();
touch("test/input/build/files/file-top.csv");
await pause();
assert.deepStrictEqual(names, new Set(["file-top.csv"]));
assert.deepStrictEqual(difference(await watches(), ["temp.csv"]), new InternSet(["file-top.csv"]));
} finally {
watcher.close();
}
Expand All @@ -220,8 +159,34 @@ describe("FileWatchers.of(root, path, names, callback)", () => {
});
});

async function pause(delay = 10): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, delay));
async function useWatcher(
root: string,
path: string,
names: string[]
): Promise<[watcher: FileWatchers, wait: (delay?: number) => Promise<string[]>]> {
let watches = new Set<string>();
let resume: ((value: string[]) => void) | null = null;
const wait = (delay?: number) => {
if (resume) throw new Error("already waiting");
const promise = new Promise<string[]>((y) => (resume = y));
if (delay == null) return promise;
const timeout = new Promise<string[]>((y) => setTimeout(() => y([...watches].sort()), delay));
return Promise.race([promise, timeout]);
};
const watch = (name: string) => {
watches.add(name);
const r = resume;
if (r == null) return;
resume = null;
setTimeout(() => (r([...watches].sort()), (watches = new Set<string>())), 10);
};
const watcher = await FileWatchers.of(root, path, names, watch);
await pause();
return [watcher, wait];
}

async function pause(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
}

function touch(path: string, date = new Date()): void {
Expand Down

0 comments on commit 2b4190f

Please sign in to comment.