// @vitest-environment happy-dom import { act } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { splitElementInHtml } from "@hyperframes/studio-server/source-mutation"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { useRazorSplit } from "./useRazorSplit"; import { createPersistentEditHistoryStore } from "./usePersistentEditHistory"; import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage"; import { createEmptyEditHistory, hashEditHistoryContent, undoEditHistory, } from "../utils/editHistory"; import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const ORIGINAL = `
hi
`; const SPLIT = splitElementInHtml(ORIGINAL, { id: "clip1" }, 2, "clip1-split").html; const element: TimelineElement = { id: "clip1", tag: "div", start: 0, duration: 4, track: 0, domId: "clip1", sourceFile: "index.html", timingSource: "authored", }; type Split = (element: TimelineElement, splitTime: number) => Promise; type SplitAll = (splitTime: number) => Promise; interface Harness { disk: Record; store: ReturnType; splitRef: { current: Split | undefined }; root: ReturnType; expected: string; previewWrites: string[]; } const SPLIT_GSAP = SPLIT.replace( "", "', ); function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}): Harness { const disk: Record = { "index.html": ORIGINAL }; const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT; const previewWrites: string[] = []; const storage = createMemoryEditHistoryStorage(); const store = createPersistentEditHistoryStore({ projectId: "p1", storage, initialState: createEmptyEditHistory(), now: (() => { let t = 1000; return () => (t += 10); })(), onChange: () => {}, }); // Faithful stand-in for the studio-server file-mutation endpoints: the server // writes the split to disk itself, then returns the patched content. const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { const u = String(url); if (u.includes("/gsap-mutations/")) { if (opts.gsap) { // Mirror the server: rewrites the GSAP script for the new id, writes to // disk, returns the final content. disk["index.html"] = SPLIT_GSAP; return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), { status: 200, headers: { "Content-Type": "application/json" }, }); } // The fixture has no GSAP script — mirror the server's 400 response. return new Response(JSON.stringify({ error: "no GSAP script found in file" }), { status: 400, headers: { "Content-Type": "application/json" }, }); } if (u.includes("/file-mutations/split-element/")) { disk["index.html"] = SPLIT; return new Response( JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (u.includes("/files/")) { return new Response(JSON.stringify({ content: disk["index.html"] }), { status: 200, headers: { "Content-Type": "application/json" }, }); } void init; throw new Error(`unexpected fetch: ${u}`); }); vi.stubGlobal("fetch", fetchMock); const splitRef: { current: Split | undefined } = { current: undefined }; function Component() { const { handleRazorSplit } = useRazorSplit({ projectId: "p1", activeCompPath: "index.html", showToast: () => {}, writeProjectFile: async (path, content) => { disk[path] = content; }, recordEdit: store.recordEdit, domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => { if (!opts.previewStamp) return; const stamped = ensureHfIds(disk["index.html"]); const idsBefore = (disk["index.html"].match(/\bdata-hf-id=/g) ?? []).length; const idsAfter = (stamped.match(/\bdata-hf-id=/g) ?? []).length; if (idsAfter > idsBefore) { disk["index.html"] = stamped; previewWrites.push(stamped); } }, forceReloadSdkSession: () => {}, }); splitRef.current = handleRazorSplit; return null; } const root = mountProbe(Component); return { disk, store, splitRef, root, expected: finalContent, previewWrites }; } async function undoViaDisk(harness: Pick) { return harness.store.undo({ readFile: async (path) => harness.disk[path], writeFile: async (path, content) => { harness.disk[path] = content; }, }); } afterEach(() => { document.body.innerHTML = ""; vi.unstubAllGlobals(); }); describe("useRazorSplit — split is undoable via edit history", () => { it("keeps history aligned when preview reload checks hf-id persistence", async () => { const harness = mountRazorSplit({ previewStamp: true }); await act(async () => { await harness.splitRef.current!(element, 2); }); const snapshot = harness.store.snapshot().state; const entry = snapshot.undo.at(-1)!; const currentHash = hashEditHistoryContent(harness.disk["index.html"]); expect(entry.files["index.html"].afterHash).toBe(currentHash); const undo = undoEditHistory(snapshot, { "index.html": currentHash }, 2000); expect(undo.ok).toBe(true); expect(undo.filesToWrite).toEqual({ "index.html": ORIGINAL }); expect(harness.previewWrites).toHaveLength(0); act(() => harness.root.unmount()); }); for (const gsap of [false, true]) { describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => { let harness: Harness; beforeEach(() => { harness = mountRazorSplit({ gsap }); }); afterEach(() => { act(() => harness.root.unmount()); }); it("records a single 'Split timeline clip' history entry that undo restores", async () => { await act(async () => { await harness.splitRef.current!(element, 2); }); // The split reached disk. expect(harness.disk["index.html"]).toBe(harness.expected); // The split must be the top of the undo stack — not a prior/other entry. expect(harness.store.snapshot().canUndo).toBe(true); expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip"); // Undo restores the exact pre-split file. const result = await undoViaDisk(harness); expect(result.ok).toBe(true); expect(result.label).toBe("Split timeline clip"); expect(harness.disk["index.html"]).toBe(ORIGINAL); }); }); } }); const BATCH_ORIGINALS = { "index.html": `
one
`, "scenes/two.html": `
two
`, }; const batchElements: TimelineElement[] = [ element, { ...element, id: "clip2", domId: "clip2", sourceFile: "scenes/two.html", track: 1, }, ]; interface SplitAllHarness { disk: Record; store: ReturnType; splitAllRef: { current: SplitAll | undefined }; root: ReturnType; } function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness { const disk: Record = { ...BATCH_ORIGINALS }; const store = createPersistentEditHistoryStore({ projectId: "p1", storage: createMemoryEditHistoryStorage(), initialState: createEmptyEditHistory(), now: () => 1000, onChange: () => {}, }); let splitCount = 0; vi.stubGlobal( "fetch", createSplitFetchMock(disk, () => { splitCount++; if (splitCount === failOnSplit) throw new Error("simulated split failure"); }), ); const splitAllRef: { current: SplitAll | undefined } = { current: undefined }; function Component() { const { handleRazorSplitAll } = useRazorSplit({ projectId: "p1", activeCompPath: "index.html", showToast: () => {}, writeProjectFile: async (path, content) => { disk[path] = content; }, recordEdit: store.recordEdit, domEditSaveTimestampRef: { current: 0 }, reloadPreview: () => {}, }); splitAllRef.current = handleRazorSplitAll; return null; } const root = mountProbe(Component); usePlayerStore.setState({ elements: batchElements }); return { disk, store, splitAllRef, root }; } describe("useRazorSplit — split-all batch history", () => { afterEach(() => { usePlayerStore.setState({ elements: [] }); }); async function runSplitAll(failOnSplit?: number) { const harness = mountRazorSplitAll(failOnSplit); await act(async () => { await harness.splitAllRef.current!(2); }); return harness; } it("records one undo entry that restores every split file", async () => { const harness = await runSplitAll(); expect(harness.store.snapshot().canUndo).toBe(true); const result = await undoViaDisk(harness); expect(result.ok).toBe(true); expect(harness.disk).toEqual(BATCH_ORIGINALS); expect(harness.store.snapshot().canUndo).toBe(false); act(() => harness.root.unmount()); }); it("restores completed writes and records no undo when a later split fails", async () => { const harness = await runSplitAll(2); expect(harness.disk).toEqual(BATCH_ORIGINALS); expect(harness.store.snapshot().canUndo).toBe(false); act(() => harness.root.unmount()); }); });