mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.
Three coordinated fixes make it work end to end:
- Parser read: resolve querySelector / querySelectorAll / getElementById
variable targets (and inline lookup calls) back to their CSS selector,
so variable-targeted tweens are recognized.
- Parser write: replace the full re-serialize (preamble + tweens +
postamble) with in-place recast AST mutation. Edits now touch only the
targeted tween's vars/position node and reprint, preserving every
surrounding statement — gsap.set calls, element declarations, the IIFE
wrapper, comments and formatting. Previously the first edit would
discard all of that.
- Linter: build overlap/clip windows directly from the parser's
structured animations instead of a regex walk paired positionally with
the parsed list. The old pairing skipped variable targets and would
drift once the parser started returning them. Removes the now-dead
regex meta helpers.
- studio-api: extractGsapScriptBlock now searches inside <template>
content (sub-compositions wrap markup + the GSAP script in a template,
which linkedom's querySelectorAll doesn't descend into), and the
frontend matches tweens to the selected element by id OR selector
rather than id only (class-targeted elements have no id).
Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
1024 lines
35 KiB
TypeScript
1024 lines
35 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseGsapScript,
|
|
gsapAnimationsToKeyframes,
|
|
SUPPORTED_PROPS,
|
|
SUPPORTED_EASES,
|
|
serializeGsapAnimations,
|
|
validateCompositionGsap,
|
|
getAnimationsForElement,
|
|
keyframesToGsapAnimations,
|
|
addAnimationToScript,
|
|
removeAnimationFromScript,
|
|
updateAnimationInScript,
|
|
} from "./gsapParser.js";
|
|
import type { GsapAnimation } from "./gsapParser.js";
|
|
import type { Keyframe } from "../core.types";
|
|
|
|
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);
|
|
|
|
// IDs are deterministic across parses
|
|
expect(result1.animations[0].id).toBe(result2.animations[0].id);
|
|
expect(result1.animations[1].id).toBe(result2.animations[1].id);
|
|
|
|
// IDs encode selector, method, and position
|
|
expect(result1.animations[0].id).toBe("#el1-to-0");
|
|
expect(result1.animations[1].id).toBe("#el2-to-1000");
|
|
});
|
|
|
|
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");
|
|
expect(result.animations[1].id).toBe("#el1-to-0-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");
|
|
});
|
|
});
|
|
|
|
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);
|
|
// stagger should NOT appear in properties
|
|
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 parsed = parseGsapScript(script);
|
|
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
|
preamble: parsed.preamble,
|
|
postamble: parsed.postamble,
|
|
});
|
|
|
|
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 parsed = parseGsapScript(script);
|
|
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
|
preamble: parsed.preamble,
|
|
postamble: parsed.postamble,
|
|
});
|
|
|
|
expect(serialized).toContain("yoyo: true");
|
|
expect(serialized).toContain("repeat: 3");
|
|
expect(serialized).toContain("repeatDelay: 0.2");
|
|
});
|
|
|
|
it("survives a full parse-edit-serialize round-trip with stagger intact", () => {
|
|
const script = `
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to(".items", { opacity: 1, x: 50, duration: 0.5, stagger: 0.1, ease: "power2.out" }, 0);
|
|
`;
|
|
const parsed = parseGsapScript(script);
|
|
const animId = parsed.animations[0].id;
|
|
// Simulate an edit — change opacity to 0.5
|
|
const updatedScript = updateAnimationInScript(script, animId, {
|
|
properties: { opacity: 0.5, x: 50 },
|
|
});
|
|
// stagger should still be in the output
|
|
expect(updatedScript).toContain("stagger: 0.1");
|
|
expect(updatedScript).toContain("opacity: 0.5");
|
|
});
|
|
});
|
|
|
|
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 parsed = parseGsapScript(script);
|
|
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
|
preamble: parsed.preamble,
|
|
postamble: parsed.postamble,
|
|
});
|
|
|
|
// The raw expression should survive — emitted without quotes
|
|
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);
|
|
|
|
// x is unresolvable (function call in expression), y is resolvable
|
|
expect(parsed.animations[0].properties.y).toBe(200);
|
|
expect(String(parsed.animations[0].properties.x)).toMatch(/^__raw:/);
|
|
});
|
|
});
|
|
|
|
describe("gsapAnimationsToKeyframes", () => {
|
|
it("converts animations to keyframes with element start offset", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 2,
|
|
properties: { x: 100, y: 200 },
|
|
},
|
|
{
|
|
id: "anim-2",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 3,
|
|
properties: { x: 300, y: 400 },
|
|
duration: 1,
|
|
ease: "power2.out",
|
|
},
|
|
];
|
|
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 2);
|
|
|
|
expect(keyframes).toHaveLength(2);
|
|
// First keyframe: time = 2 - 2 = 0
|
|
expect(keyframes[0].time).toBe(0);
|
|
expect(keyframes[0].properties.x).toBe(100);
|
|
expect(keyframes[0].properties.y).toBe(200);
|
|
// Second keyframe: time = 3 - 2 = 1
|
|
expect(keyframes[1].time).toBe(1);
|
|
expect(keyframes[1].properties.x).toBe(300);
|
|
expect(keyframes[1].ease).toBe("power2.out");
|
|
});
|
|
|
|
it("filters supported props only", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 0,
|
|
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<
|
|
string,
|
|
number | string
|
|
>,
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 0);
|
|
|
|
expect(keyframes).toHaveLength(1);
|
|
expect(keyframes[0].properties.opacity).toBe(1);
|
|
expect(keyframes[0].properties.x).toBe(50);
|
|
// String values are skipped (typeof value !== "number" check)
|
|
expect(
|
|
(keyframes[0].properties as Record<string, unknown>).someUnsupportedProp,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("skips base set keyframes at time 0 when skipBaseSet is true", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 5,
|
|
properties: { x: 0, y: 0 },
|
|
},
|
|
{
|
|
id: "anim-2",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 6,
|
|
properties: { x: 100 },
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
|
|
|
|
expect(keyframes).toHaveLength(1);
|
|
expect(keyframes[0].id).toBe("anim-2");
|
|
});
|
|
|
|
it("does NOT skip set keyframes when they have non-base values", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 5,
|
|
properties: { x: 100, y: 0 },
|
|
},
|
|
];
|
|
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 5, { skipBaseSet: true });
|
|
|
|
// x=100 is non-base, so it should NOT be skipped
|
|
expect(keyframes).toHaveLength(1);
|
|
expect(keyframes[0].properties.x).toBe(100);
|
|
});
|
|
|
|
it("clamps negative time to zero by default", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 0,
|
|
properties: { opacity: 1 },
|
|
},
|
|
];
|
|
|
|
// elementStartTime is 5, so relative time = 0 - 5 = -5
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 5);
|
|
|
|
expect(keyframes[0].time).toBe(0); // Clamped to 0
|
|
});
|
|
|
|
it("adjusts x/y/scale relative to base values", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 2,
|
|
properties: { x: 150, y: 200, scale: 2 },
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const keyframes = gsapAnimationsToKeyframes(animations, 0, {
|
|
baseX: 50,
|
|
baseY: 100,
|
|
baseScale: 2,
|
|
});
|
|
|
|
expect(keyframes[0].properties.x).toBe(100); // 150 - 50
|
|
expect(keyframes[0].properties.y).toBe(100); // 200 - 100
|
|
expect(keyframes[0].properties.scale).toBe(1); // 2 / 2
|
|
});
|
|
});
|
|
|
|
describe("keyframesToGsapAnimations", () => {
|
|
it("converts keyframes back to GSAP animations", () => {
|
|
const keyframes: Keyframe[] = [
|
|
{ id: "kf-1", time: 0, properties: { opacity: 0 } },
|
|
{ id: "kf-2", time: 1, properties: { opacity: 1 }, ease: "power2.out" },
|
|
];
|
|
|
|
const animations = keyframesToGsapAnimations("el1", keyframes, 2);
|
|
|
|
expect(animations).toHaveLength(2);
|
|
expect(animations[0].method).toBe("set");
|
|
expect(animations[0].position).toBe(2); // elementStartTime + 0
|
|
expect(animations[0].properties.opacity).toBe(0);
|
|
expect(animations[1].method).toBe("to");
|
|
expect(animations[1].position).toBe(2); // position of prev keyframe
|
|
expect(animations[1].duration).toBe(1); // kf.time - prevKf.time
|
|
expect(animations[1].ease).toBe("power2.out");
|
|
});
|
|
|
|
it("applies base x/y/scale offsets", () => {
|
|
const keyframes: Keyframe[] = [{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } }];
|
|
|
|
const animations = keyframesToGsapAnimations("el1", keyframes, 0, {
|
|
x: 50,
|
|
y: 100,
|
|
scale: 0.5,
|
|
});
|
|
|
|
expect(animations[0].properties.x).toBe(60); // baseX + value
|
|
expect(animations[0].properties.y).toBe(120); // baseY + value
|
|
expect(animations[0].properties.scale).toBe(1); // baseScale * value
|
|
});
|
|
});
|
|
|
|
describe("serializeGsapAnimations", () => {
|
|
it("serializes animations into a GSAP timeline script", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 0,
|
|
properties: { opacity: 0 },
|
|
},
|
|
{
|
|
id: "anim-2",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 0.5,
|
|
properties: { opacity: 1 },
|
|
duration: 0.5,
|
|
ease: "power2.out",
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
|
|
expect(result).toContain("const tl = gsap.timeline({ paused: true });");
|
|
expect(result).toContain('tl.set("#el1"');
|
|
expect(result).toContain('tl.to("#el1"');
|
|
expect(result).toContain("opacity: 0");
|
|
expect(result).toContain("opacity: 1");
|
|
});
|
|
|
|
it("sorts animations by position", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-2",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 2,
|
|
properties: { opacity: 1 },
|
|
duration: 0.5,
|
|
},
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 0,
|
|
properties: { opacity: 0 },
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
|
|
const setIdx = result.indexOf("tl.set");
|
|
const toIdx = result.indexOf("tl.to");
|
|
expect(setIdx).toBeLessThan(toIdx);
|
|
});
|
|
|
|
it("serializes fromTo animations correctly", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "fromTo",
|
|
position: 0,
|
|
properties: { opacity: 1 },
|
|
fromProperties: { opacity: 0 },
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
expect(result).toContain('tl.fromTo("#el1"');
|
|
});
|
|
|
|
it("uses custom timeline variable name", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "set",
|
|
position: 0,
|
|
properties: { opacity: 0 },
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations, "myTimeline");
|
|
expect(result).toContain("const myTimeline = gsap.timeline({ paused: true });");
|
|
expect(result).toContain('myTimeline.set("#el1"');
|
|
});
|
|
});
|
|
|
|
describe("validateCompositionGsap", () => {
|
|
it("returns valid for clean scripts", () => {
|
|
const script = `
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#el1", { opacity: 1, duration: 1 }, 0);
|
|
`;
|
|
const result = validateCompositionGsap(script);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
it("detects forbidden patterns", () => {
|
|
const script = `
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#el1", { opacity: 1, duration: 1, onComplete: function() {} }, 0);
|
|
setTimeout(function() {}, 100);
|
|
`;
|
|
const result = validateCompositionGsap(script);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toContain("onComplete callback not allowed");
|
|
expect(result.errors).toContain("setTimeout not allowed");
|
|
});
|
|
|
|
it("warns about yoyo and stagger", () => {
|
|
const script = `
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to(".items", { x: 100, stagger: 0.1, yoyo: true, duration: 1 }, 0);
|
|
`;
|
|
const result = validateCompositionGsap(script);
|
|
expect(result.warnings).toContain("yoyo animations may behave unexpectedly when scrubbing");
|
|
expect(result.warnings).toContain("stagger animations may not serialize correctly");
|
|
});
|
|
|
|
it("detects infinite repeat", () => {
|
|
const script = `
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#el1", { opacity: 1, duration: 1, repeat: -1 }, 0);
|
|
`;
|
|
const result = validateCompositionGsap(script);
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors).toContain("Infinite repeat (repeat: -1) not allowed");
|
|
});
|
|
});
|
|
|
|
describe("getAnimationsForElement", () => {
|
|
it("filters animations by element id", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
|
|
{
|
|
id: "a2",
|
|
targetSelector: "#el2",
|
|
method: "to",
|
|
position: 0,
|
|
properties: { opacity: 1 },
|
|
duration: 1,
|
|
},
|
|
{
|
|
id: "a3",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 1,
|
|
properties: { opacity: 1 },
|
|
duration: 0.5,
|
|
},
|
|
];
|
|
|
|
const result = getAnimationsForElement(animations, "el1");
|
|
expect(result).toHaveLength(2);
|
|
expect(result.every((a) => a.targetSelector === "#el1")).toBe(true);
|
|
});
|
|
|
|
it("returns empty array when no animations match", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
|
|
];
|
|
|
|
const result = getAnimationsForElement(animations, "el99");
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("mutation functions parse-fail safety", () => {
|
|
const garbage = "this is not valid javascript @@@ {{{{";
|
|
|
|
it("updateAnimationInScript returns original script on parse failure", () => {
|
|
const result = updateAnimationInScript(garbage, "anim-1", { duration: 2 });
|
|
expect(result).toBe(garbage);
|
|
});
|
|
|
|
it("addAnimationToScript returns original script on parse failure", () => {
|
|
const result = addAnimationToScript(garbage, {
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 0,
|
|
properties: { opacity: 1 },
|
|
duration: 1,
|
|
});
|
|
expect(result.script).toBe(garbage);
|
|
expect(result.id).toBe("");
|
|
});
|
|
|
|
it("removeAnimationFromScript returns original script on parse failure", () => {
|
|
const result = removeAnimationFromScript(garbage, "anim-1");
|
|
expect(result).toBe(garbage);
|
|
});
|
|
});
|
|
|
|
describe("serializeGsapAnimations quote escaping", () => {
|
|
it("escapes quotes and backslashes in string property values", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 0,
|
|
properties: { content: 'say "hello"' },
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
// JSON.stringify produces escaped quotes
|
|
expect(result).toContain('content: "say \\"hello\\""');
|
|
});
|
|
|
|
it("escapes backslashes in string property values", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: 0,
|
|
properties: { path: "C:\\Users\\test" },
|
|
duration: 1,
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
expect(result).toContain('path: "C:\\\\Users\\\\test"');
|
|
});
|
|
|
|
it("serializes string position values correctly", () => {
|
|
const animations: GsapAnimation[] = [
|
|
{
|
|
id: "anim-1",
|
|
targetSelector: "#el1",
|
|
method: "to",
|
|
position: "+=1",
|
|
properties: { opacity: 1 },
|
|
duration: 0.5,
|
|
},
|
|
];
|
|
|
|
const result = serializeGsapAnimations(animations);
|
|
expect(result).toContain('"+=1"');
|
|
});
|
|
});
|
|
|
|
describe("SUPPORTED_PROPS", () => {
|
|
it("includes expected properties", () => {
|
|
expect(SUPPORTED_PROPS).toContain("opacity");
|
|
expect(SUPPORTED_PROPS).toContain("x");
|
|
expect(SUPPORTED_PROPS).toContain("y");
|
|
expect(SUPPORTED_PROPS).toContain("scale");
|
|
expect(SUPPORTED_PROPS).toContain("rotation");
|
|
expect(SUPPORTED_PROPS).toContain("width");
|
|
expect(SUPPORTED_PROPS).toContain("height");
|
|
});
|
|
});
|
|
|
|
describe("SUPPORTED_EASES", () => {
|
|
it("includes common easing functions", () => {
|
|
expect(SUPPORTED_EASES).toContain("none");
|
|
expect(SUPPORTED_EASES).toContain("power2.out");
|
|
expect(SUPPORTED_EASES).toContain("bounce.out");
|
|
expect(SUPPORTED_EASES).toContain("elastic.inOut");
|
|
});
|
|
});
|
|
|
|
// ── Variable-target resolution + in-place mutation ──────────────────────────
|
|
//
|
|
// Real compositions (and everything the hyperframes skill generates) target
|
|
// tweens via element variables resolved from querySelector, wrapped in an IIFE,
|
|
// with gsap.set() calls interleaved between tl.to() calls. The parser must
|
|
// resolve those variable targets to selectors (read) and edits must preserve
|
|
// every surrounding statement (write).
|
|
|
|
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;
|
|
})();`;
|
|
|
|
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",
|
|
]);
|
|
// stagger preserved as extras
|
|
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
|
|
});
|
|
|
|
it("leaves unresolvable variable targets out of the animation list", () => {
|
|
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([".real"]);
|
|
});
|
|
});
|
|
|
|
describe("in-place AST mutation preserves surrounding code", () => {
|
|
it("updateAnimationInScript edits one tween and preserves gsap.set + var decls + IIFE", () => {
|
|
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
|
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
|
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
|
|
properties: { y: 0, opacity: 0.5 },
|
|
});
|
|
|
|
// The edit landed
|
|
expect(updated).toContain("opacity: 0.5");
|
|
// Surrounding code survived verbatim
|
|
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
|
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
|
expect(updated).toContain("gsap.set(glyph, { rotationX: 90, opacity: 0 })");
|
|
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
|
expect(updated).toContain("(function () {");
|
|
// The variable target was NOT rewritten to a string literal
|
|
expect(updated).toContain("tl.to(kicker,");
|
|
expect(updated).not.toContain('tl.to(".co-kicker"');
|
|
// The other tweens are untouched
|
|
expect(updated).toContain("tl.to(glyph,");
|
|
expect(updated).toContain("tl.to(items,");
|
|
});
|
|
|
|
it("updateAnimationInScript re-parses to the edited value (round-trip)", () => {
|
|
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
|
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
|
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, glyphAnim.id, {
|
|
properties: { rotationX: 0, opacity: 1, scale: 1.2 },
|
|
});
|
|
const reparsed = parseGsapScript(updated);
|
|
const reGlyph = reparsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
|
expect(reGlyph.properties.scale).toBe(1.2);
|
|
// unrelated tweens still present
|
|
expect(reparsed.animations).toHaveLength(3);
|
|
});
|
|
|
|
it("update-meta edits duration/ease/position in place", () => {
|
|
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
|
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
|
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
|
|
duration: 0.9,
|
|
ease: "power1.in",
|
|
});
|
|
const reparsed = parseGsapScript(updated);
|
|
const reKicker = reparsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
|
|
expect(reKicker.duration).toBe(0.9);
|
|
expect(reKicker.ease).toBe("power1.in");
|
|
// surrounding code intact
|
|
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
|
});
|
|
|
|
it("removeAnimationFromScript removes one tween and keeps the rest + setup", () => {
|
|
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
|
|
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
|
|
const updated = removeAnimationFromScript(REAL_WORLD_SCRIPT, glyphAnim.id);
|
|
const reparsed = parseGsapScript(updated);
|
|
expect(reparsed.animations.map((a) => a.targetSelector)).toEqual([".co-kicker", ".co-item"]);
|
|
// the removed tween's gsap.set setup is left untouched (not the parser's job to remove)
|
|
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
|
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
|
});
|
|
|
|
it("addAnimationToScript inserts a tween and preserves the IIFE body", () => {
|
|
const { script: updated, id } = addAnimationToScript(REAL_WORLD_SCRIPT, {
|
|
targetSelector: "#new-el",
|
|
method: "to",
|
|
position: 3,
|
|
duration: 0.5,
|
|
ease: "power2.out",
|
|
properties: { opacity: 1 },
|
|
});
|
|
expect(id).not.toBe("");
|
|
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
|
|
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
|
|
const reparsed = parseGsapScript(updated);
|
|
expect(reparsed.animations.some((a) => a.targetSelector === "#new-el")).toBe(true);
|
|
expect(reparsed.animations).toHaveLength(4);
|
|
});
|
|
|
|
it("still edits classic string-literal timelines in place", () => {
|
|
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 parsed = parseGsapScript(script);
|
|
const updated = updateAnimationInScript(script, parsed.animations[0].id, {
|
|
properties: { opacity: 0.25 },
|
|
});
|
|
expect(updated).toContain("opacity: 0.25");
|
|
// second tween untouched
|
|
expect(updated).toContain('tl.to("#el2", { x: 100, duration: 1 }, 1)');
|
|
});
|
|
});
|