mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
test(compiler): enforce preview render parity
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractCompiledHtmlParityContract } from "./htmlParityContract";
|
||||
|
||||
describe("extractCompiledHtmlParityContract", () => {
|
||||
it("normalizes legacy timing and nested variable JSON", () => {
|
||||
const contract = extractCompiledHtmlParityContract(`<main data-composition-id="main"
|
||||
data-variable-values='{"z":{"b":2,"a":1},"a":[{"d":4,"c":3}]}' data-start="1" data-end="4" data-layer="2">
|
||||
<div id="child" data-start="2" data-end="3" data-layer="5"></div>
|
||||
</main>`);
|
||||
expect(contract.compositions[0]).toMatchObject({
|
||||
start: 1,
|
||||
duration: 3,
|
||||
trackIndex: 2,
|
||||
variableValues: '{"a":[{"c":3,"d":4}],"z":{"a":1,"b":2}}',
|
||||
});
|
||||
expect(contract.timedElements[0]).toMatchObject({ start: 2, duration: 1, trackIndex: 5 });
|
||||
});
|
||||
|
||||
it("treats embedded and project-relative resources as the same local contract", () => {
|
||||
const embedded = extractCompiledHtmlParityContract(
|
||||
`<img id="logo" src="data:image/svg+xml,x" />`,
|
||||
);
|
||||
const project = extractCompiledHtmlParityContract(`<img id="logo" src="assets/logo.svg" />`);
|
||||
expect(embedded.resources).toEqual(project.resources);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
export interface HtmlParityComposition {
|
||||
id: string;
|
||||
originalId: string | null;
|
||||
start: number;
|
||||
duration: number | null;
|
||||
trackIndex: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
variableValues: string | null;
|
||||
}
|
||||
|
||||
export interface HtmlParityTimedElement {
|
||||
identity: string;
|
||||
start: number;
|
||||
duration: number | null;
|
||||
trackIndex: number | null;
|
||||
}
|
||||
|
||||
export interface HtmlParityResource {
|
||||
identity: string;
|
||||
attribute: "src" | "href" | "poster";
|
||||
locality: "local" | "remote";
|
||||
}
|
||||
|
||||
export interface CompiledHtmlParityContract {
|
||||
compositions: HtmlParityComposition[];
|
||||
timedElements: HtmlParityTimedElement[];
|
||||
authoredStyleSignatures: string[];
|
||||
parityFontFamilies: string[];
|
||||
resources: HtmlParityResource[];
|
||||
runtimeBootstrap: boolean;
|
||||
variableBootstrap: boolean;
|
||||
}
|
||||
|
||||
function finiteAttribute(element: Element, name: string): number | null {
|
||||
const raw = element.getAttribute(name);
|
||||
if (raw === null || raw.trim() === "") return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function timing(element: Element): {
|
||||
start: number;
|
||||
duration: number | null;
|
||||
trackIndex: number | null;
|
||||
} {
|
||||
const start = finiteAttribute(element, "data-start") ?? 0;
|
||||
const duration =
|
||||
finiteAttribute(element, "data-duration") ??
|
||||
(() => {
|
||||
const end = finiteAttribute(element, "data-end");
|
||||
return end === null ? null : Math.max(0, end - start);
|
||||
})();
|
||||
return {
|
||||
start,
|
||||
duration,
|
||||
trackIndex:
|
||||
finiteAttribute(element, "data-track-index") ?? finiteAttribute(element, "data-layer"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJson(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
try {
|
||||
return JSON.stringify(canonicalizeJson(JSON.parse(value) as unknown));
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalizeJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalizeJson);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, canonicalizeJson(nested)]),
|
||||
);
|
||||
}
|
||||
|
||||
function styleSignatures(document: Document): string[] {
|
||||
return [...document.querySelectorAll("style")]
|
||||
.map((style) => style.textContent ?? "")
|
||||
.flatMap((css) => css.match(/[^{}]+\{[^{}]*--parity-contract\s*:[^{}]+\}/g) ?? [])
|
||||
.map((rule) =>
|
||||
rule
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\s*([:;{},])\s*/g, "$1")
|
||||
.trim(),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function parityFontFamilies(html: string): string[] {
|
||||
const families = new Set<string>();
|
||||
for (const match of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
|
||||
for (const family of match[1]!.split(",")) {
|
||||
const normalized = family.trim().replace(/^['"]|['"]$/g, "");
|
||||
if (normalized.startsWith("Parity")) families.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...families].sort();
|
||||
}
|
||||
|
||||
function resources(document: Document): HtmlParityResource[] {
|
||||
const resources: HtmlParityResource[] = [];
|
||||
for (const [index, element] of [
|
||||
...document.querySelectorAll("[src], [href], [poster]"),
|
||||
].entries()) {
|
||||
for (const attribute of ["src", "href", "poster"] as const) {
|
||||
const value = element.getAttribute(attribute)?.trim();
|
||||
if (!value) continue;
|
||||
if (element.tagName.toLowerCase() === "script" && /hyperframes|runtime/i.test(value))
|
||||
continue;
|
||||
resources.push({
|
||||
identity:
|
||||
element.getAttribute("data-hf-id") ??
|
||||
element.getAttribute("id") ??
|
||||
`${element.tagName.toLowerCase()}[${index}]`,
|
||||
attribute,
|
||||
locality: /^https?:\/\//i.test(value) ? "remote" : "local",
|
||||
});
|
||||
}
|
||||
}
|
||||
return resources.sort((left, right) =>
|
||||
`${left.identity}:${left.attribute}`.localeCompare(`${right.identity}:${right.attribute}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract the browser-visible contract shared by preview and render compilation. */
|
||||
export function extractCompiledHtmlParityContract(html: string): CompiledHtmlParityContract {
|
||||
const { document } = parseHTML(html);
|
||||
const compositions = [...document.querySelectorAll("[data-composition-id]")].map((element) => {
|
||||
const resolved = timing(element);
|
||||
return {
|
||||
id: element.getAttribute("data-composition-id") ?? "",
|
||||
originalId: element.getAttribute("data-hf-original-composition-id"),
|
||||
...resolved,
|
||||
width: finiteAttribute(element, "data-width"),
|
||||
height: finiteAttribute(element, "data-height"),
|
||||
variableValues: normalizeJson(element.getAttribute("data-variable-values")),
|
||||
};
|
||||
});
|
||||
const timedElements = [
|
||||
...document.querySelectorAll(
|
||||
"[data-start], [data-duration], [data-end], [data-track-index], [data-layer]",
|
||||
),
|
||||
]
|
||||
.filter((element) => !element.hasAttribute("data-composition-id"))
|
||||
.map((element, index) => ({
|
||||
identity:
|
||||
element.getAttribute("data-hf-id") ??
|
||||
element.getAttribute("id") ??
|
||||
`${element.tagName.toLowerCase()}[${index}]`,
|
||||
...timing(element),
|
||||
}));
|
||||
return {
|
||||
compositions,
|
||||
timedElements,
|
||||
authoredStyleSignatures: styleSignatures(document),
|
||||
parityFontFamilies: parityFontFamilies(html),
|
||||
resources: resources(document),
|
||||
runtimeBootstrap: /__hyperframes|data-hyperframes-(?:preview-)?runtime/i.test(html),
|
||||
variableBootstrap: /__hfVariables(?:ByComp)?/.test(html),
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,14 @@ export {
|
||||
} from "./htmlBundler";
|
||||
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
|
||||
|
||||
export {
|
||||
extractCompiledHtmlParityContract,
|
||||
type CompiledHtmlParityContract,
|
||||
type HtmlParityComposition,
|
||||
type HtmlParityResource,
|
||||
type HtmlParityTimedElement,
|
||||
} from "./htmlParityContract";
|
||||
|
||||
export {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
injectScriptsAtHeadStart,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bundleToSingleHtml,
|
||||
extractCompiledHtmlParityContract,
|
||||
injectScriptsIntoHtml,
|
||||
} from "@hyperframes/core/compiler";
|
||||
import { compileForRender } from "./htmlCompiler.js";
|
||||
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-compiler-parity-"));
|
||||
tempDirs.push(dir);
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const path = join(dir, relative);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function contracts(files: Record<string, string>) {
|
||||
const dir = project(files);
|
||||
const preview = await bundleToSingleHtml(dir);
|
||||
const render = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
|
||||
allowSystemFontCapture: false,
|
||||
});
|
||||
const servedRender = injectScriptsIntoHtml(
|
||||
render.html,
|
||||
[getVerifiedHyperframeRuntimeSource()],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
return {
|
||||
preview: extractCompiledHtmlParityContract(preview),
|
||||
render: extractCompiledHtmlParityContract(servedRender),
|
||||
};
|
||||
}
|
||||
|
||||
const shell = (body: string, head = "") => `<!doctype html>
|
||||
<html><head>${head}</head><body>${body}
|
||||
<script>window.__timelines = window.__timelines || {};</script></body></html>`;
|
||||
|
||||
describe("preview/render semantic compilation parity", () => {
|
||||
it("preserves canonical timing, track, authored style, font, and resource contracts", async () => {
|
||||
const result = await contracts({
|
||||
"index.html": shell(
|
||||
`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div id="title" data-start="1" data-duration="2" data-track-index="3" class="clip parity-card">Title</div>
|
||||
<img id="logo" src="assets/logo.svg" alt="" />
|
||||
</main>`,
|
||||
`<style>@font-face { font-family: "ParityDisplay"; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
|
||||
.parity-card { --parity-contract: 1; color: red; font-family: "ParityDisplay", sans-serif; }</style>`,
|
||||
),
|
||||
"assets/logo.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`,
|
||||
});
|
||||
expect(result.render).toEqual(result.preview);
|
||||
});
|
||||
|
||||
it("keeps legacy end/layer timing semantically identical", async () => {
|
||||
const result = await contracts({
|
||||
"index.html":
|
||||
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="5">
|
||||
<div id="legacy" class="clip" data-start="1.5" data-end="4" data-layer="2">Legacy timing</div>
|
||||
</main>`),
|
||||
});
|
||||
expect(result.render).toEqual(result.preview);
|
||||
});
|
||||
|
||||
it("keeps flattened sub-composition identity and variable bootstrap identical", async () => {
|
||||
const result = await contracts({
|
||||
"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" data-variable-values='{"title":"Pro"}'></section>
|
||||
</main>`),
|
||||
"compositions/card.html": `<template id="card-template">
|
||||
<article data-composition-id="card" data-width="800" data-height="600">
|
||||
<h2 id="card-title" class="parity-card">Card</h2>
|
||||
<style>@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
|
||||
.parity-card { --parity-contract: 2; font-family: ParityBody, sans-serif; }</style>
|
||||
</article>
|
||||
</template>
|
||||
<script>window.__timelines = window.__timelines || {};</script>`,
|
||||
});
|
||||
expect(result.render).toEqual(result.preview);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user