// @vitest-environment happy-dom
import { describe, it, expect, vi } from "vitest";
import { applyUndoRestoreToPreview, diffSoftReloadableRestore } from "./gsapUndoRestore";
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
const wrap = (body: string) => `
${body}`;
describe("diffSoftReloadableRestore", () => {
it("reports the changed id for an attribute/inline-style-only diff", () => {
const prev = wrap(`t
`);
const next = wrap(`t
`);
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: ["id:a"] });
});
it("identifies id-less elements by data-hf-id (selector-targeted clips)", () => {
const prev = wrap(`t
`);
const next = wrap(`t
`);
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: ["hf:hf-x1"] });
});
it("fails closed when fallback ids are duplicated without unique data-hf-ids", () => {
const prev = wrap(
`first
last
`,
);
const next = wrap(
`first
last
`,
);
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
});
it("a child change inside an identified CONTAINER is soft (nested identities)", () => {
// The composition root wraps every clip — the old innerHTML comparison at
// the root re-detected the child's change and forced a full reload.
const prev = wrap(
``,
);
const next = wrap(
``,
);
expect(diffSoftReloadableRestore(prev, next)).toEqual({
changedElementKeys: ["id:main", "hf:hf-x1"],
});
});
it("a changed data-hf-id itself is structural — NOT soft-reloadable", () => {
const prev = wrap(`t
`);
const next = wrap(`t
`);
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
});
it("treats a structural change (added element) as NOT soft-reloadable", () => {
const prev = wrap(`t
`);
const next = wrap(`t
t
`);
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
});
it("treats an element text/child change as NOT soft-reloadable", () => {
const prev = wrap(`one
`);
const next = wrap(`two
`);
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
});
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
const prev = wrap(
`t
`,
);
const next = wrap(
`t
`,
);
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: [] });
});
});
function buildLiveIframe(bodyHtml: string) {
const doc = document.implementation.createHTMLDocument("");
doc.body.innerHTML = bodyHtml;
const contentWindow = {
gsap: { timeline: () => {} },
__hfForceTimelineRebind: () => {},
__timelines: {} as Record,
__player: { getTime: () => 3, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
};
return {
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
contentWindow,
doc,
};
}
describe("applyUndoRestoreToPreview", () => {
const ROOT = "index.html";
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
const { iframe, contentWindow, doc } = buildLiveIframe(
`t
`,
);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(
`t
`,
),
restored: wrap(`t
`),
},
};
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
expect(outcome).toBe("soft");
expect(reloadPreview).not.toHaveBeenCalled();
// Live element reverted to the restored inline style.
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
});
it("syncs a data-hf-id-identified live element (no DOM id) without reloading", () => {
const { iframe, doc } = buildLiveIframe(
`t
`,
);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(`t
`),
restored: wrap(`t
`),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
expect(reloadPreview).not.toHaveBeenCalled();
expect(doc.querySelector('[data-hf-id="hf-x1"]')!.getAttribute("style")).toBe("z-index: 3");
});
it.each([
{ label: "first", firstRestored: 3, lastRestored: 9 },
{ label: "last", firstRestored: 8, lastRestored: 3 },
])(
"soft-restores the $label element when authored ids are duplicated",
({ firstRestored, lastRestored }) => {
const markup = (firstZ: number, lastZ: number) =>
`first
` +
`last
`;
const { iframe, doc } = buildLiveIframe(markup(8, 9));
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(markup(8, 9)),
restored: wrap(markup(firstRestored, lastRestored)),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
expect(reloadPreview).not.toHaveBeenCalled();
expect(doc.querySelector('[data-hf-id="hf-first"]')!.getAttribute("style")).toBe(
`z-index: ${firstRestored}`,
);
expect(doc.querySelector('[data-hf-id="hf-last"]')!.getAttribute("style")).toBe(
`z-index: ${lastRestored}`,
);
},
);
it("full-reloads without partially restoring when any changed live target is missing", () => {
const { iframe, doc } = buildLiveIframe(`a
`);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(
`a
b
`,
),
restored: wrap(
`a
b
`,
),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
expect(reloadPreview).toHaveBeenCalledTimes(1);
// Target a resolved first, but the preflight found missing b before syncing either.
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
});
it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
// The live doc holds a script element; a re-run would mutate/remove it
// (applySoftReload removes stale script elements before re-running).
const script = `window.__timelines["root"]=gsap.timeline().to("#a",{x:1});`;
const { iframe, contentWindow, doc } = buildLiveIframe(
`t
`,
);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(`t
`),
restored: wrap(`t
`),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
expect(reloadPreview).not.toHaveBeenCalled();
// The live script element is untouched — rebind-only finalization ran instead.
expect(doc.querySelectorAll("script")).toHaveLength(1);
expect(doc.querySelector("script")!.textContent).toBe(script);
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 3");
});
it("full-reloads a changed two-script restore without partially touching the live DOM", () => {
const previousScripts = [
`window.__timelines["root"]=gsap.timeline().to("#a",{x:1});`,
`window.__timelines["captions"]=gsap.timeline().to("#a",{y:1});`,
];
const restoredScripts = previousScripts.map((script) => script.replace(":1", ":9"));
const scripts = (values: string[]) =>
values.map((value) => ``).join("");
const { iframe, doc } = buildLiveIframe(
`t
${scripts(previousScripts)}`,
);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(`t
${scripts(previousScripts)}`),
restored: wrap(`t
${scripts(restoredScripts)}`),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
expect(reloadPreview).toHaveBeenCalledTimes(1);
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
expect([...doc.querySelectorAll("script")].map((script) => script.textContent)).toEqual(
previousScripts,
);
});
it("full-reloads a multi-file restore", () => {
const { iframe } = buildLiveIframe(`t
`);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(`t
`),
restored: wrap(`t
`),
},
"scenes/intro.html": { previous: "a", restored: "b" },
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("full-reloads a structural restore (split/delete undo)", () => {
const { iframe } = buildLiveIframe(`t
t
`);
const reloadPreview = vi.fn();
const files = {
[ROOT]: {
previous: wrap(`t
t
`),
restored: wrap(`t
`),
},
};
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
const { iframe } = buildLiveIframe(`t
`);
const reloadPreview = vi.fn();
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
expect(reloadPreview).toHaveBeenCalledTimes(1);
});
});