import { describe, expect, it } from "vitest"; import { Window } from "happy-dom"; import { buildDomEditStylePatchOperation, buildElementAgentPrompt, collectDomEditLayerItems, countDomEditChildLayers, findElementForSelection, findElementForTimelineElement, getDomEditNonEditableReason, getDomEditTargetKey, isLargeRasterDomEditSelection, isTextEditableSelection, resolveVisualDomEditSelectionTarget, serializeDomEditTextFields, type DomEditSelection, resolveDomEditCapabilities, resolveDomEditSelection, } from "./domEditing"; function createDocument(markup: string): Document { const window = new Window(); Object.assign(window, { SyntaxError }); window.document.body.innerHTML = markup; return window.document; } function setElementRect( element: HTMLElement, rect: Partial>, ) { const left = rect.left ?? 0; const top = rect.top ?? 0; const width = rect.width ?? 100; const height = rect.height ?? 40; Object.defineProperty(element, "getBoundingClientRect", { configurable: true, value: () => ({ x: left, y: top, left, top, width, height, right: left + width, bottom: top + height, toJSON: () => null, }), }); } describe("resolveDomEditCapabilities", () => { it("marks absolute px-positioned layers as movable and resizable", () => { expect( resolveDomEditCapabilities({ selector: "#card", inlineStyles: { left: "120px", top: "80px", width: "240px", height: "140px", }, computedStyles: { position: "absolute", left: "120px", top: "80px", width: "240px", height: "140px", transform: "none", }, isCompositionHost: false, isMasterView: false, }), ).toEqual({ canSelect: true, canEditStyles: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, reasonIfDisabled: undefined, }); }); it("rejects flex/grid children for move and resize", () => { expect( resolveDomEditCapabilities({ selector: "#chip", tagName: "div", inlineStyles: {}, computedStyles: { position: "static", display: "block", left: "auto", top: "auto", width: "180px", height: "64px", transform: "none", }, isCompositionHost: false, isMasterView: false, }), ).toMatchObject({ canSelect: true, canEditStyles: true, canMove: false, canResize: false, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, reasonIfDisabled: undefined, }); }); it("rejects transform-driven geometry", () => { expect( resolveDomEditCapabilities({ selector: "#card", inlineStyles: { left: "120px", top: "80px", width: "240px", height: "140px", }, computedStyles: { position: "absolute", left: "120px", top: "80px", width: "240px", height: "140px", transform: "matrix(1, 0, 0, 1, 12, 0)", }, isCompositionHost: false, isMasterView: false, }), ).toMatchObject({ canMove: false, canResize: false, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }); }); it("treats identity transforms left behind by animation libraries as movable", () => { expect( resolveDomEditCapabilities({ selector: "#card", inlineStyles: { left: "120px", top: "80px", width: "240px", height: "140px", }, computedStyles: { position: "absolute", left: "120px", top: "80px", width: "240px", height: "140px", transform: "matrix(1, 0, 0, 1, 0, 0)", }, isCompositionHost: false, isMasterView: false, }), ).toMatchObject({ canMove: true, canResize: true, canApplyManualOffset: true, }); }); it("treats identity matrix3d transforms as movable", () => { expect( resolveDomEditCapabilities({ selector: "#card", inlineStyles: { left: "120px", top: "80px", width: "240px", height: "140px", }, computedStyles: { position: "absolute", left: "120px", top: "80px", width: "240px", height: "140px", transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)", }, isCompositionHost: false, isMasterView: false, }), ).toMatchObject({ canMove: true, canResize: true, }); }); it("allows imported absolute media to resize from computed px geometry", () => { expect( resolveDomEditCapabilities({ selector: "#photo", inlineStyles: { inset: "0", width: "100%", height: "100%", }, computedStyles: { position: "absolute", left: "0px", top: "0px", width: "330px", height: "228px", transform: "none", }, isCompositionHost: false, isMasterView: false, }), ).toMatchObject({ canMove: true, canResize: true, }); }); }); describe("resolveVisualDomEditSelectionTarget", () => { // fallow-ignore-next-line code-duplication it("prefers the visible leaf under the pointer over an oversized container", () => { const document = createDocument(`
Launch faster
`); const container = document.getElementById("container") as HTMLElement; const headline = document.getElementById("headline") as HTMLElement; setElementRect(container, { width: 900, height: 520 }); setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 }); expect( resolveVisualDomEditSelectionTarget([container, headline], { activeCompositionPath: "index.html", }), ).toBe(headline); }); it("skips hidden and zero-size elements before picking a rendered candidate", () => { const document = createDocument(`
Empty
Visible
`); const hidden = document.getElementById("hidden") as HTMLElement; const empty = document.getElementById("empty") as HTMLElement; const visible = document.getElementById("visible") as HTMLElement; setElementRect(hidden, { width: 120, height: 32 }); setElementRect(empty, { width: 0, height: 0 }); setElementRect(visible, { width: 120, height: 32 }); expect( resolveVisualDomEditSelectionTarget([hidden, empty, visible], { activeCompositionPath: "index.html", }), ).toBe(visible); }); it("skips transparent elements that still report a box", () => { const document = createDocument(` `); const transparent = document.getElementById("transparent") as HTMLElement; const visible = document.getElementById("visible") as HTMLElement; setElementRect(transparent, { width: 120, height: 32 }); setElementRect(visible, { width: 120, height: 32 }); expect( resolveVisualDomEditSelectionTarget([transparent, visible], { activeCompositionPath: "index.html", }), ).toBe(visible); }); it("falls back to the nearest stable editable ancestor when a visual child has no target", () => { const document = createDocument(`
Unlabeled copy
`); const card = document.getElementById("card") as HTMLElement; const span = card.querySelector("span") as HTMLElement; setElementRect(card, { width: 400, height: 200 }); setElementRect(span, { left: 40, top: 40, width: 140, height: 28 }); expect( resolveVisualDomEditSelectionTarget([span, card], { activeCompositionPath: "index.html", }), ).toBe(card); }); it("keeps explicit layer selection able to target containers", async () => { const document = createDocument(`
Launch faster
`); const container = document.getElementById("container") as HTMLElement; const headline = document.getElementById("headline") as HTMLElement; setElementRect(container, { width: 900, height: 520 }); setElementRect(headline, { left: 240, top: 160, width: 180, height: 36 }); const visualTarget = resolveVisualDomEditSelectionTarget([container, headline], { activeCompositionPath: "index.html", }); const explicitSelection = await resolveDomEditSelection(container, { activeCompositionPath: "index.html", isMasterView: false, }); expect(visualTarget).toBe(headline); expect(explicitSelection?.id).toBe("container"); }); it("prefers the visually-on-top sibling over a deeper element in a separate visual layer", () => { const document = createDocument(`
`); const pipStudio = document.getElementById("pip-studio") as HTMLElement; const sfChrome = document.getElementById("sf-chrome") as HTMLElement; const subComp = document.getElementById("sub-comp") as HTMLElement; setElementRect(pipStudio, { left: 50, top: 50, width: 320, height: 320 }); setElementRect(sfChrome, { left: 0, top: 0, width: 1920, height: 1080 }); setElementRect(subComp, { left: 0, top: 0, width: 1920, height: 1080 }); expect( resolveVisualDomEditSelectionTarget([pipStudio, subComp, sfChrome], { activeCompositionPath: "index.html", }), ).toBe(pipStudio); }); }); describe("isLargeRasterDomEditSelection", () => { it("flags large image and background targets for raster click fallback", () => { expect( isLargeRasterDomEditSelection( { tagName: "img", boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, computedStyles: {}, }, { width: 1920, height: 1080 }, ), ).toBe(true); expect( isLargeRasterDomEditSelection( { tagName: "div", boundingBox: { x: 0, y: 0, width: 1280, height: 720 }, computedStyles: { "background-image": 'url("hero.png")' }, }, { width: 1920, height: 1080 }, ), ).toBe(true); }); it("does not flag small media or text selections", () => { expect( isLargeRasterDomEditSelection( { tagName: "img", boundingBox: { x: 80, y: 80, width: 96, height: 96 }, computedStyles: {}, }, { width: 1920, height: 1080 }, ), ).toBe(false); expect( isLargeRasterDomEditSelection( { tagName: "h1", boundingBox: { x: 0, y: 0, width: 1600, height: 300 }, computedStyles: {}, }, { width: 1920, height: 1080 }, ), ).toBe(false); }); }); describe("resolveDomEditSelection", () => { it("keeps composition host transforms disabled in master view", () => { expect( resolveDomEditCapabilities({ selector: "#detail-host", inlineStyles: { left: "80px", top: "60px", width: "320px", height: "220px", }, computedStyles: { position: "absolute", left: "80px", top: "60px", width: "320px", height: "220px", transform: "none", }, isCompositionHost: true, isMasterView: true, }), ).toEqual({ canSelect: true, canEditStyles: false, canMove: true, canResize: true, canApplyManualOffset: false, canApplyManualSize: false, canApplyManualRotation: false, reasonIfDisabled: "Select an internal layer to transform it.", }); }); it("keeps the full-canvas stage layer transform disabled while allowing style edits", async () => { const document = createDocument(`
`); document.documentElement.setAttribute("data-composition-id", "root"); document.documentElement.setAttribute("data-width", "1920"); document.documentElement.setAttribute("data-height", "1080"); setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); const stage = document.getElementById("stage") as HTMLElement; setElementRect(stage, { left: 0, top: 0, width: 1920, height: 1080 }); const selection = await resolveDomEditSelection(stage, { activeCompositionPath: null, isMasterView: true, skipSourceProbe: true, }); expect(selection?.id).toBe("stage"); expect(selection?.capabilities).toMatchObject({ canSelect: true, canEditStyles: true, canMove: false, canResize: false, canApplyManualOffset: false, canApplyManualSize: false, canApplyManualRotation: false, reasonIfDisabled: "The root composition defines the preview bounds.", }); }); it("keeps direct full-bleed absolute layers editable", async () => { const document = createDocument(`
`); document.documentElement.setAttribute("data-composition-id", "root"); document.documentElement.setAttribute("data-width", "1920"); document.documentElement.setAttribute("data-height", "1080"); setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); const hero = document.getElementById("hero") as HTMLElement; setElementRect(hero, { left: 0, top: 0, width: 1920, height: 1080 }); const selection = await resolveDomEditSelection(hero, { activeCompositionPath: null, isMasterView: true, skipSourceProbe: true, }); expect(selection?.id).toBe("hero"); expect(selection?.capabilities).toMatchObject({ canSelect: true, canEditStyles: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }); }); it("lets full-canvas layers opt out of root-layer classification", async () => { const document = createDocument(`
`); document.documentElement.setAttribute("data-composition-id", "root"); document.documentElement.setAttribute("data-width", "1920"); document.documentElement.setAttribute("data-height", "1080"); setElementRect(document.documentElement, { left: 0, top: 0, width: 1920, height: 1080 }); const editableStage = document.getElementById("editable-stage") as HTMLElement; setElementRect(editableStage, { left: 0, top: 0, width: 1920, height: 1080 }); const selection = await resolveDomEditSelection(editableStage, { activeCompositionPath: null, isMasterView: true, skipSourceProbe: true, }); expect(selection?.id).toBe("editable-stage"); expect(selection?.capabilities.canApplyManualOffset).toBe(true); }); it("resolves child clicks inside a composition host to the child in master view", async () => { const document = createDocument(`
Nested scene
`); const child = document.getElementById("inner-copy") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: true, }); expect(selection?.id).toBe("inner-copy"); expect(selection?.sourceFile).toBe("compositions/detail-card.html"); expect(selection?.isCompositionHost).toBe(false); expect(selection?.capabilities.canApplyManualOffset).toBe(true); expect(selection?.capabilities.canEditStyles).toBe(true); }); // fallow-ignore-next-line code-duplication it("does not prefer a scene host clip ancestor when selecting inside it", async () => { const document = createDocument(`
Nested scene
`); const child = document.getElementById("inner-copy") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: true, preferClipAncestor: true, }); expect(selection?.id).toBe("inner-copy"); expect(selection?.sourceFile).toBe("compositions/detail-card.html"); expect(selection?.isCompositionHost).toBe(false); }); it("still prefers an internal clip ancestor inside a scene", async () => { const document = createDocument(`
Nested scene
`); const child = document.getElementById("inner-copy") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: true, preferClipAncestor: true, }); expect(selection?.id).toBe("nested-card"); expect(selection?.sourceFile).toBe("compositions/detail-card.html"); expect(selection?.isCompositionHost).toBe(false); }); it("scopes class selector indexing to the same source file", async () => { const document = createDocument(`
Root chip
Nested chip
`); const rootChip = document.getElementsByClassName("chip")[0] as HTMLElement; const selection = await resolveDomEditSelection(rootChip, { activeCompositionPath: null, isMasterView: true, }); expect(selection?.sourceFile).toBe("index.html"); expect(selection?.selector).toBe(".chip"); expect(selection?.selectorIndex).toBe(0); expect(findElementForSelection(document, selection!, null)).toBe(rootChip); }); it("resolves nested duplicate ids from master view without treating root as the nested source", async () => { const document = createDocument(`
Root card
Nested card
`); const nestedCard = document.querySelector( '[data-composition-file="scenes/nested.html"] #card', ) as HTMLElement; const selection = await resolveDomEditSelection(nestedCard, { activeCompositionPath: null, isMasterView: true, }); expect(selection?.sourceFile).toBe("scenes/nested.html"); expect(findElementForSelection(document, selection!, null)).toBe(nestedCard); }); it("does not throw when a generated timeline identity is passed as a selector", () => { const document = createDocument(`
Logo
`); expect(() => findElementForSelection( document, { id: "index.html:Hyperframes Logo Light:0", selector: '[data-composition-id="index.html:Hyperframes Logo Light:0"],#index.html:Hyperframes Logo Light:0', sourceFile: "index.html", }, null, ), ).not.toThrow(); expect( findElementForSelection( document, { id: "index.html:Hyperframes Logo Light:0", selector: '[data-composition-id="index.html:Hyperframes Logo Light:0"],#index.html:Hyperframes Logo Light:0', sourceFile: "index.html", }, null, ), ).toBeNull(); }); it("escapes ids and composition ids when creating stable selectors", async () => { const document = createDocument(`
Logo
Scene
`); const logo = document.getElementById("logo:light") as HTMLElement; const scene = Array.from(document.querySelectorAll("[data-composition-id]")).find( (element) => element.getAttribute("data-composition-id") === "scene:one", ) as HTMLElement; const logoSelection = await resolveDomEditSelection(logo, { activeCompositionPath: null, isMasterView: true, }); const sceneSelection = await resolveDomEditSelection(scene, { activeCompositionPath: null, isMasterView: true, }); expect(logoSelection?.selector).not.toBe("#logo:light"); expect(findElementForSelection(document, logoSelection!, null)).toBe(logo); expect(sceneSelection?.selector).toBe('[data-composition-id="scene:one"]'); expect(findElementForSelection(document, sceneSelection!, null)).toBe(scene); }); it("prefers the nearest clip ancestor on single-click style selection", async () => { const document = createDocument(`

Hello

`); const child = document.getElementById("copy") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: false, preferClipAncestor: true, }); expect(selection?.id).toBe("card"); expect(selection?.selector).toBe("#card"); }); it("can resolve the exact child when clip-ancestor preference is disabled", async () => { const document = createDocument(`

Hello

`); const child = document.getElementById("copy") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: false, preferClipAncestor: false, }); expect(selection?.id).toBe("copy"); expect(selection?.selector).toBe("#copy"); }); // fallow-ignore-next-line code-duplication it("collects simple child text blocks as separate editable fields", async () => { const document = createDocument(`
Headline Supporting copy
`); const selection = await resolveDomEditSelection( document.getElementById("card") as HTMLElement, { activeCompositionPath: null, isMasterView: false, }, ); expect(selection?.textFields.map((field) => field.label)).toEqual(["Text 1", "Text 2"]); expect(selection?.textFields.map((field) => field.value)).toEqual([ "Headline", "Supporting copy", ]); }); it("preserves user-entered text spacing in editable text fields", async () => { const document = createDocument(`
Headline with trailing space
`); const selection = await resolveDomEditSelection( document.getElementById("card") as HTMLElement, { activeCompositionPath: null, isMasterView: false, }, ); expect(selection?.textFields[0]?.value).toBe("Headline with trailing space "); }); it("keeps an emptied text layer editable so users can type into it again", async () => { const document = createDocument(`
`); const selection = await resolveDomEditSelection( document.getElementById("card") as HTMLElement, { activeCompositionPath: null, isMasterView: false, }, ); expect(selection?.textFields).toMatchObject([ { key: "self:0:div", label: "Content", value: "", source: "self", }, ]); expect(selection ? isTextEditableSelection(selection) : false).toBe(true); }); it("keeps emptied child text layers editable after their content is cleared", async () => { const document = createDocument(`
`); const selection = await resolveDomEditSelection( document.getElementById("card") as HTMLElement, { activeCompositionPath: null, isMasterView: false, }, ); expect(selection?.textFields.map((field) => field.tagName)).toEqual(["strong", "span"]); expect(selection?.textFields.map((field) => field.value)).toEqual(["", ""]); }); it("explains anonymous child elements that resolve to an editable parent", async () => { const document = createDocument(`
Headline
`); const child = document.querySelector("strong") as HTMLElement; const selection = await resolveDomEditSelection(child, { activeCompositionPath: null, isMasterView: false, preferClipAncestor: false, }); expect(selection?.id).toBe("card"); expect(getDomEditNonEditableReason(child, selection)).toBe("Selection resolves to Card"); }); it("does not mark an element as non-editable when Studio can edit it directly", async () => { const document = createDocument(`
Editable
`); const element = document.getElementById("card") as HTMLElement; const selection = await resolveDomEditSelection(element, { activeCompositionPath: null, isMasterView: false, }); expect(getDomEditNonEditableReason(element, selection)).toBeNull(); }); it("keeps duplicate class targets distinct for history keys", () => { const first = getDomEditTargetKey({ sourceFile: "index.html", selector: ".card", selectorIndex: 0, }); const second = getDomEditTargetKey({ sourceFile: "index.html", selector: ".card", selectorIndex: 1, }); expect(first).not.toBe(second); }); it("resolves generated timeline ids without throwing", () => { const document = createDocument(`
Topline
`); expect( findElementForTimelineElement( document, { id: "index.html:Hyperframes Logo Light:0", sourceFile: "index.html" }, { activeCompositionPath: null, isMasterView: true, }, ), ).toBeNull(); }); it("falls back to the root composition for standalone manifest clips without DOM targets", () => { const document = createDocument(`
Topline
Scene
`); const root = document.querySelector("[data-composition-id]") as HTMLElement; expect( findElementForTimelineElement( document, { id: "compositions/hook.html:Hyperframes Logo Light:0" }, { activeCompositionPath: "compositions/hook.html", isMasterView: false, }, ), ).toBe(root); }); it("resolves the standalone composition root when the fallback clip carries source metadata", () => { const document = createDocument(`
Scene
`); const root = document.querySelector("[data-composition-id]") as HTMLElement; expect( findElementForTimelineElement( document, { id: "manual", compositionSrc: "compositions/manual.html", selector: '[data-composition-id="manual"]', sourceFile: "compositions/manual.html", }, { activeCompositionPath: "compositions/manual.html", isMasterView: false, }, ), ).toBe(root); }); it("normalizes preview URLs when resolving master timeline composition clips", () => { const document = createDocument(`

Core Conviction

`); const slide = document.getElementById("slide-1") as HTMLElement; expect( findElementForTimelineElement( document, { id: "slide-1", compositionSrc: "http://127.0.0.1:5176/api/projects/apple-presentation-download/preview/compositions/slide-01-core-conviction.html", }, { activeCompositionPath: null, isMasterView: true, }, ), ).toBe(slide); }); it("does not fall back to the root composition when an explicit timeline selector misses", () => { const document = createDocument(`
Topline
`); expect( findElementForTimelineElement( document, { selector: ".missing", sourceFile: "compositions/hook.html" }, { activeCompositionPath: "compositions/hook.html", isMasterView: false, }, ), ).toBeNull(); }); }); describe("patch builders and prompt builder", () => { it("builds style patch operations", () => { expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({ type: "inline-style", property: "background-color", value: "rgb(15, 23, 42)", }); }); it("builds an agent prompt with source and selector context", () => { const selection = { element: {} as HTMLElement, id: "editable-card", selector: "#editable-card", selectorIndex: undefined, sourceFile: "index.html", compositionPath: "index.html", compositionSrc: undefined, isCompositionHost: false, label: "Drag me first", tagName: "div", boundingBox: { x: 108, y: 112, width: 380, height: 196 }, textContent: "Drag me first", dataAttributes: {}, inlineStyles: { left: "108px", top: "112px", width: "380px", height: "196px", }, computedStyles: { position: "absolute", left: "108px", top: "112px", width: "380px", height: "196px", color: "rgb(248, 250, 252)", }, textFields: [ { key: "self:0:div", label: "Content", value: "Drag me first", tagName: "div", attributes: [], inlineStyles: {}, computedStyles: {}, source: "self", }, ], capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, } satisfies DomEditSelection; const prompt = buildElementAgentPrompt({ selection, currentTime: 1.25, tagSnippet: `
; source=self; text="Drag me first"'); expect(prompt).toContain("Inline styles:"); expect(prompt).toContain("Computed styles (browser-resolved):"); expect(prompt).toContain("Target HTML:"); expect(prompt).toContain("Guardrails:"); expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning."); }); it("uses an absolute source path in copied agent prompts when provided", () => { const selection = { element: {} as HTMLElement, id: "editable-card", selector: "#editable-card", selectorIndex: undefined, sourceFile: "index.html", compositionPath: "index.html", compositionSrc: undefined, isCompositionHost: false, label: "Drag me first", tagName: "div", boundingBox: { x: 108, y: 112, width: 380, height: 196 }, textContent: "Drag me first", dataAttributes: {}, inlineStyles: {}, computedStyles: {}, textFields: [], capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, } satisfies DomEditSelection; const prompt = buildElementAgentPrompt({ selection, currentTime: 1.25, sourceFilePath: "/tmp/hf-studio-project/index.html", }); expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html"); expect(prompt).not.toContain("Source file: index.html"); }); it("includes raster click context in copied agent prompts", () => { const selection = { element: {} as HTMLElement, id: undefined, selector: ".hero-bg", selectorIndex: undefined, sourceFile: "index.html", compositionPath: "index.html", compositionSrc: undefined, isCompositionHost: false, label: "Hero Bg", tagName: "img", boundingBox: { x: 0, y: 0, width: 1920, height: 1080 }, textContent: null, dataAttributes: {}, inlineStyles: {}, computedStyles: {}, textFields: [], capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true, canApplyManualOffset: true, canApplyManualSize: true, canApplyManualRotation: true, }, } satisfies DomEditSelection; const prompt = buildElementAgentPrompt({ selection, currentTime: 3, selectionContext: "The user clicked visible text that is baked into the selected image/background.", userInstruction: "Change the title copy.", }); expect(prompt).toContain("Selection context:"); expect(prompt).toContain( "The user clicked visible text that is baked into the selected image/background.", ); expect(prompt).toContain("Change the title copy."); }); it("serializes child text fields back into HTML", () => { expect( serializeDomEditTextFields([ { key: "child:0:strong", label: "Text 1", value: "Headline <1>", tagName: "strong", attributes: [], inlineStyles: { "font-size": "22px", }, computedStyles: {}, source: "child", }, { key: "child:1:span", label: "Text 2", value: "Details & more", tagName: "span", attributes: [], inlineStyles: {}, computedStyles: {}, source: "child", }, ]), ).toBe( 'Headline <1>Details & more', ); }); it("collects nested timeline layers with stable keys and child counts", () => { const doc = createDocument(`
HyperFrames Alpha
`); const root = doc.querySelector(".scene-shell") as HTMLElement; const layers = collectDomEditLayerItems(root, { activeCompositionPath: "compositions/hook.html", isMasterView: false, }); expect( countDomEditChildLayers(root, { activeCompositionPath: "compositions/hook.html", isMasterView: false, }), ).toBe(3); expect(layers.map((layer) => layer.label)).toEqual([ "Scene Shell", "Topline", "Brand", "Badge", ]); expect(layers[0]?.childCount).toBe(1); expect(layers.find((layer) => layer.label === "Brand")?.key).toBe( "compositions/hook.html:.brand:0", ); }); it("collects timeline layers with SVG descendants without crashing", () => { const doc = createDocument(`
HyperFrames
`); const root = doc.querySelector(".scene-shell") as HTMLElement; expect(() => collectDomEditLayerItems(root, { activeCompositionPath: "compositions/hook.html", isMasterView: false, }), ).not.toThrow(); }); }); describe("hfId — find, key, capabilities (R7 fixes)", () => { it("getDomEditTargetKey keeps two hfId-only elements distinct", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const a = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-aaa" } as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any const b = getDomEditTargetKey({ sourceFile: "index.html", hfId: "hf-bbb" } as any); expect(a).not.toBe(b); }); it("findElementForSelection finds element by data-hf-id when no id or selector", () => { const doc = createDocument(`
`); const el = doc.querySelector('[data-hf-id="hf-xyz789"]') as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any const found = findElementForSelection(doc, { hfId: "hf-xyz789" } as any); expect(found).toBe(el); }); it("resolveDomEditCapabilities enables editing for hfId-only element (no CSS selector)", () => { const result = resolveDomEditCapabilities({ // eslint-disable-next-line @typescript-eslint/no-explicit-any hfId: "hf-abc" as any, selector: undefined, inlineStyles: { left: "10px", top: "20px", width: "100px", height: "50px" }, computedStyles: { position: "absolute", left: "10px", top: "20px", width: "100px", height: "50px", }, isCompositionHost: false, isInsideLockedComposition: false, isMasterView: false, }); expect(result.canSelect).toBe(true); expect(result.canMove).toBe(true); }); });