pi-mono/packages/chord/test/node/plugins/watcher.test.ts
Mario Zechner edc9c69892 feat(chord): the reload suite, kit timeouts and base marks, four reload fixes
An end-to-end reload suite (a kit app, a worker, a real Chromium page; one test per row of design's reload table).
The kit gains timeouts and base (base bundles' inputs and worker entries mark base). Fixes: a call crossing a failed
or a removal gets the cause, not not_ready; a call sent with an old method index is decoded by the names its consumer
had until the new bound is acked; a page retries a failed script fetch; applyChanges runs one at a time.
2026-11-04 16:47:07 +00:00

240 lines
11 KiB
TypeScript

// Tests of the watcher (slice 7c §5, slice 12 §4): dirty marks from the files facets used and from plugin directories,
// `install`, `set`, `base`, a change during a build keeping its mark, `node_modules` and `.git` ignored, real file
// events, the marks as a JSON watchable, and single-file plugins' files.
import { readdirSync, readFileSync } from "node:fs";
import { realpath, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { type BuiltFacet, discoverPlugins } from "../../../src/node/plugins/index.ts";
import { PluginWatcher } from "../../../src/node/plugins/watcher.ts";
import { Batch } from "../../../src/state/internal.ts";
import { changesOf, delivered } from "../../support.ts";
import { KINDS, tempRoot, writeFiles, writePlugin } from "./support.ts";
/** A root with plugins `a` (worker), `b` (server) and the contracts fixture, a base directory, and a watcher of both. */
async function watched() {
const root = await realpath(await tempRoot(["contracts"]));
const base = await realpath(await tempRoot());
await writePlugin(root, "a", "@test/a", { "worker.ts": "", "own.ts": "" });
await writePlugin(root, "b", "@test/b", { "server.ts": "" });
const failures: unknown[] = [];
const watcher = new PluginWatcher(
{ roots: [root], base: [base], kinds: KINDS, failed: (error) => failures.push(error) },
new Batch(() => {}, undefined),
);
const discovery = await discoverPlugins([root], { kinds: KINDS }, context);
watcher.know(discovery, watcher.discoverySnapshot());
const contract = join(root, "contracts", "contract.ts");
const used = (plugin: string, facet: string, files: readonly string[]): BuiltFacet => ({
kind: "script",
plugin,
facet,
source: "",
digest: "",
inputs: files.map((file) => ({ file, hash: "" })),
copied: [contract],
assets: [],
});
watcher.used([
used("@test/a", "worker", [join(root, "a", "worker.ts"), contract]),
used("@test/b", "server", [join(root, "b", "server.ts"), contract]),
]);
return { root, base, watcher, failures };
}
describe("the watcher's marks", () => {
it("dirties every plugin whose facets used a changed file, and the plugin whose directory contains it", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "contracts", "contract.ts"));
expect(watcher.value).toEqual({
dirty: ["@test/a", "@test/b", "@test/contracts"],
install: [],
set: false,
base: false,
});
watcher.close();
});
it("dirties the plugin of a file no facet read yet; asks for an install for a package.json", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "a", "new.ts"));
watcher.mark(join(root, "b", "package.json"));
expect(watcher.value).toEqual({ dirty: ["@test/a"], install: ["@test/b"], set: false, base: false });
// Installed: the mark clears, and the plugin is dirty (its packages changed).
watcher.installed("@test/b");
expect(watcher.value).toEqual({ dirty: ["@test/a", "@test/b"], install: [], set: false, base: false });
watcher.close();
});
it("asks for discovery for a directory under a root and a facet file at a plugin's top; base for base code", async () => {
const { root, base, watcher } = await watched();
watcher.mark(join(root, "c"));
expect(watcher.value).toMatchObject({ set: true, dirty: [] });
const seen = watcher.discoverySnapshot();
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
expect(watcher.value).toMatchObject({ set: false });
watcher.mark(join(root, "b", "web.tsx"));
expect(watcher.value).toMatchObject({ set: true, dirty: ["@test/b"] });
watcher.mark(join(base, "core.ts"));
expect(watcher.value).toMatchObject({ base: true });
watcher.close();
});
it("marks base for a base file given one by one, not for its siblings; never under node_modules", async () => {
const { base, watcher } = await watched();
const elsewhere = await realpath(await tempRoot());
await writeFiles(elsewhere, { "entry.ts": "", "sibling.ts": "", "node_modules/react/index.js": "" });
watcher.baseFiles([join(elsewhere, "entry.ts"), join(elsewhere, "node_modules", "react", "index.js")]);
watcher.mark(join(elsewhere, "sibling.ts"));
watcher.mark(join(elsewhere, "node_modules", "react", "index.js"));
expect(watcher.value).toMatchObject({ base: false });
// A real event on the file: its directory is watched.
await writeFile(join(elsewhere, "entry.ts"), "export {};");
await vi.waitFor(() => expect(watcher.value).toMatchObject({ base: true }), { timeout: 5_000 });
expect(base).not.toBe(elsewhere);
watcher.close();
});
it("ignores node_modules and .git", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "a", "node_modules", "x", "index.js"));
watcher.mark(join(root, "a", ".git", "HEAD"));
expect(watcher.value).toEqual({ dirty: [], install: [], set: false, base: false });
watcher.close();
});
it("keeps the mark of a change during a build: only an unchanged mark clears when applied", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "a", "own.ts"));
const snapshot = watcher.snapshot(["@test/a"]);
// Saved again while the build runs.
watcher.mark(join(root, "a", "own.ts"));
watcher.applied(["@test/a"], snapshot);
expect(watcher.value).toMatchObject({ dirty: ["@test/a"] });
watcher.applied(["@test/a"], watcher.snapshot(["@test/a"]));
expect(watcher.value).toMatchObject({ dirty: [] });
// A discovery that started before a change of the set keeps `set`.
const seen = watcher.discoverySnapshot();
watcher.mark(join(root, "d"));
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
expect(watcher.value).toMatchObject({ set: true });
watcher.close();
});
it("marks from real file events, publishing the marks to a watch", async () => {
const { root, watcher } = await watched();
const changes = changesOf(await watcher.watch(context));
await writeFile(join(root, "a", "node_modules-free.ts"), "export {};");
await writeFiles(join(root, "b", "node_modules", "pkg"), { "index.js": "" });
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
await delivered();
expect(changes.at(-1)).toMatchObject({ kind: "value", value: { dirty: ["@test/a"] } });
watcher.close();
});
});
describe("the watcher and single-file plugins (slice 12 §4)", () => {
it("dirties a single-file plugin for a change of its file; a file appearing or disappearing at a root's top sets set", async () => {
const { root, watcher } = await watched();
await writeFiles(root, { "hello.server.ts": "" });
watcher.mark(join(root, "hello.server.ts"));
expect(watcher.value).toMatchObject({ set: true, dirty: [] });
const seen = watcher.discoverySnapshot();
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
expect(watcher.value).toMatchObject({ set: false, dirty: [] });
// Its own file: only it is dirty (not every plugin of the root its directory is).
watcher.mark(join(root, "hello.server.ts"));
expect(watcher.value).toMatchObject({ set: false, dirty: ["hello"] });
await writeFiles(root, { "hello.web.tsx": "" });
watcher.mark(join(root, "hello.web.tsx"));
expect(watcher.value).toMatchObject({ set: true });
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), watcher.discoverySnapshot());
await rm(join(root, "hello.server.ts"));
watcher.mark(join(root, "hello.server.ts"));
expect(watcher.value).toMatchObject({ set: true, dirty: ["hello"] });
watcher.close();
});
});
describe("the watcher, fix round 1", () => {
it("never lets an older build's applied snapshot clear a newer edit's mark (Astra)", async () => {
const { root, watcher } = await watched();
const file = join(root, "a", "own.ts");
watcher.mark(file);
const first = watcher.snapshot(["@test/a"]);
watcher.mark(file);
const second = watcher.snapshot(["@test/a"]);
watcher.applied(["@test/a"], second);
expect(watcher.value).toMatchObject({ dirty: [] });
watcher.mark(file);
watcher.applied(["@test/a"], first);
expect(watcher.value).toMatchObject({ dirty: ["@test/a"] });
watcher.close();
});
it("drops the install mark of a plugin the next discovery no longer has (example 38)", async () => {
const { root, watcher } = await watched();
watcher.mark(join(root, "b", "package.json"));
expect(watcher.value).toMatchObject({ install: ["@test/b"] });
await rm(join(root, "b"), { recursive: true });
const seen = watcher.discoverySnapshot();
watcher.know(await discoverPlugins([root], { kinds: KINDS }, context), seen);
expect(watcher.value).toMatchObject({ install: [] });
watcher.close();
});
it.skipIf(process.platform !== "linux")(
"puts no watch on node_modules or .git (Opus N2): 200 packages add no inotify watch",
async () => {
const root = await realpath(await tempRoot());
const dir = await writePlugin(root, "a", "@test/a", { "worker.ts": "" });
for (let i = 0; i < 200; i++)
await writeFiles(join(dir, "node_modules", `pkg${i}`, "lib"), { "index.js": "" });
await writeFiles(join(dir, ".git", "objects"), { x: "" });
const before = inotifyWatches();
const watcher = new PluginWatcher(
{ roots: [root], base: [], kinds: KINDS, failed: () => {} },
new Batch(() => {}, undefined),
);
// The root and the plugin's directory.
expect(inotifyWatches() - before).toBe(2);
watcher.close();
},
);
it("watches a directory that appears later, and marks the files in it", async () => {
const { root, watcher } = await watched();
await writeFiles(join(root, "a", "lib"), { "first.ts": "" });
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
watcher.applied(["@test/a"], watcher.snapshot(["@test/a"]));
await writeFile(join(root, "a", "lib", "second.ts"), "export {};");
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
watcher.close();
});
});
describe("the watcher and links", () => {
it("survives a link loop created under a root (its stat never escapes the callback) and still marks it (Sol)", async () => {
const { root, watcher } = await watched();
await symlink("loop", join(root, "a", "loop"));
await vi.waitFor(() => expect(watcher.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 });
watcher.close();
});
});
/** The inotify watches this process holds (Linux): one line per watch in its inotify descriptors' fdinfo. */
function inotifyWatches(): number {
let count = 0;
for (const fd of readdirSync("/proc/self/fdinfo")) {
try {
count += readFileSync(`/proc/self/fdinfo/${fd}`, "utf8")
.split("\n")
.filter((line) => line.startsWith("inotify wd:")).length;
} catch {
// Closed meanwhile.
}
}
return count;
}