// @vitest-environment happy-dom import React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { openComposition } from "@hyperframes/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; import { usePlayerStore, type TimelineElement } from "../player"; import { jsonResponse, requestUrl } from "./fetchStubTestUtils"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type ZIndexEntry = { element: HTMLElement; zIndex: number; id?: string; selector?: string; selectorIndex?: number; sourceFile: string; }; afterEach(() => { document.body.innerHTML = ""; usePlayerStore.getState().reset(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function createPreviewIframe( clips: Array<{ id: string; track: number; style?: string; }> = [ { id: "front", track: 0 }, { id: "back", track: 1 }, ], ): HTMLIFrameElement { const iframe = document.createElement("iframe"); document.body.append(iframe); const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); doc.body.innerHTML = clips .map( (clip) => `
`, ) .join("\n"); return iframe; } function timelineElement(input: { id: string; track: number; zIndex: number; tag?: string; start?: number; duration?: number; sourceFile?: string; }): TimelineElement { return { id: input.id, domId: input.id, hfId: `hf-${input.id}`, tag: input.tag ?? "div", start: input.start ?? 0, duration: input.duration ?? 2, track: input.track, zIndex: input.zIndex, stackingContextId: "root", parentCompositionId: null, compositionAncestors: ["root"], sourceFile: input.sourceFile ?? "index.html", timingSource: "authored", }; } /** Mount a harness component under act() and return its unmount hook. */ function mountHarness(node: React.ReactElement): { unmount: () => void } { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render(node); }); return { unmount: () => { act(() => root.unmount()); }, }; } function renderTimelineEditingHook(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; onZIndexCommit: (entries: ZIndexEntry[]) => Promise; projectId?: string | null; writeProjectFile?: (path: string, content: string) => Promise; recordEdit?: (input: { label: string; kind: string; coalesceKey?: string; files: Record; }) => Promise; reloadPreview?: () => void; sdkSession?: Awaited> | null; forceReloadSdkSession?: () => void; showToast?: (message: string, kind?: string) => void; }): { move: ReturnType["handleTimelineElementMove"]; resize: ReturnType["handleTimelineElementResize"]; groupMove: ReturnType["handleTimelineGroupMove"]; groupResize: ReturnType["handleTimelineGroupResize"]; del: ReturnType["handleTimelineElementDelete"]; unmount: () => void; } { let move: ReturnType["handleTimelineElementMove"] | null = null; let resize: ReturnType["handleTimelineElementResize"] | null = null; let groupMove: ReturnType["handleTimelineGroupMove"] | null = null; let groupResize: ReturnType["handleTimelineGroupResize"] | null = null; let del: ReturnType["handleTimelineElementDelete"] | null = null; function Harness() { const commitRef = useRef(input.onZIndexCommit); commitRef.current = input.onZIndexCommit; const hook = useTimelineEditing({ projectId: input.projectId ?? null, activeCompPath: "index.html", timelineElements: input.timelineElements, showToast: input.showToast ?? vi.fn(), writeProjectFile: input.writeProjectFile ?? vi.fn(), recordEdit: input.recordEdit ?? vi.fn(), domEditSaveTimestampRef: { current: 0 }, reloadPreview: input.reloadPreview ?? vi.fn(), previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), sdkSession: input.sdkSession, forceReloadSdkSession: input.forceReloadSdkSession, handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; resize = hook.handleTimelineElementResize; groupMove = hook.handleTimelineGroupMove; groupResize = hook.handleTimelineGroupResize; del = hook.handleTimelineElementDelete; return null; } const { unmount } = mountHarness(); if (!move) throw new Error("Expected hook to expose move handler"); if (!resize) throw new Error("Expected hook to expose resize handler"); if (!groupMove) throw new Error("Expected hook to expose group move handler"); if (!groupResize) throw new Error("Expected hook to expose group resize handler"); if (!del) throw new Error("Expected hook to expose delete handler"); return { move, resize, groupMove, groupResize, del, unmount }; } type TimelineRecordEdit = NonNullable< Parameters[0]["recordEdit"] >; function renderTimelineEditingHookWithLifecycle(input: { timelineElements: TimelineElement[]; iframe: HTMLIFrameElement; commitDomEditPatchBatches: ReturnType< typeof vi.fn< (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }> > >; }): { move: ReturnType["handleTimelineElementMove"]; unmount: () => void; } { let move: ReturnType["handleTimelineElementMove"] | null = null; function Harness() { const lifecycle = useElementLifecycleOps({ activeCompPath: "index.html", showToast: vi.fn(), writeProjectFile: vi.fn(), domEditSaveTimestampRef: { current: 0 }, editHistory: { recordEdit: vi.fn() }, projectIdRef: { current: "p1" }, reloadPreview: vi.fn(), clearDomSelection: vi.fn(), commitDomEditPatchBatches: input.commitDomEditPatchBatches, }); const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit); commitRef.current = lifecycle.handleDomZIndexReorderCommit; const hook = useTimelineEditing({ projectId: null, activeCompPath: "index.html", timelineElements: input.timelineElements, showToast: vi.fn(), writeProjectFile: vi.fn(), recordEdit: vi.fn(), domEditSaveTimestampRef: { current: 0 }, reloadPreview: vi.fn(), previewIframeRef: { current: input.iframe }, pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; return null; } const { unmount } = mountHarness(); if (!move) throw new Error("Expected hook to expose move handler"); return { move, unmount }; } async function flushAsyncWork(): Promise { for (let i = 0; i < 8; i += 1) { await Promise.resolve(); } } /** * Stub global fetch for project "p1": serves file contents (a single source * string, or a path → content map) and answers the GSAP-mutation endpoint * with `gsapBody`. Returns the mock for call inspection. */ function stubProjectFetch(files: string | Record, gsapBody?: unknown) { // Keep this test server's capability, file-read, and mutation routes together; // splitting the fixture would obscure the request sequence asserted by callers. // fallow-ignore-next-line complexity const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { const url = requestUrl(input); if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) { return jsonResponse({ atomicOwnershipPairs: true }); } if (url.includes("/api/projects/p1/files/")) { if (typeof files === "string") return jsonResponse({ content: files }); const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); return jsonResponse({ content: files[path] }); } if (url.includes("/api/projects/p1/gsap-mutations/")) { const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html"); const content = typeof files === "string" ? files : (files[path] ?? ""); return jsonResponse( gsapBody ?? { mutated: false, scriptText: null, before: content, after: content }, ); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); return fetchMock; } /** * One clip on track 0 + the editing hook wired to project "p1" — shared setup * for the single-clip move-path tests (horizontal, vertical-only, track-only, * diagonal) so their arrange blocks aren't clones of each other. */ function setupSingleClipHarness(options?: { source?: string; clipStyle?: string; onZIndexCommit?: (entries: ZIndexEntry[]) => Promise; }) { const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]); const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); const commit = options?.onZIndexCommit ?? vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const reloadPreview = vi.fn(); const fetchMock = stubProjectFetch( options?.source ?? '
', ); const hook = renderTimelineEditingHook({ timelineElements: [clip], iframe, onZIndexCommit: commit, projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview, }); return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook }; } /** Assert a lane write landed in both the live iframe DOM and the persisted file. */ function expectLanePersisted( iframe: HTMLIFrameElement, writeProjectFile: { mock: { calls: unknown[][] } }, track: string, ): void { const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track); expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`); } /** * Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe. * Shared by the group-move tests; `bSourceFile` puts b in its own file for the * cross-file partition test. */ function makeTwoClipPair(bSourceFile?: string) { const iframe = createPreviewIframe([ { id: "a", track: 0 }, { id: "b", track: 1 }, ]); const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }); const b = timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1, sourceFile: bSourceFile, }); return { iframe, a, b }; } const ROOT_DURATION_FALLBACK_SOURCE = [ `
`, `
`, `
`, ].join("\n"); /** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */ async function setupRootDurationFallback() { const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE); const setTimingSpy = vi.spyOn(sdkSession, "setTiming"); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async () => {}); const forceReloadSdkSession = vi.fn(); const reloadPreview = vi.fn(); const iframeWindow = iframe.contentWindow; if (!iframeWindow) throw new Error("Expected iframe window"); const postMessageSpy = vi.spyOn(iframeWindow, "postMessage"); stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { mutated: false, scriptText: null, before: ROOT_DURATION_FALLBACK_SOURCE, after: ROOT_DURATION_FALLBACK_SOURCE, }); usePlayerStore.getState().setDuration(4); const hook = renderTimelineEditingHook({ timelineElements: [clip], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit, sdkSession, forceReloadSdkSession, reloadPreview, }); return { hook, clip, setTimingSpy, writeProjectFile, forceReloadSdkSession, reloadPreview, postMessageSpy, }; } /** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */ function expectRootDurationExtendedViaFallback( ctx: Awaited>, ): void { expect(ctx.setTimingSpy).not.toHaveBeenCalled(); expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain( 'data-composition-id="main" data-duration="5"', ); expect(usePlayerStore.getState().duration).toBe(5); expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1); // The GSAP endpoint returned no rewritten scriptText, so the timing sync // escalates from the flash-free soft reload to ONE full reload. The root // duration travels via the persisted content-driven `data-duration` (above), // not a `set-root-duration` postMessage. expect(ctx.reloadPreview).toHaveBeenCalledTimes(1); expect(ctx.postMessageSpy).not.toHaveBeenCalledWith( expect.objectContaining({ action: "set-root-duration" }), "*", ); } describe("useTimelineEditing timeline z-index reorder", () => { it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => { const ctx = await setupRootDurationFallback(); await act(async () => { await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }); }); expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"'); expectRootDurationExtendedViaFallback(ctx); ctx.hook.unmount(); }); it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => { const ctx = await setupRootDurationFallback(); await act(async () => { await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined }); }); expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5">'); expectRootDurationExtendedViaFallback(ctx); ctx.hook.unmount(); }); it("routes a vertical drag through the shared z-index commit without writing track-index", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 10" }, { id: "back", track: 2, style: "position: relative; z-index: 1" }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 10 }); const back = timelineElement({ id: "back", track: 2, zIndex: 1 }); const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, back], iframe, onZIndexCommit: commit, }); await act(async () => { await move(back, { start: back.start, track: back.track, stackingReorder: { contextKey: "root", placement: { type: "onto", layerId: "layer-front" }, zIndexChanges: [{ key: "back", zIndex: 10 }], }, }); }); const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); expect(commit).toHaveBeenCalledTimes(1); expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ ["back", 10], ]); expect(doc.getElementById("back")?.getAttribute("data-track-index")).toBe("2"); unmount(); }); it("never writes z-index when the dragged clip is audio (no visual layer)", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0 }, { id: "music", track: 1 }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); const music = timelineElement({ id: "music", track: 1, zIndex: 0, tag: "audio" }); const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, music], iframe, onZIndexCommit: commit, }); await act(async () => { await move(music, { start: music.start, track: music.track, stackingReorder: { contextKey: "root", placement: { type: "onto", layerId: "layer-front" }, zIndexChanges: [{ key: "music", zIndex: 2 }], }, }); }); expect(commit).not.toHaveBeenCalled(); unmount(); }); it("commits only the minimum z-index changes resolved by the timeline drag", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 2" }, { id: "back", track: 1, style: "position: relative; z-index: 1" }, { id: "dragged", track: 2, style: "position: relative; z-index: 0" }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 2 }); const back = timelineElement({ id: "back", track: 1, zIndex: 1 }); const dragged = timelineElement({ id: "dragged", track: 2, zIndex: 0 }); const commit = vi.fn<(entries: ZIndexEntry[]) => Promise>().mockResolvedValue(undefined); const { move, unmount } = renderTimelineEditingHook({ timelineElements: [front, back, dragged], iframe, onZIndexCommit: commit, }); await act(async () => { await move(dragged, { start: dragged.start, track: dragged.track, stackingReorder: { contextKey: "root", placement: { type: "between", beforeLayerId: "front", afterLayerId: "back" }, zIndexChanges: [ { key: "dragged", zIndex: 2 }, { key: "front", zIndex: 3 }, ], }, }); }); expect(commit.mock.calls[0]![0].map((entry) => [entry.id, entry.zIndex])).toEqual([ ["dragged", 2], ["front", 3], ]); unmount(); }); it("uses the shared lifecycle commit so static clips receive position relative", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: static" }, { id: "back", track: 1, style: "position: static" }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 0 }); const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); const commitDomEditPatchBatches = vi.fn< (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }> >(async () => ({ durable: true, allMatched: true, changed: true })); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], iframe, commitDomEditPatchBatches, }); await act(async () => { await move(back, { start: back.start, track: back.track, stackingReorder: { contextKey: "root", placement: { type: "above", layerId: "front" }, zIndexChanges: [{ key: "back", zIndex: 2 }], }, }); await flushAsyncWork(); }); expect(commitDomEditPatchBatches).toHaveBeenCalled(); const batch = commitDomEditPatchBatches.mock.calls[0]![0] as Array<{ patches: Array<{ operations: unknown[] }>; }>; expect(batch[0]?.patches[0]?.operations).toEqual([ { type: "inline-style", property: "z-index", value: "2" }, { type: "inline-style", property: "position", value: "relative" }, ]); unmount(); }); it("rejects and rolls back DOM and store z-index changes when a reorder save fails", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 7" }, { id: "back", track: 1, style: "position: static" }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 7 }); const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); usePlayerStore.getState().setElements([ { ...front, hasExplicitZIndex: true }, { ...back, hasExplicitZIndex: false }, ]); const saveError = new Error("save failed"); const commitDomEditPatchBatches = vi .fn< (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }> >() .mockRejectedValueOnce(saveError); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], iframe, commitDomEditPatchBatches, }); const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); const frontElement = doc.getElementById("front") as HTMLElement | null; const backElement = doc.getElementById("back") as HTMLElement | null; if (!frontElement || !backElement) throw new Error("Expected reordered elements"); let rejection: unknown; await act(async () => { try { await move(back, { start: back.start, track: back.track, stackingReorder: { contextKey: "root", placement: { type: "above", layerId: "front" }, zIndexChanges: [ { key: "front", zIndex: 2 }, { key: "back", zIndex: 5 }, ], }, }); } catch (error) { rejection = error; } await flushAsyncWork(); }); expect(rejection).toBe(saveError); expect(frontElement.style.zIndex).toBe("7"); expect(frontElement.style.position).toBe("relative"); expect(backElement.style.zIndex).toBe(""); expect(backElement.style.position).toBe("static"); const storeEntries = usePlayerStore.getState().elements; expect(storeEntries.find((entry) => entry.id === "front")).toMatchObject({ zIndex: 7, hasExplicitZIndex: true, }); expect(storeEntries.find((entry) => entry.id === "back")).toMatchObject({ zIndex: 0, hasExplicitZIndex: false, }); unmount(); }); it("waits for the lifecycle z-index batch before resolving a reorder", async () => { const iframe = createPreviewIframe([ { id: "front", track: 0, style: "position: relative; z-index: 1" }, { id: "back", track: 1, style: "position: relative; z-index: 0" }, ]); const front = timelineElement({ id: "front", track: 0, zIndex: 1 }); const back = timelineElement({ id: "back", track: 1, zIndex: 0 }); let releaseBatch!: () => void; const batchSave = new Promise((resolve) => { releaseBatch = resolve; }); const commitDomEditPatchBatches = vi .fn< (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }> >() .mockReturnValueOnce( batchSave.then(() => ({ durable: true, allMatched: true, changed: true })), ); const { move, unmount } = renderTimelineEditingHookWithLifecycle({ timelineElements: [front, back], iframe, commitDomEditPatchBatches, }); let settled = false; let movePromise!: Promise; await act(async () => { movePromise = move(back, { start: back.start, track: back.track, stackingReorder: { contextKey: "root", placement: { type: "above", layerId: "front" }, zIndexChanges: [ { key: "front", zIndex: 2 }, { key: "back", zIndex: 3 }, ], }, }).then(() => { settled = true; }); await flushAsyncWork(); }); expect(commitDomEditPatchBatches).toHaveBeenCalledTimes(1); expect(settled).toBe(false); await act(async () => { releaseBatch(); await movePromise; await flushAsyncWork(); }); expect(settled).toBe(true); unmount(); }); it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => { const h = setupSingleClipHarness(); await act(async () => { await h.move(h.clip, { start: 1.25, track: h.clip.track }); }); const doc = h.iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25"); expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0"); expect(h.commit).not.toHaveBeenCalled(); expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"'); expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"'); expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index"); expect( h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), ).toBe(true); h.unmount(); }); it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => { // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a // pure lane change routed through onMoveElement (no onMoveElements wired) // wrote NOTHING — the lane snapped back on the next reload. const h = setupSingleClipHarness(); await act(async () => { // Vertical-only: same start, new track (already authored-space on this path). await h.move(h.clip, { start: h.clip.start, track: 2 }); }); // Live DOM patched (no pre-reload lane snap-back) and the file write // carries the new data-track-index with start intact. expectLanePersisted(h.iframe, h.writeProjectFile, "2"); expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"'); h.unmount(); }); it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => { // Gate the z-index commit so we can observe whether the timing write waits. let releaseCommit!: () => void; const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); const h = setupSingleClipHarness({ clipStyle: "position: relative; z-index: 0", onZIndexCommit: vi .fn<(entries: ZIndexEntry[]) => Promise>() .mockReturnValue(commitGate), }); const { clip, commit, writeProjectFile, move, unmount } = h; // Diagonal drag: both a time move (start change) and a restack (z-index change). let movePromise!: Promise; await act(async () => { movePromise = move(clip, { start: 1.25, track: clip.track, stackingReorder: { contextKey: "root", placement: { type: "onto", layerId: "layer-clip" }, zIndexChanges: [{ key: "clip", zIndex: 5 }], }, }); await flushAsyncWork(); }); // The z-index commit is in flight but gated; the full-file timing write must // not have run yet, or it would overwrite the file without the z-index change. expect(commit).toHaveBeenCalledTimes(1); expect(writeProjectFile).not.toHaveBeenCalled(); // Release the z-index commit → the timing write now proceeds, on top of it. await act(async () => { releaseCommit(); await movePromise; await flushAsyncWork(); }); expect(writeProjectFile).toHaveBeenCalled(); unmount(); }); it("persists a same-file group move with one write containing every clip timing", async () => { const source = [ '
', '
', '
', ].join("\n"); const iframe = createPreviewIframe([ { id: "a", track: 0 }, { id: "b", track: 1 }, { id: "c", track: 2 }, ]); const clips = [ timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 }), timelineElement({ id: "b", track: 1, zIndex: 0, start: 1, duration: 1 }), timelineElement({ id: "c", track: 2, zIndex: 0, start: 2, duration: 1 }), ]; const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); stubProjectFetch(source); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: clips, iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit, }); await act(async () => { await groupMove([ { element: clips[0], start: 0.5 }, { element: clips[1], start: 1.5 }, { element: clips[2], start: 2.5 }, ]); }); expect(writeProjectFile).toHaveBeenCalledTimes(1); const written = writeProjectFile.mock.calls[0]![1] as string; expect(written).toContain('id="a" data-start="0.5"'); expect(written).toContain('id="b" data-start="1.5"'); expect(written).toContain('id="c" data-start="2.5"'); expect(recordEdit).toHaveBeenCalledTimes(1); expect(Object.keys(recordEdit.mock.calls[0]![0].files)).toEqual(["index.html"]); unmount(); }); it("partitions a group move by source file while keeping one undo entry", async () => { const files: Record = { "index.html": '
', "scene.html": '
', }; const { iframe, a, b } = makeTwoClipPair("scene.html"); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const recordEdit = vi.fn(async (_entry) => {}); stubProjectFetch(files); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [a, b], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit, }); await act(async () => { await groupMove([ { element: a, start: 0.25 }, { element: b, start: 1.25 }, ]); }); expect(writeProjectFile.mock.calls.map((call) => call[0])).toEqual([ "index.html", "scene.html", ]); expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0.25"'); expect(writeProjectFile.mock.calls[1]![1]).toContain('data-start="1.25"'); expect(recordEdit).toHaveBeenCalledTimes(1); expect(Object.keys(recordEdit.mock.calls[0]![0].files).sort()).toEqual([ "index.html", "scene.html", ]); unmount(); }); it("waits for a z-index commit before the group timing write", async () => { const source = '
'; const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); let releaseCommit!: () => void; const zIndexCommit = new Promise((resolve) => { releaseCommit = resolve; }); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); stubProjectFetch(source); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [clip], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), }); let movePromise!: Promise; await act(async () => { movePromise = groupMove([{ element: clip, start: 0.75 }], { beforeTiming: zIndexCommit }); await flushAsyncWork(); }); expect(writeProjectFile).not.toHaveBeenCalled(); await act(async () => { releaseCommit(); await movePromise; await flushAsyncWork(); }); expect(writeProjectFile).toHaveBeenCalledTimes(1); unmount(); }); it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => { // The mirrored z-order lane move persists {start: unchanged, track: new}. // Nothing timing-related changed — data-track-index is never read by the // renderer — so the persist must NOT be followed by the GSAP round-trip or // the full preview reload (the reload is what blinked the canvas). const h = setupSingleClipHarness({ source: '
', }); await act(async () => { await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]); await flushAsyncWork(); }); // The lane write still persisted (live DOM + file)... expectLanePersisted(h.iframe, h.writeProjectFile, "2"); expect(h.writeProjectFile).toHaveBeenCalledTimes(1); // ...but no GSAP mutation ran and the preview was NOT reloaded. expect( h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), ).toBe(false); expect(h.reloadPreview).not.toHaveBeenCalled(); h.unmount(); }); it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => { // The gap-close blink path: "Close gap" issues pure TIME moves through // handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a // .sub caption) has no id-addressed GSAP tweens, so the mutation step has // nothing to rewrite and used to fall through to a full reloadPreview(). // The sync must instead rebind the runtime timing in place — seek + rebind // re-derive the clip windows from the already-patched DOM — WITHOUT // re-executing any script (a comp's init-style scripts, e.g. a three.js // setup, must never run twice) and without the full-reload blink. const iframe = document.createElement("iframe"); document.body.append(iframe); const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); doc.body.innerHTML = '
'; const liveScript = doc.createElement("script"); liveScript.textContent = 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };'; doc.body.appendChild(liveScript); const win = iframe.contentWindow as unknown as Record; win.gsap = { timeline: vi.fn(), set: vi.fn() }; win.__timelines = { root: { kill: vi.fn() } }; win.__hfForceTimelineRebind = vi.fn(); win.__player = { getTime: () => 0, seek: vi.fn() }; const cap: TimelineElement = { ...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }), domId: undefined, selector: ".cap", selectorIndex: 0, }; const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const reloadPreview = vi.fn(); const fetchMock = stubProjectFetch( '
', ); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [cap], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview, }); await act(async () => { // Mirror timelineGapCommit: a pure time move keeping the current lane. await groupMove([{ element: cap, start: 1, track: cap.track }]); await flushAsyncWork(); }); // The move persisted (live DOM + file)... expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1"); expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"'); // ...no GSAP mutation ran (nothing id-addressed to rewrite)... expect( fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), ).toBe(false); // ...and the preview was rebound in place, NOT full-reloaded (blink), // with the live script element left untouched (no re-execution). expect(reloadPreview).not.toHaveBeenCalled(); expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1); expect(doc.body.contains(liveScript)).toBe(true); expect(doc.querySelectorAll("script")).toHaveLength(1); unmount(); }); it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => { // One clip changes lane only, the other shifts in time — the batch is not // track-only, so the existing behavior (GSAP shift + full reload when no // rewritten scriptText comes back — this stub iframe has no runtime rebind // hook, so the in-place rebind can't apply) must be preserved. const source = [ '
', '
', ].join("\n"); const { iframe, a, b } = makeTwoClipPair(); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const reloadPreview = vi.fn(); const fetchMock = stubProjectFetch(source, { mutated: false, scriptText: null, before: source, after: source, }); const { groupMove, unmount } = renderTimelineEditingHook({ timelineElements: [a, b], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview, }); await act(async () => { await groupMove([ { element: a, start: a.start, track: 2 }, { element: b, start: 1.5, track: b.track }, ]); await flushAsyncWork(); }); expect(writeProjectFile).toHaveBeenCalledTimes(1); // The time-shifted clip still goes through the GSAP shift endpoint... expect( fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")), ).toBe(true); // ...and with no rewritten scriptText the sync escalates to one full reload. expect(reloadPreview).toHaveBeenCalledTimes(1); unmount(); }); it("matches the single-clip move output when a group move contains one clip", async () => { const source = '
'; const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 }); stubProjectFetch(source); const singleWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const single = renderTimelineEditingHook({ timelineElements: [clip], iframe: createPreviewIframe([{ id: "clip", track: 0 }]), onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile: singleWrite, recordEdit: vi.fn(async () => {}), }); await act(async () => { await single.move(clip, { start: 0.5, track: clip.track }); }); single.unmount(); const groupWrite = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const group = renderTimelineEditingHook({ timelineElements: [clip], iframe: createPreviewIframe([{ id: "clip", track: 0 }]), onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile: groupWrite, recordEdit: vi.fn(async () => {}), }); await act(async () => { await group.groupMove([{ element: clip, start: 0.5 }]); }); expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]); group.unmount(); }); }); describe("useTimelineEditing duration rollback on failed persist", () => { const ROLLBACK_SOURCE = [ `
`, `
`, `
`, ].join("\n"); /** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */ function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement { const iframe = document.createElement("iframe"); document.body.append(iframe); const doc = iframe.contentDocument; if (!doc) throw new Error("Expected iframe document"); doc.body.innerHTML = source; return iframe; } function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined { return iframe.contentDocument ?.querySelector("[data-composition-id]") ?.getAttribute("data-duration"); } function setupFailedPersist() { const iframe = createRootedIframe(); const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 }); const writeError = new Error("write failed"); const writeProjectFile = vi .fn<(...args: unknown[]) => Promise>() .mockRejectedValue(writeError); stubProjectFetch(ROLLBACK_SOURCE); usePlayerStore.getState().setDuration(4); vi.spyOn(console, "error").mockImplementation(() => {}); const hook = renderTimelineEditingHook({ timelineElements: [clip], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview: vi.fn(), }); return { iframe, clip, hook, writeError }; } /** * Run a persist that must reject with the harness's writeError, then assert * the store duration AND the live root rolled back to the pre-edit 4s — * the shared act/assert core of the four failed-persist tests below. */ async function expectPersistRollback( ctx: ReturnType, run: () => Promise, ): Promise { let rejection: unknown; await act(async () => { await run().catch((error) => { rejection = error; }); await flushAsyncWork(); }); expect(rejection).toBe(ctx.writeError); expect(usePlayerStore.getState().duration).toBe(4); expect(rootDurationAttr(ctx.iframe)).toBe("4"); } it("rolls back the store duration and live root when a move persist fails", async () => { const ctx = setupFailedPersist(); // Move past the end: the optimistic sync grows the readout to 5s. await expectPersistRollback(ctx, () => ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }), ); ctx.hook.unmount(); }); it("rolls back the store duration and live root when a resize persist fails", async () => { const ctx = setupFailedPersist(); await expectPersistRollback(ctx, () => ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }), ); ctx.hook.unmount(); }); it("rolls back the store duration and live root when a group move persist fails", async () => { const ctx = setupFailedPersist(); await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }])); ctx.hook.unmount(); }); it("rolls back the store duration and live root when a group resize persist fails", async () => { const ctx = setupFailedPersist(); await expectPersistRollback(ctx, () => ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]), ); ctx.hook.unmount(); }); it("rolls back the store duration and live root when a delete persist fails", async () => { // Two clips; deleting the furthest one shrinks the content-driven duration // optimistically (4s -> 2s), so a failed write must roll that shrink back. const DELETE_SOURCE = [ `
`, `
`, `
`, `
`, ].join("\n"); const DELETE_REMOVED_SOURCE = [ `
`, `
`, `
`, ].join("\n"); const iframe = createRootedIframe(DELETE_SOURCE); const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 }); const writeError = new Error("write failed"); const writeProjectFile = vi .fn<(...args: unknown[]) => Promise>() .mockRejectedValue(writeError); // The delete path reads the file, then asks the server-side remove-element // mutation for the post-removal source before persisting it. const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { const url = requestUrl(input); if (url.includes("/api/projects/p1/files/")) { return jsonResponse({ content: DELETE_SOURCE }); } if (url.includes("/api/projects/p1/file-mutations/remove-element/")) { return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE }); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); usePlayerStore.getState().setDuration(4); const showToast = vi.fn(); const hook = renderTimelineEditingHook({ timelineElements: [tail], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview: vi.fn(), showToast, }); await act(async () => { // Unlike move/resize, the delete handler swallows the persist failure // into a toast, so the promise resolves. await hook.del(tail); await flushAsyncWork(); }); // The optimistic shrink reached the persist attempt (root patched to the // furthest remaining clip end, 2s)... expect(writeProjectFile).toHaveBeenCalledTimes(1); expect(String(writeProjectFile.mock.calls[0]![1])).toContain( 'data-composition-id="main" data-duration="2"', ); expect(showToast).toHaveBeenCalledWith("write failed"); // ...and the failed write rolled the readout AND the live root back. expect(usePlayerStore.getState().duration).toBe(4); expect(rootDurationAttr(iframe)).toBe("4"); hook.unmount(); }); it("keeps the grown duration when the persist succeeds", async () => { const { iframe, clip, hook } = setupFailedPersist(); // Same harness, but with a write that succeeds this time. hook.unmount(); const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const succeeding = renderTimelineEditingHook({ timelineElements: [clip], iframe, onZIndexCommit: vi.fn().mockResolvedValue(undefined), projectId: "p1", writeProjectFile, recordEdit: vi.fn(async () => {}), reloadPreview: vi.fn(), }); await act(async () => { await succeeding.move(clip, { start: 3, track: clip.track }); await flushAsyncWork(); }); expect(usePlayerStore.getState().duration).toBe(5); expect(rootDurationAttr(iframe)).toBe("5"); succeeding.unmount(); }); });