mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(core): stop dropping a mounted composition styles, and gate the divergence (#3094)
## Why A composition mounted as a sub-composition lost its entire stylesheet and scripts whenever they were authored as siblings of the composition root inside its `<template>`. That shape is legal and common, so three catalog components — `oversized-cursor`, `device-frame-stage`, `touch-indicator` — rendered **completely unstyled** in the live preview. `oversized-cursor` drew its pointer at 1280px against an authored `7cqw` (~134px at 1920), because `width: 7cqw` was never declared at all. Confirmed in the mounted document, where only the host's own `<style>` was present. The rendered video was correct the entire time. This was a preview-versus-render divergence, and it survived a fully green test suite. ## How **The fix.** `mountCompositionContent` collected assets from the composition root element, so sibling nodes were invisible to it. It now collects from the source node — a superset of the root, and the single point every mount path routes through (external fetch, inline template, nested). It also strips the mounted *clone* rather than the source: the previous code removed extracted nodes from the node it was handed, which on the inline-template path is a live `<template>` still in the document, so a remount would have found it emptied. **Why nothing caught it.** Every CLI gate — `check`, `lint`, `validate` — reaches the compiler path through `bundleToSingleHtml`, and the compiler always collected from the whole template. Nothing in the CLI exercises the mount path, which is reachable only through the player and Studio. The repo's own parity test assembled a fixture two ways and deep-equalled a contract across them, but **both arms were static-compiler paths** — which is exactly why the runtime could drift unnoticed. **The gate.** A third arm mounts the same fixture through `loadExternalCompositions` and extracts the same contract. Three fixtures run through all three arms, one authoring its assets as root siblings — the shape that broke. `authoredStyleSignatures` was already in the contract and is exactly the signal that was missing, so no contract field was added. **The owner.** Both paths answer the same questions — which nodes are a composition's assets, in what order its scripts run, how its CSS is scoped, which head elements hoist, how nested hosts are discovered, which element carries variable defaults. They now have one module to answer them from. It holds decisions only, never I/O: the two paths differ at their boundary in ways that are essential (Node + linkedom + synchronous + strings; browser + fetch + live DOM + script *execution*), and the runtime ships as a bundle to a CDN, so anything it can reach is weight and risk. Hence zero imports, a structural input type rather than `Document`, and a test asserting the import surface stays empty. Routing both paths through that module is deliberately **not** in this PR — it changes behaviour in four places (below) and belongs where each can be judged and reverted on its own. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) Every claim here was verified in both directions rather than assumed. The fix's regression test fails on pre-fix code and passes after — run both ways. The parity arm was proven able to fail: with the fix reverted, the sibling fixture fails and names the composition's own scoped selector against an empty list, while the other two fixtures stay green, so the arm is targeted rather than blanket-red. Restored, 7/7 pass. `bun run lint` exits 0. Core: 1690 tests passing, plus `typecheck:runtime` and `lint:runtime-preview-guards` clean. Producer: 571 tests passing. The shared module's own defect was reproduced by mutation — collecting from the composition root instead of the whole template fails three of its 17 tests, including the sibling case. ## Found while doing this, not fixed here Deriving the shared decisions surfaced **four more live divergences**, none of them the reported bug, each a behaviour change to decide deliberately: - The compiler silently drops inline `<head>` scripts — it handles the `src` case and has no `else` — while the runtime executes them. - `<link>` hoisting is conditional on render and unconditional on mount, so a templated sub-composition's webfont link is dropped in video and kept in preview. This one reproduces under the new parity arm and is explicitly excluded from its contract, with the reason recorded in the file. - For a host naming no id, the compiler falls back to the first declared composition and scopes to it; the runtime mounts the content whole, unflattened and unscoped. - The compiler keeps two scope ids, CSS and scripts, so a script's self-referencing query resolves when a host names an id the content does not declare; the runtime keeps one. Separately: the mount path **does not recurse at all**, so a sub-composition containing its own `data-composition-src` is silently dropped in live preview. The compiler has a dedicated recursive-discovery suite; the runtime has no nesting, circularity or depth coverage. Each is recorded with its evidence in the commit messages here, and sequenced so the behaviour-changing ones land separately, after this gate exists to catch a mistake in them. ## Not covered This does not heal the published docs by itself. Previews load `@hyperframes/player` unpinned, but the player bakes a version-pinned core runtime URL at build time, and core and player publish in lockstep — so the live catalog only recovers after both ship. There is no hotfix path short of a release.
This commit is contained in:
@@ -0,0 +1,269 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
enumerateNestedCompositionHosts,
|
||||||
|
MAX_SUB_COMPOSITION_DEPTH,
|
||||||
|
planCompositionAssembly,
|
||||||
|
type CompositionAssemblyPlan,
|
||||||
|
} from "./compositionAssembly";
|
||||||
|
|
||||||
|
function parseComposition(html: string): Document {
|
||||||
|
return new DOMParser().parseFromString(html, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the module the way both real paths do: find the `<template>`, fall back
|
||||||
|
* to `<body>`, and hand it the document's head and root element. The explicit
|
||||||
|
* `<Element>` type argument is the whole point of the structural inputs — a
|
||||||
|
* `DocumentFragment` and an `HTMLElement` both satisfy them without a cast.
|
||||||
|
*/
|
||||||
|
function planFor(html: string, compositionId: string | null): CompositionAssemblyPlan<Element> {
|
||||||
|
const doc = parseComposition(html);
|
||||||
|
const template = doc.querySelector("template");
|
||||||
|
return planCompositionAssembly<Element>({
|
||||||
|
contentNode: template ? template.content : doc.body,
|
||||||
|
head: doc.head,
|
||||||
|
documentElement: doc.documentElement,
|
||||||
|
hasTemplate: Boolean(template),
|
||||||
|
compositionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIBLING_ASSETS = `
|
||||||
|
<html data-composition-variables='{"tone":"dark"}'>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<template id="scene-template">
|
||||||
|
<style id="sibling-style">#root { container-type: size; }</style>
|
||||||
|
<div data-composition-id="scene" id="root" data-width="1920" data-height="1080"></div>
|
||||||
|
<script id="sibling-script">window.__a = 1;</script>
|
||||||
|
</template>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const ROOT_INTERNAL_ASSETS = `
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<template id="scene-template">
|
||||||
|
<div data-composition-id="scene" id="root">
|
||||||
|
<style id="inner-style">#root { color: red; }</style>
|
||||||
|
<script id="inner-script">window.__b = 1;</script>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const BOTH_SHAPES = `
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<template id="scene-template">
|
||||||
|
<style id="sibling-style">#root { container-type: size; }</style>
|
||||||
|
<div data-composition-id="scene" id="root">
|
||||||
|
<style id="inner-style">#root { color: red; }</style>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const FULL_DOCUMENT = `
|
||||||
|
<html data-composition-variables='{"tone":"dark"}'>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.example">
|
||||||
|
<link rel="stylesheet" href="fonts.css">
|
||||||
|
<link rel="icon" href="favicon.ico">
|
||||||
|
<style id="head-style">body { background: black; }</style>
|
||||||
|
<script id="head-script" src="https://cdn.example/gsap.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-composition-id="scene" id="root" data-composition-variables='{"tone":"light"}'>
|
||||||
|
<style id="body-style">#root { color: red; }</style>
|
||||||
|
</div>
|
||||||
|
<script id="body-script">window.__c = 1;</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const ids = (elements: Element[]): string[] => elements.map((el) => el.getAttribute("id") || "");
|
||||||
|
|
||||||
|
describe("planCompositionAssembly", () => {
|
||||||
|
it("reports assets authored as siblings of the composition root inside <template>", () => {
|
||||||
|
// This is the original defect: collecting from the composition root alone
|
||||||
|
// dropped the whole stylesheet on mount while the render stayed correct.
|
||||||
|
const plan = planFor(SIBLING_ASSETS, "scene");
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toEqual(["sibling-style"]);
|
||||||
|
expect(ids(plan.scriptSources)).toEqual(["sibling-script"]);
|
||||||
|
expect(plan.styleSources[0]?.textContent).toContain("container-type: size");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports assets authored inside the composition root", () => {
|
||||||
|
const plan = planFor(ROOT_INTERNAL_ASSETS, "scene");
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toEqual(["inner-style"]);
|
||||||
|
expect(ids(plan.scriptSources)).toEqual(["inner-script"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports both shapes once each, in document order", () => {
|
||||||
|
const plan = planFor(BOTH_SHAPES, "scene");
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toEqual(["sibling-style", "inner-style"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports body assets for a composition with no <template>", () => {
|
||||||
|
const plan = planFor(FULL_DOCUMENT, "scene");
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toContain("body-style");
|
||||||
|
expect(ids(plan.scriptSources)).toContain("body-script");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders head-sourced assets before content-sourced ones", () => {
|
||||||
|
const plan = planFor(FULL_DOCUMENT, "scene");
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toEqual(["head-style", "body-style"]);
|
||||||
|
expect(ids(plan.scriptSources)).toEqual(["head-script", "body-script"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat a templated composition's page head as an asset source", () => {
|
||||||
|
// The head belongs to the host page, not to the composition; only the
|
||||||
|
// non-templated (full-document) shape carries composition assets in <head>.
|
||||||
|
const plan = planFor(
|
||||||
|
SIBLING_ASSETS.replace("<head></head>", '<head><style id="page-style">a{}</style></head>'),
|
||||||
|
"scene",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ids(plan.styleSources)).toEqual(["sibling-style"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hoists stylesheet and preconnect links from <head> and nothing else", () => {
|
||||||
|
const plan = planFor(FULL_DOCUMENT, "scene");
|
||||||
|
|
||||||
|
expect(plan.linkSources.map((link) => link.getAttribute("href"))).toEqual([
|
||||||
|
"https://fonts.example",
|
||||||
|
"fonts.css",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifies the composition from the host id, matching the root exactly", () => {
|
||||||
|
const plan = planFor(SIBLING_ASSETS, "scene");
|
||||||
|
|
||||||
|
expect(plan.innerRoot?.getAttribute("data-composition-id")).toBe("scene");
|
||||||
|
expect(plan.authoredCompositionId).toBe("scene");
|
||||||
|
expect(plan.scriptCompositionId).toBe("scene");
|
||||||
|
expect(plan.authoredRootId).toBe("root");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps CSS on the host id and scripts on the declared id when they disagree", () => {
|
||||||
|
// A `captions-comp` host mounting a `captions` template: there is no exact
|
||||||
|
// root match, so nothing is flattened, but the script's self-referencing
|
||||||
|
// querySelector still has to resolve against the id the content declares.
|
||||||
|
const plan = planFor(SIBLING_ASSETS, "scene-comp");
|
||||||
|
|
||||||
|
expect(plan.innerRoot).toBeNull();
|
||||||
|
expect(plan.authoredCompositionId).toBe("scene-comp");
|
||||||
|
expect(plan.scriptCompositionId).toBe("scene");
|
||||||
|
expect(plan.authoredRootId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first declared root for an anonymous host", () => {
|
||||||
|
const plan = planFor(SIBLING_ASSETS, null);
|
||||||
|
|
||||||
|
expect(plan.innerRoot?.getAttribute("id")).toBe("root");
|
||||||
|
expect(plan.authoredCompositionId).toBe("scene");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports both the document element and the inner root as variable carriers", () => {
|
||||||
|
const plan = planFor(FULL_DOCUMENT, "scene");
|
||||||
|
|
||||||
|
expect(plan.variableDefaultCarriers).toHaveLength(2);
|
||||||
|
expect(plan.variableDefaultCarriers[0]?.tagName.toLowerCase()).toBe("html");
|
||||||
|
expect(plan.variableDefaultCarriers[1]?.getAttribute("id")).toBe("root");
|
||||||
|
// Precedence order, lowest first: the inner root's declaration wins.
|
||||||
|
expect(plan.variableDefaultCarriers[1]?.getAttribute("data-composition-variables")).toContain(
|
||||||
|
"light",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports only the document element when the content declares no root", () => {
|
||||||
|
const plan = planFor(
|
||||||
|
`<html><head></head><body><template id="scene-template"><p>no root</p></template></body></html>`,
|
||||||
|
"scene",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan.innerRoot).toBeNull();
|
||||||
|
expect(plan.variableDefaultCarriers).toHaveLength(1);
|
||||||
|
expect(plan.authoredCompositionId).toBe("scene");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enumerateNestedCompositionHosts", () => {
|
||||||
|
const assembled = (html: string): Element => {
|
||||||
|
const doc = parseComposition(`<html><body><div id="host">${html}</div></body></html>`);
|
||||||
|
const host = doc.getElementById("host");
|
||||||
|
if (!host) throw new Error("fixture host missing");
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("enumerates nested hosts with their ancestry", () => {
|
||||||
|
const host = assembled(
|
||||||
|
`<div data-composition-src="child-a.html"></div><div data-composition-src="child-b.html"></div>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hosts, skipped } = enumerateNestedCompositionHosts(host, ["outer.html"]);
|
||||||
|
|
||||||
|
expect(hosts.map((entry) => entry.src)).toEqual(["child-a.html", "child-b.html"]);
|
||||||
|
expect(hosts[0]?.ancestry).toEqual(["outer.html", "child-a.html"]);
|
||||||
|
expect(hosts[0]?.host.getAttribute("data-composition-src")).toBe("child-a.html");
|
||||||
|
expect(skipped).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a src already in its own ancestry instead of recursing forever", () => {
|
||||||
|
const host = assembled(`<div data-composition-src="outer.html"></div>`);
|
||||||
|
|
||||||
|
const { hosts, skipped } = enumerateNestedCompositionHosts(host, ["outer.html"]);
|
||||||
|
|
||||||
|
expect(hosts).toEqual([]);
|
||||||
|
expect(skipped).toEqual([{ src: "outer.html", reason: "circular composition reference" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the depth cap", () => {
|
||||||
|
const host = assembled(`<div data-composition-src="deep.html"></div>`);
|
||||||
|
const atCap = Array.from({ length: MAX_SUB_COMPOSITION_DEPTH }, (_, i) => `level-${i}.html`);
|
||||||
|
|
||||||
|
const { hosts, skipped } = enumerateNestedCompositionHosts(host, atCap);
|
||||||
|
|
||||||
|
expect(hosts).toEqual([]);
|
||||||
|
expect(skipped).toEqual([{ src: "deep.html", reason: "nesting depth exceeded" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a host whose src attribute is empty", () => {
|
||||||
|
const host = assembled(`<div data-composition-src=""></div>`);
|
||||||
|
|
||||||
|
expect(enumerateNestedCompositionHosts(host, ["outer.html"])).toEqual({
|
||||||
|
hosts: [],
|
||||||
|
skipped: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import surface", () => {
|
||||||
|
it("imports nothing that the CDN runtime bundle must not carry", () => {
|
||||||
|
// The runtime ships as an esbuild IIFE. A DOM, filesystem, or cross-package
|
||||||
|
// import here would ride along, so assert the module's own source rather
|
||||||
|
// than trusting a comment.
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(resolve(here, "compositionAssembly.ts"), "utf8");
|
||||||
|
const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
const specifiers = Array.from(code.matchAll(/\bfrom\s+["']([^"']+)["']/g)).map(
|
||||||
|
(match) => match[1] ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(specifiers).toEqual([]);
|
||||||
|
expect(code).not.toMatch(/\brequire\s*\(/);
|
||||||
|
// No ambient DOM or Node globals either — the structural inputs exist so
|
||||||
|
// this module never needs them.
|
||||||
|
expect(code).not.toMatch(/\b(document|window|globalThis|process|fetch)\s*[.[]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Composition assembly decisions — the questions both assembly paths answer.
|
||||||
|
*
|
||||||
|
* A composition is assembled twice in this repo: `compiler/inlineSubCompositions.ts`
|
||||||
|
* assembles it for rendering (Node, linkedom, synchronous, emits strings) and
|
||||||
|
* `runtime/compositionLoader.ts` assembles it for mounting (browser, fetch, live
|
||||||
|
* DOM, executes scripts). Their I/O is genuinely different; their *decisions* are
|
||||||
|
* not. This module owns the decisions:
|
||||||
|
*
|
||||||
|
* - which nodes are a composition's asset sources, and in what order
|
||||||
|
* - which `<head>` elements hoist into the host document
|
||||||
|
* - the composition's scope identity (CSS scope, script scope, authored root id)
|
||||||
|
* - which nodes carry the composition's declared variable defaults
|
||||||
|
* - how nested `data-composition-src` hosts are enumerated
|
||||||
|
*
|
||||||
|
* It is deliberately DOM-free, filesystem-free and network-free: the runtime is
|
||||||
|
* bundled by esbuild into the IIFE published to a CDN, so anything reachable
|
||||||
|
* from `runtime/entry.ts` ships to every viewer. It takes narrow structural
|
||||||
|
* types instead of `Document`/`Element` for the same reason `compositionScoping.ts`
|
||||||
|
* takes strings — so both a linkedom document and a live browser document
|
||||||
|
* satisfy it without an `as T` cast at either call site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COMPOSITION_ID_ATTR = "data-composition-id";
|
||||||
|
const COMPOSITION_SRC_ATTR = "data-composition-src";
|
||||||
|
const COMPOSITION_ROOT_SELECTOR = `[${COMPOSITION_ID_ATTR}]`;
|
||||||
|
const COMPOSITION_HOST_SELECTOR = `[${COMPOSITION_SRC_ATTR}]`;
|
||||||
|
const STYLE_SELECTOR = "style";
|
||||||
|
const SCRIPT_SELECTOR = "script";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<head>` links both paths hoist into the host document. Stylesheets carry
|
||||||
|
* webfonts a composition's CSS depends on; preconnects are their latency hint.
|
||||||
|
*/
|
||||||
|
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compiler's nesting cap, enforced against the ancestry chain rather than a
|
||||||
|
* counter so a wide tree is not penalised for a deep sibling.
|
||||||
|
*/
|
||||||
|
export const MAX_SUB_COMPOSITION_DEPTH = 20;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structural inputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** An element this module only ever reads attributes from. */
|
||||||
|
export interface AssemblyAttributed {
|
||||||
|
getAttribute(name: string): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node this module only ever searches with a CSS selector. */
|
||||||
|
export interface AssemblyQueryable<TElement> {
|
||||||
|
querySelectorAll(selectors: string): Iterable<TElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAssemblyInput<TElement extends AssemblyAttributed> {
|
||||||
|
/**
|
||||||
|
* The composition's content: a `<template>`'s content when the composition is
|
||||||
|
* templated, the parsed document's `<body>` otherwise.
|
||||||
|
*
|
||||||
|
* Asset sources are collected from the WHOLE content node, never from the
|
||||||
|
* composition root alone. The canonical authored shape puts `<style>`/`<script>`
|
||||||
|
* as SIBLINGS of the root inside `<template>`; scanning only the root dropped a
|
||||||
|
* composition's entire stylesheet on mount while its render stayed correct.
|
||||||
|
*/
|
||||||
|
contentNode: AssemblyQueryable<TElement>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed document's `<head>`, when the composition was loaded as a full
|
||||||
|
* HTML document. Omit it for an inline `<template>`, which has no head of its
|
||||||
|
* own.
|
||||||
|
*/
|
||||||
|
head?: AssemblyQueryable<TElement> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed document's `<html>` element, when there is one. It is the first
|
||||||
|
* variable-default carrier; see `variableDefaultCarriers`.
|
||||||
|
*/
|
||||||
|
documentElement?: TElement | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `contentNode` came from a `<template>`. A templated composition's
|
||||||
|
* `<head>` styles and scripts are not part of the composition — the template
|
||||||
|
* already carries everything it needs — so they are not collected.
|
||||||
|
*/
|
||||||
|
hasTemplate: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composition id the host asks this composition to mount as, or `null` for
|
||||||
|
* an anonymous host.
|
||||||
|
*/
|
||||||
|
compositionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionAssemblyPlan<TElement extends AssemblyAttributed> {
|
||||||
|
/**
|
||||||
|
* The composition root inside `contentNode`. Matched on an EXACT id when the
|
||||||
|
* host names one — a template may intentionally use a different local id (a
|
||||||
|
* `captions-comp` host mounting a `captions` template), and flattening that
|
||||||
|
* fallback root changes the assembled DOM. Anonymous hosts fall back to the
|
||||||
|
* first root in the content.
|
||||||
|
*/
|
||||||
|
innerRoot: TElement | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id CSS is scoped to: the host's id when it names one, otherwise the id
|
||||||
|
* declared inside the content.
|
||||||
|
*/
|
||||||
|
authoredCompositionId: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id composition scripts are scoped to. It differs from
|
||||||
|
* `authoredCompositionId` only when a host names an id that no root inside the
|
||||||
|
* content declares: CSS stays on the host's id while scripts follow the id the
|
||||||
|
* content actually declares, so a script's self-referencing
|
||||||
|
* `querySelector('[data-composition-id="X"]')` still resolves.
|
||||||
|
*/
|
||||||
|
scriptCompositionId: string | null;
|
||||||
|
|
||||||
|
/** The `id` attribute authored on the composition root, if any. */
|
||||||
|
authoredRootId: string | null;
|
||||||
|
|
||||||
|
/** `<style>` sources in injection order: head-sourced first, then content. */
|
||||||
|
styleSources: TElement[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<script>` sources in execution order: head-sourced first, then content. A
|
||||||
|
* head script (a GSAP CDN tag in a non-templated composition) has to run
|
||||||
|
* before the content scripts that call into it.
|
||||||
|
*/
|
||||||
|
scriptSources: TElement[];
|
||||||
|
|
||||||
|
/** `<head>` links to hoist into the host document. */
|
||||||
|
linkSources: TElement[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nodes that may declare the composition's variable defaults, in precedence
|
||||||
|
* order — later wins. Full-document compositions declare on `<html>`;
|
||||||
|
* template/fragment compositions declare on the `[data-composition-id]` root
|
||||||
|
* div, because they have no `<html>` of their own. Callers read the declared
|
||||||
|
* defaults off each carrier and merge left to right.
|
||||||
|
*/
|
||||||
|
variableDefaultCarriers: TElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decisions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function toArray<TElement>(items: Iterable<TElement> | null | undefined): TElement[] {
|
||||||
|
return items ? Array.from(items) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer, for one composition, where its assets come from, in what order, and
|
||||||
|
* how it is identified. Pure: it reads attributes and runs selectors, and does
|
||||||
|
* not mutate, fetch, or touch a filesystem.
|
||||||
|
*/
|
||||||
|
export function planCompositionAssembly<TElement extends AssemblyAttributed>(
|
||||||
|
input: CompositionAssemblyInput<TElement>,
|
||||||
|
): CompositionAssemblyPlan<TElement> {
|
||||||
|
const { contentNode, head, documentElement, hasTemplate, compositionId } = input;
|
||||||
|
|
||||||
|
const compositionRoots = toArray(contentNode.querySelectorAll(COMPOSITION_ROOT_SELECTOR));
|
||||||
|
const innerRoot = compositionId
|
||||||
|
? (compositionRoots.find((root) => root.getAttribute(COMPOSITION_ID_ATTR) === compositionId) ??
|
||||||
|
null)
|
||||||
|
: (compositionRoots[0] ?? null);
|
||||||
|
|
||||||
|
// The id declared inside the content, even when the host asked for a
|
||||||
|
// different one. `innerRoot` is preferred so an exact match always wins.
|
||||||
|
const declaredCompositionId =
|
||||||
|
(innerRoot ?? compositionRoots[0])?.getAttribute(COMPOSITION_ID_ATTR)?.trim() || "";
|
||||||
|
|
||||||
|
// A templated composition's <head> belongs to its host page, not to it.
|
||||||
|
const assetHead = hasTemplate ? null : (head ?? null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
innerRoot,
|
||||||
|
authoredCompositionId: compositionId || declaredCompositionId || null,
|
||||||
|
scriptCompositionId: declaredCompositionId || compositionId || null,
|
||||||
|
authoredRootId: innerRoot?.getAttribute("id")?.trim() || null,
|
||||||
|
styleSources: [
|
||||||
|
...toArray(assetHead?.querySelectorAll(STYLE_SELECTOR)),
|
||||||
|
...toArray(contentNode.querySelectorAll(STYLE_SELECTOR)),
|
||||||
|
],
|
||||||
|
scriptSources: [
|
||||||
|
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
|
||||||
|
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
|
||||||
|
],
|
||||||
|
linkSources: toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
|
||||||
|
variableDefaultCarriers: [documentElement, innerRoot].filter(
|
||||||
|
(carrier): carrier is TElement => carrier != null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nested hosts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type NestedHostSkipReason = "circular composition reference" | "nesting depth exceeded";
|
||||||
|
|
||||||
|
export interface NestedCompositionHost<TElement> {
|
||||||
|
host: TElement;
|
||||||
|
src: string;
|
||||||
|
/**
|
||||||
|
* The chain of `data-composition-src` values from the outermost composition
|
||||||
|
* down to AND INCLUDING this host's own `src`. Pass it straight back into
|
||||||
|
* `enumerateNestedCompositionHosts` when this host is itself assembled.
|
||||||
|
*/
|
||||||
|
ancestry: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NestedCompositionHosts<TElement> {
|
||||||
|
hosts: NestedCompositionHost<TElement>[];
|
||||||
|
skipped: { src: string; reason: NestedHostSkipReason }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate the sub-composition hosts nested inside a composition that has just
|
||||||
|
* been assembled, refusing the two shapes that do not terminate.
|
||||||
|
*
|
||||||
|
* `ancestry` is the chain down to and including the assembled composition's own
|
||||||
|
* `src`, so a top-level host is enumerated with `[itsOwnSrc]`.
|
||||||
|
*/
|
||||||
|
export function enumerateNestedCompositionHosts<TElement extends AssemblyAttributed>(
|
||||||
|
assembledHost: AssemblyQueryable<TElement>,
|
||||||
|
ancestry: readonly string[],
|
||||||
|
): NestedCompositionHosts<TElement> {
|
||||||
|
const hosts: NestedCompositionHost<TElement>[] = [];
|
||||||
|
const skipped: { src: string; reason: NestedHostSkipReason }[] = [];
|
||||||
|
|
||||||
|
for (const nestedHost of assembledHost.querySelectorAll(COMPOSITION_HOST_SELECTOR)) {
|
||||||
|
const src = nestedHost.getAttribute(COMPOSITION_SRC_ATTR);
|
||||||
|
if (!src) continue;
|
||||||
|
if (ancestry.includes(src)) {
|
||||||
|
skipped.push({ src, reason: "circular composition reference" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ancestry.length >= MAX_SUB_COMPOSITION_DEPTH) {
|
||||||
|
skipped.push({ src, reason: "nesting depth exceeded" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hosts.push({ host: nestedHost, src, ancestry: [...ancestry, src] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hosts, skipped };
|
||||||
|
}
|
||||||
@@ -90,6 +90,43 @@ describe("loadExternalCompositions", () => {
|
|||||||
expect(injectedStyles.length).toBeGreaterThan(0);
|
expect(injectedStyles.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("mounts <style>/<script> authored as siblings of the composition root", async () => {
|
||||||
|
// The canonical sub-composition shape puts <style> and <script> directly
|
||||||
|
// inside <template>, NEXT TO the root div rather than inside it. Collecting
|
||||||
|
// only from the root dropped the composition's whole stylesheet, so rules
|
||||||
|
// keyed on the root (`#root { container-type: size }`) never landed and every
|
||||||
|
// container-query unit in the composition resolved against the wrong basis.
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.setAttribute("data-composition-src", "https://example.com/scene.html");
|
||||||
|
host.setAttribute("data-composition-id", "scene");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const compositionHtml =
|
||||||
|
`
|
||||||
|
<html><body>
|
||||||
|
<template>
|
||||||
|
<style>#root { container-type: size; }</style>
|
||||||
|
<div id="root" data-composition-id="scene"><p>Scene</p></div>
|
||||||
|
<script>window.__sceneRan = true;</scr` +
|
||||||
|
`ipt>
|
||||||
|
</template>
|
||||||
|
</body></html>
|
||||||
|
`;
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||||
|
|
||||||
|
const injectedStyles: HTMLStyleElement[] = [];
|
||||||
|
const injectedScripts: HTMLScriptElement[] = [];
|
||||||
|
await loadExternalCompositions({ ...defaultParams, injectedStyles, injectedScripts });
|
||||||
|
|
||||||
|
expect(injectedStyles.map((style) => style.textContent).join("")).toContain(
|
||||||
|
"container-type: size",
|
||||||
|
);
|
||||||
|
expect(injectedScripts.map((script) => script.textContent).join("")).toContain("__sceneRan");
|
||||||
|
// The extracted nodes are stripped from the mounted copy, not duplicated.
|
||||||
|
expect(host.querySelectorAll("style, script")).toHaveLength(0);
|
||||||
|
expect(host.querySelector("p")?.textContent).toBe("Scene");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves head stylesheets when an external composition uses a template", async () => {
|
it("preserves head stylesheets when an external composition uses a template", async () => {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||||
|
|||||||
@@ -181,6 +181,20 @@ function resetCompositionHost(host: Element) {
|
|||||||
host.textContent = "";
|
host.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composition's `<style>`/`<script>` are extracted and re-injected into the
|
||||||
|
* host document (scoped), so strip them from the copy that gets mounted —
|
||||||
|
* otherwise the mount re-declares the same CSS unscoped and re-runs the script.
|
||||||
|
*
|
||||||
|
* Strips the CLONE, never the source: `sourceNode` is a live `<template>` on the
|
||||||
|
* inline-template path, and mutating it would leave a remount with no styles.
|
||||||
|
*/
|
||||||
|
function stripExtractedCompositionAssets(node: ParentNode): void {
|
||||||
|
for (const el of Array.from(node.querySelectorAll("style, script"))) {
|
||||||
|
el.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
|
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
|
||||||
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
const prepared = document.importNode(innerRoot, true) as HTMLElement;
|
||||||
markFlattenedInnerRoot(prepared);
|
markFlattenedInnerRoot(prepared);
|
||||||
@@ -450,68 +464,37 @@ async function mountCompositionContent(params: {
|
|||||||
// element styles like backgrounds and positioning that the composition needs),
|
// element styles like backgrounds and positioning that the composition needs),
|
||||||
// then the content styles.
|
// then the content styles.
|
||||||
if (params.headStyles) injectScopedStyles(params.headStyles);
|
if (params.headStyles) injectScopedStyles(params.headStyles);
|
||||||
injectScopedStyles(Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style")));
|
// Collect from `sourceNode`, not the composition root: the canonical authored
|
||||||
|
// shape puts <style>/<script> as SIBLINGS of the root inside <template>, so
|
||||||
|
// scanning only the root dropped a composition's entire stylesheet (and with
|
||||||
|
// it `#root { container-type: size }`, leaving every cq* unit unanchored).
|
||||||
|
// `sourceNode` is a superset of the root, so nothing is collected twice.
|
||||||
|
injectScopedStyles(Array.from(params.sourceNode.querySelectorAll<HTMLStyleElement>("style")));
|
||||||
|
|
||||||
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
|
const toPendingScript = (script: HTMLScriptElement): PendingScript | null => {
|
||||||
// then content scripts. Head scripts must execute before content scripts.
|
const type = script.getAttribute("type")?.trim() ?? "";
|
||||||
const headScriptPayloads: PendingScript[] = [];
|
const src = script.getAttribute("src")?.trim() ?? "";
|
||||||
if (params.headScripts) {
|
if (src) {
|
||||||
for (const script of params.headScripts) {
|
const resolvedSrc = resolveScriptSourceUrl(src, params.compositionUrl);
|
||||||
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
// A sub-comp that <script src>s itself would re-enter the mount; skip it.
|
||||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
||||||
if (scriptSrc) {
|
|
||||||
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
||||||
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
|
|
||||||
} else {
|
|
||||||
const scriptText = script.textContent?.trim() ?? "";
|
|
||||||
if (scriptText) {
|
|
||||||
headScriptPayloads.push({
|
|
||||||
kind: "inline",
|
|
||||||
content: scriptText,
|
|
||||||
type: scriptType,
|
|
||||||
scopeCompositionId: authoredScopeCompositionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
|
|
||||||
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
|
|
||||||
for (const script of scripts) {
|
|
||||||
const scriptType = script.getAttribute("type")?.trim() ?? "";
|
|
||||||
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
|
|
||||||
if (scriptSrc) {
|
|
||||||
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
|
|
||||||
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
|
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
|
||||||
script.parentNode?.removeChild(script);
|
return null;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
scriptPayloads.push({
|
|
||||||
kind: "external",
|
|
||||||
src: resolvedSrc,
|
|
||||||
type: scriptType,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const scriptText = script.textContent?.trim() ?? "";
|
|
||||||
if (scriptText) {
|
|
||||||
scriptPayloads.push({
|
|
||||||
kind: "inline",
|
|
||||||
content: scriptText,
|
|
||||||
type: scriptType,
|
|
||||||
scopeCompositionId: authoredScopeCompositionId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return { kind: "external", src: resolvedSrc, type };
|
||||||
}
|
}
|
||||||
script.parentNode?.removeChild(script);
|
const content = script.textContent?.trim() ?? "";
|
||||||
}
|
if (!content) return null;
|
||||||
const remainingStyles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
|
return { kind: "inline", content, type, scopeCompositionId: authoredScopeCompositionId };
|
||||||
for (const style of remainingStyles) {
|
};
|
||||||
style.parentNode?.removeChild(style);
|
|
||||||
}
|
// <head> scripts first (e.g. a GSAP CDN tag in a non-template sub-comp): they
|
||||||
|
// must execute before the content scripts that call into them.
|
||||||
|
const scriptPayloads = [
|
||||||
|
...(params.headScripts ?? []),
|
||||||
|
...Array.from(params.sourceNode.querySelectorAll<HTMLScriptElement>("script")),
|
||||||
|
]
|
||||||
|
.map(toPendingScript)
|
||||||
|
.filter((payload): payload is PendingScript => payload !== null);
|
||||||
|
|
||||||
if (innerRoot) {
|
if (innerRoot) {
|
||||||
const widthRaw = innerRoot.getAttribute("data-width");
|
const widthRaw = innerRoot.getAttribute("data-width");
|
||||||
@@ -525,9 +508,13 @@ async function mountCompositionContent(params: {
|
|||||||
if (innerRoot.hasAttribute("data-timeline-locked")) {
|
if (innerRoot.hasAttribute("data-timeline-locked")) {
|
||||||
params.host.setAttribute("data-timeline-locked", "");
|
params.host.setAttribute("data-timeline-locked", "");
|
||||||
}
|
}
|
||||||
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
|
const flattenedRoot = prepareFlattenedInnerRoot(innerRoot);
|
||||||
|
stripExtractedCompositionAssets(flattenedRoot);
|
||||||
|
params.host.appendChild(flattenedRoot);
|
||||||
} else if (params.hasTemplate) {
|
} else if (params.hasTemplate) {
|
||||||
params.host.appendChild(document.importNode(contentNode, true));
|
const mountedContent = document.importNode(contentNode, true);
|
||||||
|
stripExtractedCompositionAssets(mountedContent);
|
||||||
|
params.host.appendChild(mountedContent);
|
||||||
} else {
|
} else {
|
||||||
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,39 @@
|
|||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
// @vitest-environment happy-dom
|
||||||
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
bundleToSingleHtml,
|
bundleToSingleHtml,
|
||||||
extractCompiledHtmlParityContract,
|
extractCompiledHtmlParityContract,
|
||||||
injectScriptsIntoHtml,
|
injectScriptsIntoHtml,
|
||||||
} from "@hyperframes/core/compiler";
|
} from "@hyperframes/core/compiler";
|
||||||
|
// Deep import: the mount path is not part of core's published export map (it is
|
||||||
|
// bundled into the runtime IIFE, not imported by consumers). Same shape as
|
||||||
|
// engine/src/services/videoFrameExtractor.test.ts reaching into core's runtime.
|
||||||
|
import { loadExternalCompositions } from "../../../core/src/runtime/compositionLoader.js";
|
||||||
import { compileForRender } from "./htmlCompiler.js";
|
import { compileForRender } from "./htmlCompiler.js";
|
||||||
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// The mount path scopes CSS with CSS.escape, which the DOM stub omits.
|
||||||
|
if (typeof globalThis.CSS === "undefined") {
|
||||||
|
Object.defineProperty(globalThis, "CSS", { value: {}, configurable: true, writable: true });
|
||||||
|
}
|
||||||
|
if (typeof CSS.escape !== "function") {
|
||||||
|
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete (window as Window & { __hfVariablesByComp?: unknown }).__hfVariablesByComp;
|
||||||
|
delete (window as Window & { __timelines?: unknown }).__timelines;
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function project(files: Record<string, string>): string {
|
function project(files: Record<string, string>): string {
|
||||||
@@ -27,6 +47,39 @@ function project(files: Record<string, string>): string {
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParityContract = ReturnType<typeof extractCompiledHtmlParityContract>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the project the way the player does — parse `index.html` into the live
|
||||||
|
* document, serve its sub-compositions over a stubbed `fetch`, and let the
|
||||||
|
* runtime assemble them — then read the same contract off the resulting DOM.
|
||||||
|
*
|
||||||
|
* This is the third assembly path. Both compiler arms below run the same code
|
||||||
|
* up to `bundleToSingleHtml`, which is why a runtime-only regression (a mounted
|
||||||
|
* composition losing every `<style>` authored beside its root) stayed invisible
|
||||||
|
* to a green suite.
|
||||||
|
*/
|
||||||
|
async function mountContract(dir: string, indexHtml: string): Promise<ParityContract> {
|
||||||
|
const parsed = new DOMParser().parseFromString(indexHtml, "text/html");
|
||||||
|
document.head.innerHTML = parsed.head.innerHTML;
|
||||||
|
document.body.innerHTML = parsed.body.innerHTML;
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) =>
|
||||||
|
Promise.resolve(new Response(readFileSync(join(dir, String(input)), "utf8"), { status: 200 })),
|
||||||
|
);
|
||||||
|
await loadExternalCompositions({
|
||||||
|
injectedStyles: [],
|
||||||
|
injectedScripts: [],
|
||||||
|
injectedLinks: [],
|
||||||
|
parseDimensionPx: (value: string | null) => (value ? `${value}px` : null),
|
||||||
|
// A mount that fails is not a parity result. Surface it instead of
|
||||||
|
// comparing the contract of an empty host against a compiled one.
|
||||||
|
onDiagnostic: ({ code, details }) => {
|
||||||
|
throw new Error(`mount diagnostic ${code}: ${JSON.stringify(details)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return extractCompiledHtmlParityContract(`<!doctype html>${document.documentElement.outerHTML}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function contracts(files: Record<string, string>) {
|
async function contracts(files: Record<string, string>) {
|
||||||
const dir = project(files);
|
const dir = project(files);
|
||||||
const preview = await bundleToSingleHtml(dir);
|
const preview = await bundleToSingleHtml(dir);
|
||||||
@@ -42,6 +95,7 @@ async function contracts(files: Record<string, string>) {
|
|||||||
return {
|
return {
|
||||||
preview: extractCompiledHtmlParityContract(preview),
|
preview: extractCompiledHtmlParityContract(preview),
|
||||||
render: extractCompiledHtmlParityContract(servedRender),
|
render: extractCompiledHtmlParityContract(servedRender),
|
||||||
|
mount: await mountContract(dir, files["index.html"]!),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,3 +148,110 @@ describe("preview/render semantic compilation parity", () => {
|
|||||||
expect(result.render).toEqual(result.preview);
|
expect(result.render).toEqual(result.preview);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FONT_FACE = `@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }`;
|
||||||
|
|
||||||
|
const cardHost = (body: string) => ({
|
||||||
|
"index.html":
|
||||||
|
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6">
|
||||||
|
<section id="card-host" data-composition-id="card" data-composition-src="compositions/card.html"
|
||||||
|
data-start="1" data-duration="3"></section>
|
||||||
|
</main>`),
|
||||||
|
"compositions/card.html": body,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every fixture here mounts a sub-composition, because the mount path only runs
|
||||||
|
* for one. The set has to keep covering the shape that broke — see the
|
||||||
|
* assertion below, which fails if a refactor quietly drops it.
|
||||||
|
*/
|
||||||
|
const MOUNT_PARITY_FIXTURES: { name: string; files: Record<string, string> }[] = [
|
||||||
|
{
|
||||||
|
name: "assets authored as siblings of the composition root",
|
||||||
|
files: cardHost(`<template id="card-template">
|
||||||
|
<style>${FONT_FACE}
|
||||||
|
.parity-card { --parity-contract: 3; font-family: ParityBody, sans-serif; }</style>
|
||||||
|
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
|
||||||
|
<h2 class="parity-card">Card</h2>
|
||||||
|
</article>
|
||||||
|
</template>`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "assets authored inside the composition root",
|
||||||
|
files: cardHost(`<template id="card-template">
|
||||||
|
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
|
||||||
|
<style>${FONT_FACE}
|
||||||
|
.parity-card { --parity-contract: 4; font-family: ParityBody, sans-serif; }</style>
|
||||||
|
<h2 class="parity-card">Card</h2>
|
||||||
|
</article>
|
||||||
|
</template>`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a full-document composition hoisting a head stylesheet link",
|
||||||
|
files: cardHost(`<!doctype html><html><head>
|
||||||
|
<link rel="preconnect" href="https://fonts.example.com" />
|
||||||
|
<style>${FONT_FACE}
|
||||||
|
.parity-card { --parity-contract: 5; font-family: ParityBody, sans-serif; }</style>
|
||||||
|
</head><body>
|
||||||
|
<article id="card-root" data-composition-id="card" data-width="800" data-height="600">
|
||||||
|
<h2 class="parity-card">Card</h2>
|
||||||
|
</article>
|
||||||
|
</body></html>`),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** True when a sub-composition authors a `<style>`/`<script>` outside its root. */
|
||||||
|
function hasRootSiblingAssets(subCompositionHtml: string): boolean {
|
||||||
|
const doc = new DOMParser().parseFromString(subCompositionHtml, "text/html");
|
||||||
|
const template = doc.querySelector("template");
|
||||||
|
const scope: ParentNode = template ? template.content : doc.body;
|
||||||
|
const root = scope.querySelector("[data-composition-id]");
|
||||||
|
const assets = Array.from(scope.querySelectorAll("style, script"));
|
||||||
|
return assets.length > 0 && assets.some((asset) => !root?.contains(asset));
|
||||||
|
}
|
||||||
|
|
||||||
|
const subCompositions = (files: Record<string, string>) =>
|
||||||
|
Object.entries(files)
|
||||||
|
.filter(([path]) => path !== "index.html")
|
||||||
|
.map(([, content]) => content);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime's assembly is not a compiler, so two contract fields are outside
|
||||||
|
* what this arm gates, and are asserted rather than compared:
|
||||||
|
*
|
||||||
|
* - `runtimeBootstrap` / `variableBootstrap` — the runtime IIFE and the variable
|
||||||
|
* bootstrap script are injected by the player and the producer AROUND a mount,
|
||||||
|
* never by `loadExternalCompositions`. Comparing them would compare harnesses.
|
||||||
|
*
|
||||||
|
* Excluded for now, and deliberately NOT worked around: a templated
|
||||||
|
* sub-composition whose document `<head>` carries a `<link>` — the mount path
|
||||||
|
* hoists it unconditionally, the compiler only for a non-templated composition.
|
||||||
|
* That is one of the live divergences U1 catalogued; closing it is a behaviour
|
||||||
|
* decision for a later unit, not something a gate should paper over. The
|
||||||
|
* non-templated shape IS covered above, where both paths agree.
|
||||||
|
*/
|
||||||
|
function assembledContract(contract: ParityContract) {
|
||||||
|
const { runtimeBootstrap: _runtime, variableBootstrap: _variables, ...assembled } = contract;
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mount/compile assembly parity", () => {
|
||||||
|
it("keeps a fixture set that still covers the shape the mount path used to drop", () => {
|
||||||
|
expect(MOUNT_PARITY_FIXTURES.length).toBeGreaterThan(0);
|
||||||
|
const siblingShaped = MOUNT_PARITY_FIXTURES.filter((fixture) =>
|
||||||
|
subCompositions(fixture.files).some(hasRootSiblingAssets),
|
||||||
|
);
|
||||||
|
expect(siblingShaped.map((fixture) => fixture.name)).not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(MOUNT_PARITY_FIXTURES)(
|
||||||
|
"assembles $name identically on all three paths",
|
||||||
|
async ({ files }) => {
|
||||||
|
const result = await contracts(files);
|
||||||
|
expect(result.render).toEqual(result.preview);
|
||||||
|
expect(assembledContract(result.mount)).toEqual(assembledContract(result.preview));
|
||||||
|
expect(result.mount.runtimeBootstrap).toBe(false);
|
||||||
|
expect(result.mount.variableBootstrap).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user