Skip to content

Commit 8e49e95

Browse files
committed
test(compiler): enforce preview render parity
1 parent 528f2f2 commit 8e49e95

4 files changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { extractCompiledHtmlParityContract } from "./htmlParityContract";
3+
4+
describe("extractCompiledHtmlParityContract", () => {
5+
it("normalizes legacy timing and nested variable JSON", () => {
6+
const contract = extractCompiledHtmlParityContract(`<main data-composition-id="main"
7+
data-variable-values='{"z":{"b":2,"a":1},"a":[{"d":4,"c":3}]}' data-start="1" data-end="4" data-layer="2">
8+
<div id="child" data-start="2" data-end="3" data-layer="5"></div>
9+
</main>`);
10+
expect(contract.compositions[0]).toMatchObject({
11+
start: 1,
12+
duration: 3,
13+
trackIndex: 2,
14+
variableValues: '{"a":[{"c":3,"d":4}],"z":{"a":1,"b":2}}',
15+
});
16+
expect(contract.timedElements[0]).toMatchObject({ start: 2, duration: 1, trackIndex: 5 });
17+
});
18+
19+
it("treats embedded and project-relative resources as the same local contract", () => {
20+
const embedded = extractCompiledHtmlParityContract(
21+
`<img id="logo" src="data:image/svg+xml,x" />`,
22+
);
23+
const project = extractCompiledHtmlParityContract(`<img id="logo" src="assets/logo.svg" />`);
24+
expect(embedded.resources).toEqual(project.resources);
25+
});
26+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { parseHTML } from "linkedom";
2+
3+
export interface HtmlParityComposition {
4+
id: string;
5+
originalId: string | null;
6+
start: number;
7+
duration: number | null;
8+
trackIndex: number | null;
9+
width: number | null;
10+
height: number | null;
11+
variableValues: string | null;
12+
}
13+
14+
export interface HtmlParityTimedElement {
15+
identity: string;
16+
start: number;
17+
duration: number | null;
18+
trackIndex: number | null;
19+
}
20+
21+
export interface HtmlParityResource {
22+
identity: string;
23+
attribute: "src" | "href" | "poster";
24+
locality: "local" | "remote";
25+
}
26+
27+
export interface CompiledHtmlParityContract {
28+
compositions: HtmlParityComposition[];
29+
timedElements: HtmlParityTimedElement[];
30+
authoredStyleSignatures: string[];
31+
parityFontFamilies: string[];
32+
resources: HtmlParityResource[];
33+
runtimeBootstrap: boolean;
34+
variableBootstrap: boolean;
35+
}
36+
37+
function finiteAttribute(element: Element, name: string): number | null {
38+
const raw = element.getAttribute(name);
39+
if (raw === null || raw.trim() === "") return null;
40+
const value = Number(raw);
41+
return Number.isFinite(value) ? value : null;
42+
}
43+
44+
function timing(element: Element): {
45+
start: number;
46+
duration: number | null;
47+
trackIndex: number | null;
48+
} {
49+
const start = finiteAttribute(element, "data-start") ?? 0;
50+
const duration =
51+
finiteAttribute(element, "data-duration") ??
52+
(() => {
53+
const end = finiteAttribute(element, "data-end");
54+
return end === null ? null : Math.max(0, end - start);
55+
})();
56+
return {
57+
start,
58+
duration,
59+
trackIndex:
60+
finiteAttribute(element, "data-track-index") ?? finiteAttribute(element, "data-layer"),
61+
};
62+
}
63+
64+
function normalizeJson(value: string | null): string | null {
65+
if (value === null) return null;
66+
try {
67+
return JSON.stringify(canonicalizeJson(JSON.parse(value) as unknown));
68+
} catch {
69+
return value.trim();
70+
}
71+
}
72+
73+
function canonicalizeJson(value: unknown): unknown {
74+
if (Array.isArray(value)) return value.map(canonicalizeJson);
75+
if (value === null || typeof value !== "object") return value;
76+
return Object.fromEntries(
77+
Object.entries(value)
78+
.sort(([left], [right]) => left.localeCompare(right))
79+
.map(([key, nested]) => [key, canonicalizeJson(nested)]),
80+
);
81+
}
82+
83+
function styleSignatures(document: Document): string[] {
84+
return [...document.querySelectorAll("style")]
85+
.map((style) => style.textContent ?? "")
86+
.flatMap((css) => css.match(/[^{}]+\{[^{}]*--parity-contract\s*:[^{}]+\}/g) ?? [])
87+
.map((rule) =>
88+
rule
89+
.replace(/\s+/g, " ")
90+
.replace(/\s*([:;{},])\s*/g, "$1")
91+
.trim(),
92+
)
93+
.sort();
94+
}
95+
96+
function parityFontFamilies(html: string): string[] {
97+
const families = new Set<string>();
98+
for (const match of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) {
99+
for (const family of match[1]!.split(",")) {
100+
const normalized = family.trim().replace(/^['"]|['"]$/g, "");
101+
if (normalized.startsWith("Parity")) families.add(normalized);
102+
}
103+
}
104+
return [...families].sort();
105+
}
106+
107+
function resources(document: Document): HtmlParityResource[] {
108+
const resources: HtmlParityResource[] = [];
109+
for (const [index, element] of [
110+
...document.querySelectorAll("[src], [href], [poster]"),
111+
].entries()) {
112+
for (const attribute of ["src", "href", "poster"] as const) {
113+
const value = element.getAttribute(attribute)?.trim();
114+
if (!value) continue;
115+
if (element.tagName.toLowerCase() === "script" && /hyperframes|runtime/i.test(value))
116+
continue;
117+
resources.push({
118+
identity:
119+
element.getAttribute("data-hf-id") ??
120+
element.getAttribute("id") ??
121+
`${element.tagName.toLowerCase()}[${index}]`,
122+
attribute,
123+
locality: /^https?:\/\//i.test(value) ? "remote" : "local",
124+
});
125+
}
126+
}
127+
return resources.sort((left, right) =>
128+
`${left.identity}:${left.attribute}`.localeCompare(`${right.identity}:${right.attribute}`),
129+
);
130+
}
131+
132+
/** Extract the browser-visible contract shared by preview and render compilation. */
133+
export function extractCompiledHtmlParityContract(html: string): CompiledHtmlParityContract {
134+
const { document } = parseHTML(html);
135+
const compositions = [...document.querySelectorAll("[data-composition-id]")].map((element) => {
136+
const resolved = timing(element);
137+
return {
138+
id: element.getAttribute("data-composition-id") ?? "",
139+
originalId: element.getAttribute("data-hf-original-composition-id"),
140+
...resolved,
141+
width: finiteAttribute(element, "data-width"),
142+
height: finiteAttribute(element, "data-height"),
143+
variableValues: normalizeJson(element.getAttribute("data-variable-values")),
144+
};
145+
});
146+
const timedElements = [
147+
...document.querySelectorAll(
148+
"[data-start], [data-duration], [data-end], [data-track-index], [data-layer]",
149+
),
150+
]
151+
.filter((element) => !element.hasAttribute("data-composition-id"))
152+
.map((element, index) => ({
153+
identity:
154+
element.getAttribute("data-hf-id") ??
155+
element.getAttribute("id") ??
156+
`${element.tagName.toLowerCase()}[${index}]`,
157+
...timing(element),
158+
}));
159+
return {
160+
compositions,
161+
timedElements,
162+
authoredStyleSignatures: styleSignatures(document),
163+
parityFontFamilies: parityFontFamilies(html),
164+
resources: resources(document),
165+
runtimeBootstrap: /__hyperframes|data-hyperframes-(?:preview-)?runtime/i.test(html),
166+
variableBootstrap: /__hfVariables(?:ByComp)?/.test(html),
167+
};
168+
}

packages/core/src/compiler/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export {
3838
} from "./htmlBundler";
3939
export { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables";
4040

41+
export {
42+
extractCompiledHtmlParityContract,
43+
type CompiledHtmlParityContract,
44+
type HtmlParityComposition,
45+
type HtmlParityResource,
46+
type HtmlParityTimedElement,
47+
} from "./htmlParityContract";
48+
4149
export {
4250
RUNTIME_BOOTSTRAP_ATTR,
4351
injectScriptsAtHeadStart,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
bundleToSingleHtml,
7+
extractCompiledHtmlParityContract,
8+
injectScriptsIntoHtml,
9+
} from "@hyperframes/core/compiler";
10+
import { compileForRender } from "./htmlCompiler.js";
11+
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
12+
13+
const tempDirs: string[] = [];
14+
15+
afterEach(() => {
16+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
17+
});
18+
19+
function project(files: Record<string, string>): string {
20+
const dir = mkdtempSync(join(tmpdir(), "hf-compiler-parity-"));
21+
tempDirs.push(dir);
22+
for (const [relative, content] of Object.entries(files)) {
23+
const path = join(dir, relative);
24+
mkdirSync(dirname(path), { recursive: true });
25+
writeFileSync(path, content);
26+
}
27+
return dir;
28+
}
29+
30+
async function contracts(files: Record<string, string>) {
31+
const dir = project(files);
32+
const preview = await bundleToSingleHtml(dir);
33+
const render = await compileForRender(dir, join(dir, "index.html"), join(dir, ".downloads"), {
34+
allowSystemFontCapture: false,
35+
});
36+
const servedRender = injectScriptsIntoHtml(
37+
render.html,
38+
[getVerifiedHyperframeRuntimeSource()],
39+
[],
40+
true,
41+
);
42+
return {
43+
preview: extractCompiledHtmlParityContract(preview),
44+
render: extractCompiledHtmlParityContract(servedRender),
45+
};
46+
}
47+
48+
const shell = (body: string, head = "") => `<!doctype html>
49+
<html><head>${head}</head><body>${body}
50+
<script>window.__timelines = window.__timelines || {};</script></body></html>`;
51+
52+
describe("preview/render semantic compilation parity", () => {
53+
it("preserves canonical timing, track, authored style, font, and resource contracts", async () => {
54+
const result = await contracts({
55+
"index.html": shell(
56+
`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="4">
57+
<div id="title" data-start="1" data-duration="2" data-track-index="3" class="clip parity-card">Title</div>
58+
<img id="logo" src="assets/logo.svg" alt="" />
59+
</main>`,
60+
`<style>@font-face { font-family: "ParityDisplay"; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
61+
.parity-card { --parity-contract: 1; color: red; font-family: "ParityDisplay", sans-serif; }</style>`,
62+
),
63+
"assets/logo.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`,
64+
});
65+
expect(result.render).toEqual(result.preview);
66+
});
67+
68+
it("keeps legacy end/layer timing semantically identical", async () => {
69+
const result = await contracts({
70+
"index.html":
71+
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="5">
72+
<div id="legacy" class="clip" data-start="1.5" data-end="4" data-layer="2">Legacy timing</div>
73+
</main>`),
74+
});
75+
expect(result.render).toEqual(result.preview);
76+
});
77+
78+
it("keeps flattened sub-composition identity and variable bootstrap identical", async () => {
79+
const result = await contracts({
80+
"index.html":
81+
shell(`<main data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="6">
82+
<section id="card-host" data-composition-id="card" data-composition-src="compositions/card.html"
83+
data-start="1" data-duration="3" data-variable-values='{"title":"Pro"}'></section>
84+
</main>`),
85+
"compositions/card.html": `<template id="card-template">
86+
<article data-composition-id="card" data-width="800" data-height="600">
87+
<h2 id="card-title" class="parity-card">Card</h2>
88+
<style>@font-face { font-family: ParityBody; src: url(data:font/woff2;base64,d09GMgAB) format("woff2"); }
89+
.parity-card { --parity-contract: 2; font-family: ParityBody, sans-serif; }</style>
90+
</article>
91+
</template>
92+
<script>window.__timelines = window.__timelines || {};</script>`,
93+
});
94+
expect(result.render).toEqual(result.preview);
95+
});
96+
});

0 commit comments

Comments
 (0)