import { describe, it, expect } from "vitest"; import { parseGsapScript, serializeGsapAnimations, updateAnimationInScript, addAnimationToScript, removeAnimationFromScript, } from "./gsapParser.js"; import type { ParsedGsap } from "./gsapParser.js"; import { parseAndSerialize, parseSingleAnimation, expectStaggerRaw, expectRawWithResolvable, expectSingleAnimPosition, } from "./gsapParser.test-helpers.js"; // ── Helpers ──────────────────────────────────────────────────────────────── /** Assert a parse completed without crashing and returned the safe-default shape. */ function expectSafeDefault(result: ParsedGsap) { expect(result).toBeDefined(); expect(Array.isArray(result.animations)).toBe(true); expect(typeof result.timelineVar).toBe("string"); } /** Parse, serialize, re-parse, and assert structural equality of the animation IR. */ function assertRoundTrip(script: string) { const parsed1 = parseGsapScript(script); expect(parsed1.animations.length).toBeGreaterThan(0); const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, { preamble: parsed1.preamble, postamble: parsed1.postamble, }); const parsed2 = parseGsapScript(serialized); expect(parsed2.animations.length).toBe(parsed1.animations.length); for (let i = 0; i < parsed1.animations.length; i++) { const a = parsed1.animations[i]; const b = parsed2.animations[i]; expect(b.targetSelector).toBe(a.targetSelector); expect(b.method).toBe(a.method); expect(b.position).toEqual(a.position); expect(b.duration).toEqual(a.duration); expect(b.ease).toEqual(a.ease); // Properties: numeric values must match; __raw values may re-serialize differently for (const [key, val] of Object.entries(a.properties)) { if (typeof val === "number") { expect(b.properties[key]).toBe(val); } else if (typeof val === "string" && val.startsWith("__raw:")) { // Raw values survive in some form — just confirm the key exists expect(b.properties).toHaveProperty(key); } } // Extras survive if (a.extras) { expect(b.extras).toBeDefined(); for (const key of Object.keys(a.extras)) { expect(b.extras).toHaveProperty(key); } } } } // ── 1. Malformed Scripts ─────────────────────────────────────────────────── describe("1. Malformed scripts", () => { const cases = [ { name: "unclosed brace", script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1", }, { name: "unclosed parenthesis", script: "const tl = gsap.timeline({ paused: true }); tl.to('#a', { x: 1 }, 0", }, { name: "random garbage", script: "@@@ not javascript at all ~~~" }, { name: "partial assignment", script: "const tl =" }, { name: "missing semicolons everywhere", script: "const tl = gsap.timeline({ paused: true })\ntl.to('#a', { x: 1 }, 0)\ntl.to('#b', { y: 2 }, 1)", }, { name: "double commas", script: 'const tl = gsap.timeline({ paused: true }); tl.to("#a",, { x: 1 }, 0);', }, { name: "HTML mixed in", script: "
hello
\nconst tl = gsap.timeline();" }, { name: "only opening brace", script: "{" }, { name: "only closing brace", script: "}" }, { name: "null byte", script: "const tl = gsap.timeline();\x00 tl.to('#a', { x: 1 }, 0);" }, ]; for (const { name, script } of cases) { it(`does not crash on: ${name}`, () => { const result = parseGsapScript(script); expectSafeDefault(result); }); it(`mutation functions are safe on: ${name}`, () => { // Some malformed scripts might parse as valid but empty — mutation safety // still applies (either noop or a valid transform) expect(() => updateAnimationInScript(script, "id", { duration: 1 })).not.toThrow(); expect(() => addAnimationToScript(script, { targetSelector: "#el", method: "to", position: 0, properties: { opacity: 1 }, duration: 1, }), ).not.toThrow(); expect(() => removeAnimationFromScript(script, "id")).not.toThrow(); }); } it("missing semicolons still parse tweens (ASI)", () => { const script = ` const tl = gsap.timeline({ paused: true }) tl.to("#a", { x: 100, duration: 0.5 }, 0) tl.to("#b", { y: 200, duration: 1 }, 1) `; const result = parseGsapScript(script); // Babel handles ASI — these should parse fine expect(result.animations.length).toBe(2); }); }); // ── 2. Empty / Minimal Scripts ───────────────────────────────────────────── describe("2. Empty / minimal scripts", () => { it("empty string", () => { const result = parseGsapScript(""); expectSafeDefault(result); expect(result.animations).toHaveLength(0); }); it("whitespace only", () => { const result = parseGsapScript(" \n\t\n "); expectSafeDefault(result); expect(result.animations).toHaveLength(0); }); it("window.__timelines = {} with no tweens", () => { const script = "window.__timelines = {};"; const result = parseGsapScript(script); expectSafeDefault(result); expect(result.animations).toHaveLength(0); }); it("timeline declaration with no tween calls", () => { const script = "const tl = gsap.timeline({ paused: true });"; const result = parseGsapScript(script); expect(result.timelineVar).toBe("tl"); expect(result.animations).toHaveLength(0); }); it("only comments", () => { const script = "// this is a comment\n/* block comment */"; const result = parseGsapScript(script); expectSafeDefault(result); expect(result.animations).toHaveLength(0); }); it("tween with only selector and empty vars object", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", {}, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(Object.keys(result.animations[0].properties)).toHaveLength(0); expect(result.animations[0].duration).toBeUndefined(); }); }); // ── 3. Extreme Values ────────────────────────────────────────────────────── describe("3. Extreme values", () => { it("very large numbers", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 1e10, y: 99999999, duration: 1000000 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].properties.x).toBe(1e10); expect(result.animations[0].properties.y).toBe(99999999); expect(result.animations[0].duration).toBe(1000000); }); it("very small numbers", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 0.001, duration: 0.0001 }, 0.00001); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].properties.opacity).toBe(0.001); expect(result.animations[0].duration).toBe(0.0001); expect(result.animations[0].position).toBeCloseTo(0.00001); }); it("negative position", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1 }, -5); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].position).toBe(-5); }); it("zero duration", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 0 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].duration).toBe(0); }); it("NaN-producing division by zero is handled", () => { const script = ` const ZERO = 0; const BAD = 100 / ZERO; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: BAD, y: 50, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); // Division by zero returns undefined from resolveNode, so BAD is unresolvable // x should be __raw: or undefined, y should be 50 expect(result.animations[0].properties.y).toBe(50); // BAD was never bound (division by zero returns undefined), so the reference is raw const xVal = result.animations[0].properties.x; expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); }); it("Infinity literal", () => { expectRawWithResolvable( ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0); `, "x", "y", 50, ); }); }); // ── 4. Unicode in Selectors ──────────────────────────────────────────────── describe("4. Unicode in selectors", () => { it("Japanese characters in selector", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#日本語", { x: 100, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].targetSelector).toBe("#日本語"); }); it("emoji in selector", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#rocket-🚀", { opacity: 1, duration: 0.5 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].targetSelector).toBe("#rocket-🚀"); }); it("Arabic and Cyrillic selectors", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#عربي", { x: 50, duration: 1 }, 0); tl.to("#кириллица", { y: 100, duration: 1 }, 1); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(2); expect(result.animations[0].targetSelector).toBe("#عربي"); expect(result.animations[1].targetSelector).toBe("#кириллица"); }); it("class selector with unicode", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(".コンポーネント", { scale: 2, duration: 0.5 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].targetSelector).toBe(".コンポーネント"); }); }); // ── 5. Deeply Nested Objects ─────────────────────────────────────────────── describe("5. Deeply nested objects", () => { it("complex stagger object preserved in extras", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0); `; const anim = parseSingleAnimation(script); expectStaggerRaw(anim, "amount", "grid", "center"); }); it("complex stagger survives round-trip serialization", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0); `; const { serialized } = parseAndSerialize(script); expect(serialized).toContain("stagger:"); expect(serialized).toContain("amount"); expect(serialized).toContain("grid"); }); it("nested ease config object (non-string ease)", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1, ease: "back.out(1.7)" }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].ease).toBe("back.out(1.7)"); }); }); // ── 6. Chained Method Calls ──────────────────────────────────────────────── describe("6. Chained method calls", () => { it("chained tl.to().to().from() — every link is detected", () => { // Each link of a chain is called on the return value of the previous one // (ultimately the timeline). The parser walks the member chain to its root, // so every link is captured, not just the first. const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#a", { x: 100, duration: 0.5 }, 0).to("#b", { y: 200, duration: 0.5 }, 1).from("#c", { scale: 0, duration: 1 }, 2); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(3); const bySelector = Object.fromEntries(result.animations.map((a) => [a.targetSelector, a])); expect(bySelector["#a"]?.properties.x).toBe(100); expect(bySelector["#b"]?.properties.y).toBe(200); expect(bySelector["#c"]?.method).toBe("from"); }); it("separate statements all parse", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#a", { x: 100, duration: 0.5 }, 0); tl.to("#b", { y: 200, duration: 0.5 }, 1); tl.from("#c", { scale: 0, duration: 1 }, 2); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(3); }); }); // ── 7. Template Literals in Values ───────────────────────────────────────── describe("7. Template literals in values", () => { it("template literal with no expressions resolves to string", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 50, duration: 1, ease: \`power2.out\` }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].ease).toBe("power2.out"); }); it("template literal with expression becomes __raw", () => { expectRawWithResolvable( ` const val = 100; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0); `, "x", "y", 50, ); }); }); // ── 8. Multiple Scripts in One HTML ──────────────────────────────────────── describe("8. Multiple timelines", () => { it("two gsap.timeline() calls sets multipleTimelines flag", () => { const script = ` const tl1 = gsap.timeline({ paused: true }); tl1.to("#a", { x: 100, duration: 1 }, 0); const tl2 = gsap.timeline({ paused: true }); tl2.to("#b", { y: 200, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.multipleTimelines).toBe(true); // Parser only tracks the first timeline variable expect(result.timelineVar).toBe("tl1"); // Only tl1 tweens are captured expect(result.animations.every((a) => a.targetSelector === "#a")).toBe(true); }); it("two scripts concatenated with same variable name", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#first", { opacity: 1, duration: 0.5 }, 0); // Second block re-uses tl but creates a new timeline const tl2 = gsap.timeline({ paused: true }); tl.to("#second", { opacity: 0.5, duration: 1 }, 1); `; const result = parseGsapScript(script); // Both .to() calls use "tl" as the callee object, so both are captured expect(result.multipleTimelines).toBe(true); const selectors = result.animations.map((a) => a.targetSelector); expect(selectors).toContain("#first"); expect(selectors).toContain("#second"); }); }); // ── 9. Comments Everywhere ───────────────────────────────────────────────── describe("9. Comments everywhere", () => { it("inline comments inside tween args", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { /* fade in */ opacity: 1 /*, y: 200*/, duration: 0.5 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].properties.opacity).toBe(1); // y: 200 is commented out, should not appear expect(result.animations[0].properties).not.toHaveProperty("y"); }); it("line comments between tween calls", () => { const script = ` const tl = gsap.timeline({ paused: true }); // First animation tl.set("#el", { opacity: 0 }, 0); // Second animation tl.to("#el", { opacity: 1, duration: 1 }, 0.5); // Done `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(2); }); it("comment inside selector string (not really a comment)", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el /* not a comment */", { x: 100, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].targetSelector).toBe("#el /* not a comment */"); }); }); // ── 10. Arrow Functions as Values ────────────────────────────────────────── describe("10. Arrow functions as values", () => { it("arrow function property becomes __raw", () => { expectRawWithResolvable( ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0); `, "x", "opacity", 1, ); }); it("arrow function in stagger becomes __raw extra", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0); `; const anim = parseSingleAnimation(script); expectStaggerRaw(anim); }); it("arrow function round-trips via serialization", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0); `; const { serialized } = parseAndSerialize(script); // The raw arrow function should be emitted without quotes expect(serialized).toContain("(i) => i * 50"); expect(serialized).not.toContain('"(i) => i * 50"'); }); }); // ── 11. Spread Operator ──────────────────────────────────────────────────── describe("11. Spread operator", () => { it("spread in vars object does not crash — spread properties are skipped", () => { const script = ` const baseVars = { opacity: 0, x: -50 }; const tl = gsap.timeline({ paused: true }); tl.to("#el", { ...baseVars, y: 100, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); // Spread properties are SpreadElement, not ObjectProperty — they're skipped // Only explicitly written properties are captured expect(result.animations[0].properties.y).toBe(100); expect(result.animations[0].duration).toBe(1); }); }); // ── 12. Conditional Expressions ──────────────────────────────────────────── describe("12. Conditional expressions", () => { it("ternary expression becomes __raw", () => { expectRawWithResolvable( ` const condition = true; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0); `, "x", "y", 50, ); }); it("conditional in position argument defaults to 0", () => { expectSingleAnimPosition( ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2); `, 0, ); }); }); // ── 13. Round-Trip Stability ─────────────────────────────────────────────── describe("13. Round-trip stability", () => { it("basic .to() round-trips", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, x: 50, duration: 0.5, ease: "power2.out" }, 0); `); }); it("basic .from() round-trips", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.from("#el", { opacity: 0, y: -100, duration: 1, ease: "back.out" }, 0.5); `); }); it("basic .set() round-trips", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.set("#el", { opacity: 0, scale: 0.5 }, 0); `); }); it("basic .fromTo() round-trips", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1, ease: "power1.inOut" }, 2); `); }); it("stagger extra round-trips", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: 0.1 }, 0); `); }); it("yoyo + repeat extras round-trip", () => { assertRoundTrip(` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0); `); }); it("multiple tweens round-trip with ordering preserved", () => { assertRoundTrip(` 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); tl.from("#el3", { y: -50, duration: 0.3 }, 2); `); }); it("string position round-trips", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); tl.to("#el2", { x: 100, duration: 1 }, "<"); `; const parsed1 = parseGsapScript(script); const serialized = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, { preamble: parsed1.preamble, postamble: parsed1.postamble, }); const parsed2 = parseGsapScript(serialized); expect(parsed2.animations[0].position).toBe("+=1"); expect(parsed2.animations[1].position).toBe("<"); }); it("double round-trip: parse -> serialize -> parse -> serialize -> parse gives stable IR", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.set("#a", { opacity: 0 }, 0); tl.to("#a", { opacity: 1, x: 100, duration: 0.5, ease: "power2.out" }, 0.5); tl.to("#b", { y: -50, scale: 1.5, duration: 1, stagger: 0.1 }, 1); `; const parsed1 = parseGsapScript(script); const ser1 = serializeGsapAnimations(parsed1.animations, parsed1.timelineVar, { preamble: parsed1.preamble, postamble: parsed1.postamble, }); const parsed2 = parseGsapScript(ser1); const ser2 = serializeGsapAnimations(parsed2.animations, parsed2.timelineVar, { preamble: parsed2.preamble, postamble: parsed2.postamble, }); const parsed3 = parseGsapScript(ser2); // Third parse should match second parse exactly expect(parsed3.animations.length).toBe(parsed2.animations.length); for (let i = 0; i < parsed2.animations.length; i++) { expect(parsed3.animations[i].targetSelector).toBe(parsed2.animations[i].targetSelector); expect(parsed3.animations[i].method).toBe(parsed2.animations[i].method); expect(parsed3.animations[i].position).toEqual(parsed2.animations[i].position); expect(parsed3.animations[i].properties).toEqual(parsed2.animations[i].properties); } }); }); // ── 14. ID Collision ─────────────────────────────────────────────────────── describe("14. ID collision", () => { it("three tweens with same selector, method, position get disambiguated", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 0, duration: 0.3 }, 0); tl.to("#el", { x: 100, duration: 0.5 }, 0); tl.to("#el", { y: 50, duration: 0.7 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(3); const ids = result.animations.map((a) => a.id); // All IDs must be unique expect(new Set(ids).size).toBe(3); expect(ids[0]).toBe("#el-to-0-visual"); expect(ids[1]).toBe("#el-to-0-position"); expect(ids[2]).toBe("#el-to-0-position-2"); }); it("disambiguated IDs are stable across parses", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 0 }, 0); tl.to("#el", { x: 100 }, 0); `; const r1 = parseGsapScript(script); const r2 = parseGsapScript(script); expect(r1.animations[0].id).toBe(r2.animations[0].id); expect(r1.animations[1].id).toBe(r2.animations[1].id); }); it("mutation by ID targets the correct animation among collisions", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 0, duration: 0.3 }, 0); tl.to("#el", { opacity: 1, duration: 0.5 }, 0); `; const parsed = parseGsapScript(script); const secondId = parsed.animations[1].id; // "#el-to-0-2" const updated = updateAnimationInScript(script, secondId, { duration: 2 }); const reparsed = parseGsapScript(updated); // The second animation should have updated duration expect(reparsed.animations[1].duration).toBe(2); // The first should be untouched expect(reparsed.animations[0].duration).toBe(0.3); }); }); // ── 15. Very Long Scripts ────────────────────────────────────────────────── describe("15. Very long scripts (50+ tweens)", () => { it("parses 50 sequential tweens", () => { const tweens = Array.from( { length: 50 }, (_, i) => `tl.to("#el${i}", { x: ${i * 10}, opacity: ${(i % 10) / 10}, duration: 0.5 }, ${i * 0.5});`, ).join("\n "); const script = ` const tl = gsap.timeline({ paused: true }); ${tweens} `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(50); // Spot check first and last expect(result.animations[0].targetSelector).toBe("#el0"); expect(result.animations[0].properties.x).toBe(0); expect(result.animations[49].targetSelector).toBe("#el49"); expect(result.animations[49].properties.x).toBe(490); }); it("parses 100 tweens targeting the same element", () => { const tweens = Array.from( { length: 100 }, (_, i) => `tl.to("#el", { x: ${i}, duration: 0.1 }, ${i * 0.1});`, ).join("\n "); const script = ` const tl = gsap.timeline({ paused: true }); ${tweens} `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(100); // All IDs must be unique despite same selector const ids = result.animations.map((a) => a.id); expect(new Set(ids).size).toBe(100); }); it("round-trips 50 tweens", () => { const tweens = Array.from( { length: 50 }, (_, i) => `tl.to("#el${i}", { x: ${i * 10}, duration: 0.5 }, ${i * 0.5});`, ).join("\n "); const script = ` const tl = gsap.timeline({ paused: true }); ${tweens} `; const parsed = parseGsapScript(script); const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { preamble: parsed.preamble, postamble: parsed.postamble, }); const reparsed = parseGsapScript(serialized); expect(reparsed.animations.length).toBe(50); for (let i = 0; i < 50; i++) { expect(reparsed.animations[i].targetSelector).toBe(parsed.animations[i].targetSelector); expect(reparsed.animations[i].properties.x).toBe(parsed.animations[i].properties.x); } }); }); // ── Additional Edge Cases ────────────────────────────────────────────────── describe("Additional edge cases", () => { it("selector with special CSS characters", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#my-element_v2.class", { x: 100, duration: 1 }, 0); tl.to(".parent > .child", { y: 50, duration: 0.5 }, 0); tl.to("[data-anim='fade']", { opacity: 1, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(3); expect(result.animations[0].targetSelector).toBe("#my-element_v2.class"); expect(result.animations[1].targetSelector).toBe(".parent > .child"); expect(result.animations[2].targetSelector).toBe("[data-anim='fade']"); }); it("string concatenation in property value", () => { const script = ` const prefix = "100"; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: prefix + "px", y: 50, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].properties.x).toBe("100px"); expect(result.animations[0].properties.y).toBe(50); }); it("arithmetic in position argument", () => { const script = ` const START = 2; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1 }, START + 0.5); `; const result = parseGsapScript(script); expect(result.animations[0].position).toBe(2.5); }); it("var declaration for timeline", () => { const script = ` var tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.timelineVar).toBe("tl"); expect(result.animations).toHaveLength(1); }); it("assignment expression for timeline (no declaration keyword)", () => { const script = ` window.tl = gsap.timeline({ paused: true }); `; const result = parseGsapScript(script); // Window member expression is not a bare Identifier, so timelineVar may not be found // The parser checks for Identifier left in assignment expressions // window.tl is a MemberExpression, not Identifier — should not set timelineVar expectSafeDefault(result); }); it("non-GSAP method calls on the timeline are ignored", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, duration: 0.5 }, 0); tl.play(); tl.pause(); tl.reverse(); tl.seek(2); `; const result = parseGsapScript(script); // Only .to() is a tween method — play/pause/reverse/seek are not in GSAP_METHODS expect(result.animations).toHaveLength(1); }); it("tween with only one argument (selector only) is skipped", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el"); tl.to("#el2", { opacity: 1, duration: 0.5 }, 0); `; const result = parseGsapScript(script); // First tween has < 2 args — should be skipped expect(result.animations).toHaveLength(1); expect(result.animations[0].targetSelector).toBe("#el2"); }); it("resolves a variable reference selector to its queried CSS selector", () => { const script = ` const el = document.querySelector("#el"); const tl = gsap.timeline({ paused: true }); tl.to(el, { opacity: 1, duration: 0.5 }, 0); tl.to("#el2", { x: 100, duration: 0.5 }, 0); `; const result = parseGsapScript(script); // `el` is bound to `document.querySelector("#el")`, so it resolves to "#el". expect(result.animations).toHaveLength(2); expect(result.animations[0].targetSelector).toBe("#el"); expect(result.animations[1].targetSelector).toBe("#el2"); }); it("marks a variable target that is not bound to a DOM lookup as __unresolved__", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0); tl.to("#el2", { x: 100, duration: 0.5 }, 0); `; const result = parseGsapScript(script); // mysteryTarget has no resolvable selector binding — kept with __unresolved__ marker. expect(result.animations).toHaveLength(2); expect(result.animations[0].targetSelector).toBe("__unresolved__"); expect(result.animations[0].hasUnresolvedSelector).toBe(true); expect(result.animations[1].targetSelector).toBe("#el2"); }); it("boolean values in vars are not included in properties", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, immediateRender: false, duration: 0.5 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].properties.opacity).toBe(1); // immediateRender is in EXTRAS_KEYS, should be in extras expect(result.animations[0].extras).toBeDefined(); expect(result.animations[0].extras!.immediateRender).toBeDefined(); // Should not be in properties expect(result.animations[0].properties).not.toHaveProperty("immediateRender"); }); it("callbacks (onComplete etc.) are dropped", () => { // Note: validation would flag these, but the parser just drops them const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, duration: 1, onComplete: function() { console.log("done"); } }, 0); `; const result = parseGsapScript(script); expect(result.animations).toHaveLength(1); expect(result.animations[0].properties).not.toHaveProperty("onComplete"); expect(result.animations[0].extras).toBeUndefined(); }); it("delay is not included in properties (BUILTIN_VAR_KEYS)", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { opacity: 1, duration: 0.5, delay: 0.2 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].properties).not.toHaveProperty("delay"); expect(result.animations[0].properties).not.toHaveProperty("duration"); }); it("percentage string values in properties survive", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { width: "50%", opacity: 1, duration: 1 }, 0); `; const result = parseGsapScript(script); expect(result.animations[0].properties.width).toBe("50%"); expect(result.animations[0].properties.opacity).toBe(1); }); it("scope resolution: binary expression with one unresolvable side", () => { expectRawWithResolvable( ` const BASE = 100; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0); `, "x", "y", 200, ); }); it("negative position in ID generation", () => { const script = ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1 }, -2.5); `; const result = parseGsapScript(script); // ID uses Math.round(position * 1000) for numeric positions expect(result.animations[0].id).toBe("#el-to--2500-position"); }); it("fromTo with no position arg defaults to 0", () => { expectSingleAnimPosition( ` const tl = gsap.timeline({ paused: true }); tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 }); `, 0, ); }); });