mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): address PR #2347 review findings (rounds 1-2)
Review 1 (restore commit): - asset reveal now clears any open preview overlay (stuck-overlay repro: preview on A, click already-added B — A stayed open over the reveal) - duration readout rolls back on failed persist: captureDurationRollback snapshots store + live root data-duration before the optimistic sync and restores both in every move/resize/delete/group catch (golden's previousDuration pattern) - asset preview opened during running playback dismisses immediately (the RAF loop bypasses the store, so the subscription alone never fired) - persistTimelineBatchEdit resolves the target (findTagByTarget) before treating identical output as a no-op — a mistargeted member now throws like the single-element path instead of being silently dropped - a post-mutation history-fold failure no longer suppresses the preview sync: fold errors are surfaced separately and the rewritten script still syncs (previously the preview kept stale GSAP positions with no recovery) - timelineRevealScroll guards degenerate viewports (windowSize <= 0) - CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches Review 2 (single-source-of-truth pass): - createTimelineElementFromManifestClip — the one manifest->element boundary — now carries authoredTrack and stackingContextId; expanded sub-comp children preserve both (authoredTrack in their OWN file's space) - authoredTrackForLane scopes occupants to the dragged clip's sourceFile (a foreign file's authored values are a different coordinate space); nearest-same-file-lane offset fallback - optimistic store updates mirror the persisted track into authoredTrack (and roll it back on failure), so consecutive drags before a reload resolve from fresh data - spill sub-lanes: documented decision — dropping onto a spill lane is a legitimate same-track join (occupants share the authored track by construction); false 'never a lane-move target' docstring rewritten - single-element fallback persists vertical-only moves (early return now requires neither start nor track changed; live DOM patch includes data-track-index) - canonical contextKey helper for stacking-context normalization - new pipeline test crosses the REAL factory boundary (sparse authored tracks -> factory -> expansion -> normalize -> drag commit -> persisted attribute), no injected fields
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import {
|
||||
captureDurationRollback,
|
||||
finishClipTimingFallback,
|
||||
readFileContent,
|
||||
shiftGsapPositions,
|
||||
} from "./timelineTimingSync";
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub fetch: `/files/` reads return contents from the queue (repeating the
|
||||
* last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
|
||||
* Error rejects the call with a non-ok response).
|
||||
*/
|
||||
function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
|
||||
let readIndex = 0;
|
||||
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/files/")) {
|
||||
const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
|
||||
readIndex += 1;
|
||||
return jsonResponse({ content });
|
||||
}
|
||||
if (url.includes("/gsap-mutations/")) {
|
||||
if (gsapBody instanceof Error) {
|
||||
return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
|
||||
}
|
||||
return jsonResponse(gsapBody);
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function clipFallbackInput(overrides: {
|
||||
reloadPreview: () => void;
|
||||
recordEdit: (edit: unknown) => Promise<void>;
|
||||
}) {
|
||||
return {
|
||||
iframe: null,
|
||||
reloadPreview: overrides.reloadPreview,
|
||||
projectId: "p1",
|
||||
targetPath: "index.html",
|
||||
domId: "clip",
|
||||
label: "Move timeline clip",
|
||||
recordEdit: overrides.recordEdit as never,
|
||||
edit: { kind: "shift", delta: 1 } as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finishClipTimingFallback failure domains", () => {
|
||||
it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
|
||||
// Mutation succeeds (server rewrite already on disk), but recordEdit (the
|
||||
// fold step) throws. The preview MUST still be synced — otherwise stale
|
||||
// GSAP positions stay on screen. iframe=null makes the sync observable as
|
||||
// one reloadPreview() call.
|
||||
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
|
||||
const reloadPreview = vi.fn();
|
||||
const foldError = new Error("history fold failed");
|
||||
const recordEdit = vi.fn(async () => {
|
||||
throw foldError;
|
||||
});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
// The fold error is surfaced, not swallowed silently.
|
||||
expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
|
||||
});
|
||||
|
||||
it("skips the preview sync when the MUTATION itself fails", async () => {
|
||||
stubFetch(["<before>"], new Error("mutation blew up"));
|
||||
const reloadPreview = vi.fn();
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).not.toHaveBeenCalled();
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("records the fold and syncs on the happy path", async () => {
|
||||
stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
|
||||
const reloadPreview = vi.fn();
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
|
||||
await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
|
||||
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureDurationRollback", () => {
|
||||
it("restores the pre-sync duration only when it changed", () => {
|
||||
usePlayerStore.getState().setDuration(4);
|
||||
const rollback = captureDurationRollback(null);
|
||||
|
||||
// No change → rollback is a no-op (no spurious set).
|
||||
rollback();
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
|
||||
usePlayerStore.getState().setDuration(9);
|
||||
rollback();
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch URL encoding (user-influenced segments)", () => {
|
||||
it("URI-encodes the projectId in file reads", async () => {
|
||||
const fetchMock = stubFetch(["<html>"], {});
|
||||
await readFileContent("p/../evil", "index.html");
|
||||
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
|
||||
"/api/projects/p%2F..%2Fevil/files/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("URI-encodes the projectId in GSAP mutation calls", async () => {
|
||||
const fetchMock = stubFetch([], { mutated: false, scriptText: null });
|
||||
await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
|
||||
expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
|
||||
"/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user