|
| 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 (& → & etc.), so check for the encoded form of the key names. |
| 171 | + expect(html).toContain("data-composition-variables="); |
| 172 | + expect(html).toContain(""headline""); |
| 173 | + expect(html).toContain(""accent""); |
| 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