import { describe, expect, it } from "vitest";
import { parseHTML } from "linkedom";
import { inlineSubCompositions } from "./inlineSubCompositions";
import { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
// Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked.
/**
* Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope.
* This is the pattern that breaks when the producer path strips the inner root.
*/
const SUB_COMP_HTML = `
`);
return document;
}
describe("inlineSubCompositions – #ID selector scoping divergence", () => {
it.each([
{ label: "empty", html: "" },
{ label: "whitespace-only", html: " \n \t " },
{
label: "valid-parse-empty-body",
html: "",
},
// linkedom's parseHTML("just some text") returns documentElement === null.
// Any code that then touches .head/.body (as linkedom's own internals do)
// throws "Cannot destructure property 'firstElementChild' of
// 'documentElement' as it is null" — the #1 raw crash in production
// telemetry. Must be skipped gracefully, not crash.
{ label: "malformed non-HTML text", html: "just some plain text, no tags at all" },
])("skips $label sub-composition files gracefully", ({ html }) => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const missing: string[] = [];
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => html,
parseHtml: (h) => parseHTML(h).document,
onMissingComposition: (src) => missing.push(src),
});
expect(missing).toEqual(["intro.html"]);
expect(result.styles).toHaveLength(0);
expect(result.scripts).toHaveLength(0);
});
it("passes the failure reason through to onMissingComposition", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const reasons: Array = [];
inlineSubCompositions(document, [host], {
resolveHtml: () => "",
parseHtml: (h) => parseHTML(h).document,
onMissingComposition: (_src, reason) => reasons.push(reason),
});
expect(reasons).toHaveLength(1);
expect(reasons[0]).toContain("empty");
});
it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The producer path takes innerHTML when compId matches, stripping the
// wrapper
. The host element should NOT contain a
// child with id="intro" — the id attribute is lost.
const innerRootById = host.querySelector("#intro");
expect(innerRootById).toBeNull();
// The host itself still has data-composition-id="intro" (from the
// original markup), but no element inside has id="intro".
expect(host.getAttribute("data-composition-id")).toBe("intro");
// CSS was scoped: #intro selectors should be rewritten to use
// data-hf-authored-id attribute selector so they still resolve.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).not.toContain("#intro");
});
it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]`
// so that the selector resolves against the flattened structure.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title');
});
it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The wrapped script should contain the authored root id normalization
// logic so that runtime querySelector('#intro .title') maps to the
// data-hf-authored-id attribute selector.
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain("__hfAuthoredRootId");
expect(wrappedScript).toContain('"intro"');
});
it("maps a template-local timeline id onto a differently named mount", () => {
const document = makeHostDocument("captions-comp");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const captionsHtml = `
`;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => captionsHtml,
parseHtml: (html) => parseHTML(html).document,
});
expect(host.getAttribute("data-composition-id")).toBe("captions-comp");
expect(host.querySelector('[data-composition-id="captions"]')).not.toBeNull();
expect(result.styles.join("\n")).toContain('[data-composition-id="captions-comp"]');
const wrappedScript = result.scripts.join("\n");
expect(wrappedScript).toContain('var __hfCompId = "captions"');
expect(wrappedScript).toContain('var __hfTimelineCompId = "captions-comp"');
});
it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
// Simulate the bundler's flattenInnerRoot: clone the element, add
// data-hf-authored-id, strip timing attrs (simplified here).
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
const authoredId = clone.getAttribute("id");
if (authoredId) {
clone.setAttribute("data-hf-authored-id", authoredId);
clone.removeAttribute("id");
}
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
// With flattenInnerRoot, the inner root is preserved as a child of the
// host via outerHTML. The data-hf-authored-id attribute is present.
const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]');
expect(authoredRoot).not.toBeNull();
// CSS is still rewritten to use the attribute selector.
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain('[data-hf-authored-id="intro"]');
});
it("with flattenInnerRoot: restores data-composition-id on the wrapper for an anonymous host", () => {
// Regression test: a host mounted via data-composition-src with no
// data-composition-id of its own (an "anonymous" host). The composition
// styles its own root box via the bare composition-id selector and a
// script self-references it too — both need something in the render DOM
// to actually carry that id once flattenInnerRoot strips it from the
// wrapper by default.
const { document } = parseHTML(`
`;
function flattenInnerRoot(innerRoot: Element): Element {
const clone = innerRoot.cloneNode(true) as Element;
clone.removeAttribute("data-composition-id");
clone.removeAttribute("data-start");
clone.removeAttribute("data-duration");
clone.setAttribute("data-hf-inner-root", "true");
return clone;
}
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => scopedTextHtml,
parseHtml: (html) => parseHTML(html).document,
flattenInnerRoot,
});
const wrapper = host.querySelector("[data-hf-inner-root]");
expect(wrapper?.getAttribute("data-composition-id")).toBe("scoped-text");
const scopedCss = result.styles.join("\n");
expect(scopedCss).toContain("display: flex");
});
it("extracts elements from sub-composition with original rel and crossorigin", () => {
const subCompWithLinks = `
Hello
`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => subCompWithLinks,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.externalLinks).toHaveLength(3);
expect(result.externalLinks[0]).toEqual({
href: "https://fonts.googleapis.com",
rel: "preconnect",
crossorigin: undefined,
});
expect(result.externalLinks[1]).toEqual({
href: "https://fonts.gstatic.com",
rel: "preconnect",
crossorigin: "",
});
expect(result.externalLinks[2]).toEqual({
href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap",
rel: "stylesheet",
crossorigin: undefined,
});
});
it("collects an inline script instead of discarding it", () => {
// The loop had a `src` branch and no else, so an inline
// script was silently dropped on render while the mount path executed it.
const subCompWithHeadScript = `
Hi
`;
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => subCompWithHeadScript,
parseHtml: (html) => parseHTML(html).document,
});
expect(result.scripts.join("\n")).toContain("window.__headScriptRan = true;");
expect(result.scriptItems).toContainEqual({
kind: "inline",
content: expect.stringContaining("window.__headScriptRan = true;"),
});
});
it("hoists a from a TEMPLATED sub-composition's head", () => {
// Hoisting used to be gated on the composition being non-templated, so a
// templated composition's webfont link was kept in preview (the mount path
// hoists unconditionally) and dropped from the render.
const templatedSubCompWithLink = `
`;
const document = makeHostDocument("captions");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => lockedSubComp,
parseHtml: (html) => parseHTML(html).document,
});
expect(host.hasAttribute("data-timeline-locked")).toBe(true);
});
it("producer path propagates data-hf-authored-id to host when inner root has id", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
});
// The inner root's id="intro" is stripped (innerHTML), but the producer
// now propagates it as data-hf-authored-id on the host element so that
// rewritten #ID selectors ([data-hf-authored-id="intro"]) resolve.
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
// The original #intro element is still gone — innerHTML stripped it.
const introById = host.querySelector("#intro");
expect(introById).toBeNull();
expect(host.getAttribute("data-composition-id")).toBe("intro");
});
it("producer path: scoped CSS matches host element when both attributes coexist", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP_HTML,
parseHtml: (html) => parseHTML(html).document,
compoundAuthoredRoot: true,
});
// After inlining, the host has both data-composition-id and data-hf-authored-id.
// CSS selectors targeting the root must be compound (no space) so they match
// when both attributes are on the same element.
expect(host.getAttribute("data-composition-id")).toBe("intro");
expect(host.getAttribute("data-hf-authored-id")).toBe("intro");
const scopedCss = result.styles.join("\n");
// Root-only selector: must be compound
expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/);
// Must NOT have a descendant combinator between the two attribute selectors
expect(scopedCss).not.toMatch(
/\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/,
);
// Descendant selector: compound root + space + child
expect(scopedCss).toMatch(
/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/,
);
});
});
describe("inlineSubCompositions – variable defaults on a template sub-comp root div", () => {
const SUB_COMP_WITH_VAR = `
Hi there
`;
function hostDoc() {
const { document } = parseHTML(`