Skip to content

Commit f11b608

Browse files
vanceingallsclaude
andauthored
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>
1 parent 59a69a1 commit f11b608

1 file changed

Lines changed: 187 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* WS-F — Render-faithfulness test.
3+
*
4+
* Contract: after a representative op batch (setStyle + setText + setTiming +
5+
* addGsapTween + moveElement), session.serialize() emits fully override-baked,
6+
* render-ready HTML containing ALL edits. This is the guarantee that
7+
* HyperframesRenderActivityInput{ source_s3_key(baked HTML) + variables } can
8+
* be satisfied without a separate override-set field.
9+
*
10+
* Also asserts:
11+
* - The GSAP <script> is present in serialized output with new tweens applied.
12+
* - data-composition-variables on <html> is preserved unchanged (variables live
13+
* via the variables dict, not via re-bake, so the attribute must survive).
14+
*
15+
* Decision recorded: no SDK bake helper. session.serialize() IS the bake.
16+
* The content-address → zip → S3 upload → pointer-swap flow is host/backend
17+
* (WS-P / WS-R); this test pins only the SDK-side contract.
18+
*/
19+
20+
import { describe, it, expect } from "vitest";
21+
import { openComposition } from "./session.js";
22+
23+
// ─── Fixture ──────────────────────────────────────────────────────────────────
24+
25+
const VARIABLES = JSON.stringify([
26+
{ id: "headline", type: "string", label: "Headline", default: "Hello" },
27+
{ id: "accent", type: "color", label: "Accent color", default: "#ff0000" },
28+
]);
29+
30+
const GSAP_SCRIPT = `var tl = gsap.timeline({ paused: true });
31+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5, ease: "power2.out" }, 0.2);
32+
window.__timelines = { t: tl };`;
33+
34+
/**
35+
* A full-document fixture (wrapped=false) so that serialize() emits the
36+
* complete <!DOCTYPE html> shell, including the <html> attributes that carry
37+
* data-composition-variables.
38+
*/
39+
const BASE_HTML = `<!DOCTYPE html>
40+
<html data-composition-variables='${VARIABLES}'>
41+
<head></head>
42+
<body>
43+
<div data-hf-id="hf-stage" data-hf-root data-width="1920" data-height="1080" data-duration="8">
44+
<h1 data-hf-id="hf-title" data-start="0" data-end="5" data-track-index="0"
45+
style="color: #fff; font-size: 64px; position: absolute">Hello World</h1>
46+
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo"
47+
data-x="100" data-y="200" data-start="0" data-end="8" />
48+
<p data-hf-id="hf-body" data-start="1" data-end="6"
49+
style="font-size: 24px">Body copy here</p>
50+
<div data-hf-id="hf-box" style="opacity: 0; position: absolute"
51+
data-x="50" data-y="50" data-start="0" data-end="8"></div>
52+
<script>${GSAP_SCRIPT}</script>
53+
</div>
54+
</body>
55+
</html>`;
56+
57+
// ─── helpers ──────────────────────────────────────────────────────────────────
58+
59+
function extractScript(html: string): string {
60+
const m = /<script>([\s\S]*?)<\/script>/i.exec(html);
61+
return m ? (m[1]?.trim() ?? "") : "";
62+
}
63+
64+
// ─── render-faithfulness ──────────────────────────────────────────────────────
65+
66+
describe("serialize() render-faithfulness (WS-F)", () => {
67+
it("setStyle edit is present in serialized output", async () => {
68+
const comp = await openComposition(BASE_HTML);
69+
comp.setStyle("hf-title", { color: "#ff6600", fontSize: "80px" });
70+
const html = comp.serialize();
71+
expect(html).toContain("color: #ff6600");
72+
expect(html).toContain("font-size: 80px");
73+
});
74+
75+
it("setText edit is present in serialized output", async () => {
76+
const comp = await openComposition(BASE_HTML);
77+
comp.setText("hf-title", "Baked Headline");
78+
const html = comp.serialize();
79+
expect(html).toContain("Baked Headline");
80+
expect(html).not.toContain("Hello World");
81+
});
82+
83+
it("setTiming edit is present in serialized output", async () => {
84+
const comp = await openComposition(BASE_HTML);
85+
comp.setTiming("hf-body", { start: 2, duration: 3 });
86+
const html = comp.serialize();
87+
// `writeClipTiming` canonicalizes timing onto data-start + data-duration and
88+
// drops the legacy data-end, so that pair IS the serialized form.
89+
expect(html).toContain('data-start="2"');
90+
expect(html).toContain('data-duration="3"');
91+
// hf-body's own pre-mutation end has to be gone. Asserting the presence of
92+
// `data-end="5"` instead would pass on a no-op write — hf-title carries
93+
// that exact value in the fixture.
94+
expect(html).not.toContain('data-end="6"');
95+
});
96+
97+
it("moveElement edit is present in serialized output", async () => {
98+
const comp = await openComposition(BASE_HTML);
99+
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 500, y: 300 });
100+
const html = comp.serialize();
101+
expect(html).toContain('data-x="500"');
102+
expect(html).toContain('data-y="300"');
103+
});
104+
105+
it("addGsapTween edit is present in the serialized <script>", async () => {
106+
const comp = await openComposition(BASE_HTML);
107+
const tweenId = comp.addGsapTween("hf-box", {
108+
method: "to",
109+
duration: 0.8,
110+
position: 1,
111+
properties: { x: 200, scale: 1.5 },
112+
});
113+
expect(tweenId).not.toBe("");
114+
const html = comp.serialize();
115+
const script = extractScript(html);
116+
expect(script).toContain("x: 200");
117+
expect(script).toContain("scale: 1.5");
118+
});
119+
120+
it("full op batch: all five edits survive serialize() together", async () => {
121+
const comp = await openComposition(BASE_HTML);
122+
123+
// Apply all five op types in a single session
124+
comp.setStyle("hf-title", { color: "#cc00ff", letterSpacing: "2px" });
125+
comp.setText("hf-title", "Render Ready");
126+
comp.setTiming("hf-title", { start: 0.5, duration: 4 });
127+
comp.dispatch({ type: "moveElement", target: "hf-logo", x: 900, y: 50 });
128+
const tweenId = comp.addGsapTween("hf-box", {
129+
method: "from",
130+
duration: 0.6,
131+
position: 0.5,
132+
properties: { opacity: 0, y: -40 },
133+
});
134+
135+
const html = comp.serialize();
136+
137+
// setStyle
138+
expect(html).toContain("color: #cc00ff");
139+
expect(html).toContain("letter-spacing: 2px");
140+
141+
// setText
142+
expect(html).toContain("Render Ready");
143+
144+
// setTiming → data-start / data-duration, with hf-title's legacy data-end
145+
// dropped by the canonicalization.
146+
expect(html).toContain('data-start="0.5"');
147+
expect(html).toContain('data-duration="4"');
148+
expect(html).not.toContain('data-end="5"');
149+
150+
// moveElement. data-x="900" is unique to this edit; data-y="50" is not —
151+
// hf-box carries it in the fixture — so pin the disappearance of hf-logo's
152+
// own pre-move y as well.
153+
expect(html).toContain('data-x="900"');
154+
expect(html).toContain('data-y="50"');
155+
expect(html).not.toContain('data-y="200"');
156+
157+
// addGsapTween — id is returned and script contains new tween
158+
expect(tweenId).not.toBe("");
159+
const script = extractScript(html);
160+
expect(script).toContain("y: -40");
161+
expect(script).toContain("opacity: 0");
162+
});
163+
164+
it("data-composition-variables attribute is preserved in serialized output", async () => {
165+
const comp = await openComposition(BASE_HTML);
166+
// Apply an edit to force a real mutation
167+
comp.setStyle("hf-title", { color: "#0000ff" });
168+
const html = comp.serialize();
169+
// The attribute must survive serialize(). linkedom entity-encodes JSON inside
170+
// attribute values (& → &amp; etc.), so check for the encoded form of the key names.
171+
expect(html).toContain("data-composition-variables=");
172+
expect(html).toContain("&quot;headline&quot;");
173+
expect(html).toContain("&quot;accent&quot;");
174+
});
175+
176+
it("serialize → reopen preserves baked state (round-trip)", async () => {
177+
const comp = await openComposition(BASE_HTML);
178+
comp.setStyle("hf-title", { color: "#abcdef" });
179+
comp.setText("hf-body", "Round-tripped body");
180+
181+
const baked = comp.serialize();
182+
const comp2 = await openComposition(baked);
183+
184+
expect(comp2.getElement("hf-title")?.inlineStyles.color).toBe("#abcdef");
185+
expect(comp2.getElement("hf-body")?.text).toContain("Round-tripped body");
186+
});
187+
});

0 commit comments

Comments
 (0)