Files
hyperframes/packages/studio/src/hooks/useDomEditSession.test.tsx
T
Vance IngallsandClaude Opus 5 a05f16c5e6 fix(studio): don't offer layout grouping for a selection of audio clips
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>
2026-08-20 16:40:16 -07:00

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);
});
});