85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
948 lines
35 KiB
TypeScript
948 lines
35 KiB
TypeScript
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: "<div>hello</div>\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,
|
|
);
|
|
});
|
|
});
|