From ec2701948ad5562957fa734e5d82d7abd982fb8f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 13:26:16 -0700 Subject: [PATCH] test(compiler): enforce preview render parity --- .../src/compiler/htmlParityContract.test.ts | 26 +++ .../core/src/compiler/htmlParityContract.ts | 168 ++++++++++++++++++ packages/core/src/compiler/index.ts | 8 + .../src/services/htmlCompiler.parity.test.ts | 96 ++++++++++ 4 files changed, 298 insertions(+) create mode 100644 packages/core/src/compiler/htmlParityContract.test.ts create mode 100644 packages/core/src/compiler/htmlParityContract.ts create mode 100644 packages/producer/src/services/htmlCompiler.parity.test.ts diff --git a/packages/core/src/compiler/htmlParityContract.test.ts b/packages/core/src/compiler/htmlParityContract.test.ts new file mode 100644 index 0000000000..01ec5909d1 --- /dev/null +++ b/packages/core/src/compiler/htmlParityContract.test.ts @@ -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(`
+
+
`); + 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( + ``, + ); + const project = extractCompiledHtmlParityContract(``); + expect(embedded.resources).toEqual(project.resources); + }); +}); diff --git a/packages/core/src/compiler/htmlParityContract.ts b/packages/core/src/compiler/htmlParityContract.ts new file mode 100644 index 0000000000..2a610b7513 --- /dev/null +++ b/packages/core/src/compiler/htmlParityContract.ts @@ -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(); + 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), + }; +} diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index 0474d19d57..fff2ec1422 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -38,6 +38,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, diff --git a/packages/producer/src/services/htmlCompiler.parity.test.ts b/packages/producer/src/services/htmlCompiler.parity.test.ts new file mode 100644 index 0000000000..462afec2a7 --- /dev/null +++ b/packages/producer/src/services/htmlCompiler.parity.test.ts @@ -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 { + 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) { + 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 = "") => ` +${head}${body} +`; + +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( + `
+
Title
+ +
`, + ``, + ), + "assets/logo.svg": ``, + }); + expect(result.render).toEqual(result.preview); + }); + + it("keeps legacy end/layer timing semantically identical", async () => { + const result = await contracts({ + "index.html": + shell(`
+
Legacy timing
+
`), + }); + expect(result.render).toEqual(result.preview); + }); + + it("keeps flattened sub-composition identity and variable bootstrap identical", async () => { + const result = await contracts({ + "index.html": + shell(`
+
+
`), + "compositions/card.html": ` + `, + }); + expect(result.render).toEqual(result.preview); + }); +});