Skip to content

Commit be4a28a

Browse files
authored
feat(core): acorn GSAP read path with T6b differential corpus tests (#1368)
## Summary Replaces the regex-based GSAP script parser with an acorn AST parser for the read path. This is the first of three parser PRs (T6b → T6c → T6d) that together migrate hyperframes off fragile regex parsing onto a proper AST. ## Why The existing `gsapParser.ts` regex-based parser silently misparses edge cases: chained `.to()` calls, template literal targets, `gsap.utils.toArray(...)` expansions, lexically scoped variables, and percent-keyframe arrays. These misparses produce wrong `animationId` values that downstream SDK write ops use as keys — write ops targeting the wrong node corrupt the script. The fix is to parse with a real JS AST. ## What changed **`packages/core/src/parsers/gsapParserAcorn.ts`** (new, ~1100 lines) - `parseGsapScriptAcorn(script)` — full-featured read-path parser. Walks an acorn AST to extract: - Timeline variable detection (`gsap.timeline()` assignment) - `resolvedStart` computation: handles absolute positions, label references, relative `+=`/`-=`, chained calls - Property group classification (`transform`, `opacity`, `color`, etc.) - GSAP keyframes: percentage-object, object-array, simple-array with three-level easing - Variable target resolution: `querySelector`, `getElementById`, `querySelectorAll`, `gsap.utils.toArray`, array literals, forEach/map callbacks - Timeline `defaults` inheritance - Stagger / repeat / yoyo extraction - All `animationId` values are content-addressed (`target-method-startMs-group`) for deterministic round-trips - Note: `parseGsapScriptAcornForWrite` (the write-path slice used by T6c) lives in T6c (#1369), not this PR **`packages/core/src/parsers/gsapParser.acorn.test.ts`** (new, ~220 lines) - Differential corpus tests: same input run through both the old regex parser and the new acorn parser, asserting outputs are equal on the scenarios the old parser handled correctly - Catches regressions during the transition without requiring tests to be rewritten - `onComplete`/`onStart`/`onUpdate`/`onRepeat` dropped-key assertions added in Phase 3b commit (#1379) where `DROPPED_VAR_KEYS` is defined — the test file is in T6b but the extended assertions live one commit up-stack **`packages/core/package.json`** - Added `acorn` and `acorn-walk` dependencies ## Test plan - `bun run test packages/core` → all tests pass (35 passing in the T6b suite alone) - Stacked on: `main` - Stack above: T6c (write path), T6d (parity suite)
1 parent a9f7d90 commit be4a28a

4 files changed

Lines changed: 1337 additions & 10 deletions

File tree

bun.lock

Lines changed: 16 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@
213213
"dependencies": {
214214
"@babel/parser": "^7.27.0",
215215
"@chenglou/pretext": "^0.0.5",
216+
"acorn": "^8.17.0",
217+
"acorn-walk": "^8.3.5",
216218
"bpm-detective": "^2.0.5",
217219
"postcss": "^8.5.8",
218220
"postcss-selector-parser": "^7.1.2",
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// fallow-ignore-file duplication
2+
/**
3+
* T6b — acorn vs golden differential harness.
4+
*
5+
* Each corpus script runs through `parseGsapScriptAcorn` and must produce
6+
* output identical to the T6a golden files (captured from the recast/babel
7+
* baseline). Any mismatch = fidelity bug in the acorn port to fix before
8+
* recast is removed.
9+
*
10+
* Also includes the targeted preservation test (comments, custom JS, postamble)
11+
* and a coverage check against the fromTo / chained-call patterns.
12+
*/
13+
import { describe, expect, it } from "vitest";
14+
import { join } from "node:path";
15+
import { fileURLToPath } from "node:url";
16+
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
17+
18+
const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__");
19+
const g = (name: string) => join(__goldens__, name);
20+
21+
// ---------------------------------------------------------------------------
22+
// Corpus scripts — identical to gsapParser.golden.test.ts so goldens are shared
23+
// ---------------------------------------------------------------------------
24+
25+
const MINIMAL_SCRIPT = `\
26+
var tl = gsap.timeline({ paused: true });
27+
var notification = document.getElementById("notification");
28+
gsap.set(notification, { x: 420, opacity: 0 });
29+
tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
30+
tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
31+
window.__timelines["macos-notification"] = tl;`;
32+
33+
const MODERATE_SCRIPT = `\
34+
window.__timelines = window.__timelines || {};
35+
var tl = gsap.timeline({ paused: true });
36+
var card = document.getElementById("card");
37+
var btn = document.getElementById("subscribe-btn");
38+
var textSub = document.getElementById("btn-subscribe");
39+
var textSubd = document.getElementById("btn-subscribed");
40+
gsap.set(card, { y: 300, opacity: 0 });
41+
tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
42+
tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0);
43+
tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
44+
tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
45+
tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
46+
tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
47+
window.__timelines["yt-lower-third"] = tl;`;
48+
49+
const COMPLEX_SCRIPT = `\
50+
window.__timelines = window.__timelines || {};
51+
gsap.defaults({ force3D: true });
52+
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
53+
tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05)
54+
.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2)
55+
.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08)
56+
.from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16);
57+
window.__timelines["vpn-youtube-spot"] = tl;`;
58+
59+
const FROMTO_SCRIPT = `\
60+
var tl = gsap.timeline({ paused: true });
61+
var hero = document.getElementById("hero");
62+
var caption = document.getElementById("caption");
63+
tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
64+
tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
65+
window.__timelines["hero-reveal"] = tl;`;
66+
67+
// ---------------------------------------------------------------------------
68+
// T6b differential: acorn output must match T6a golden files
69+
// ---------------------------------------------------------------------------
70+
71+
describe("T6b — acorn vs recast golden differential", () => {
72+
it("minimal — matches golden (macos-notification)", async () => {
73+
const result = parseGsapScriptAcorn(MINIMAL_SCRIPT);
74+
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("minimal.parsed.json"));
75+
});
76+
77+
it("moderate — matches golden (yt-lower-third)", async () => {
78+
const result = parseGsapScriptAcorn(MODERATE_SCRIPT);
79+
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("moderate.parsed.json"));
80+
});
81+
82+
it("complex — matches golden (vpn-youtube-spot, chained .from() calls)", async () => {
83+
const result = parseGsapScriptAcorn(COMPLEX_SCRIPT);
84+
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("complex.parsed.json"));
85+
});
86+
87+
it("fromTo — matches golden (hero-reveal, negative positions)", async () => {
88+
const result = parseGsapScriptAcorn(FROMTO_SCRIPT);
89+
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("fromto.parsed.json"));
90+
});
91+
});
92+
93+
// ---------------------------------------------------------------------------
94+
// T6b preservation test — the acorn claim: untouched code survives verbatim
95+
// ---------------------------------------------------------------------------
96+
97+
describe("T6b — preservation (comments, custom JS, postamble)", () => {
98+
it("preserves preamble and postamble around tween calls", () => {
99+
const script = `
100+
// author comment preserved
101+
const tl = gsap.timeline({ paused: true });
102+
tl.to('#hero', { opacity: 1, duration: 0.5, ease: 'power2.out' });
103+
window.__timelines['scene'] = tl;
104+
`.trim();
105+
const result = parseGsapScriptAcorn(script);
106+
expect(result.preamble).toContain("// author comment preserved");
107+
expect(result.preamble).toContain("gsap.timeline");
108+
expect(result.postamble).toContain("window.__timelines");
109+
expect(result.postamble).toContain("scene");
110+
});
111+
112+
it("extracts correct animation from script with custom JS around tweens", () => {
113+
const script = `
114+
var tl = gsap.timeline({ paused: true });
115+
var el = document.querySelector('.box');
116+
console.log('before tween');
117+
tl.to(el, { x: 100, duration: 0.5 }, 0);
118+
console.log('after tween');
119+
window.__timelines['custom'] = tl;
120+
`.trim();
121+
const result = parseGsapScriptAcorn(script);
122+
expect(result.animations).toHaveLength(1);
123+
expect(result.animations[0]?.targetSelector).toBe(".box");
124+
expect(result.animations[0]?.properties.x).toBe(100);
125+
expect(result.postamble).toContain("window.__timelines");
126+
});
127+
});
128+
129+
// ---------------------------------------------------------------------------
130+
// T6b structural coverage — patterns exercised by existing corpus
131+
// ---------------------------------------------------------------------------
132+
133+
describe("T6b — structural coverage", () => {
134+
it("resolves getElementById targets", () => {
135+
const script = `
136+
var tl = gsap.timeline({ paused: true });
137+
var hero = document.getElementById("hero");
138+
tl.to(hero, { opacity: 1, duration: 0.5 }, 0);
139+
window.__timelines['t'] = tl;
140+
`.trim();
141+
const result = parseGsapScriptAcorn(script);
142+
expect(result.animations[0]?.targetSelector).toBe("#hero");
143+
});
144+
145+
it("resolves querySelector targets", () => {
146+
const script = `
147+
var tl = gsap.timeline({ paused: true });
148+
var el = document.querySelector(".box");
149+
tl.to(el, { x: 50, duration: 0.3 }, 0);
150+
window.__timelines['t'] = tl;
151+
`.trim();
152+
const result = parseGsapScriptAcorn(script);
153+
expect(result.animations[0]?.targetSelector).toBe(".box");
154+
});
155+
156+
it("handles stagger as __raw: extra", () => {
157+
const script = `
158+
var tl = gsap.timeline({ paused: true });
159+
tl.from(".item", { y: 20, opacity: 0, stagger: 0.1, duration: 0.4 }, 0);
160+
window.__timelines['t'] = tl;
161+
`.trim();
162+
const result = parseGsapScriptAcorn(script);
163+
const anim = result.animations[0];
164+
expect(anim?.extras?.stagger).toBe("__raw:0.1");
165+
expect(anim?.properties).not.toHaveProperty("stagger");
166+
});
167+
168+
it("handles stagger as __raw: when expressed as object", () => {
169+
const script = `
170+
var tl = gsap.timeline({ paused: true });
171+
tl.from(".item", { y: 20, stagger: { each: 0.1, from: "start" }, duration: 0.4 }, 0);
172+
window.__timelines['t'] = tl;
173+
`.trim();
174+
const result = parseGsapScriptAcorn(script);
175+
const extras = result.animations[0]?.extras;
176+
const stagger = extras?.stagger;
177+
expect(typeof stagger).toBe("string");
178+
expect(typeof stagger === "string" && stagger.startsWith("__raw:")).toBe(true);
179+
expect(stagger).toContain("each");
180+
});
181+
182+
it("drops dropped keys (onComplete, onStart, onUpdate, onRepeat)", () => {
183+
const script = `
184+
var tl = gsap.timeline({ paused: true });
185+
tl.to(".box", { x: 100, duration: 0.5, onComplete: function() {}, onStart: function() {}, onUpdate: function() {}, onRepeat: function() {} }, 0);
186+
window.__timelines['t'] = tl;
187+
`.trim();
188+
const result = parseGsapScriptAcorn(script);
189+
const anim = result.animations[0];
190+
expect(anim?.properties).not.toHaveProperty("onComplete");
191+
expect(anim?.properties).not.toHaveProperty("onStart");
192+
expect(anim?.properties).not.toHaveProperty("onUpdate");
193+
expect(anim?.properties).not.toHaveProperty("onRepeat");
194+
expect(anim?.extras).toBeUndefined();
195+
});
196+
197+
it("assigns stable IDs based on selector + method + position", () => {
198+
const script = `
199+
var tl = gsap.timeline({ paused: true });
200+
tl.to(".a", { x: 1, duration: 0.5 }, 0);
201+
tl.to(".a", { x: 2, duration: 0.5 }, 0);
202+
window.__timelines['t'] = tl;
203+
`.trim();
204+
const result = parseGsapScriptAcorn(script);
205+
expect(result.animations[0]?.id).toBe(".a-to-0-position");
206+
expect(result.animations[1]?.id).toBe(".a-to-0-position-2");
207+
});
208+
209+
it("returns empty result on syntax error (graceful fail)", () => {
210+
const result = parseGsapScriptAcorn("this is not valid js {{{{");
211+
expect(result.animations).toHaveLength(0);
212+
expect(result.timelineVar).toBe("tl");
213+
});
214+
215+
it("detects multipleTimelines when script has >1 timeline", () => {
216+
const script = `
217+
var tl1 = gsap.timeline({ paused: true });
218+
var tl2 = gsap.timeline({ paused: true });
219+
tl1.to(".a", { x: 1, duration: 0.5 }, 0);
220+
window.__timelines['t'] = tl1;
221+
`.trim();
222+
const result = parseGsapScriptAcorn(script);
223+
expect(result.multipleTimelines).toBe(true);
224+
});
225+
});

0 commit comments

Comments
 (0)