mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
Selecting two audio clips offered "Group selection", which is the layout grouper: it wraps the members in a positioned <div> at their bounding box, rebases each child's left/top against that origin, and adopts the topmost member's z-index. None of that means anything for audio. An <audio> clip has no box — offsetWidth/Height are 0 — so the wrapper came out `width: 0px; height: 0px` with inline left/top written onto elements that are never laid out, and the composition gained a <div> standing for nothing audible. Confirmed against `wrapElementsInHtml` directly: it matched and wrapped, producing exactly that. Refused in `handleGroupSelection`, which is where the G shortcut also lands — a hidden button cannot gate a keystroke. The panel withholds the button as well, so the refusal is not the first the author hears of it. The message names the alternative rather than only declining: audio's answer to "these clips belong together" is an <hf-audio-group> bus, which the timeline's own FX pointer already creates. A mixed selection is refused too — the wrapper would take the audio in with the rest. `isAudioDomElement` sits beside `isAudioTimelineElement` and delegates to it, so the selection layer and the timeline cannot drift into disagreeing about what counts as audio. Six tests across both entry points, each mutation-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
// @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<void>>(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> = {},
|
|
): 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 <T,>(save: () => Promise<T>) => 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 <T,>(save: () => Promise<T>) => save()) as <T>(
|
|
save: () => Promise<T>,
|
|
) => Promise<T>,
|
|
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(<Probe />);
|
|
});
|
|
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(<Probe />));
|
|
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
|
|
// <audio> clip has no box — offsetWidth/Height are 0 — so this produced a 0x0
|
|
// div with inline left/top on elements that are never laid out. Enforced here
|
|
// rather than only by hiding the button, because the G shortcut routes through
|
|
// the same handler and no hidden button can gate a keystroke.
|
|
|
|
describe("handleGroupSelection with audio in the selection", () => {
|
|
const sel = (tag: string): DomEditSelection =>
|
|
({
|
|
id: tag,
|
|
element: document.createElement(tag),
|
|
sourceFile: "index.html",
|
|
}) as unknown as DomEditSelection;
|
|
|
|
async function group(members: DomEditSelection[]) {
|
|
const { useDomEditSession } = await import("./useDomEditSession");
|
|
groupSelectionSpy.mockClear();
|
|
domEditGroupSelectionsRef.current = members;
|
|
const showToast = vi.fn();
|
|
const captured: { fn?: () => void } = {};
|
|
function Probe() {
|
|
captured.fn = useDomEditSession(createSessionParams({ showToast })).handleGroupSelection;
|
|
return null;
|
|
}
|
|
const root = createRoot(document.createElement("div"));
|
|
act(() => root.render(<Probe />));
|
|
act(() => captured.fn?.());
|
|
act(() => root.unmount());
|
|
domEditGroupSelectionsRef.current = [];
|
|
return { showToast };
|
|
}
|
|
|
|
it("refuses a selection of audio clips, and says where grouping audio lives", async () => {
|
|
const { showToast } = await group([sel("audio"), sel("audio")]);
|
|
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
|
expect(String(showToast.mock.calls[0]?.[0])).toContain("bus");
|
|
});
|
|
|
|
it("refuses a mixed selection, since the wrapper would take the audio in too", async () => {
|
|
const { showToast } = await group([sel("div"), sel("audio")]);
|
|
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
|
expect(String(showToast.mock.calls[0]?.[0])).toContain("layout");
|
|
});
|
|
|
|
it("still groups a selection of layout elements", async () => {
|
|
await group([sel("div"), sel("span")]);
|
|
expect(groupSelectionSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|