feat(core): parse-parity suite for acorn parser (T6d) (#1370)

This commit is contained in:
Vance Ingalls
2026-06-15 00:42:47 -07:00
committed by GitHub
parent 0fbda8acff
commit 8b56e558c6
2 changed files with 915 additions and 1 deletions
@@ -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<string, number | string>,
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);
});
});
+1 -1
View File
@@ -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++) {