diff --git a/packages/core/src/parsers/gsapParserAcorn.full.test.ts b/packages/core/src/parsers/gsapParserAcorn.full.test.ts new file mode 100644 index 000000000..6001c794f --- /dev/null +++ b/packages/core/src/parsers/gsapParserAcorn.full.test.ts @@ -0,0 +1,914 @@ +// fallow-ignore-file duplication +/** + * T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios + * against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live + * in gsapWriter.acorn.test.ts. + */ +import { describe, it, expect } from "vitest"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; +import { serializeGsapAnimations } from "./gsapParser.js"; +import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js"; + +const parseGsapScript = parseGsapScriptAcorn; + +// ── Local helpers ───────────────────────────────────────────────────────────── + +function parseAndSerialize(script: string) { + const parsed = parseGsapScript(script); + const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); + return { parsed, serialized }; +} + +function parseSingleAnimation(script: string): GsapAnimation { + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + return result.animations[0]!; +} + +function expectKeyframe( + kf: GsapPercentageKeyframe, + percentage: number, + properties: Record, + ease?: string, +): void { + expect(kf.percentage).toBe(percentage); + for (const [key, value] of Object.entries(properties)) { + expect(kf.properties[key]).toBe(value); + } + if (ease !== undefined) expect(kf.ease).toBe(ease); +} + +function expectKeyframesFormat( + anim: GsapAnimation, + format: string, + count: number, +): GsapPercentageKeyframe[] { + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe(format); + expect(anim.keyframes!.keyframes).toHaveLength(count); + return anim.keyframes!.keyframes; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const REAL_WORLD_SCRIPT = `(function () { + window.__timelines = window.__timelines || {}; + const tl = gsap.timeline({ paused: true }); + const root = document.querySelector('#cold-open'); + const kicker = root.querySelector(".co-kicker"); + const glyph = root.querySelector(".co-new"); + const items = root.querySelectorAll(".co-item"); + + gsap.set(kicker, { y: 16, opacity: 0 }); + tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3); + + gsap.set(glyph, { rotationX: 90, opacity: 0 }); + tl.to(glyph, { rotationX: 0, opacity: 1, duration: 0.5, ease: "power3.inOut" }, 2.06); + + tl.to(items, { opacity: 1, duration: 0.4, stagger: 0.1 }, 1.0); + + window.__timelines["cold-open"] = tl; +})();`; + +// ── parseGsapScript ─────────────────────────────────────────────────────────── + +describe("parseGsapScript", () => { + it("parses a basic timeline with .to()", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.timelineVar).toBe("tl"); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].method).toBe("to"); + expect(result.animations[0].targetSelector).toBe("#el1"); + expect(result.animations[0].properties.opacity).toBe(1); + expect(result.animations[0].duration).toBe(0.5); + expect(result.animations[0].position).toBe(0); + }); + + it("parses a timeline with .from()", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.from("#el2", { x: 100, duration: 1 }, 0.5); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].method).toBe("from"); + expect(result.animations[0].targetSelector).toBe("#el2"); + expect(result.animations[0].properties.x).toBe(100); + expect(result.animations[0].duration).toBe(1); + expect(result.animations[0].position).toBe(0.5); + }); + + it("parses a timeline with .set()", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el3", { opacity: 0, x: 50 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].method).toBe("set"); + expect(result.animations[0].targetSelector).toBe("#el3"); + expect(result.animations[0].properties.opacity).toBe(0); + expect(result.animations[0].properties.x).toBe(50); + expect(result.animations[0].duration).toBeUndefined(); + }); + + it("parses a timeline with .fromTo() and position offset", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.fromTo("#el4", { opacity: 0, x: 100 }, { opacity: 1, x: 200, duration: 1 }, 2); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + const anim = result.animations[0]; + expect(anim.method).toBe("fromTo"); + expect(anim.targetSelector).toBe("#el4"); + expect(anim.fromProperties).toBeDefined(); + expect(anim.fromProperties?.opacity).toBe(0); + expect(anim.fromProperties?.x).toBe(100); + expect(anim.properties.opacity).toBe(1); + expect(anim.properties.x).toBe(200); + expect(anim.duration).toBe(1); + expect(anim.position).toBe(2); + }); + + it("parses negative numbers in property values", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.fromTo("#el5", { opacity: 0, x: -100 }, { opacity: 1, x: 0, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + const anim = result.animations[0]; + expect(anim.fromProperties).toBeDefined(); + expect(anim.fromProperties?.opacity).toBe(0); + expect(anim.fromProperties?.x).toBe(-100); + }); + + it("handles an empty script", () => { + const result = parseGsapScript(""); + + expect(result.animations).toHaveLength(0); + expect(result.timelineVar).toBe("tl"); + expect(result.preamble).toBe("const tl = gsap.timeline({ paused: true });"); + expect(result.postamble).toBe(""); + }); + + it("extracts preamble correctly", () => { + const script = ` + const myTl = gsap.timeline({ paused: true }); + myTl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.timelineVar).toBe("myTl"); + expect(result.preamble).toContain("const myTl = gsap.timeline"); + }); + + it("extracts postamble correctly", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + console.log("done"); + `; + const result = parseGsapScript(script); + + expect(result.postamble).toContain('console.log("done");'); + }); + + it("parses multiple animations", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el1", { opacity: 0 }, 0); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 1); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + expect(result.animations[0].method).toBe("set"); + expect(result.animations[1].method).toBe("to"); + expect(result.animations[2].method).toBe("to"); + }); + + it("extracts all GSAP properties including non-standard ones", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, backgroundColor: "red", x: 50, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].properties.opacity).toBe(1); + expect(result.animations[0].properties.x).toBe(50); + expect(result.animations[0].properties.backgroundColor).toBe("red"); + }); + + it("extracts ease from properties", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 1, ease: "power2.out" }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("power2.out"); + }); + + it("uses 'let' or 'var' for timeline declaration", () => { + const script = ` + let timeline = gsap.timeline({ paused: true }); + timeline.to("#el1", { opacity: 1, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.timelineVar).toBe("timeline"); + expect(result.animations).toHaveLength(1); + }); + + it("preserves string position values like '+=1' and '<'", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + expect(result.animations[0].position).toBe("+=1"); + expect(result.animations[1].position).toBe("<"); + expect(result.animations[2].position).toBe("-=0.5"); + }); + + it("resolves variable references from const declarations in the same script", () => { + const script = ` + const FADE = 0.8; + const OFFSET = -60; + const MY_EASE = "power3.out"; + const tl = gsap.timeline({ paused: true }); + tl.from("#el1", { y: OFFSET, opacity: 0, duration: FADE, ease: MY_EASE }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].properties.y).toBe(-60); + expect(result.animations[0].properties.opacity).toBe(0); + expect(result.animations[0].duration).toBe(0.8); + expect(result.animations[0].ease).toBe("power3.out"); + }); + + it("resolves computed expressions from scope bindings", () => { + const script = ` + const BASE = 100; + const HALF = BASE / 2; + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: HALF, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].properties.x).toBe(50); + }); + + it("preserves unresolvable references as __raw: prefixed strings", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: someUndefinedVar, x: 50, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].properties.x).toBe(50); + expect(result.animations[0].properties.opacity).toBe("__raw:someUndefinedVar"); + }); + + it("generates stable content-based IDs", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 1); + `; + const result1 = parseGsapScript(script); + const result2 = parseGsapScript(script); + + expect(result1.animations[0].id).toBe(result2.animations[0].id); + expect(result1.animations[1].id).toBe(result2.animations[1].id); + + expect(result1.animations[0].id).toBe("#el1-to-0-visual"); + expect(result1.animations[1].id).toBe("#el2-to-1000-position"); + }); + + it("disambiguates colliding IDs with a suffix", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 0, duration: 0.3 }, 0); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].id).toBe("#el1-to-0-visual"); + expect(result.animations[1].id).toBe("#el1-to-0-visual-2"); + }); + + it("uses string position in ID for relative positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].id).toBe("#el1-to-+=1-visual"); + }); +}); + +// ── resolvedStart ───────────────────────────────────────────────────────────── + +describe("resolvedStart — timeline position resolution", () => { + it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); + tl.from("#headline", { duration: 0.6, scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { duration: 0.5, scale: 0.92, transformOrigin: "left center" }, "-=0.3") + .from("#box", { duration: 0.5, scale: 0.5, transformOrigin: "center center" }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + expect(result.animations[0].targetSelector).toBe("#headline"); + expect(result.animations[1].targetSelector).toBe("#subtext"); + expect(result.animations[2].targetSelector).toBe("#box"); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + expect(result.animations[1].resolvedStart).toBe(0.3); + + expect(result.animations[2].resolvedStart).toBe(0.5); + }); + + it("resolves += and < positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(1); + expect(result.animations[1].resolvedStart).toBe(1); + expect(result.animations[2].resolvedStart).toBe(1.5); + }); + + it("resolves numeric positions directly", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 2); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(2); + }); + + it("resolves implicit sequential positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }) + .to("#el2", { x: 100, duration: 1 }) + .to("#el3", { y: 50, duration: 0.3 }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + expect(result.animations[1].resolvedStart).toBe(0.5); + expect(result.animations[1].implicitPosition).toBe(true); + + expect(result.animations[2].resolvedStart).toBe(1.5); + expect(result.animations[2].implicitPosition).toBe(true); + }); + + it("clamps negative resolvedStart to 0", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.2 }); + tl.to("#el2", { x: 100, duration: 1 }, "-=5"); + `; + const result = parseGsapScript(script); + + expect(result.animations[1].resolvedStart).toBe(0); + }); + + it("uses GSAP default duration (0.5) for tweens with no explicit duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1 }) + .to("#el2", { x: 100 }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(0.5); + }); + + it("treats set() as zero-duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el1", { opacity: 0 }); + tl.to("#el2", { opacity: 1, duration: 1 }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(0); + }); +}); + +// ── timeline defaults ───────────────────────────────────────────────────────── + +describe("timeline defaults inheritance", () => { + it("inherits ease and duration from timeline defaults onto tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.from("#headline", { scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { scale: 0.92 }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("power3.out"); + expect(result.animations[0].duration).toBe(0.6); + expect(result.animations[1].ease).toBe("power3.out"); + expect(result.animations[1].duration).toBe(0.6); + }); + + it("does not override explicit ease/duration on individual tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.to("#el1", { opacity: 1, duration: 1, ease: "none" }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("none"); + expect(result.animations[0].duration).toBe(1); + }); + + it("uses inherited duration for position resolution", () => { + const script = ` + const tl = gsap.timeline({ defaults: { duration: 0.8 } }); + tl.from("#a", { scale: 0.5 }) + .from("#b", { scale: 0.5 }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(0.8); + }); +}); + +// ── property group classification ───────────────────────────────────────────── + +describe("property group classification", () => { + it("classifies individual properties into groups", () => { + expect(classifyPropertyGroup("x")).toBe("position"); + expect(classifyPropertyGroup("y")).toBe("position"); + expect(classifyPropertyGroup("xPercent")).toBe("position"); + expect(classifyPropertyGroup("scale")).toBe("scale"); + expect(classifyPropertyGroup("scaleX")).toBe("scale"); + expect(classifyPropertyGroup("width")).toBe("size"); + expect(classifyPropertyGroup("height")).toBe("size"); + expect(classifyPropertyGroup("rotation")).toBe("rotation"); + expect(classifyPropertyGroup("skewX")).toBe("rotation"); + expect(classifyPropertyGroup("opacity")).toBe("visual"); + expect(classifyPropertyGroup("autoAlpha")).toBe("visual"); + expect(classifyPropertyGroup("borderRadius")).toBe("other"); + expect(classifyPropertyGroup("fontSize")).toBe("other"); + }); + + it("classifies a pure position tween", () => { + expect(classifyTweenPropertyGroup({ x: 100, y: 50 })).toBe("position"); + }); + + it("classifies a pure scale tween", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5 })).toBe("scale"); + }); + + it("classifies scale + transformOrigin as scale (transformOrigin follows group)", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5, transformOrigin: "center center" })).toBe( + "scale", + ); + }); + + it("returns undefined for mixed-group tweens", () => { + expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); + expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); + }); + + it("classifies tweens during parsing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#a", { x: 100, y: 50, duration: 1 }, 0); + tl.to("#b", { scale: 0.5, duration: 0.5 }, 0); + tl.to("#c", { x: 100, scale: 0.5, opacity: 0, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].propertyGroup).toBe("position"); + expect(result.animations[1].propertyGroup).toBe("scale"); + expect(result.animations[2].propertyGroup).toBeUndefined(); + }); +}); + +// ── stagger / yoyo / repeat ─────────────────────────────────────────────────── + +describe("stagger/yoyo/repeat round-trip", () => { + it("preserves stagger as extras on parse", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(1); + expect(result.animations[0].extras).toBeDefined(); + expect(result.animations[0].extras!.stagger).toBe("__raw:0.1"); + expect(result.animations[0].properties.opacity).toBe(1); + expect(result.animations[0].properties).not.toHaveProperty("stagger"); + }); + + it("preserves complex stagger object on round-trip", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0); + `; + const { serialized } = parseAndSerialize(script); + + expect(serialized).toContain("stagger: {"); + expect(serialized).toContain("each: 0.15"); + expect(serialized).toContain('from: "start"'); + }); + + it("preserves yoyo and repeat on round-trip", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0); + `; + const { serialized } = parseAndSerialize(script); + + expect(serialized).toContain("yoyo: true"); + expect(serialized).toContain("repeat: 3"); + expect(serialized).toContain("repeatDelay: 0.2"); + }); + + it.skip("survives a full parse-edit-serialize round-trip with stagger intact (write-path)", () => { + // Requires updateAnimationInScript — tested in gsapWriter.acorn.test.ts + }); +}); + +// ── unresolvable value round-trip ───────────────────────────────────────────── + +describe("unresolvable value round-trip", () => { + it("preserves unresolvable property values through serialize", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0); + `; + const { serialized } = parseAndSerialize(script); + + expect(serialized).toContain("opacity: someFn()"); + expect(serialized).toContain("x: 50"); + }); + + it("preserves complex unresolvable expressions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { x: getOffset() + 10, y: 200, duration: 1 }, 0); + `; + const parsed = parseGsapScript(script); + + expect(parsed.animations[0].properties.y).toBe(200); + expect(String(parsed.animations[0].properties.x)).toMatch(/^__raw:/); + }); +}); + +// ── variable-target resolution ──────────────────────────────────────────────── + +describe("variable-target resolution (querySelector pattern)", () => { + it("resolves a const element variable to its selector", () => { + const script = ` + const root = document.querySelector('#scene'); + const kicker = root.querySelector(".co-kicker"); + const tl = gsap.timeline({ paused: true }); + tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].targetSelector).toBe(".co-kicker"); + expect(result.animations[0].properties.opacity).toBe(1); + expect(result.animations[0].duration).toBe(0.45); + expect(result.animations[0].ease).toBe("expo.out"); + }); + + it("resolves document.querySelector and querySelectorAll targets", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const title = document.querySelector("#title"); + const items = document.querySelectorAll(".item"); + tl.to(title, { opacity: 1, duration: 0.5 }, 0); + tl.to(items, { y: 0, duration: 0.5, stagger: 0.1 }, 0.5); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(2); + expect(result.animations[0].targetSelector).toBe("#title"); + expect(result.animations[1].targetSelector).toBe(".item"); + }); + + it("resolves getElementById targets to an id selector", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const el = document.getElementById("hero"); + tl.to(el, { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].targetSelector).toBe("#hero"); + }); + + it("resolves an inline querySelector call passed directly as the target", () => { + const script = ` + const root = document.querySelector('#scene'); + const tl = gsap.timeline({ paused: true }); + tl.to(root.querySelector(".inline"), { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].targetSelector).toBe(".inline"); + }); + + it("parses mixed string-literal and variable targets in one timeline", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const kicker = document.querySelector(".kicker"); + tl.to(".literal", { opacity: 1, duration: 0.5 }, 0); + tl.to(kicker, { y: 0, duration: 0.5 }, 0.5); + `; + const result = parseGsapScript(script); + expect(result.animations.map((a) => a.targetSelector)).toEqual([".literal", ".kicker"]); + }); + + it("parses every tween in a real-world IIFE composition with interleaved gsap.set", () => { + const result = parseGsapScript(REAL_WORLD_SCRIPT); + expect(result.animations.map((a) => a.targetSelector)).toEqual([ + ".co-kicker", + ".co-new", + ".co-item", + ]); + expect(result.animations[2].extras?.stagger).toBe("__raw:0.1"); + }); + + it("marks unresolvable variable targets with __unresolved__ and hasUnresolvedSelector", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0); + tl.to(".real", { opacity: 1, duration: 0.5 }, 1); + `; + const result = parseGsapScript(script); + expect(result.animations.map((a) => a.targetSelector)).toEqual(["__unresolved__", ".real"]); + expect(result.animations[0].hasUnresolvedSelector).toBe(true); + expect(result.animations[1].hasUnresolvedSelector).toBeUndefined(); + }); +}); + +// ── array targets ───────────────────────────────────────────────────────────── + +describe("array targets", () => { + it("resolves an array of element variables to a CSS group selector", () => { + const script = ` + const root = document.querySelector('#s'); + const face = root.querySelector(".clock-face"); + const hand = root.querySelector(".clock-hand"); + const tl = gsap.timeline({ paused: true }); + tl.to([face, hand], { opacity: 1, duration: 0.5 }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].targetSelector).toBe(".clock-face, .clock-hand"); + }); + + it.skip("does not rewrite the array argument when editing the tween (write-path)", () => { + // Requires updateAnimationInScript — tested in gsapWriter.acorn.test.ts + }); +}); + +// ── chained tween calls ─────────────────────────────────────────────────────── + +describe("chained tween calls", () => { + const CHAIN = ` + const tl = gsap.timeline({ paused: true }); + const flash = document.querySelector(".flash"); + tl.to(flash, { opacity: 0.5, duration: 0.16 }, 2.06) + .to(flash, { opacity: 0, duration: 0.5 }, 2.22); + `; + + it("captures every link of a chained call", () => { + const result = parseGsapScript(CHAIN); + expect(result.animations).toHaveLength(2); + expect(result.animations.every((a) => a.targetSelector === ".flash")).toBe(true); + expect(result.animations.map((a) => a.position).sort()).toEqual([2.06, 2.22]); + }); + + it.skip("edits one link of a chain in place, leaving the other intact (write-path)", () => { + // Requires updateAnimationInScript — tested in gsapWriter.acorn.test.ts + }); + + it.skip("deletes one link of a chain, keeping the other (write-path)", () => { + // Requires removeAnimationFromScript — tested in gsapWriter.acorn.test.ts + }); +}); + +// ── gsap.utils.toArray targets ──────────────────────────────────────────────── + +describe("gsap.utils.toArray targets", () => { + it("resolves an inline toArray selector", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to(gsap.utils.toArray(".item"), { opacity: 1, duration: 0.5, stagger: 0.1 }, 0); + `; + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); + }); + + it("resolves a toArray result stored in a variable", () => { + const script = ` + const items = gsap.utils.toArray(".item"); + const tl = gsap.timeline({ paused: true }); + tl.to(items, { opacity: 1, duration: 0.5 }, 0); + `; + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); + }); +}); + +// ── lexical scoping ─────────────────────────────────────────────────────────── + +describe("lexical scoping of element bindings", () => { + it("resolves the same variable name to different selectors per IIFE scope", () => { + const script = ` + (function () { + const tl = gsap.timeline({ paused: true }); + const kicker = document.querySelector(".scene-a-kicker"); + tl.to(kicker, { opacity: 1, duration: 0.5 }, 0); + })(); + (function () { + const tl = gsap.timeline({ paused: true }); + const kicker = document.querySelector(".scene-b-kicker"); + tl.to(kicker, { opacity: 1, duration: 0.5 }, 0); + })(); + `; + const result = parseGsapScript(script); + const selectors = result.animations.map((a) => a.targetSelector); + expect(selectors).toContain(".scene-a-kicker"); + expect(selectors).toContain(".scene-b-kicker"); + }); +}); + +// ── forEach / map callback targets ──────────────────────────────────────────── + +describe("forEach / map callback targets", () => { + it("resolves a forEach callback param to the collection's selector", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const items = document.querySelectorAll(".item"); + items.forEach((el) => { + tl.to(el, { opacity: 1, duration: 0.4 }, 0); + }); + `; + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); + }); + + it("resolves an inline querySelectorAll().forEach callback param", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + document.querySelectorAll(".dot").forEach((dot) => { + tl.to(dot, { scale: 1, duration: 0.3 }, 0); + }); + `; + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".dot"); + }); +}); + +// ── native GSAP keyframes parsing ───────────────────────────────────────────── + +describe("native GSAP keyframes parsing", () => { + it("parses percentage keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0, opacity: 1 }, "50%": { x: 100, ease: "power2.out" }, "100%": { x: 200 } }, + duration: 5 + }, 0); + `; + const anim = parseSingleAnimation(script); + const kfs = expectKeyframesFormat(anim, "percentage", 3); + + expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); + expectKeyframe(kfs[1], 50, { x: 100 }, "power2.out"); + expectKeyframe(kfs[2], 100, { x: 200 }); + }); + + it("parses object array keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: [ + { x: 0, opacity: 1, duration: 0.5 }, + { x: 100, duration: 1, ease: "power2.out" }, + { x: 200, duration: 0.8 } + ] + }, 0); + `; + const anim = parseSingleAnimation(script); + const kfs = expectKeyframesFormat(anim, "object-array", 3); + + expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 }); + expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out"); + expectKeyframe(kfs[2], 100, { x: 200 }); + }); + + it("parses simple array keyframes format", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { x: [0, 100, 200, 0], opacity: [0, 1, 1, 0], easeEach: "power2.inOut" }, + duration: 5 + }, 0); + `; + const anim = parseSingleAnimation(script); + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe("simple-array"); + expect(anim.keyframes!.easeEach).toBe("power2.inOut"); + expect(anim.keyframes!.keyframes).toHaveLength(4); + + expectKeyframe(anim.keyframes!.keyframes[0], 0, { x: 0, opacity: 0 }); + expectKeyframe(anim.keyframes!.keyframes[1], 33, { x: 100, opacity: 1 }); + expectKeyframe(anim.keyframes!.keyframes[2], 67, { x: 200, opacity: 1 }); + expectKeyframe(anim.keyframes!.keyframes[3], 100, { x: 0, opacity: 0 }); + }); + + it("parses three-level easing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "50%": { x: 100, ease: "back.out(1.7)" }, "100%": { x: 200 } }, + ease: "none", + easeEach: "power2.out", + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + const anim = result.animations[0]; + + expect(anim.ease).toBe("none"); + expect(anim.keyframes!.easeEach).toBe("power2.out"); + expect(anim.keyframes!.keyframes[1].ease).toBe("back.out(1.7)"); + }); + + it("flat tween without keyframes still works", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el", { x: 100, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + expect(result.animations[0].keyframes).toBeUndefined(); + expect(result.animations[0].properties.x).toBe(100); + }); + + it("keyframes tween has empty top-level properties", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0 }, "100%": { x: 200 } }, + duration: 5 + }, 0); + `; + const result = parseGsapScript(script); + const anim = result.animations[0]; + expect(anim.keyframes).toBeDefined(); + expect(Object.keys(anim.properties)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 954d6b6e4..a7e08417f 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -629,13 +629,13 @@ function parseObjectArrayKeyframes( if (totalDuration > 0) { let cumulative = 0; for (const entry of raw) { + cumulative += entry.duration ?? 0; const percentage = Math.round((cumulative / totalDuration) * 100); keyframes.push({ percentage, properties: entry.properties, ...(entry.ease ? { ease: entry.ease } : {}), }); - cumulative += entry.duration ?? 0; } } else { for (let i = 0; i < raw.length; i++) {