mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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)
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -101,10 +101,12 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"acorn": "^8.17.0",
|
||||
"acorn-walk": "^8.3.5",
|
||||
"bpm-detective": "^2.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
@@ -132,7 +134,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -150,7 +152,7 @@
|
||||
},
|
||||
"packages/gcp-cloud-run": {
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google-cloud/workflows": "^4.2.0",
|
||||
@@ -170,7 +172,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
@@ -182,7 +184,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -236,7 +238,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -248,7 +250,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.95",
|
||||
"version": "0.6.98",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -1086,7 +1088,9 @@
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
|
||||
|
||||
"adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="],
|
||||
|
||||
@@ -2140,6 +2144,8 @@
|
||||
|
||||
"minizlib/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"mlly/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"pac-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"acorn": "^8.17.0",
|
||||
"acorn-walk": "^8.3.5",
|
||||
"bpm-detective": "^2.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// fallow-ignore-file duplication
|
||||
/**
|
||||
* T6b — acorn vs golden differential harness.
|
||||
*
|
||||
* Each corpus script runs through `parseGsapScriptAcorn` and must produce
|
||||
* output identical to the T6a golden files (captured from the recast/babel
|
||||
* baseline). Any mismatch = fidelity bug in the acorn port to fix before
|
||||
* recast is removed.
|
||||
*
|
||||
* Also includes the targeted preservation test (comments, custom JS, postamble)
|
||||
* and a coverage check against the fromTo / chained-call patterns.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__");
|
||||
const g = (name: string) => join(__goldens__, name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corpus scripts — identical to gsapParser.golden.test.ts so goldens are shared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MINIMAL_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var notification = document.getElementById("notification");
|
||||
gsap.set(notification, { x: 420, opacity: 0 });
|
||||
tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
|
||||
tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
|
||||
window.__timelines["macos-notification"] = tl;`;
|
||||
|
||||
const MODERATE_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var card = document.getElementById("card");
|
||||
var btn = document.getElementById("subscribe-btn");
|
||||
var textSub = document.getElementById("btn-subscribe");
|
||||
var textSubd = document.getElementById("btn-subscribed");
|
||||
gsap.set(card, { y: 300, opacity: 0 });
|
||||
tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
|
||||
tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0);
|
||||
tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
|
||||
tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
|
||||
tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
|
||||
tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
|
||||
window.__timelines["yt-lower-third"] = tl;`;
|
||||
|
||||
const COMPLEX_SCRIPT = `\
|
||||
window.__timelines = window.__timelines || {};
|
||||
gsap.defaults({ force3D: true });
|
||||
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
|
||||
tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05)
|
||||
.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2)
|
||||
.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08)
|
||||
.from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16);
|
||||
window.__timelines["vpn-youtube-spot"] = tl;`;
|
||||
|
||||
const FROMTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var hero = document.getElementById("hero");
|
||||
var caption = document.getElementById("caption");
|
||||
tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
|
||||
tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
|
||||
window.__timelines["hero-reveal"] = tl;`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b differential: acorn output must match T6a golden files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — acorn vs recast golden differential", () => {
|
||||
it("minimal — matches golden (macos-notification)", async () => {
|
||||
const result = parseGsapScriptAcorn(MINIMAL_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("minimal.parsed.json"));
|
||||
});
|
||||
|
||||
it("moderate — matches golden (yt-lower-third)", async () => {
|
||||
const result = parseGsapScriptAcorn(MODERATE_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("moderate.parsed.json"));
|
||||
});
|
||||
|
||||
it("complex — matches golden (vpn-youtube-spot, chained .from() calls)", async () => {
|
||||
const result = parseGsapScriptAcorn(COMPLEX_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("complex.parsed.json"));
|
||||
});
|
||||
|
||||
it("fromTo — matches golden (hero-reveal, negative positions)", async () => {
|
||||
const result = parseGsapScriptAcorn(FROMTO_SCRIPT);
|
||||
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("fromto.parsed.json"));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b preservation test — the acorn claim: untouched code survives verbatim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — preservation (comments, custom JS, postamble)", () => {
|
||||
it("preserves preamble and postamble around tween calls", () => {
|
||||
const script = `
|
||||
// author comment preserved
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#hero', { opacity: 1, duration: 0.5, ease: 'power2.out' });
|
||||
window.__timelines['scene'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.preamble).toContain("// author comment preserved");
|
||||
expect(result.preamble).toContain("gsap.timeline");
|
||||
expect(result.postamble).toContain("window.__timelines");
|
||||
expect(result.postamble).toContain("scene");
|
||||
});
|
||||
|
||||
it("extracts correct animation from script with custom JS around tweens", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var el = document.querySelector('.box');
|
||||
console.log('before tween');
|
||||
tl.to(el, { x: 100, duration: 0.5 }, 0);
|
||||
console.log('after tween');
|
||||
window.__timelines['custom'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0]?.targetSelector).toBe(".box");
|
||||
expect(result.animations[0]?.properties.x).toBe(100);
|
||||
expect(result.postamble).toContain("window.__timelines");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6b structural coverage — patterns exercised by existing corpus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("T6b — structural coverage", () => {
|
||||
it("resolves getElementById targets", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var hero = document.getElementById("hero");
|
||||
tl.to(hero, { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.targetSelector).toBe("#hero");
|
||||
});
|
||||
|
||||
it("resolves querySelector targets", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var el = document.querySelector(".box");
|
||||
tl.to(el, { x: 50, duration: 0.3 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.targetSelector).toBe(".box");
|
||||
});
|
||||
|
||||
it("handles stagger as __raw: extra", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".item", { y: 20, opacity: 0, stagger: 0.1, duration: 0.4 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim?.extras?.stagger).toBe("__raw:0.1");
|
||||
expect(anim?.properties).not.toHaveProperty("stagger");
|
||||
});
|
||||
|
||||
it("handles stagger as __raw: when expressed as object", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.from(".item", { y: 20, stagger: { each: 0.1, from: "start" }, duration: 0.4 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const extras = result.animations[0]?.extras;
|
||||
const stagger = extras?.stagger;
|
||||
expect(typeof stagger).toBe("string");
|
||||
expect(typeof stagger === "string" && stagger.startsWith("__raw:")).toBe(true);
|
||||
expect(stagger).toContain("each");
|
||||
});
|
||||
|
||||
it("drops dropped keys (onComplete, onStart, onUpdate, onRepeat)", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".box", { x: 100, duration: 0.5, onComplete: function() {}, onStart: function() {}, onUpdate: function() {}, onRepeat: function() {} }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
const anim = result.animations[0];
|
||||
expect(anim?.properties).not.toHaveProperty("onComplete");
|
||||
expect(anim?.properties).not.toHaveProperty("onStart");
|
||||
expect(anim?.properties).not.toHaveProperty("onUpdate");
|
||||
expect(anim?.properties).not.toHaveProperty("onRepeat");
|
||||
expect(anim?.extras).toBeUndefined();
|
||||
});
|
||||
|
||||
it("assigns stable IDs based on selector + method + position", () => {
|
||||
const script = `
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".a", { x: 1, duration: 0.5 }, 0);
|
||||
tl.to(".a", { x: 2, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.animations[0]?.id).toBe(".a-to-0-position");
|
||||
expect(result.animations[1]?.id).toBe(".a-to-0-position-2");
|
||||
});
|
||||
|
||||
it("returns empty result on syntax error (graceful fail)", () => {
|
||||
const result = parseGsapScriptAcorn("this is not valid js {{{{");
|
||||
expect(result.animations).toHaveLength(0);
|
||||
expect(result.timelineVar).toBe("tl");
|
||||
});
|
||||
|
||||
it("detects multipleTimelines when script has >1 timeline", () => {
|
||||
const script = `
|
||||
var tl1 = gsap.timeline({ paused: true });
|
||||
var tl2 = gsap.timeline({ paused: true });
|
||||
tl1.to(".a", { x: 1, duration: 0.5 }, 0);
|
||||
window.__timelines['t'] = tl1;
|
||||
`.trim();
|
||||
const result = parseGsapScriptAcorn(script);
|
||||
expect(result.multipleTimelines).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user