mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): catch up the preview after missed file changes (#5126)
* fix(studio): recover previews after missed file changes * fix(studio): keep queued reloads with the current owner * fix(studio): keep a queued outside change when switching compositions * test(cli): the project watcher closes its parent folder's watch too * fix(studio): keep the project-folder change out of the shared signature check * fix(studio): keep the edited file's draft and reload every session after a reconnect A reconnect or replaced folder is a project-wide change: every open SDK session reloads, a failed save is recorded against the file the draft belongs to, and two queued changes to different files merge into one project-wide reload. * fix(studio): refresh the preview cache when the .hyperframes folder is replaced * fix(cli): report the files of a folder moved into the project Replacing a populated folder such as .hyperframes/ now reaches the signature cache through its files, so the shared signature rule stays as on main and Studio's own writes inside .hyperframes/ still invalidate nothing. * refactor(studio): name the merged file-change payload and share the test sender * fix(studio): keep a failed draft's recovery copy until that file itself saves * style(studio): rewrap the merged payload line after the rename * fix(studio): deliver a held change's full reload when its block clears A clean save, Use external and Keep Studio now all deliver the merged scope a blocked change owes, and a moved-in .hyperframes folder reports its manifests so a dropped one still clears the cached signature. * fix(studio): keep restored drafts out of the reload scope and clear the lint gates * fix(studio): retain held reload scope before React renders
This commit is contained in:
@@ -14,11 +14,10 @@ describe("createProjectWatcher on a real directory", () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const watchProject = async () => {
|
||||
const watchProject = (projectDir = dir) => {
|
||||
const seen: string[] = [];
|
||||
watcher = createProjectWatcher(dir);
|
||||
watcher = createProjectWatcher(projectDir);
|
||||
watcher.addListener((path) => seen.push(path));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return seen;
|
||||
};
|
||||
const expectReported = async (seen: string[], path: string) => {
|
||||
@@ -35,7 +34,7 @@ describe("createProjectWatcher on a real directory", () => {
|
||||
mkdirSync(join(dir, "compositions"));
|
||||
writeFileSync(join(dir, "index.html"), "v0");
|
||||
writeFileSync(join(dir, "compositions", "scene.html"), "v0");
|
||||
const seen = await watchProject();
|
||||
const seen = watchProject();
|
||||
|
||||
for (const path of ["index.html", join("compositions", "scene.html")]) {
|
||||
replaceByRename(join(dir, path), "stamped");
|
||||
@@ -47,10 +46,71 @@ describe("createProjectWatcher on a real directory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"follows a project folder replaced by rename",
|
||||
async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-root-"));
|
||||
const project = join(dir, "project");
|
||||
const replacement = join(dir, "replacement");
|
||||
mkdirSync(project);
|
||||
mkdirSync(join(replacement, "scenes"), { recursive: true });
|
||||
writeFileSync(join(project, "index.html"), "before");
|
||||
writeFileSync(join(replacement, "index.html"), "after");
|
||||
writeFileSync(join(replacement, "scenes", "intro.html"), "new scene");
|
||||
const seen = watchProject(project);
|
||||
|
||||
renameSync(project, join(dir, "previous"));
|
||||
renameSync(replacement, project);
|
||||
await expectReported(seen, ".");
|
||||
writeFileSync(join(project, "index.html"), "edited root");
|
||||
await expectReported(seen, "index.html");
|
||||
writeFileSync(join(project, "scenes", "intro.html"), "edited scene");
|
||||
await expectReported(seen, join("scenes", "intro.html"));
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"re-arms after the project path is absent between replacements",
|
||||
async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-gap-"));
|
||||
const project = join(dir, "project");
|
||||
mkdirSync(project);
|
||||
writeFileSync(join(project, "index.html"), "before");
|
||||
const seen = watchProject(project);
|
||||
|
||||
renameSync(project, join(dir, "previous"));
|
||||
await expectReported(seen, ".");
|
||||
mkdirSync(project);
|
||||
writeFileSync(join(project, "index.html"), "after");
|
||||
await expectReported(seen, ".");
|
||||
writeFileSync(join(project, "index.html"), "later edit");
|
||||
await expectReported(seen, "index.html");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"follows a replaced subdirectory's new inode",
|
||||
async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-subdir-"));
|
||||
const scenes = join(dir, "scenes");
|
||||
mkdirSync(scenes);
|
||||
mkdirSync(join(dir, "replacement"));
|
||||
writeFileSync(join(scenes, "intro.html"), "before");
|
||||
writeFileSync(join(dir, "replacement", "intro.html"), "after");
|
||||
const seen = watchProject();
|
||||
|
||||
renameSync(scenes, join(dir, "previous"));
|
||||
renameSync(join(dir, "replacement"), scenes);
|
||||
await expectReported(seen, "scenes");
|
||||
writeFileSync(join(scenes, "intro.html"), "later edit");
|
||||
await expectReported(seen, join("scenes", "intro.html"));
|
||||
},
|
||||
);
|
||||
|
||||
it("reports Studio's manifest writes inside .hyperframes", async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
|
||||
mkdirSync(join(dir, ".hyperframes"));
|
||||
const seen = await watchProject();
|
||||
const seen = watchProject();
|
||||
|
||||
writeFileSync(join(dir, ".hyperframes", "studio-motion.json"), "{}");
|
||||
await expectReported(seen, join(".hyperframes", "studio-motion.json"));
|
||||
@@ -60,7 +120,7 @@ describe("createProjectWatcher on a real directory", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
|
||||
mkdirSync(join(dir, "scene"));
|
||||
mkdirSync(join(dir, "scenes"));
|
||||
const seen = await watchProject();
|
||||
const seen = watchProject();
|
||||
|
||||
rmSync(join(dir, "scene"), { recursive: true });
|
||||
await expectReported(seen, "scene");
|
||||
@@ -70,7 +130,7 @@ describe("createProjectWatcher on a real directory", () => {
|
||||
|
||||
it("reports files in a directory created after it started", async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-watch-"));
|
||||
const seen = await watchProject();
|
||||
const seen = watchProject();
|
||||
|
||||
mkdirSync(join(dir, "scenes"));
|
||||
await expectReported(seen, "scenes");
|
||||
|
||||
@@ -97,6 +97,29 @@ describe("createProjectWatcher", () => {
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { lstatSync, readdirSync, watch, type Dirent, type FSWatcher } from "node:fs";
|
||||
import { basename, dirname, join, relative, sep } from "node:path";
|
||||
import { isAtomicTempPath } from "@hyperframes/core/atomic-file";
|
||||
import { affectsProjectSignature } from "@hyperframes/studio-server";
|
||||
import {
|
||||
affectsProjectSignature,
|
||||
STUDIO_SIGNATURE_MANIFEST_PATHS,
|
||||
} from "@hyperframes/studio-server";
|
||||
|
||||
export type FileChangeListener = (relativePath: string) => void;
|
||||
|
||||
@@ -70,8 +73,8 @@ function watchProjectTree(
|
||||
}
|
||||
}
|
||||
};
|
||||
const watchDirectory = (dir: string) => {
|
||||
if (directories.has(dir)) return;
|
||||
const watchDirectory = (dir: string, movedIn = false) => {
|
||||
if (directories.has(dir)) unwatch(dir);
|
||||
let watcher: FSWatcher;
|
||||
try {
|
||||
watcher = watch(dir, { persistent: true }, (event, name) => {
|
||||
@@ -79,34 +82,54 @@ function watchProjectTree(
|
||||
const path = join(dir, name.toString());
|
||||
onChange(relative(projectDir, path));
|
||||
if (event !== "rename") return;
|
||||
if (isDirectory(path)) descend(path);
|
||||
if (isDirectory(path)) descend(path, true);
|
||||
else unwatch(path);
|
||||
});
|
||||
} catch (error) {
|
||||
// One unwatchable subdirectory (EACCES, inotify limit) must not cost the rest of the tree.
|
||||
if (dir === projectDir) throw error;
|
||||
} catch {
|
||||
// A directory can vanish during replacement; its parent reports its return.
|
||||
return;
|
||||
}
|
||||
watcher.on("error", () => unwatch(dir));
|
||||
directories.set(dir, watcher);
|
||||
let entries: string[] = [];
|
||||
walkChildren(dir, movedIn);
|
||||
};
|
||||
const walkChildren = (dir: string, movedIn: boolean) => {
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => join(dir, entry.name));
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Gone before we could list it; its parent reports the removal.
|
||||
}
|
||||
for (const child of entries) descend(child);
|
||||
for (const entry of entries) {
|
||||
const child = join(dir, entry.name);
|
||||
// A folder moved in brings files that never get an event of their own, and drops others.
|
||||
if (entry.isDirectory()) descend(child, movedIn);
|
||||
else if (movedIn) onChange(relative(projectDir, child));
|
||||
}
|
||||
if (movedIn && relative(projectDir, dir) === ".hyperframes") {
|
||||
for (const manifest of STUDIO_SIGNATURE_MANIFEST_PATHS) onChange(manifest);
|
||||
}
|
||||
};
|
||||
// `.hyperframes/` itself holds the two manifests the signature reads; nothing below it matters.
|
||||
const descend = (dir: string) => {
|
||||
const descend = (dir: string, movedIn = false) => {
|
||||
const rel = relative(projectDir, dir);
|
||||
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir);
|
||||
if (shouldWatchProjectFile(rel) || rel === ".hyperframes") watchDirectory(dir, movedIn);
|
||||
};
|
||||
|
||||
let parent: FSWatcher | null = null;
|
||||
try {
|
||||
parent = watch(dirname(projectDir), { persistent: true }, (event, name) => {
|
||||
if (event !== "rename" || name?.toString() !== basename(projectDir)) return;
|
||||
watchDirectory(projectDir);
|
||||
onChange(".");
|
||||
});
|
||||
parent.on("error", () => parent?.close());
|
||||
} catch {
|
||||
// The project can remain watchable even when its parent is not.
|
||||
}
|
||||
watchDirectory(projectDir);
|
||||
return () => {
|
||||
parent?.close();
|
||||
for (const watcher of directories.values()) watcher.close();
|
||||
directories.clear();
|
||||
};
|
||||
@@ -121,11 +144,8 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher {
|
||||
|
||||
try {
|
||||
closeTree = watchProjectTree(projectDir, (relativePath) => {
|
||||
// The reload filter excludes all of `.hyperframes/`, but two files in
|
||||
// there feed the preview signature and Studio writes one of them at
|
||||
// runtime — dropping those at ingest left the CLI server's ETag stale
|
||||
// until restart. Admit them here and let the reload listener re-apply
|
||||
// its own filter, so what triggers a browser reload is unchanged.
|
||||
// Studio's two manifests affect the signature despite the .hyperframes exclusion.
|
||||
// Admit them here; the reload listener still applies its own filter.
|
||||
if (
|
||||
!shouldWatchProjectFile(relativePath) &&
|
||||
!affectsProjectSignature(projectDir, join(projectDir, relativePath))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { compositionsAffectedBy } from "@hyperframes/studio-server";
|
||||
import { createStudioServer, type StudioServer } from "./studioServer.js";
|
||||
import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js";
|
||||
|
||||
let root: string;
|
||||
let server: StudioServer | undefined;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await reader?.cancel();
|
||||
if (server) cleanupStudioServerRoot(server, root);
|
||||
});
|
||||
|
||||
it.runIf(process.platform === "linux")(
|
||||
"refreshes every composition and cached preview when its project directory is replaced",
|
||||
async () => {
|
||||
const fixture = makeStudioServerRoot("hf-preview-replace-");
|
||||
root = fixture.root;
|
||||
const { projectDir } = fixture;
|
||||
const rootHtml = (label: string) => `<html><head></head><body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180">
|
||||
<p>${label}</p><div data-composition-id="intro" data-composition-src="scenes/intro.html" data-start="0" data-duration="1"></div>
|
||||
</div></body></html>`;
|
||||
const scene = (
|
||||
text: string,
|
||||
) => `<template><div data-composition-id="intro" data-width="320" data-height="180">
|
||||
<h1>${text}</h1></div></template>`;
|
||||
mkdirSync(join(projectDir, "scenes"));
|
||||
writeFileSync(join(projectDir, "index.html"), rootHtml("Initial"));
|
||||
writeFileSync(join(projectDir, "scenes/intro.html"), scene("Original"));
|
||||
expect(compositionsAffectedBy(projectDir, "index.html")).toBeNull();
|
||||
writeFileSync(join(projectDir, "index.html"), rootHtml("Unchanged root"));
|
||||
server = createStudioServer({ projectDir, projectName: "film" });
|
||||
expect(compositionsAffectedBy(projectDir, "index.html")).toEqual(["index.html"]);
|
||||
const app = server.app;
|
||||
const endpoint = "/api/projects/film/preview/comp/scenes/intro.html";
|
||||
const before = await app.request(endpoint);
|
||||
expect(before.status).toBe(200);
|
||||
expect(await before.text()).toContain("Original");
|
||||
const beforeTag = before.headers.get("etag");
|
||||
expect(beforeTag).toBeTruthy();
|
||||
|
||||
const response = await app.request("/api/events");
|
||||
reader = response.body!.getReader();
|
||||
const deliveries: string[] = [];
|
||||
const consume = async () => {
|
||||
while (true) {
|
||||
const next = await reader!.read();
|
||||
if (next.done) return;
|
||||
deliveries.push(new TextDecoder().decode(next.value));
|
||||
}
|
||||
};
|
||||
const consuming = consume();
|
||||
try {
|
||||
const replacement = join(root, "replacement");
|
||||
mkdirSync(join(replacement, "scenes"), { recursive: true });
|
||||
writeFileSync(join(replacement, "index.html"), readFileSync(join(projectDir, "index.html")));
|
||||
writeFileSync(join(replacement, "scenes/intro.html"), scene("Replacement"));
|
||||
renameSync(projectDir, join(root, "previous"));
|
||||
renameSync(replacement, projectDir);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const event = deliveries
|
||||
.join("")
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data:") && line.includes('"path":"."'));
|
||||
expect(event).toBeDefined();
|
||||
expect(JSON.parse(event!.slice(5))).toMatchObject({
|
||||
path: ".",
|
||||
projectId: "film",
|
||||
affectsPreview: true,
|
||||
affectedCompositions: null,
|
||||
});
|
||||
});
|
||||
const after = await app.request(endpoint, { headers: { "If-None-Match": beforeTag! } });
|
||||
expect(after.status).toBe(200);
|
||||
expect(after.headers.get("etag")).not.toBe(beforeTag);
|
||||
expect(await after.text()).toContain("Replacement");
|
||||
|
||||
writeFileSync(join(projectDir, "scenes/intro.html"), scene("Later edit"));
|
||||
await vi.waitFor(async () => {
|
||||
const later = await app.request(endpoint);
|
||||
expect(later.status).toBe(200);
|
||||
expect(await later.text()).toContain("Later edit");
|
||||
});
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
await consuming;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform === "linux").each([
|
||||
{ kept: "a changed manifest", file: "studio-motion.json", content: '{"intro":{"opacity":0.5}}' },
|
||||
{ kept: "no manifest", file: "arbitrary.txt", content: "not a manifest" },
|
||||
])(
|
||||
"refreshes the cached preview when a populated .hyperframes folder is replaced by one with $kept",
|
||||
async ({ file, content }) => {
|
||||
const fixture = makeStudioServerRoot("hf-preview-replace-manifests-");
|
||||
root = fixture.root;
|
||||
const { projectDir } = fixture;
|
||||
writeFileSync(
|
||||
join(projectDir, "index.html"),
|
||||
`<html><body><div data-composition-id="main" data-width="320" data-height="180"></div></body></html>`,
|
||||
);
|
||||
mkdirSync(join(projectDir, ".hyperframes"));
|
||||
writeFileSync(join(projectDir, ".hyperframes/studio-motion.json"), "{}");
|
||||
server = createStudioServer({ projectDir, projectName: "film" });
|
||||
const endpoint = "/api/projects/film/preview/comp/index.html";
|
||||
const before = await server.app.request(endpoint);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeTag = before.headers.get("etag");
|
||||
expect(beforeTag).toBeTruthy();
|
||||
|
||||
const replacement = join(root, "next-manifests");
|
||||
mkdirSync(replacement);
|
||||
writeFileSync(join(replacement, file), content);
|
||||
renameSync(join(projectDir, ".hyperframes"), join(root, "previous-manifests"));
|
||||
renameSync(replacement, join(projectDir, ".hyperframes"));
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const after = await server!.app.request(endpoint, {
|
||||
headers: { "If-None-Match": beforeTag! },
|
||||
});
|
||||
expect(after.status).toBe(200);
|
||||
expect(after.headers.get("etag")).not.toBe(beforeTag);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -425,7 +425,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId };
|
||||
let cachedProjectSignature: string | null = null;
|
||||
watcher.addListener((changedPath) => {
|
||||
if (affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
|
||||
if (changedPath === "." || affectsProjectSignature(projectDir, join(projectDir, changedPath))) {
|
||||
cachedProjectSignature = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export { createStudioApi } from "./createStudioApi.js";
|
||||
export { MAX_UPLOAD_BYTES } from "./routes/files.js";
|
||||
export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js";
|
||||
export {
|
||||
createProjectSignature,
|
||||
affectsProjectSignature,
|
||||
STUDIO_SIGNATURE_MANIFEST_PATHS,
|
||||
} from "./helpers/projectSignature.js";
|
||||
export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
|
||||
export { affectsPreview } from "./helpers/previewReads.js";
|
||||
export * from "./history/index.js";
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
} from "./manualEditsTypes";
|
||||
|
||||
export {
|
||||
mergeFileChangeAffectedCompositions,
|
||||
mergeFileChangePayloads,
|
||||
readFileChangeAffectedCompositions,
|
||||
readFileChangeAffectsPreview,
|
||||
readFileChangeField,
|
||||
|
||||
@@ -44,9 +44,21 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s
|
||||
return value.map(normalizeStudioFileChangePath);
|
||||
}
|
||||
|
||||
export function mergeFileChangeAffectedCompositions(waiting: unknown, incoming: unknown): unknown {
|
||||
export function mergeFileChangePayloads(waiting: unknown, incoming: unknown): unknown {
|
||||
const before = readFileChangeAffectedCompositions(waiting);
|
||||
const after = readFileChangeAffectedCompositions(incoming);
|
||||
const merged = before && after ? [...new Set([...before, ...after])] : null;
|
||||
return { ...asPayloadRecord(incoming), affectedCompositions: merged };
|
||||
const next = asPayloadRecord(incoming);
|
||||
const affectsPreview =
|
||||
readFileChangeAffectsPreview(waiting) || readFileChangeAffectsPreview(incoming);
|
||||
if (readStudioFileChangePath(waiting) === readStudioFileChangePath(incoming)) {
|
||||
return { ...next, affectsPreview, affectedCompositions: merged };
|
||||
}
|
||||
// Changes to two files are owed: only a project-wide reload ("." path) covers both.
|
||||
return {
|
||||
path: ".",
|
||||
projectId: next?.projectId,
|
||||
affectsPreview,
|
||||
affectedCompositions: merged,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
|
||||
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
// vitest defines `import.meta.hot`, so the selection is the one claim left to
|
||||
// the source; the channel's own behaviour is exercised below.
|
||||
expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1);
|
||||
expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
class FakeEventSource {
|
||||
addEventListener(type: string, listener: (event: unknown) => void) {
|
||||
listeners.set(type, listener);
|
||||
}
|
||||
close = vi.fn();
|
||||
}
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
try {
|
||||
const onReconnect = vi.fn();
|
||||
sseFileChangeChannel(vi.fn(), onReconnect);
|
||||
listeners.get("open")?.(new Event("open"));
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
listeners.get("error")?.(new Event("error"));
|
||||
listeners.get("open")?.(new Event("open"));
|
||||
listeners.get("open")?.(new Event("open"));
|
||||
expect(onReconnect).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
|
||||
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
|
||||
vi.stubGlobal("EventSource", FakeEventSource);
|
||||
try {
|
||||
const onDelivery = vi.fn();
|
||||
const stop = sseFileChangeChannel(onDelivery);
|
||||
const stop = sseFileChangeChannel(onDelivery, vi.fn());
|
||||
const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
|
||||
listeners.get("file-change")?.(event);
|
||||
expect(opened).toEqual(["/api/events"]);
|
||||
|
||||
@@ -41,7 +41,11 @@ async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
|
||||
return null;
|
||||
}
|
||||
await act(async () => root.render(<Probe />));
|
||||
return { captured, options };
|
||||
const rerender = async (next: Partial<CoordinatorOptions>) => {
|
||||
Object.assign(options, next);
|
||||
await act(async () => root.render(<Probe />));
|
||||
};
|
||||
return { captured, options, rerender };
|
||||
}
|
||||
|
||||
describe("external file change coordinator", () => {
|
||||
@@ -64,6 +68,443 @@ describe("external file change coordinator", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("SSE reconnect recovery", () => {
|
||||
let source: EventTarget;
|
||||
let close: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", undefined);
|
||||
source = new EventTarget();
|
||||
close = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
addEventListener = source.addEventListener.bind(source);
|
||||
close = close;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const open = () =>
|
||||
act(async () => {
|
||||
source.dispatchEvent(new Event("open"));
|
||||
});
|
||||
const send = (payload: object) =>
|
||||
act(async () => {
|
||||
source.dispatchEvent(new MessageEvent("file-change", { data: JSON.stringify(payload) }));
|
||||
});
|
||||
|
||||
it("delivers the held reconnect scope when a queued clean drain precedes React's commit", async () => {
|
||||
let finishAcceptance = () => {};
|
||||
const accepted = new Promise<void>((resolve) => {
|
||||
finishAcceptance = resolve;
|
||||
});
|
||||
const blockedAtSecondDrain: Array<ExternalFileChangeCoordinatorHandle["blocked"]> = [];
|
||||
const onAcceptedPersistedFileChange = vi.fn(() => finishAcceptance());
|
||||
const drainPendingChanges = vi.fn(async () => {
|
||||
if (drainPendingChanges.mock.calls.length === 1) {
|
||||
return { status: "failed" as const, error: new Error("save rejected") };
|
||||
}
|
||||
blockedAtSecondDrain.push(captured.handle?.blocked ?? null);
|
||||
return { status: "clean" as const };
|
||||
});
|
||||
const { captured, options } = await mountCoordinator({
|
||||
activeCompPath: "film.html",
|
||||
drainPendingChanges,
|
||||
onAcceptedPersistedFileChange,
|
||||
refreshFileTree: vi.fn(),
|
||||
});
|
||||
await open();
|
||||
await act(async () => {
|
||||
source.dispatchEvent(new Event("open"));
|
||||
source.dispatchEvent(
|
||||
new MessageEvent("file-change", {
|
||||
data: JSON.stringify({
|
||||
path: "scenes/nested.html",
|
||||
affectsPreview: false,
|
||||
affectedCompositions: ["scenes/nested.html"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await accepted;
|
||||
});
|
||||
|
||||
expect(blockedAtSecondDrain).toEqual([null]);
|
||||
expect(drainPendingChanges).toHaveBeenCalledTimes(2);
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
expect(onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
|
||||
expect(options.refreshFileTree).toHaveBeenCalledOnce();
|
||||
expect(captured.handle?.blocked).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for pending edits before refreshing Preview, SDK, thumbnails and tree on each reconnect", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const { options } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
refreshFileTree: vi.fn(),
|
||||
});
|
||||
await open();
|
||||
expect(options.drainPendingChanges).not.toHaveBeenCalled();
|
||||
await open();
|
||||
expect(options.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
||||
await act(async () => finishDrain());
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledWith(".");
|
||||
expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledWith(".", null);
|
||||
expect(options.refreshFileTree).toHaveBeenCalledOnce();
|
||||
await open();
|
||||
expect(options.reloadPreview).toHaveBeenCalledTimes(2);
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
|
||||
expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
|
||||
expect(options.refreshFileTree).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reloads every session for a project-directory change, not just the current composition", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const { options } = await mountCoordinator({
|
||||
activeCompPath: "scenes/intro.html",
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
});
|
||||
await act(async () => {
|
||||
source.dispatchEvent(
|
||||
new MessageEvent("file-change", {
|
||||
data: JSON.stringify({
|
||||
path: ".",
|
||||
projectId: "project-a",
|
||||
affectsPreview: true,
|
||||
affectedCompositions: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(options.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
||||
await act(async () => finishDrain());
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
|
||||
});
|
||||
|
||||
it("holds a reconnect behind a persisted conflict until the user accepts the external file", async () => {
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
const { captured, options } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
|
||||
});
|
||||
await open();
|
||||
await open();
|
||||
expect(options.persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
|
||||
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
||||
expect(options.onAcceptedPersistedFileChange).not.toHaveBeenCalled();
|
||||
await act(async () => captured.handle?.useExternalFile());
|
||||
expect(options.discardPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledWith(".");
|
||||
});
|
||||
|
||||
it("reloads every session after navigation and when no composition is selected", async () => {
|
||||
const { options, rerender } = await mountCoordinator();
|
||||
await open();
|
||||
await rerender({ activeCompPath: "scenes/next.html" });
|
||||
await open();
|
||||
expect(options.reloadSdkSession).toHaveBeenLastCalledWith(".");
|
||||
await rerender({ activeCompPath: null });
|
||||
await open();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledTimes(2);
|
||||
expect(options.reloadSdkSession).toHaveBeenLastCalledWith(".");
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a reconnect's project-wide reload when a file change queues behind it", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const reloadSdkSession = vi.fn();
|
||||
await mountCoordinator({
|
||||
reloadSdkSession,
|
||||
drainPendingChanges: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
})
|
||||
.mockImplementation(async () => ({ status: "clean" as const })),
|
||||
});
|
||||
await send({ path: "notes.md", version: "n1" });
|
||||
await open();
|
||||
await open();
|
||||
await send({ path: "index.html", version: "v2" });
|
||||
await act(async () => finishDrain());
|
||||
expect(reloadSdkSession.mock.calls.map(([path]) => path)).toEqual(["notes.md", "."]);
|
||||
});
|
||||
|
||||
it("delivers a held reconnect's project-wide reload when a later change saves cleanly", async () => {
|
||||
const { options } = await mountCoordinator({
|
||||
drainPendingChanges: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
|
||||
.mockResolvedValue({ status: "clean" as const }),
|
||||
getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
|
||||
persistFailureSnapshot: vi.fn(async () => undefined),
|
||||
});
|
||||
await open();
|
||||
await open();
|
||||
expect(options.reloadSdkSession).not.toHaveBeenCalled();
|
||||
await send({ path: "scenes/nested.html", version: "n1", affectsPreview: false });
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
|
||||
});
|
||||
|
||||
it("keeps the unsaved draft of the file open in the code panel when a reconnect cannot save it", async () => {
|
||||
const failure = new Error("network unavailable");
|
||||
const persistFailureSnapshot = vi.fn(async () => undefined);
|
||||
const readProjectFile = vi.fn(async () => "disk script");
|
||||
const onUseExternalFile = vi.fn();
|
||||
const { captured, options } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })),
|
||||
getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
|
||||
persistFailureSnapshot,
|
||||
readProjectFile,
|
||||
onUseExternalFile,
|
||||
});
|
||||
await open();
|
||||
await open();
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
status: "failed",
|
||||
path: "script.js",
|
||||
studioContent: "unsaved script",
|
||||
});
|
||||
expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith(
|
||||
"project-a",
|
||||
"script.js",
|
||||
"unsaved script",
|
||||
null,
|
||||
null,
|
||||
failure,
|
||||
);
|
||||
await act(async () => captured.handle?.useExternalFile());
|
||||
expect(readProjectFile).toHaveBeenCalledWith("script.js");
|
||||
expect(onUseExternalFile).toHaveBeenCalledWith("script.js", "disk script");
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
});
|
||||
|
||||
it("keeps the drafted file's recovery off another file's external version", async () => {
|
||||
const failure = new Error("network unavailable");
|
||||
const persistFailureSnapshot = vi.fn(async () => undefined);
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "failed" as const, error: failure })),
|
||||
getPendingCandidate: () => ({ path: "script.js", content: "unsaved script" }),
|
||||
persistFailureSnapshot,
|
||||
});
|
||||
await send({ path: "index.html", version: "v2", content: "agent html" });
|
||||
expect(persistFailureSnapshot).toHaveBeenCalledExactlyOnceWith(
|
||||
"project-a",
|
||||
"script.js",
|
||||
"unsaved script",
|
||||
null,
|
||||
null,
|
||||
failure,
|
||||
);
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
path: "script.js",
|
||||
payload: { path: "index.html" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps foreign project changes out of recovery", async () => {
|
||||
const { options } = await mountCoordinator();
|
||||
await open();
|
||||
await act(async () => {
|
||||
source.dispatchEvent(
|
||||
new MessageEvent("file-change", {
|
||||
data: JSON.stringify({ path: "index.html", projectId: "project-b", version: "v2" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(options.drainPendingChanges).not.toHaveBeenCalled();
|
||||
await open();
|
||||
expect(options.reloadPreview).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the new project's owner when a reconnect arrives during the previous drain", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const first = {
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
onAcceptedPersistedFileChange: vi.fn(),
|
||||
};
|
||||
const { rerender } = await mountCoordinator(first);
|
||||
await open();
|
||||
await open();
|
||||
const next = {
|
||||
projectId: "project-b",
|
||||
activeCompPath: "scenes/next.html",
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
onAcceptedPersistedFileChange: vi.fn(),
|
||||
};
|
||||
await rerender(next);
|
||||
await open();
|
||||
expect(next.drainPendingChanges).not.toHaveBeenCalled();
|
||||
await act(async () => finishDrain());
|
||||
expect(first.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(first.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(first.reloadSdkSession).not.toHaveBeenCalled();
|
||||
expect(first.onAcceptedPersistedFileChange).not.toHaveBeenCalled();
|
||||
expect(next.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(next.reloadPreview).toHaveBeenCalledOnce();
|
||||
expect(next.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
expect(next.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
|
||||
});
|
||||
|
||||
it("persists a queued reconnect conflict with the new project's owner", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const first = {
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
persistConflictSnapshot: vi.fn(async () => undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
};
|
||||
const { captured, rerender } = await mountCoordinator(first);
|
||||
await open();
|
||||
await open();
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "scenes/next.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
const next = {
|
||||
projectId: "project-b",
|
||||
activeCompPath: "scenes/next.html",
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
|
||||
persistConflictSnapshot: vi.fn(async () => undefined),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
};
|
||||
await rerender(next);
|
||||
await open();
|
||||
await act(async () => finishDrain());
|
||||
expect(next.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(next.persistConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-b", conflict);
|
||||
expect(first.persistConflictSnapshot).not.toHaveBeenCalled();
|
||||
expect(first.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(first.reloadSdkSession).not.toHaveBeenCalled();
|
||||
expect(next.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(next.reloadSdkSession).not.toHaveBeenCalled();
|
||||
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "the project changes", projectId: "project-b", activeCompPath: "index.html" },
|
||||
{ label: "the project is left", projectId: null, activeCompPath: null },
|
||||
])("drops queued old-scope changes when $label", async ({ projectId, activeCompPath }) => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const first = {
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
};
|
||||
const { rerender } = await mountCoordinator(first);
|
||||
await open();
|
||||
await open();
|
||||
await open();
|
||||
const next = {
|
||||
projectId,
|
||||
activeCompPath,
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
|
||||
reloadPreview: vi.fn(),
|
||||
reloadSdkSession: vi.fn(),
|
||||
};
|
||||
await rerender(next);
|
||||
await act(async () => finishDrain());
|
||||
expect(first.drainPendingChanges).toHaveBeenCalledOnce();
|
||||
expect(next.drainPendingChanges).not.toHaveBeenCalled();
|
||||
expect(first.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(first.reloadSdkSession).not.toHaveBeenCalled();
|
||||
expect(next.reloadPreview).not.toHaveBeenCalled();
|
||||
expect(next.reloadSdkSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refreshes the tree for a queued outside change after switching compositions", async () => {
|
||||
let finishDrain = () => {};
|
||||
const pendingEdit = new Promise<void>((resolve) => {
|
||||
finishDrain = resolve;
|
||||
});
|
||||
const refreshFileTree = vi.fn();
|
||||
const onAcceptedPersistedFileChange = vi.fn();
|
||||
const { rerender } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => {
|
||||
await pendingEdit;
|
||||
return { status: "clean" as const };
|
||||
}),
|
||||
refreshFileTree,
|
||||
onAcceptedPersistedFileChange,
|
||||
});
|
||||
await send({ path: "index.html", content: "external", version: "v2" });
|
||||
await send({ path: "scenes/old.html", content: "agent", version: "v3" });
|
||||
await rerender({ activeCompPath: "scenes/next.html" });
|
||||
await act(async () => finishDrain());
|
||||
expect(onAcceptedPersistedFileChange.mock.calls.map(([path]) => path)).toContain(
|
||||
"scenes/old.html",
|
||||
);
|
||||
expect(refreshFileTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores reconnects after leaving the active project", async () => {
|
||||
const { options, rerender } = await mountCoordinator();
|
||||
await open();
|
||||
await rerender({ projectId: null });
|
||||
await open();
|
||||
expect(options.drainPendingChanges).not.toHaveBeenCalled();
|
||||
expect(options.reloadPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("drains before reloading Preview and SDK exactly once", async () => {
|
||||
const order: string[] = [];
|
||||
const { captured } = await mountCoordinator({
|
||||
@@ -431,16 +872,17 @@ describe("external file change coordinator", () => {
|
||||
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
|
||||
it("keeps the failed draft blocked, owing both files, when deleting its snapshot fails", async () => {
|
||||
const deleteConflictSnapshot = vi.fn(async () => {
|
||||
throw new Error("delete failed");
|
||||
});
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
|
||||
.mockResolvedValueOnce({ status: "clean" as const }),
|
||||
getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
|
||||
deleteConflictSnapshot: vi.fn(async () => {
|
||||
throw new Error("delete failed");
|
||||
}),
|
||||
deleteConflictSnapshot,
|
||||
});
|
||||
await act(async () =>
|
||||
handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
|
||||
@@ -450,7 +892,95 @@ describe("external file change coordinator", () => {
|
||||
);
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
status: "failed",
|
||||
payload: { path: "scene.html", content: "scene-external", version: "s1" },
|
||||
path: "scene.html",
|
||||
payload: { path: "." },
|
||||
});
|
||||
expect(deleteConflictSnapshot).toHaveBeenCalledExactlyOnceWith("project-a", "scene.html");
|
||||
});
|
||||
|
||||
it("keeps a failed draft's snapshot when a clean save was for another file", async () => {
|
||||
const deleteConflictSnapshot = vi.fn(async () => undefined);
|
||||
let candidate = { path: "script.js", content: "unsaved script" };
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
|
||||
.mockResolvedValue({ status: "clean" as const }),
|
||||
getPendingCandidate: () => candidate,
|
||||
persistFailureSnapshot: vi.fn(async () => undefined),
|
||||
deleteConflictSnapshot,
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html", version: "v1" }));
|
||||
expect(captured.handle?.blocked).toMatchObject({ status: "failed", path: "script.js" });
|
||||
candidate = { path: "style.css", content: "saved style" };
|
||||
await act(async () => handler?.({ path: "index.html", version: "v2" }));
|
||||
expect(deleteConflictSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a restored draft's snapshot when an unrelated change saves cleanly", async () => {
|
||||
const deleteConflictSnapshot = vi.fn(async () => undefined);
|
||||
const { captured, options } = await mountCoordinator({
|
||||
recoveryFilePath: "script.js",
|
||||
deleteConflictSnapshot,
|
||||
loadConflictSnapshot: vi.fn(async () => ({
|
||||
kind: "failed" as const,
|
||||
projectId: "project-a",
|
||||
filePath: "script.js",
|
||||
externalVersion: null,
|
||||
externalContent: null,
|
||||
studioContent: "recover me",
|
||||
failureMessage: "network unavailable",
|
||||
createdAt: 100,
|
||||
})),
|
||||
});
|
||||
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
|
||||
await act(async () => handler?.({ path: "index.html", version: "v2" }));
|
||||
expect(deleteConflictSnapshot).not.toHaveBeenCalled();
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith("index.html");
|
||||
});
|
||||
|
||||
it("reloads the held change's scope too when Keep Studio settles a held conflict", async () => {
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "film.html",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
const { captured, options } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
|
||||
});
|
||||
await act(async () => handler?.({ path: "film.html", version: "v2" }));
|
||||
await act(async () =>
|
||||
handler?.({
|
||||
path: "scenes/nested.html",
|
||||
version: "n1",
|
||||
affectedCompositions: ["scenes/nested.html"],
|
||||
}),
|
||||
);
|
||||
await act(async () => captured.handle?.keepStudioFile());
|
||||
expect(options.overwriteConflict).toHaveBeenCalledWith(conflict);
|
||||
expect(options.reloadSdkSession).toHaveBeenCalledExactlyOnceWith(".");
|
||||
expect(options.onAcceptedPersistedFileChange).toHaveBeenCalledExactlyOnceWith(".", null);
|
||||
});
|
||||
|
||||
it("names the conflicting file when its conflict snapshot cannot be saved", async () => {
|
||||
const conflict = new StudioFileConflictError({
|
||||
filePath: "script.js",
|
||||
currentVersion: "v2",
|
||||
currentContent: "external",
|
||||
attemptedContent: "studio",
|
||||
});
|
||||
const { captured } = await mountCoordinator({
|
||||
drainPendingChanges: vi.fn(async () => ({ status: "conflict" as const, error: conflict })),
|
||||
persistConflictSnapshot: vi.fn(async () => {
|
||||
throw new Error("storage full");
|
||||
}),
|
||||
});
|
||||
await act(async () => handler?.({ path: "index.html", version: "v2" }));
|
||||
expect(captured.handle?.blocked).toMatchObject({
|
||||
status: "failed",
|
||||
path: "script.js",
|
||||
studioContent: "studio",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,10 +1059,6 @@ describe("external file change coordinator", () => {
|
||||
expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// `hyperframes preview` serves file-change over SSE, where the delivery is a
|
||||
// MessageEvent whose `data` is a JSON STRING. Driven through the test adapter
|
||||
// because vitest defines `import.meta.hot`, so the EventSource rung is
|
||||
// unreachable here, which is exactly why decoding is shared by all rungs.
|
||||
describe("SSE-shaped deliveries", () => {
|
||||
const sseDelivery = (payload: unknown) =>
|
||||
new MessageEvent("file-change", { data: JSON.stringify(payload) });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import {
|
||||
mergeFileChangeAffectedCompositions,
|
||||
mergeFileChangePayloads,
|
||||
readFileChangeAffectedCompositions,
|
||||
readFileChangeAffectsPreview,
|
||||
readFileChangeField,
|
||||
@@ -125,14 +125,18 @@ function decodeFileChange(delivery: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The production transport. vitest defines `import.meta.hot`, so the selection
|
||||
* below never reaches this rung under test; the rung itself is exported so a test
|
||||
* can drive a real `MessageEvent` through the listener it registers.
|
||||
*/
|
||||
export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void {
|
||||
export function sseFileChangeChannel(
|
||||
onDelivery: (delivery: unknown) => void,
|
||||
onReconnect: () => void,
|
||||
): () => void {
|
||||
const eventSource = new EventSource("/api/events");
|
||||
eventSource.addEventListener("file-change", onDelivery);
|
||||
// Reopening the stream does not replay changes missed during a disconnect.
|
||||
let opened = false;
|
||||
eventSource.addEventListener("open", () => {
|
||||
if (opened) onReconnect();
|
||||
opened = true;
|
||||
});
|
||||
return () => eventSource.close();
|
||||
}
|
||||
|
||||
@@ -168,18 +172,19 @@ export function useExternalFileChangeCoordinator({
|
||||
const generationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const lastEventIdentityRef = useRef<string | null>(null);
|
||||
const blockedRef = useRef(blocked);
|
||||
const blockedRef = useRef<ExternalFileChangeBlockedState | null>(null);
|
||||
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const drainingRef = useRef(false);
|
||||
const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
|
||||
blockedRef.current = blocked;
|
||||
// A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
|
||||
// A newer blocked change replaces the held one, so it inherits what the held one owed.
|
||||
const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
|
||||
setBlockedState((held) =>
|
||||
const held = blockedRef.current;
|
||||
const current =
|
||||
next && held
|
||||
? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
|
||||
: next,
|
||||
);
|
||||
? { ...next, payload: mergeFileChangePayloads(held.payload, next.payload) }
|
||||
: next;
|
||||
blockedRef.current = current;
|
||||
setBlockedState(current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -190,6 +195,10 @@ export function useExternalFileChangeCoordinator({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pendingPayloadRef.current = null;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
setBlocked(null);
|
||||
@@ -240,8 +249,12 @@ export function useExternalFileChangeCoordinator({
|
||||
};
|
||||
}, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
|
||||
|
||||
const reloadAcceptedGeneration = useCallback(
|
||||
(path: string, affectsPreview = true) => {
|
||||
// What a settled change owes: thumbnails (unless its own write's echo brings them), then reloads.
|
||||
const deliverOwed = useCallback(
|
||||
(owed: unknown, accept = true) => {
|
||||
const path = readStudioFileChangePath(owed) ?? ".";
|
||||
const affectsPreview = readFileChangeAffectsPreview(owed);
|
||||
if (accept) onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
|
||||
logReload(affectsPreview ? "reload" : "file-tree only", {
|
||||
path,
|
||||
by: "external-change coordinator",
|
||||
@@ -255,7 +268,7 @@ export function useExternalFileChangeCoordinator({
|
||||
// a watcher event the SSE never delivered).
|
||||
void refreshFileTree?.();
|
||||
},
|
||||
[reloadPreview, reloadSdkSession, refreshFileTree],
|
||||
[onAcceptedPersistedFileChange, reloadPreview, reloadSdkSession, refreshFileTree],
|
||||
);
|
||||
|
||||
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
|
||||
@@ -274,21 +287,28 @@ export function useExternalFileChangeCoordinator({
|
||||
if (!path) return;
|
||||
|
||||
const generation = ++generationRef.current;
|
||||
const savingPath = getPendingCandidate?.()?.path;
|
||||
const result = await drainPendingChanges();
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
|
||||
if (result.status === "clean") {
|
||||
const previousBlocked = blockedRef.current;
|
||||
if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
|
||||
// Only saving the failed draft's file retires its copy; restored drafts are never queued.
|
||||
if (
|
||||
previousBlocked?.status === "failed" &&
|
||||
!previousBlocked.recovered &&
|
||||
previousBlocked.path === savingPath &&
|
||||
deleteConflictSnapshot
|
||||
) {
|
||||
try {
|
||||
await deleteConflictSnapshot(projectId!, path);
|
||||
await deleteConflictSnapshot(projectId!, previousBlocked.path);
|
||||
} catch (error) {
|
||||
if (mountedRef.current && generation === generationRef.current) {
|
||||
setBlocked({
|
||||
...previousBlocked,
|
||||
generation,
|
||||
error,
|
||||
payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
|
||||
payload: mergeFileChangePayloads(payload, previousBlocked.payload),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -296,27 +316,30 @@ export function useExternalFileChangeCoordinator({
|
||||
}
|
||||
if (!mountedRef.current || generation !== generationRef.current) return;
|
||||
setBlocked(null);
|
||||
const owed = previousBlocked
|
||||
? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
|
||||
: payload;
|
||||
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
|
||||
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
|
||||
const restored = previousBlocked?.status === "failed" && previousBlocked.recovered;
|
||||
deliverOwed(
|
||||
previousBlocked && !restored
|
||||
? mergeFileChangePayloads(previousBlocked.payload, payload)
|
||||
: payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const content = readFileChangeContent(payload);
|
||||
if (result.status === "failed") {
|
||||
// The draft names the file to recover; the payload stays the scope to reload.
|
||||
const candidate = getPendingCandidate?.();
|
||||
const studioContent = candidate?.path === path ? candidate.content : null;
|
||||
const failedPath = candidate?.path ?? (path === "." ? (recoveryFilePath ?? path) : path);
|
||||
const studioContent = candidate?.content ?? null;
|
||||
const external = failedPath === path ? payload : null;
|
||||
let error = result.error;
|
||||
if (studioContent != null && persistFailureSnapshot) {
|
||||
try {
|
||||
await persistSnapshotInOrder(() =>
|
||||
persistFailureSnapshot(
|
||||
projectId!,
|
||||
path,
|
||||
failedPath,
|
||||
studioContent,
|
||||
readFileChangeVersion(payload),
|
||||
content,
|
||||
readFileChangeVersion(external),
|
||||
readFileChangeContent(external),
|
||||
result.error,
|
||||
),
|
||||
);
|
||||
@@ -333,7 +356,7 @@ export function useExternalFileChangeCoordinator({
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation,
|
||||
path,
|
||||
path: failedPath,
|
||||
error,
|
||||
payload,
|
||||
studioContent,
|
||||
@@ -348,7 +371,7 @@ export function useExternalFileChangeCoordinator({
|
||||
setBlocked({
|
||||
status: "failed",
|
||||
generation,
|
||||
path,
|
||||
path: result.error.filePath,
|
||||
error,
|
||||
payload,
|
||||
studioContent: result.error.attemptedContent,
|
||||
@@ -365,14 +388,17 @@ export function useExternalFileChangeCoordinator({
|
||||
projectId,
|
||||
deleteConflictSnapshot,
|
||||
getPendingCandidate,
|
||||
recoveryFilePath,
|
||||
persistConflictSnapshot,
|
||||
persistFailureSnapshot,
|
||||
persistSnapshotInOrder,
|
||||
reloadAcceptedGeneration,
|
||||
onAcceptedPersistedFileChange,
|
||||
deliverOwed,
|
||||
],
|
||||
);
|
||||
|
||||
const drainOnePendingRef = useRef(drainOnePending);
|
||||
drainOnePendingRef.current = drainOnePending;
|
||||
|
||||
const startDrainLoop = useCallback(async () => {
|
||||
if (drainingRef.current) return;
|
||||
drainingRef.current = true;
|
||||
@@ -381,12 +407,12 @@ export function useExternalFileChangeCoordinator({
|
||||
const pending = pendingPayloadRef.current;
|
||||
if (!pending) break;
|
||||
pendingPayloadRef.current = null;
|
||||
await drainOnePending(pending.payload);
|
||||
await drainOnePendingRef.current(pending.payload);
|
||||
}
|
||||
} finally {
|
||||
drainingRef.current = false;
|
||||
}
|
||||
}, [drainOnePending]);
|
||||
}, []);
|
||||
|
||||
const processChange = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -439,8 +465,7 @@ export function useExternalFileChangeCoordinator({
|
||||
!readFileChangeAffectsPreview(payload);
|
||||
if (!waitingChangeOutranksThis) {
|
||||
pendingPayloadRef.current = {
|
||||
payload:
|
||||
waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
|
||||
payload: waiting == null ? payload : mergeFileChangePayloads(waiting, payload),
|
||||
};
|
||||
}
|
||||
void startDrainLoop();
|
||||
@@ -462,7 +487,8 @@ export function useExternalFileChangeCoordinator({
|
||||
import.meta.hot.on("hf:file-change", handler);
|
||||
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
||||
}
|
||||
return sseFileChangeChannel(handler);
|
||||
const catchUp = () => processChangeRef.current({ path: "." });
|
||||
return sseFileChangeChannel(handler, catchUp);
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
@@ -489,18 +515,16 @@ export function useExternalFileChangeCoordinator({
|
||||
onUseExternalFile?.(path, external);
|
||||
await deleteConflictSnapshot?.(projectId, path);
|
||||
setBlocked(null);
|
||||
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
|
||||
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(current.payload));
|
||||
deliverOwed(current.payload);
|
||||
},
|
||||
[
|
||||
deleteConflictSnapshot,
|
||||
setBlocked,
|
||||
discardPendingChanges,
|
||||
onUseExternalFile,
|
||||
onAcceptedPersistedFileChange,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
reloadAcceptedGeneration,
|
||||
deliverOwed,
|
||||
resetSaveQueues,
|
||||
],
|
||||
);
|
||||
@@ -550,7 +574,10 @@ export function useExternalFileChangeCoordinator({
|
||||
resetSaveQueues?.();
|
||||
await deleteConflictSnapshot?.(projectId, conflict.filePath);
|
||||
setBlocked(null);
|
||||
reloadAcceptedGeneration(conflict.filePath);
|
||||
deliverOwed(
|
||||
mergeFileChangePayloads(current.payload, { path: conflict.filePath, affectsPreview: true }),
|
||||
readStudioFileChangePath(current.payload) !== conflict.filePath,
|
||||
);
|
||||
}, [
|
||||
deleteConflictSnapshot,
|
||||
setBlocked,
|
||||
@@ -558,7 +585,7 @@ export function useExternalFileChangeCoordinator({
|
||||
overwriteConflict,
|
||||
projectId,
|
||||
readProjectFile,
|
||||
reloadAcceptedGeneration,
|
||||
deliverOwed,
|
||||
resetSaveQueues,
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("@hyperframes/sdk", () => ({
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { notifyExternalFileReload } from "./externalFileReloadBus";
|
||||
|
||||
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
||||
|
||||
@@ -173,6 +174,25 @@ describe("useSdkSession ownership", () => {
|
||||
await act(async () => root.unmount());
|
||||
expect(published.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reopens on a project-wide external change whatever composition it holds", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => response("INDEX")),
|
||||
);
|
||||
openComposition.mockImplementation(async () => fakeSession());
|
||||
const root = createRoot(document.createElement("div"));
|
||||
await act(async () => root.render(<Probe projectId="project-a" />));
|
||||
await flushAsyncEffects();
|
||||
expect(openComposition).toHaveBeenCalledOnce();
|
||||
await act(async () => notifyExternalFileReload("film.html"));
|
||||
await flushAsyncEffects();
|
||||
expect(openComposition).toHaveBeenCalledOnce();
|
||||
await act(async () => notifyExternalFileReload("."));
|
||||
await flushAsyncEffects();
|
||||
expect(openComposition).toHaveBeenCalledTimes(2);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSdkSession unreachable project", () => {
|
||||
|
||||
@@ -409,7 +409,10 @@ export function useSdkSession(
|
||||
useEffect(
|
||||
() =>
|
||||
addExternalFileReloadListener((changedPath) => {
|
||||
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
|
||||
// "." is a project-wide change (a reconnect or a replaced folder): every session reloads.
|
||||
if (changedPath === "." || changedPath === activeCompPathRef.current) {
|
||||
setReloadToken((token) => token + 1);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
esbuild: { define: { "import.meta.hot": "undefined" } },
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user