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:
Vance Ingalls
2026-08-22 16:42:40 -07:00
committed by GitHub
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 (& → &amp; etc.), so check for the encoded form of the key names.
expect(html).toContain("data-composition-variables=");
expect(html).toContain("&quot;headline&quot;");
expect(html).toContain("&quot;accent&quot;");
});
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");
});
});