|
| 1 | +// fallow-ignore-file duplication |
| 2 | +/** |
| 3 | + * T6b — acorn vs golden differential harness. |
| 4 | + * |
| 5 | + * Each corpus script runs through `parseGsapScriptAcorn` and must produce |
| 6 | + * output identical to the T6a golden files (captured from the recast/babel |
| 7 | + * baseline). Any mismatch = fidelity bug in the acorn port to fix before |
| 8 | + * recast is removed. |
| 9 | + * |
| 10 | + * Also includes the targeted preservation test (comments, custom JS, postamble) |
| 11 | + * and a coverage check against the fromTo / chained-call patterns. |
| 12 | + */ |
| 13 | +import { describe, expect, it } from "vitest"; |
| 14 | +import { join } from "node:path"; |
| 15 | +import { fileURLToPath } from "node:url"; |
| 16 | +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; |
| 17 | + |
| 18 | +const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__"); |
| 19 | +const g = (name: string) => join(__goldens__, name); |
| 20 | + |
| 21 | +// --------------------------------------------------------------------------- |
| 22 | +// Corpus scripts — identical to gsapParser.golden.test.ts so goldens are shared |
| 23 | +// --------------------------------------------------------------------------- |
| 24 | + |
| 25 | +const MINIMAL_SCRIPT = `\ |
| 26 | +var tl = gsap.timeline({ paused: true }); |
| 27 | +var notification = document.getElementById("notification"); |
| 28 | +gsap.set(notification, { x: 420, opacity: 0 }); |
| 29 | +tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2); |
| 30 | +tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2); |
| 31 | +window.__timelines["macos-notification"] = tl;`; |
| 32 | + |
| 33 | +const MODERATE_SCRIPT = `\ |
| 34 | +window.__timelines = window.__timelines || {}; |
| 35 | +var tl = gsap.timeline({ paused: true }); |
| 36 | +var card = document.getElementById("card"); |
| 37 | +var btn = document.getElementById("subscribe-btn"); |
| 38 | +var textSub = document.getElementById("btn-subscribe"); |
| 39 | +var textSubd = document.getElementById("btn-subscribed"); |
| 40 | +gsap.set(card, { y: 300, opacity: 0 }); |
| 41 | +tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1); |
| 42 | +tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0); |
| 43 | +tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15); |
| 44 | +tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15); |
| 45 | +tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18); |
| 46 | +tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8); |
| 47 | +window.__timelines["yt-lower-third"] = tl;`; |
| 48 | + |
| 49 | +const COMPLEX_SCRIPT = `\ |
| 50 | +window.__timelines = window.__timelines || {}; |
| 51 | +gsap.defaults({ force3D: true }); |
| 52 | +const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } }); |
| 53 | +tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05) |
| 54 | + .from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2) |
| 55 | + .from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08) |
| 56 | + .from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16); |
| 57 | +window.__timelines["vpn-youtube-spot"] = tl;`; |
| 58 | + |
| 59 | +const FROMTO_SCRIPT = `\ |
| 60 | +var tl = gsap.timeline({ paused: true }); |
| 61 | +var hero = document.getElementById("hero"); |
| 62 | +var caption = document.getElementById("caption"); |
| 63 | +tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1); |
| 64 | +tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5); |
| 65 | +window.__timelines["hero-reveal"] = tl;`; |
| 66 | + |
| 67 | +// --------------------------------------------------------------------------- |
| 68 | +// T6b differential: acorn output must match T6a golden files |
| 69 | +// --------------------------------------------------------------------------- |
| 70 | + |
| 71 | +describe("T6b — acorn vs recast golden differential", () => { |
| 72 | + it("minimal — matches golden (macos-notification)", async () => { |
| 73 | + const result = parseGsapScriptAcorn(MINIMAL_SCRIPT); |
| 74 | + await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("minimal.parsed.json")); |
| 75 | + }); |
| 76 | + |
| 77 | + it("moderate — matches golden (yt-lower-third)", async () => { |
| 78 | + const result = parseGsapScriptAcorn(MODERATE_SCRIPT); |
| 79 | + await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("moderate.parsed.json")); |
| 80 | + }); |
| 81 | + |
| 82 | + it("complex — matches golden (vpn-youtube-spot, chained .from() calls)", async () => { |
| 83 | + const result = parseGsapScriptAcorn(COMPLEX_SCRIPT); |
| 84 | + await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("complex.parsed.json")); |
| 85 | + }); |
| 86 | + |
| 87 | + it("fromTo — matches golden (hero-reveal, negative positions)", async () => { |
| 88 | + const result = parseGsapScriptAcorn(FROMTO_SCRIPT); |
| 89 | + await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("fromto.parsed.json")); |
| 90 | + }); |
| 91 | +}); |
| 92 | + |
| 93 | +// --------------------------------------------------------------------------- |
| 94 | +// T6b preservation test — the acorn claim: untouched code survives verbatim |
| 95 | +// --------------------------------------------------------------------------- |
| 96 | + |
| 97 | +describe("T6b — preservation (comments, custom JS, postamble)", () => { |
| 98 | + it("preserves preamble and postamble around tween calls", () => { |
| 99 | + const script = ` |
| 100 | +// author comment preserved |
| 101 | +const tl = gsap.timeline({ paused: true }); |
| 102 | +tl.to('#hero', { opacity: 1, duration: 0.5, ease: 'power2.out' }); |
| 103 | +window.__timelines['scene'] = tl; |
| 104 | +`.trim(); |
| 105 | + const result = parseGsapScriptAcorn(script); |
| 106 | + expect(result.preamble).toContain("// author comment preserved"); |
| 107 | + expect(result.preamble).toContain("gsap.timeline"); |
| 108 | + expect(result.postamble).toContain("window.__timelines"); |
| 109 | + expect(result.postamble).toContain("scene"); |
| 110 | + }); |
| 111 | + |
| 112 | + it("extracts correct animation from script with custom JS around tweens", () => { |
| 113 | + const script = ` |
| 114 | +var tl = gsap.timeline({ paused: true }); |
| 115 | +var el = document.querySelector('.box'); |
| 116 | +console.log('before tween'); |
| 117 | +tl.to(el, { x: 100, duration: 0.5 }, 0); |
| 118 | +console.log('after tween'); |
| 119 | +window.__timelines['custom'] = tl; |
| 120 | +`.trim(); |
| 121 | + const result = parseGsapScriptAcorn(script); |
| 122 | + expect(result.animations).toHaveLength(1); |
| 123 | + expect(result.animations[0]?.targetSelector).toBe(".box"); |
| 124 | + expect(result.animations[0]?.properties.x).toBe(100); |
| 125 | + expect(result.postamble).toContain("window.__timelines"); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +// --------------------------------------------------------------------------- |
| 130 | +// T6b structural coverage — patterns exercised by existing corpus |
| 131 | +// --------------------------------------------------------------------------- |
| 132 | + |
| 133 | +describe("T6b — structural coverage", () => { |
| 134 | + it("resolves getElementById targets", () => { |
| 135 | + const script = ` |
| 136 | +var tl = gsap.timeline({ paused: true }); |
| 137 | +var hero = document.getElementById("hero"); |
| 138 | +tl.to(hero, { opacity: 1, duration: 0.5 }, 0); |
| 139 | +window.__timelines['t'] = tl; |
| 140 | +`.trim(); |
| 141 | + const result = parseGsapScriptAcorn(script); |
| 142 | + expect(result.animations[0]?.targetSelector).toBe("#hero"); |
| 143 | + }); |
| 144 | + |
| 145 | + it("resolves querySelector targets", () => { |
| 146 | + const script = ` |
| 147 | +var tl = gsap.timeline({ paused: true }); |
| 148 | +var el = document.querySelector(".box"); |
| 149 | +tl.to(el, { x: 50, duration: 0.3 }, 0); |
| 150 | +window.__timelines['t'] = tl; |
| 151 | +`.trim(); |
| 152 | + const result = parseGsapScriptAcorn(script); |
| 153 | + expect(result.animations[0]?.targetSelector).toBe(".box"); |
| 154 | + }); |
| 155 | + |
| 156 | + it("handles stagger as __raw: extra", () => { |
| 157 | + const script = ` |
| 158 | +var tl = gsap.timeline({ paused: true }); |
| 159 | +tl.from(".item", { y: 20, opacity: 0, stagger: 0.1, duration: 0.4 }, 0); |
| 160 | +window.__timelines['t'] = tl; |
| 161 | +`.trim(); |
| 162 | + const result = parseGsapScriptAcorn(script); |
| 163 | + const anim = result.animations[0]; |
| 164 | + expect(anim?.extras?.stagger).toBe("__raw:0.1"); |
| 165 | + expect(anim?.properties).not.toHaveProperty("stagger"); |
| 166 | + }); |
| 167 | + |
| 168 | + it("handles stagger as __raw: when expressed as object", () => { |
| 169 | + const script = ` |
| 170 | +var tl = gsap.timeline({ paused: true }); |
| 171 | +tl.from(".item", { y: 20, stagger: { each: 0.1, from: "start" }, duration: 0.4 }, 0); |
| 172 | +window.__timelines['t'] = tl; |
| 173 | +`.trim(); |
| 174 | + const result = parseGsapScriptAcorn(script); |
| 175 | + const extras = result.animations[0]?.extras; |
| 176 | + const stagger = extras?.stagger; |
| 177 | + expect(typeof stagger).toBe("string"); |
| 178 | + expect(typeof stagger === "string" && stagger.startsWith("__raw:")).toBe(true); |
| 179 | + expect(stagger).toContain("each"); |
| 180 | + }); |
| 181 | + |
| 182 | + it("drops dropped keys (onComplete, onStart, onUpdate, onRepeat)", () => { |
| 183 | + const script = ` |
| 184 | +var tl = gsap.timeline({ paused: true }); |
| 185 | +tl.to(".box", { x: 100, duration: 0.5, onComplete: function() {}, onStart: function() {}, onUpdate: function() {}, onRepeat: function() {} }, 0); |
| 186 | +window.__timelines['t'] = tl; |
| 187 | +`.trim(); |
| 188 | + const result = parseGsapScriptAcorn(script); |
| 189 | + const anim = result.animations[0]; |
| 190 | + expect(anim?.properties).not.toHaveProperty("onComplete"); |
| 191 | + expect(anim?.properties).not.toHaveProperty("onStart"); |
| 192 | + expect(anim?.properties).not.toHaveProperty("onUpdate"); |
| 193 | + expect(anim?.properties).not.toHaveProperty("onRepeat"); |
| 194 | + expect(anim?.extras).toBeUndefined(); |
| 195 | + }); |
| 196 | + |
| 197 | + it("assigns stable IDs based on selector + method + position", () => { |
| 198 | + const script = ` |
| 199 | +var tl = gsap.timeline({ paused: true }); |
| 200 | +tl.to(".a", { x: 1, duration: 0.5 }, 0); |
| 201 | +tl.to(".a", { x: 2, duration: 0.5 }, 0); |
| 202 | +window.__timelines['t'] = tl; |
| 203 | +`.trim(); |
| 204 | + const result = parseGsapScriptAcorn(script); |
| 205 | + expect(result.animations[0]?.id).toBe(".a-to-0-position"); |
| 206 | + expect(result.animations[1]?.id).toBe(".a-to-0-position-2"); |
| 207 | + }); |
| 208 | + |
| 209 | + it("returns empty result on syntax error (graceful fail)", () => { |
| 210 | + const result = parseGsapScriptAcorn("this is not valid js {{{{"); |
| 211 | + expect(result.animations).toHaveLength(0); |
| 212 | + expect(result.timelineVar).toBe("tl"); |
| 213 | + }); |
| 214 | + |
| 215 | + it("detects multipleTimelines when script has >1 timeline", () => { |
| 216 | + const script = ` |
| 217 | +var tl1 = gsap.timeline({ paused: true }); |
| 218 | +var tl2 = gsap.timeline({ paused: true }); |
| 219 | +tl1.to(".a", { x: 1, duration: 0.5 }, 0); |
| 220 | +window.__timelines['t'] = tl1; |
| 221 | +`.trim(); |
| 222 | + const result = parseGsapScriptAcorn(script); |
| 223 | + expect(result.multipleTimelines).toBe(true); |
| 224 | + }); |
| 225 | +}); |
0 commit comments