mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
test(sdk): render-faithfulness test for serialize() bake contract (WS-F) (#1575)
* test(sdk): render-faithfulness test for serialize() bake contract (WS-F) Adds session.render-faithful.test.ts with 8 assertions covering the full op batch (setStyle + setText + setTiming + addGsapTween + moveElement). Confirms serialize() emits fully override-baked, render-ready HTML — this is the SDK-side guarantee that the backend render input needs no separate override-set field. Also asserts GSAP <script> edits and data-composition- variables survive serialization unchanged. Decision recorded: no SDK bake helper. session.serialize() IS the bake. Content-address → zip → S3 upload → pointer-swap is host/backend (WS-P/WS-R). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(sdk): close the two false-positive slots in the bake-contract assertions `toContain('data-end="5"')` in the setTiming case and `toContain('data-y="50"')` in the full-batch case both match a DIFFERENT element in the fixture — hf-title already ends at 5, hf-box already sits at y=50 — so either assertion would still pass if its write regressed to a no-op. Paired each with the disappearance of the target element's own pre-mutation value, which is the pattern the setText case already uses. * test(sdk): assert the canonical data-duration timing shape, not legacy data-end The false-positive slot was hiding a real behaviour change. `setTiming` now routes through the parsers' `writeClipTiming`, which canonicalizes timing onto `data-start` + `data-duration` and REMOVES the legacy `data-end`. The full-batch case asserted `data-end="4.5"` and failed once rebased onto main; the standalone case asserted `data-end="5"` and passed only because hf-title carries that exact value in the fixture — the very collision this pass set out to close. Both now assert the start/duration pair and the disappearance of the target's own legacy end. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
59a69a145b
commit
f11b60854a
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* WS-F — Render-faithfulness test.
|
||||||
|
*
|
||||||
|
* Contract: after a representative op batch (setStyle + setText + setTiming +
|
||||||
|
* addGsapTween + moveElement), session.serialize() emits fully override-baked,
|
||||||
|
* render-ready HTML containing ALL edits. This is the guarantee that
|
||||||
|
* HyperframesRenderActivityInput{ source_s3_key(baked HTML) + variables } can
|
||||||
|
* be satisfied without a separate override-set field.
|
||||||
|
*
|
||||||
|
* Also asserts:
|
||||||
|
* - The GSAP <script> is present in serialized output with new tweens applied.
|
||||||
|
* - data-composition-variables on <html> is preserved unchanged (variables live
|
||||||
|
* via the variables dict, not via re-bake, so the attribute must survive).
|
||||||
|
*
|
||||||
|
* Decision recorded: no SDK bake helper. session.serialize() IS the bake.
|
||||||
|
* The content-address → zip → S3 upload → pointer-swap flow is host/backend
|
||||||
|
* (WS-P / WS-R); this test pins only the SDK-side contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { openComposition } from "./session.js";
|
||||||
|
|
||||||
|
// ─── Fixture ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const VARIABLES = JSON.stringify([
|
||||||
|
{ id: "headline", type: "string", label: "Headline", default: "Hello" },
|
||||||
|
{ id: "accent", type: "color", label: "Accent color", default: "#ff0000" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const GSAP_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||||
|
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5, ease: "power2.out" }, 0.2);
|
||||||
|
window.__timelines = { t: tl };`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A full-document fixture (wrapped=false) so that serialize() emits the
|
||||||
|
* complete <!DOCTYPE html> shell, including the <html> attributes that carry
|
||||||
|
* data-composition-variables.
|
||||||
|
*/
|
||||||
|
const BASE_HTML = `<!DOCTYPE html>
|
||||||
|
<html data-composition-variables='${VARIABLES}'>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<div data-hf-id="hf-stage" data-hf-root data-width="1920" data-height="1080" data-duration="8">
|
||||||
|
<h1 data-hf-id="hf-title" data-start="0" data-end="5" data-track-index="0"
|
||||||
|
style="color: #fff; font-size: 64px; position: absolute">Hello World</h1>
|
||||||
|
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo"
|
||||||
|
data-x="100" data-y="200" data-start="0" data-end="8" />
|
||||||
|
<p data-hf-id="hf-body" data-start="1" data-end="6"
|
||||||
|
style="font-size: 24px">Body copy here</p>
|
||||||
|
<div data-hf-id="hf-box" style="opacity: 0; position: absolute"
|
||||||
|
data-x="50" data-y="50" data-start="0" data-end="8"></div>
|
||||||
|
<script>${GSAP_SCRIPT}</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractScript(html: string): string {
|
||||||
|
const m = /<script>([\s\S]*?)<\/script>/i.exec(html);
|
||||||
|
return m ? (m[1]?.trim() ?? "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── render-faithfulness ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("serialize() render-faithfulness (WS-F)", () => {
|
||||||
|
it("setStyle edit is present in serialized output", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
comp.setStyle("hf-title", { color: "#ff6600", fontSize: "80px" });
|
||||||
|
const html = comp.serialize();
|
||||||
|
expect(html).toContain("color: #ff6600");
|
||||||
|
expect(html).toContain("font-size: 80px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setText edit is present in serialized output", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
comp.setText("hf-title", "Baked Headline");
|
||||||
|
const html = comp.serialize();
|
||||||
|
expect(html).toContain("Baked Headline");
|
||||||
|
expect(html).not.toContain("Hello World");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTiming edit is present in serialized output", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
comp.setTiming("hf-body", { start: 2, duration: 3 });
|
||||||
|
const html = comp.serialize();
|
||||||
|
// `writeClipTiming` canonicalizes timing onto data-start + data-duration and
|
||||||
|
// drops the legacy data-end, so that pair IS the serialized form.
|
||||||
|
expect(html).toContain('data-start="2"');
|
||||||
|
expect(html).toContain('data-duration="3"');
|
||||||
|
// hf-body's own pre-mutation end has to be gone. Asserting the presence of
|
||||||
|
// `data-end="5"` instead would pass on a no-op write — hf-title carries
|
||||||
|
// that exact value in the fixture.
|
||||||
|
expect(html).not.toContain('data-end="6"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveElement edit is present in serialized output", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 500, y: 300 });
|
||||||
|
const html = comp.serialize();
|
||||||
|
expect(html).toContain('data-x="500"');
|
||||||
|
expect(html).toContain('data-y="300"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addGsapTween edit is present in the serialized <script>", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
const tweenId = comp.addGsapTween("hf-box", {
|
||||||
|
method: "to",
|
||||||
|
duration: 0.8,
|
||||||
|
position: 1,
|
||||||
|
properties: { x: 200, scale: 1.5 },
|
||||||
|
});
|
||||||
|
expect(tweenId).not.toBe("");
|
||||||
|
const html = comp.serialize();
|
||||||
|
const script = extractScript(html);
|
||||||
|
expect(script).toContain("x: 200");
|
||||||
|
expect(script).toContain("scale: 1.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full op batch: all five edits survive serialize() together", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
|
||||||
|
// Apply all five op types in a single session
|
||||||
|
comp.setStyle("hf-title", { color: "#cc00ff", letterSpacing: "2px" });
|
||||||
|
comp.setText("hf-title", "Render Ready");
|
||||||
|
comp.setTiming("hf-title", { start: 0.5, duration: 4 });
|
||||||
|
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 900, y: 50 });
|
||||||
|
const tweenId = comp.addGsapTween("hf-box", {
|
||||||
|
method: "from",
|
||||||
|
duration: 0.6,
|
||||||
|
position: 0.5,
|
||||||
|
properties: { opacity: 0, y: -40 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = comp.serialize();
|
||||||
|
|
||||||
|
// setStyle
|
||||||
|
expect(html).toContain("color: #cc00ff");
|
||||||
|
expect(html).toContain("letter-spacing: 2px");
|
||||||
|
|
||||||
|
// setText
|
||||||
|
expect(html).toContain("Render Ready");
|
||||||
|
|
||||||
|
// setTiming → data-start / data-duration, with hf-title's legacy data-end
|
||||||
|
// dropped by the canonicalization.
|
||||||
|
expect(html).toContain('data-start="0.5"');
|
||||||
|
expect(html).toContain('data-duration="4"');
|
||||||
|
expect(html).not.toContain('data-end="5"');
|
||||||
|
|
||||||
|
// moveElement. data-x="900" is unique to this edit; data-y="50" is not —
|
||||||
|
// hf-box carries it in the fixture — so pin the disappearance of hf-logo's
|
||||||
|
// own pre-move y as well.
|
||||||
|
expect(html).toContain('data-x="900"');
|
||||||
|
expect(html).toContain('data-y="50"');
|
||||||
|
expect(html).not.toContain('data-y="200"');
|
||||||
|
|
||||||
|
// addGsapTween — id is returned and script contains new tween
|
||||||
|
expect(tweenId).not.toBe("");
|
||||||
|
const script = extractScript(html);
|
||||||
|
expect(script).toContain("y: -40");
|
||||||
|
expect(script).toContain("opacity: 0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("data-composition-variables attribute is preserved in serialized output", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
// Apply an edit to force a real mutation
|
||||||
|
comp.setStyle("hf-title", { color: "#0000ff" });
|
||||||
|
const html = comp.serialize();
|
||||||
|
// The attribute must survive serialize(). linkedom entity-encodes JSON inside
|
||||||
|
// attribute values (& → & etc.), so check for the encoded form of the key names.
|
||||||
|
expect(html).toContain("data-composition-variables=");
|
||||||
|
expect(html).toContain(""headline"");
|
||||||
|
expect(html).toContain(""accent"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serialize → reopen preserves baked state (round-trip)", async () => {
|
||||||
|
const comp = await openComposition(BASE_HTML);
|
||||||
|
comp.setStyle("hf-title", { color: "#abcdef" });
|
||||||
|
comp.setText("hf-body", "Round-tripped body");
|
||||||
|
|
||||||
|
const baked = comp.serialize();
|
||||||
|
const comp2 = await openComposition(baked);
|
||||||
|
|
||||||
|
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#abcdef");
|
||||||
|
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped body");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user