// @vitest-environment happy-dom import { act } from "react"; import { createRoot } from "react-dom/client"; import { describe, expect, it, vi } from "vitest"; import { shouldUseSdkCutover } from "../utils/sdkCutover"; import type { PatchOperation } from "../utils/sourcePatcher"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { UseDomEditSessionParams } from "./useDomEditSession"; const styleOp = (property: string, value: string): PatchOperation => ({ type: "inline-style", property, value, }); const attrOp = (property: string, value: string): PatchOperation => ({ type: "attribute", property, value, }); describe("shouldUseSdkCutover", () => { it("returns false when flag is disabled", () => { expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false); }); it("returns false when no SDK session", () => { expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false); }); it("returns false when selection has 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 array is empty", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false); }); it("returns true when all conditions met with supported op types", () => { expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true); expect( shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]), ).toBe(true); }); }); // ── onReorderShadow source filter (Fix 3) ──────────────────────────────────── // // useDomEditSession composes ~9 sub-hooks; the only one relevant to this fix is // useDomEditCommits, which receives the onReorderShadow callback built inline. // Every other sub-hook is stubbed to a minimal shape so the hook under test can // render without pulling in unrelated preview/GSAP/selection machinery. const recordResolverParity = vi.fn<(...args: unknown[]) => Promise>(async () => {}); const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined } = { fn: undefined, }; const domEditSelectionRef: { current: DomEditSelection | null } = { current: null }; const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] }; const groupSelectionSpy = vi.fn(); const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() }); function createSessionParams( overrides: Partial = {}, ): UseDomEditSessionParams { return { projectId: "proj-1", activeCompPath: "index.html", compIdToSrc: new Map(), captionEditMode: false, compositionLoading: false, previewIframeRef: { current: null }, timelineElements: [], getTimelineSelectionSet: () => new Set(), setSelectedTimelineElementId: vi.fn(), setTimelineSelectionSet: vi.fn(), setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn(), showToast: vi.fn(), refreshPreviewDocumentVersion: vi.fn(), queueDomEditSave: async (save: () => Promise) => save(), readProjectFile: async () => "", writeProjectFile: async () => {}, updateEditingFileContent: vi.fn(), domEditSaveTimestampRef: { current: 0 }, editHistory: { recordEdit: async () => {} }, fileTree: [], importedFontAssetsRef: { current: [] }, projectDir: null, projectIdRef: { current: "proj-1" }, previewIframe: null, refreshKey: 0, previewDocumentVersion: 0, rightPanelTab: "design", applyStudioManualEditsToPreviewRef: { current: async () => {} }, syncPreviewHotkeys: vi.fn(), reloadPreview: vi.fn(), setRefreshKey: vi.fn(), ...overrides, }; } vi.mock("../utils/sdkResolverShadow", () => ({ runResolverShadow: vi.fn(), recordResolverParity: (...args: unknown[]) => recordResolverParity(...args), })); vi.mock("./useDomEditCommits", () => ({ useDomEditCommits: (params: { onReorderShadow?: (targets: string[]) => void }) => { capturedOnReorderShadow.fn = params.onReorderShadow; return { resolveImportedFontAsset: vi.fn(), handleDomStyleCommit: vi.fn(), handleDomAttributeCommit: vi.fn(), handleDomAttributeLiveCommit: vi.fn(), handleDomHtmlAttributeCommit: vi.fn(), handleDomTextCommit: vi.fn(), handleDomTextFieldStyleCommit: vi.fn(), handleDomAddTextField: vi.fn(), handleDomRemoveTextField: vi.fn(), handleDomBoxSizeCommit: vi.fn(), handleDomManualEditsReset: vi.fn(), handleDomEditElementDelete: vi.fn(), handleDomZIndexReorderCommit: vi.fn(), }; }, })); vi.mock("./useDomSelection", () => ({ useDomSelection: () => ({ domEditSelection: domEditSelectionRef.current, domEditGroupSelections: [], domEditHoverSelection: null, activeGroupElement: null, domEditSelectionRef, domEditGroupSelectionsRef, setActiveGroupElement: vi.fn(), applyDomSelection: vi.fn(), clearDomSelection: vi.fn(), buildDomSelectionFromTarget: vi.fn(), resolveDomSelectionFromPreviewPoint: vi.fn(), resolveAllDomSelectionsFromPreviewPoint: vi.fn(), updateDomEditHoverSelection: vi.fn(), buildDomSelectionForTimelineElement: vi.fn(), handleTimelineElementSelect: vi.fn(), refreshDomEditSelectionFromPreview: vi.fn(), applyMarqueeSelection: vi.fn(), }), })); vi.mock("./useAskAgentModal", () => ({ useAskAgentModal: () => ({ agentModalOpen: false, agentModalAnchorPoint: null, copiedAgentPrompt: null, agentPromptSelectionContext: null, setAgentModalOpen: vi.fn(), setAgentPromptSelectionContext: vi.fn(), setAgentModalAnchorPoint: vi.fn(), handleAskAgent: vi.fn(), handleAgentModalSubmit: vi.fn(), }), })); vi.mock("./useStudioSelectionPublisher", () => ({ useStudioSelectionPublisher: () => {}, })); vi.mock("./useGsapTweenCache", () => ({ useGsapCacheVersion: () => ({ version: 0, bump: vi.fn() }), })); vi.mock("./useGsapScriptCommits", () => ({ useGsapScriptCommits: () => ({ commitMutation: gsapCommitMutation, updateGsapProperty: vi.fn(), updateGsapMeta: vi.fn(), deleteGsapAnimation: vi.fn(), deleteAllForSelector: vi.fn(), addGsapAnimation: vi.fn(), addGsapProperty: vi.fn(), removeGsapProperty: vi.fn(), updateGsapFromProperty: vi.fn(), addGsapFromProperty: vi.fn(), removeGsapFromProperty: vi.fn(), addKeyframe: vi.fn(), addKeyframeBatch: vi.fn(), removeKeyframe: vi.fn(), moveKeyframe: vi.fn(), resizeKeyframedTween: vi.fn(), convertToKeyframes: vi.fn(), removeAllKeyframes: vi.fn(), setArcPath: vi.fn(), updateArcSegment: vi.fn(), }), })); vi.mock("./useGroupCommits", () => ({ useGroupCommits: () => ({ groupSelection: (...args: unknown[]) => groupSelectionSpy(...args), ungroupSelection: vi.fn(), }), })); vi.mock("./useDomEditWiring", () => ({ useDomEditWiring: () => ({ onClickToSource: vi.fn(), selectedGsapAnimations: [], gsapMultipleTimelines: false, gsapUnsupportedTimelinePattern: false, trackGsapInteractionFailure: vi.fn(), makeFetchFallback: vi.fn(), handleGsapUpdateProperty: vi.fn(), handleGsapUpdateMeta: vi.fn(), handleGsapDeleteAnimation: vi.fn(), handleGsapDeleteAllForElement: vi.fn(), handleGsapAddAnimation: vi.fn(), handleGsapAddProperty: vi.fn(), handleGsapRemoveProperty: vi.fn(), handleGsapUpdateFromProperty: vi.fn(), handleGsapAddFromProperty: vi.fn(), handleGsapRemoveFromProperty: vi.fn(), handleGsapAddKeyframe: vi.fn(), handleGsapAddKeyframeBatch: vi.fn(), handleGsapRemoveKeyframe: vi.fn(), handleGsapMoveKeyframeToPlayhead: vi.fn(), handleGsapMoveKeyframe: vi.fn(), handleGsapResizeKeyframedTween: vi.fn(), handleGsapConvertToKeyframes: vi.fn(), handleGsapRemoveAllKeyframes: vi.fn(), handleResetSelectedElementKeyframes: vi.fn(), }), })); vi.mock("./usePreviewInteraction", () => ({ usePreviewInteraction: () => ({ handlePreviewCanvasMouseDown: vi.fn(), handlePreviewCanvasPointerMove: vi.fn(), handlePreviewCanvasPointerLeave: vi.fn(), handleBlockedDomMove: vi.fn(), handleDomManualDragStart: vi.fn(), }), })); vi.mock("./useGsapAwareEditing", () => ({ useGsapAwareEditing: () => ({ handleGsapAwarePathOffsetCommit: vi.fn(), handleGsapAwareGroupPathOffsetCommit: vi.fn(), handleGsapAwareBoxSizeCommit: vi.fn(), handleGsapAwareRotationCommit: vi.fn(), commitAnimatedProperty: vi.fn(), commitAnimatedProperties: vi.fn(), handleSetArcPath: vi.fn(), handleUpdateArcSegment: vi.fn(), handleUnroll: vi.fn(), commitMutation: vi.fn(), }), })); // Tell React this is an act-capable environment so act(...) flushes effects. (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; describe("onReorderShadow source filter", () => { it("passes a readSource function as the 4th recordResolverParity argument when sdkSession and activeCompPath are set", async () => { const { useDomEditSession } = await import("./useDomEditSession"); const readProjectFile = vi.fn(async (path: string) => `content of ${path}`); // Minimal opaque test double: no sub-hook under test actually calls into the // session (useDomEditCommits and recordResolverParity are both mocked above), // so it only needs to flow through as a reference. const sdkSession = {} as unknown as Composition; function Probe() { const params = createSessionParams({ queueDomEditSave: vi.fn(async (save: () => Promise) => save()) as ( save: () => Promise, ) => Promise, readProjectFile, writeProjectFile: vi.fn(async () => {}), editHistory: { recordEdit: vi.fn(async () => {}) }, sdkSession, forceReloadSdkSession: vi.fn(), }); useDomEditSession(params); return null; } const container = document.createElement("div"); const root = createRoot(container); act(() => { root.render(); }); try { expect(capturedOnReorderShadow.fn).toBeTypeOf("function"); capturedOnReorderShadow.fn?.(["hf-target"]); expect(recordResolverParity).toHaveBeenCalledWith( sdkSession, "hf-target", "reorderElements", expect.any(Function), ); } finally { act(() => root.unmount()); } }); }); describe("bulk segment ease commits", () => { it("uses one ordered batch for many ids and sane paths for one or no ids", async () => { const { useDomEditSession } = await import("./useDomEditSession"); const selection: DomEditSelection = { id: "hero", element: document.createElement("div"), label: "Hero", tagName: "DIV", sourceFile: "index.html", compositionPath: "index.html", isCompositionHost: false, isInsideLockedComposition: false, boundingBox: { x: 0, y: 0, width: 100, height: 100 }, textContent: null, dataAttributes: {}, inlineStyles: {}, computedStyles: {}, textFields: [], capabilities: { canSelect: true, canEditStyles: true, canCrop: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, }; domEditSelectionRef.current = selection; gsapCommitMutation.mockClear(); gsapCommitMutation.batch.mockClear(); let updateSegmentEase: | ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void) | undefined; function Probe() { const params = createSessionParams(); updateSegmentEase = useDomEditSession(params).handleUpdateSegmentEase; return null; } const container = document.createElement("div"); const root = createRoot(container); act(() => root.render()); try { expect(updateSegmentEase).toBeTypeOf("function"); if (!updateSegmentEase) return; const options = { label: "Update segment ease", softReload: true }; const targets = [ { animationId: "move-x", tweenPercentage: 20 }, { animationId: "move-y", tweenPercentage: 50 }, { animationId: "fade", tweenPercentage: 80 }, ]; updateSegmentEase(targets, "power2.inOut"); expect(gsapCommitMutation).not.toHaveBeenCalled(); expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1); expect(gsapCommitMutation.batch).toHaveBeenCalledWith( targets.map(({ animationId, tweenPercentage }) => ({ selection, mutation: { type: "update-keyframe", animationId, percentage: tweenPercentage, properties: {}, ease: "power2.inOut", }, options, })), options, ); gsapCommitMutation.mockClear(); gsapCommitMutation.batch.mockClear(); updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none"); expect(gsapCommitMutation).toHaveBeenCalledTimes(1); expect(gsapCommitMutation).toHaveBeenCalledWith( selection, { type: "update-keyframe", animationId: "fade", percentage: 25, properties: {}, ease: "none", }, { label: "Update keyframe ease", softReload: true }, ); expect(gsapCommitMutation.batch).not.toHaveBeenCalled(); gsapCommitMutation.mockClear(); updateSegmentEase([], "linear"); expect(gsapCommitMutation).not.toHaveBeenCalled(); expect(gsapCommitMutation.batch).not.toHaveBeenCalled(); // A commit with no batch transport (a wrapped one, e.g. inside a gesture // transaction) must still write every tween. Optional-chaining the batch // call would drop the whole edit here and report nothing. gsapCommitMutation.mockClear(); const restoreBatch = gsapCommitMutation.batch; Reflect.deleteProperty(gsapCommitMutation, "batch"); try { updateSegmentEase(targets, "power1.in"); expect(gsapCommitMutation).toHaveBeenCalledTimes(targets.length); } finally { gsapCommitMutation.batch = restoreBatch; } } finally { domEditSelectionRef.current = null; act(() => root.unmount()); } }); }); // ── Grouping refuses audio ─────────────────────────────────────────────────── // // A layout group is a positioned wrapper: it takes the members' bounding box, // rebases each child's left/top against it and adopts the topmost z-index. An //