Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ pre-commit:
# fails on issues introduced by the branch, not inherited findings.
fallow:
glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
run: bunx fallow audit --base origin/main --fail-on-issues
run: >
bunx fallow audit --base origin/main --fail-on-issues
--health-baseline .fallow/health-baseline.json
--dupes-baseline .fallow/dupes-baseline.json
--dead-code-baseline .fallow/dead-code-baseline.json
filesize:
# Scoped to packages/studio — the 600 LOC limit is a studio architecture
# standard enforced as part of the App.tsx decomposition work. Player and
Expand Down
135 changes: 58 additions & 77 deletions packages/core/src/parsers/gsapParser.stress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import {
removeAnimationFromScript,
} from "./gsapParser.js";
import type { ParsedGsap } from "./gsapParser.js";
import {
parseAndSerialize,
parseSingleAnimation,
expectStaggerRaw,
expectRawWithResolvable,
expectSingleAnimPosition,
} from "./gsapParser.test-helpers.js";

// ── Helpers ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -232,16 +239,15 @@ describe("3. Extreme values", () => {
});

it("Infinity literal", () => {
const script = `
expectRawWithResolvable(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Infinity is an Identifier, not a NumericLiteral — should be __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});
});

Expand Down Expand Up @@ -298,28 +304,16 @@ describe("5. Deeply nested objects", () => {
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 result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].extras).toBeDefined();
expect(result.animations[0].extras!.stagger).toBeDefined();
// stagger should be __raw: containing the nested object source
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
expect(stagger).toContain("amount");
expect(stagger).toContain("grid");
expect(stagger).toContain("center");
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 parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
expect(serialized).toContain("stagger:");
expect(serialized).toContain("amount");
expect(serialized).toContain("grid");
Expand Down Expand Up @@ -380,17 +374,16 @@ describe("7. Template literals in values", () => {
});

it("template literal with expression becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const val = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Template literal with expressions is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});
});

Expand Down Expand Up @@ -472,40 +465,32 @@ describe("9. Comments everywhere", () => {

describe("10. Arrow functions as values", () => {
it("arrow function property becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Arrow function is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// Resolvable values still work
expect(result.animations[0].properties.opacity).toBe(1);
`,
"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 result = parseGsapScript(script);
expect(result.animations[0].extras).toBeDefined();
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
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 parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
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"');
Expand Down Expand Up @@ -534,28 +519,26 @@ describe("11. Spread operator", () => {

describe("12. Conditional expressions", () => {
it("ternary expression becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const condition = true;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// ConditionalExpression is not handled by resolveNode
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});

it("conditional in position argument defaults to 0", () => {
const script = `
expectSingleAnimPosition(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Position can't be resolved — falls back to 0
expect(result.animations[0].position).toBe(0);
`,
0,
);
});
});

Expand Down Expand Up @@ -930,17 +913,16 @@ describe("Additional edge cases", () => {
});

it("scope resolution: binary expression with one unresolvable side", () => {
const script = `
expectRawWithResolvable(
`
const BASE = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
// BASE + unknownVar: left is 100, right is undefined => result is undefined => __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// BASE * 2: both resolved => 200
expect(result.animations[0].properties.y).toBe(200);
`,
"x",
"y",
200,
);
});

it("negative position in ID generation", () => {
Expand All @@ -954,13 +936,12 @@ describe("Additional edge cases", () => {
});

it("fromTo with no position arg defaults to 0", () => {
const script = `
expectSingleAnimPosition(
`
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 });
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// For fromTo, position is args[3] which is undefined => defaults to 0
expect(result.animations[0].position).toBe(0);
`,
0,
);
});
});
130 changes: 130 additions & 0 deletions packages/core/src/parsers/gsapParser.test-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect } from "vitest";
import {
parseGsapScript,
serializeGsapAnimations,
convertToKeyframesInScript,
} from "./gsapParser.js";
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js";

/**
* Parse a script and serialize the result, returning both the parsed output
* and the serialized string for assertion. Shared across gsapParser.test.ts
* and gsapParser.stress.test.ts.
*/
export function parseAndSerialize(script: string) {
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
return { parsed, serialized };
}

/**
* Parse a script expecting exactly one animation, and return it directly.
*/
export function parseSingleAnimation(script: string): GsapAnimation {
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
return result.animations[0]!;
}

/**
* Assert that a parsed animation's stagger extra exists and contains
* the expected substrings (as a __raw: prefixed string).
*/
export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void {
expect(anim.extras).toBeDefined();
expect(anim.extras!.stagger).toBeDefined();
const stagger = String(anim.extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
for (const sub of expectedSubstrings) {
expect(stagger).toContain(sub);
}
}

/**
* Assert a single keyframe's percentage, properties, and optional ease.
*/
export 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);
}
}

/**
* Assert that an animation has a defined keyframes block with the expected format
* and count, and return the keyframes array for further assertions.
*/
export 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;
}

/**
* Parse a script expecting one animation, assert that `rawProp` is a __raw: string
* and `resolvableProp` has the expected value.
*/
export function expectRawWithResolvable(
script: string,
rawProp: string,
resolvableProp: string,
resolvableValue: number | string,
): void {
const anim = parseSingleAnimation(script);
const val = anim.properties[rawProp];
expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true);
expect(anim.properties[resolvableProp]).toBe(resolvableValue);
}

/**
* Parse a script expecting one animation, assert that `position` matches the expected value.
*/
export function expectSingleAnimPosition(script: string, position: number): void {
const anim = parseSingleAnimation(script);
expect(anim.position).toBe(position);
}

/**
* Parse a script, get the first animation id, run convertToKeyframesInScript,
* reparse, and return the first animation for assertion.
*/
export function convertAndReparse(
script: string,
runtimeValues?: Record<string, number | string>,
): GsapAnimation {
const id = parseSingleAnimation(script).id;
const updated = convertToKeyframesInScript(script, id, runtimeValues);
return parseSingleAnimation(updated);
}

/**
* Parse a script, return the first animation and run a split-related reparse.
* Asserts the reparse result has exactly `expectedCount` animations and returns
* the selector of the first animation.
*/
export function parseSplitAndAssert(
script: string,
splitFn: (s: string) => string,
expectedCount: number,
): string[] {
const result = splitFn(script);
const parsed = parseGsapScript(result);
expect(parsed.animations).toHaveLength(expectedCount);
return parsed.animations.map((a) => a.targetSelector);
}
Loading