mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
test: add regression fixture for sub-comp #ID selector scoping divergence
The producer inlining path strips the inner root element (taking innerHTML when compId matches), losing the id attribute. The bundler path preserves it via flattenInnerRoot + data-hf-authored-root-id. This causes #ID selectors in sub-comp CSS and GSAP to fail silently during render while working in preview. Adds a minimal fixture with a sub-comp using #intro scope to catch this divergence in future compiler changes.
This commit is contained in:
@@ -496,4 +496,61 @@ window.__afterTimeline = window.__timelines.scene;
|
||||
expect(fakeWindow.__afterTimeline).toBe("updated");
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites #id CSS selectors to [data-hf-authored-id] when authoredRootId is provided", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
`#intro { background: #111; }
|
||||
#intro .title { font-size: 120px; color: #fff; }`,
|
||||
"intro",
|
||||
undefined,
|
||||
"intro",
|
||||
);
|
||||
|
||||
// #intro should become [data-hf-authored-id="intro"]
|
||||
expect(scoped).toContain('[data-hf-authored-id="intro"]');
|
||||
expect(scoped).toContain('[data-hf-authored-id="intro"] .title');
|
||||
// Raw #intro selectors should be gone
|
||||
expect(scoped).not.toMatch(/#intro\b/);
|
||||
});
|
||||
|
||||
it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
|
||||
const { document } = parseHTML(`
|
||||
<div data-composition-id="intro">
|
||||
<div data-hf-authored-id="intro">
|
||||
<div class="title">HELLO</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const gsapTargets: string[][] = [];
|
||||
const fakeWindow = {
|
||||
document,
|
||||
__timelines: {},
|
||||
gsap: {
|
||||
timeline: () => ({
|
||||
fromTo(targets: Element[], _from: unknown, _to: unknown) {
|
||||
gsapTargets.push(Array.from(targets).map((t) => t.textContent || ""));
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
`
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo('#intro .title', { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines['intro'] = tl;
|
||||
`,
|
||||
"intro",
|
||||
"[HyperFrames] composition script error:",
|
||||
undefined,
|
||||
"intro",
|
||||
"intro",
|
||||
);
|
||||
|
||||
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
|
||||
|
||||
// The scoped script should resolve '#intro .title' against the
|
||||
// data-hf-authored-id="intro" element, finding the .title child.
|
||||
expect(gsapTargets).toEqual([["HELLO"]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { inlineSubCompositions } from "./inlineSubCompositions";
|
||||
|
||||
/**
|
||||
* 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 = `<template id="intro-template">
|
||||
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title" style="opacity:0;">HELLO WORLD</div>
|
||||
<style>
|
||||
#intro { position:relative; width:1920px; height:1080px; background:#111; }
|
||||
#intro .title { font-size:120px; color:#fff; }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo('#intro .title', { opacity:0 }, { opacity:1, duration:0.5 }, 0.2);
|
||||
window.__timelines['intro'] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
|
||||
function makeHostDocument(compId: string) {
|
||||
const { document } = parseHTML(`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-composition-id="main">
|
||||
<div data-composition-id="${compId}" data-composition-src="intro.html"
|
||||
data-start="0" data-duration="4" data-track-index="0"></div>
|
||||
</div>
|
||||
</body></html>`);
|
||||
return document;
|
||||
}
|
||||
|
||||
describe("inlineSubCompositions – #ID selector scoping divergence", () => {
|
||||
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 <div id="intro" ...>. 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("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"]');
|
||||
});
|
||||
|
||||
/**
|
||||
* Known divergence: the producer path strips the inner root element via
|
||||
* innerHTML (line 307 of inlineSubCompositions.ts), losing the id attribute.
|
||||
* The bundler path preserves it via flattenInnerRoot + data-hf-authored-id.
|
||||
*
|
||||
* Both paths rewrite CSS and GSAP selectors from `#intro` to
|
||||
* `[data-hf-authored-id="intro"]`, but only the bundler path actually adds
|
||||
* that attribute to an element. In the producer path, no element carries
|
||||
* `data-hf-authored-id`, so the rewritten selectors match nothing.
|
||||
*
|
||||
* Workaround: use [data-composition-id="name"] as scope in sub-compositions
|
||||
* instead of #name.
|
||||
*
|
||||
* Proper fix (follow-up): make the producer path add data-hf-authored-id
|
||||
* to the host element when the inner root has an id attribute.
|
||||
*/
|
||||
it("documents the divergence: producer path lacks data-hf-authored-id element", () => {
|
||||
const document = makeHostDocument("intro");
|
||||
const host = document.querySelector('[data-composition-src="intro.html"]')!;
|
||||
|
||||
// Producer path: no flattenInnerRoot
|
||||
inlineSubCompositions(document, [host], {
|
||||
resolveHtml: () => SUB_COMP_HTML,
|
||||
parseHtml: (html) => parseHTML(html).document,
|
||||
});
|
||||
|
||||
// After producer inlining, no element inside the host has
|
||||
// data-hf-authored-id="intro". The rewritten CSS/GSAP selectors
|
||||
// targeting [data-hf-authored-id="intro"] will match nothing.
|
||||
const authoredIdElement = host.querySelector('[data-hf-authored-id="intro"]');
|
||||
expect(authoredIdElement).toBeNull();
|
||||
|
||||
// The original #intro element is gone — innerHTML stripped it.
|
||||
const introById = host.querySelector("#intro");
|
||||
expect(introById).toBeNull();
|
||||
|
||||
// Only the host itself has data-composition-id="intro", which is the
|
||||
// correct workaround scope to use.
|
||||
expect(host.getAttribute("data-composition-id")).toBe("intro");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<template id="intro-template">
|
||||
<div id="intro" data-composition-id="intro" data-width="1920" data-height="1080">
|
||||
<div class="title" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:120px;font-family:Impact,sans-serif;color:#fff;opacity:0;">HELLO WORLD</div>
|
||||
<style>
|
||||
#intro { position:relative; width:1920px; height:1080px; background:#111; }
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var scope = '#intro';
|
||||
tl.fromTo(scope + ' .title',
|
||||
{ opacity: 0, y: 50, scale: 0.8 },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: 0.5, ease: 'power3.out' }, 0.2);
|
||||
window.__timelines['intro'] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
|
||||
.scene { position: absolute; inset: 0; overflow: hidden; }
|
||||
#scene-intro { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="4" data-width="1920" data-height="1080">
|
||||
<div id="scene-intro" class="scene" data-composition-id="intro" data-composition-src="intro.html" data-start="0" data-duration="4" data-track-index="0"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user