import { describe, expect, it, vi } from "vitest";
import {
shouldUseSdkCutover,
sdkCutoverPersist,
sdkDeletePersist,
sdkTimingPersist,
sdkGsapTweenPersist,
sdkGsapKeyframePersist,
} from "./sdkCutover";
import { openComposition } from "@hyperframes/sdk";
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
import type { PatchOperation } from "./sourcePatcher";
import type { MutableRefObject } from "react";
vi.mock("../components/editor/manualEditingAvailability", () => ({
STUDIO_SDK_CUTOVER_ENABLED: true,
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
}));
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: vi.fn(),
}));
const styleOp = (property: string, value: string): PatchOperation => ({
type: "inline-style",
property,
value,
});
const textOp = (value: string): PatchOperation => ({
type: "text-content",
property: "text",
value,
});
const attrOp = (property: string, value: string): PatchOperation => ({
type: "attribute",
property,
value,
});
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
type: "html-attribute",
property,
value,
});
describe("shouldUseSdkCutover", () => {
it("returns false when flag disabled", () => {
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when no session", () => {
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
});
it("returns false when no hfId", () => {
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
});
it("returns false when ops empty", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
});
it("returns true for inline-style ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
});
it("returns true for text-content ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true);
});
it("returns true for attribute ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true);
});
it("returns true for html-attribute ops", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true);
});
it("returns false for an attribute op that maps to a reserved data-* name", () => {
// {type:'attribute', property:'end'} → 'data-end', which the SDK's
// validateSetAttribute rejects. Decline the batch so it takes the server
// path cleanly instead of throwing inside dispatch and falling back per op.
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("end", "2")])).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-start", "1")])).toBe(false);
});
it("declines a case-variant reserved attribute (SDK lowercases before checking)", () => {
// attribute op "END" → "data-END" → lower → "data-end" (reserved).
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("END", "2")])).toBe(false);
});
it("declines an html-attribute op whose raw name is reserved", () => {
// html-attribute ops aren't data-prefixed, so a raw reserved name must still
// be caught (the SDK throws on it just the same).
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("data-end", "3")])).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
});
it("declines html-attribute ops with event handler names", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
false,
);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
false,
);
});
it("declines html-attribute ops with disallowed attribute names", () => {
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
});
it("declines html-attribute ops with dangerous URI schemes", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
).toBe(false);
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
false,
);
});
it("declines html-attribute ops with dangerous data URIs", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [
htmlAttrOp("href", "data:text/html,"),
]),
).toBe(false);
});
it("returns true when ops mix all supported types", () => {
expect(
shouldUseSdkCutover(true, true, "hf-abc", [
styleOp("color", "red"),
textOp("hello"),
attrOp("x", "1"),
htmlAttrOp("class", "foo"),
]),
).toBe(true);
});
});
describe("sdkCutoverPersist", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = (overrides: Partial[5]> = {}) => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
...overrides,
});
const makeSession = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
dispatch: vi.fn(),
// Distinct before/after so the no-op guard (after === before → fall back)
// treats this as a real change; "after" matches the write assertions.
serialize: vi
.fn()
.mockReturnValueOnce("before")
.mockReturnValue(""),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[4];
it("returns false when session is null", async () => {
const deps = makeDeps();
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/path.html",
null,
deps,
);
expect(result).toBe(false);
});
it("returns false when element not found in session", async () => {
const deps = makeDeps();
const session = makeSession(false);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/path.html",
session,
deps,
);
expect(result).toBe(false);
});
it("dispatches setStyle for inline-style ops", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red"), styleOp("opacity", "0.5")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setStyle",
target: "hf-abc",
styles: { color: "red", opacity: "0.5" },
});
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "");
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("dispatches setText for text-content op", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[textOp("Hello world")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setText",
target: "hf-abc",
value: "Hello world",
});
});
it.each([
{ name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
{ name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
])("declines text-content cutover for $name", async ({ children }) => {
const deps = makeDeps();
const session = makeSession(true);
(session!.getElement as ReturnType).mockReturnValue({ children });
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[textOp("Hello world")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(session!.dispatch).not.toHaveBeenCalled();
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
it("dispatches setAttribute for attribute op with data- prefix", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[attrOp("x", "42")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setAttribute",
target: "hf-abc",
name: "data-x",
value: "42",
});
});
it("dispatches setAttribute for html-attribute op", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[htmlAttrOp("class", "foo bar")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "setAttribute",
target: "hf-abc",
name: "class",
value: "foo bar",
});
});
it("passes caller label to recordEdit", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
label: "Resize layer box",
});
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({ label: "Resize layer box" }),
);
});
it("passes caller coalesceKey to recordEdit", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
coalesceKey: "my-key",
});
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({ coalesceKey: "my-key" }),
);
});
it("returns false and does not throw on dispatch error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.dispatch as ReturnType).mockImplementation(() => {
throw new Error("dispatch failed");
});
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
it("wraps all dispatches in session.batch() for atomic rollback", async () => {
const deps = makeDeps();
const session = makeSession(true);
const sel = { hfId: "hf-abc" } as never;
await sdkCutoverPersist(
sel,
[styleOp("color", "red"), styleOp("opacity", "0.5")],
"before",
"/comp.html",
session,
deps,
);
expect(
(session as unknown as { batch: ReturnType }).batch,
).toHaveBeenCalledOnce();
});
it("returns false when second dispatch throws (batch prevents partial mutation)", async () => {
// inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches.
const deps = makeDeps();
const session = makeSession(true);
let callCount = 0;
(session!.dispatch as ReturnType).mockImplementation(() => {
callCount++;
if (callCount === 2) throw new Error("2nd op failed");
});
const sel = { hfId: "hf-abc" } as never;
const result = await sdkCutoverPersist(
sel,
[styleOp("color", "red"), textOp("hello")],
"before",
"/comp.html",
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
describe("sdkDeletePersist", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
const makeSession = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
removeElement: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("before-snap")
.mockReturnValue("after"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[3];
it("returns false when session is null", async () => {
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
});
it("returns false when element not found in session", async () => {
const session = makeSession(false);
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
false,
);
});
it("calls removeElement and writes serialized content", async () => {
const deps = makeDeps();
const session = makeSession(true);
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(result).toBe(true);
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after");
});
it("records edit history with before/after diff", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
label: "Delete element",
files: { "/comp.html": { before: "before-content", after: "after" } },
}),
);
});
it("calls reloadPreview on success", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("returns false and does not write on removeElement error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.removeElement as ReturnType).mockImplementation(() => {
throw new Error("remove failed");
});
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
expect(deps.reloadPreview).not.toHaveBeenCalled();
});
});
describe("sdkTimingPersist", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
const makeSession = (hasEl = true) =>
({
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null),
setTiming: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("before")
.mockReturnValue("after"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[3];
it("returns false when session is null", async () => {
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe(
false,
);
});
it("returns false when element not found in session", async () => {
const session = makeSession(false);
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe(
false,
);
});
it("calls setTiming with provided update and writes serialized content", async () => {
const deps = makeDeps();
const session = makeSession(true);
const result = await sdkTimingPersist(
"hf-clip",
"/comp.html",
{ start: 2, duration: 5, trackIndex: 1 },
session,
deps,
);
expect(result).toBe(true);
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
start: 2,
duration: 5,
trackIndex: 1,
});
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after");
});
it("captures before-state before setTiming dispatch", async () => {
const deps = makeDeps();
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: { "/comp.html": { before: "before", after: "after" } },
}),
);
});
it("returns false and does not write on setTiming error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.setTiming as ReturnType).mockImplementation(() => {
throw new Error("timing error");
});
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
// Finding #12: undo baseline must be the EXACT on-disk bytes (matching the
// style/delete paths), not a normalized SDK serialize() re-emit — otherwise
// undoing a timing edit reformats the whole file.
it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => {
const deps = {
...makeDeps(),
readProjectFile: vi.fn().mockResolvedValue("EXACT ON-DISK BYTES"),
};
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"/comp.html": { before: "EXACT ON-DISK BYTES", after: "after" },
},
}),
);
});
it("falls back to serialize() before when the reader throws", async () => {
const deps = {
...makeDeps(),
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
};
const session = makeSession(true);
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: { "/comp.html": { before: "before", after: "after" } },
}),
);
});
});
describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeSession = () =>
({
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
setGsapTween: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("serialized-before")
.mockReturnValue("after"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[2];
it("records the on-disk content as the undo before, not serialize()", async () => {
const deps = {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
readProjectFile: vi.fn().mockResolvedValue("on-disk gsap bytes"),
};
const session = makeSession();
await sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
session,
deps,
);
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
expect.objectContaining({
files: {
"/comp.html": { before: "on-disk gsap bytes", after: "after" },
},
}),
);
});
});
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => {
const order: string[] = [];
let writeResolve: (() => void) | null = null;
const deps = {
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
// First write blocks until we release it, so without serialization the
// second op's serialize()/dispatch would interleave ahead of it.
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
order.push(`write-start:${content}`);
if (content === "after-1") {
return new Promise((res) => {
writeResolve = () => {
order.push(`write-done:${content}`);
res();
};
});
}
order.push(`write-done:${content}`);
return Promise.resolve();
}),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
// A real per-key serializer: tasks under the same key run strictly in order.
serialize: (() => {
const inFlight = new Map>();
return (key: string, task: () => Promise): Promise => {
const prior = inFlight.get(key) ?? Promise.resolve();
const next = prior.then(task, task);
inFlight.set(key, next);
return next as Promise;
};
})(),
};
let serializeCall = 0;
const session = {
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
setGsapTween: vi.fn(() => order.push("dispatch")),
serialize: vi.fn(() => {
serializeCall++;
// before-1, after-1, before-2, after-2
return `${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}`;
}),
batch: vi.fn((fn: () => void) => fn()),
} as unknown as Parameters[2];
const p1 = sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "a" } },
session,
deps,
);
const p2 = sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "b" } },
session,
deps,
);
// Let the first op reach its (blocked) write before releasing it.
await Promise.resolve();
await Promise.resolve();
writeResolve?.();
await Promise.all([p1, p2]);
// The second op's write must NOT start before the first op's write completes.
const firstWriteDone = order.indexOf("write-done:after-1");
const secondWriteStart = order.indexOf("write-start:after-2");
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
});
});
describe("sdkGsapTweenPersist", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
({
getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null),
addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"),
setGsapTween: vi.fn(),
removeGsapTween: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("before")
.mockReturnValue("after"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[2];
it("returns false when session is null", async () => {
expect(
await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
null,
makeDeps(),
),
).toBe(false);
});
it("calls addGsapTween and writes for kind=add", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{
kind: "add",
target: "hf-box",
spec: { method: "to", duration: 1, properties: { opacity: 1 } },
},
session,
deps,
);
expect(result).toBe(true);
expect(session!.addGsapTween).toHaveBeenCalledWith(
"hf-box",
expect.objectContaining({ method: "to" }),
);
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after");
});
it("returns false for kind=add when element not found", async () => {
const deps = makeDeps();
const session = makeSession({ hasEl: false });
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } },
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
it("calls setGsapTween and writes for kind=set", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
session,
deps,
);
expect(result).toBe(true);
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("calls removeGsapTween for kind=remove", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
session,
deps,
);
expect(result).toBe(true);
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
});
it("returns false and does not write on SDK error", async () => {
const deps = makeDeps();
const session = makeSession();
(session!.removeGsapTween as ReturnType).mockImplementation(() => {
throw new Error("gsap error");
});
const result = await sdkGsapTweenPersist(
"/comp.html",
{ kind: "remove", animationId: "tw-1" },
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
});
describe("sdkGsapKeyframePersist", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
const makeSession = () =>
({
dispatch: vi.fn(),
serialize: vi
.fn()
.mockReturnValueOnce("before")
.mockReturnValue("after"),
batch: vi.fn((fn: () => void) => fn()),
}) as unknown as Parameters[4];
it("returns false when session is null", async () => {
expect(
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
).toBe(false);
});
it("dispatches addGsapKeyframe and writes serialized content", async () => {
const deps = makeDeps();
const session = makeSession();
const result = await sdkGsapKeyframePersist(
"/comp.html",
"tw-1",
50,
{ opacity: 0.5 },
session,
deps,
);
expect(result).toBe(true);
expect(session!.dispatch).toHaveBeenCalledWith({
type: "addGsapKeyframe",
animationId: "tw-1",
position: 50,
value: { opacity: 0.5 },
});
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after");
expect(deps.reloadPreview).toHaveBeenCalled();
});
it("returns false and does not write on dispatch error", async () => {
const deps = makeDeps();
const session = makeSession();
(session!.dispatch as ReturnType).mockImplementation(() => {
throw new Error("dispatch failed");
});
const result = await sdkGsapKeyframePersist(
"/comp.html",
"tw-1",
25,
{ x: 100 },
session,
deps,
);
expect(result).toBe(false);
expect(deps.writeProjectFile).not.toHaveBeenCalled();
});
});
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
const makeRef = (val: T): MutableRefObject => ({ current: val });
const makeDeps = () => ({
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
writeProjectFile: vi.fn().mockResolvedValue(undefined),
reloadPreview: vi.fn(),
domEditSaveTimestampRef: makeRef(0),
});
it("preserves GSAP