Files
hyperframes/packages/cli/src/server/fileWatcher.test.ts
T
Miguel Ángel 93c321285c fix(studio): render scratch stays out of the project and error telemetry counts real failures only (#5902)
* fix(studio): a lost preview server or a superseded edit no longer files as a crash

* fix(studio-server): a render's scratch files no longer stream changes or list as compositions
2026-10-19 19:52:04 +00:00

176 lines
7.1 KiB
TypeScript

import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
type WatchCallback = (eventType: string, filename: string | Buffer | null) => void;
const mockWatcher = new EventEmitter() as EventEmitter & { close: () => void };
mockWatcher.close = vi.fn();
const fakeDirs = { children: [] as string[], unwatchable: "" };
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs")>();
return {
...original,
watch: vi.fn((path: string, _options: unknown, onChange: WatchCallback) => {
if (fakeDirs.unwatchable && path.endsWith(fakeDirs.unwatchable)) {
throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" });
}
mockWatcher.on("change", onChange);
return mockWatcher;
}),
readdirSync: vi.fn((path: string) =>
path === "/fake/project/dir"
? fakeDirs.children.map((name) => ({ name, isDirectory: () => true }))
: [],
),
};
});
const { shouldWatchProjectFile, createProjectWatcher } = await import("./fileWatcher.js");
const { watch } = await import("node:fs");
describe("shouldWatchProjectFile", () => {
it("watches files that can affect the project signature", () => {
expect(shouldWatchProjectFile("index.html")).toBe(true);
expect(shouldWatchProjectFile("src/scene.tsx")).toBe(true);
expect(shouldWatchProjectFile("assets/hero.png")).toBe(true);
expect(shouldWatchProjectFile("Dockerfile")).toBe(true);
});
it("skips generated and dependency directories", () => {
expect(shouldWatchProjectFile("node_modules/pkg/index.js")).toBe(false);
expect(shouldWatchProjectFile("renders/output.mp4")).toBe(false);
expect(shouldWatchProjectFile("dist/index.html")).toBe(false);
expect(shouldWatchProjectFile(".hyperframes/cache.json")).toBe(false);
expect(shouldWatchProjectFile(".transcode-cache/proxy.mp4")).toBe(false);
expect(shouldWatchProjectFile(".thumbnails/frame.jpg")).toBe(false);
expect(shouldWatchProjectFile(".waveform-cache/peaks.json")).toBe(false);
});
it("skips a render's scratch dirs, so a render beside the project does not stream changes", () => {
const work = "work-0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d-Ab12Cd";
expect(shouldWatchProjectFile(`${work}/compiled/index.html`)).toBe(false);
expect(shouldWatchProjectFile(`${work}/frames/frame-000042.png`)).toBe(false);
expect(shouldWatchProjectFile(".video.mp4.hf-transaction-Ab12Cd/video.mp4")).toBe(false);
expect(shouldWatchProjectFile("work-notes/index.html")).toBe(true);
});
it("skips the temp file of a save in flight, but not a user's own .tmp file", () => {
expect(shouldWatchProjectFile("index.html.hf0a1b2c.tmp")).toBe(false);
expect(shouldWatchProjectFile("compositions/intro.html.hf0a1b2c.tmp")).toBe(false);
expect(shouldWatchProjectFile("foo.12345678.tmp")).toBe(true);
expect(shouldWatchProjectFile("notes.tmp")).toBe(true);
});
});
describe("createProjectWatcher", () => {
beforeEach(() => {
mockWatcher.removeAllListeners();
vi.clearAllMocks();
fakeDirs.children = [];
fakeDirs.unwatchable = "";
vi.useRealTimers();
});
it("notifies once for every file changed in one debounce burst", () => {
vi.useFakeTimers();
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);
mockWatcher.emit("change", "change", "scene-a.html");
mockWatcher.emit("change", "change", "scene-b.html");
mockWatcher.emit("change", "change", "scene-a.html");
vi.advanceTimersByTime(29);
expect(listener).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(listener.mock.calls).toEqual([["scene-a.html"], ["scene-b.html"]]);
projectWatcher.close();
});
it.runIf(process.platform === "linux")(
"keeps watching the rest of the tree when one subdirectory cannot be watched",
() => {
fakeDirs.children = ["full", "compositions"];
fakeDirs.unwatchable = "full";
const projectWatcher = createProjectWatcher("/fake/project/dir");
expect(vi.mocked(watch).mock.calls.map(([path]) => path)).toContain(
"/fake/project/dir/compositions",
);
projectWatcher.close();
expect(mockWatcher.close).toHaveBeenCalled();
},
);
it.runIf(process.platform === "linux")("closes every watch it opened, its parent's too", () => {
const projectWatcher = createProjectWatcher("/fake/project/dir");
const opened = vi.mocked(watch).mock.calls.map(([path]) => path);
expect(opened).toContain("/fake/project");
projectWatcher.close();
expect(mockWatcher.close).toHaveBeenCalledTimes(opened.length);
});
it.runIf(process.platform === "linux")(
"keeps reporting project files when its parent cannot be watched",
() => {
vi.useFakeTimers();
fakeDirs.unwatchable = "/fake/project";
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);
mockWatcher.emit("change", "change", "index.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenCalledExactlyOnceWith("index.html");
projectWatcher.close();
},
);
it("degrades to no live reload when the project root cannot be watched", () => {
fakeDirs.unwatchable = "/fake/project/dir";
let projectWatcher: ReturnType<typeof createProjectWatcher> | null = null;
expect(() => {
projectWatcher = createProjectWatcher("/fake/project/dir");
}).not.toThrow();
expect(() => projectWatcher?.close()).not.toThrow();
});
it("flushes at most once per 300 ms while writes keep coming", () => {
vi.useFakeTimers();
const projectWatcher = createProjectWatcher("/fake/project/dir");
const listener = vi.fn();
projectWatcher.addListener(listener);
mockWatcher.emit("change", "change", "a.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenCalledTimes(1);
mockWatcher.emit("change", "change", "b.html");
vi.advanceTimersByTime(100);
mockWatcher.emit("change", "change", "c.html");
vi.advanceTimersByTime(199);
expect(listener).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(listener.mock.calls.slice(1)).toEqual([["b.html"], ["c.html"]]);
// A save that lands late in the window waits only for quiet, not a whole window.
vi.advanceTimersByTime(290);
mockWatcher.emit("change", "change", "d.html");
vi.advanceTimersByTime(30);
expect(listener).toHaveBeenLastCalledWith("d.html");
projectWatcher.close();
});
// Regression: fs.watch can fail asynchronously (e.g. EMFILE from exhausted
// OS watch handles) via an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process — this
// must degrade gracefully instead, per the sibling synchronous-failure path.
it("does not crash the process when the underlying watcher emits 'error'", () => {
createProjectWatcher("/fake/project/dir");
expect(() => mockWatcher.emit("error", new Error("EMFILE"))).not.toThrow();
expect(mockWatcher.close).toHaveBeenCalled();
});
});