mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
* fix(studio): restore timeline move/resize fallback parity (review #1466) The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the pre-cutover behavior. Restored, on both fallback entry points (no-session and sdkTimingPersist-returned-unhandled): - Resize live DOM patch dropped the conditional data-playback-start/media-start attr — restored so a start-trim updates the preview's in-point immediately. - Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions) + reloadPreview — restored so server-path edits keep GSAP tweens in sync and refresh the preview (the SDK path folds both into setTiming). - Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK branch did — plumbed coalesceKey through persistTimelineEdit so undo granularity is identical on either path. - Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture transition limitation. Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange flagged 'potential' but verified correct (no comma residue on any block position). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): retire duplicate removeGsapKeyframe keyframeIndex variant (review #1498) EditOp had two removeGsapKeyframe members with the same discriminant but different shapes (keyframeIndex vs percentage) — TS can't discriminate them and a handler could get the wrong shape. Per both reviewers (option 2): retire the keyframeIndex variant. It had no production caller (Studio dispatches percentage only); removed the dead by-index handleRemoveGsapKeyframe + simplified the dispatcher. resolveKeyframe stays (setGsapKeyframe still uses keyframeIndex). Converted the one by-index test to the percentage API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): gate ALL cutover persist paths on the flag — true dark launch (review #1469 finding #6) Only sdkCutoverPersist (style/text/attr) checked STUDIO_SDK_CUTOVER_ENABLED. sdkTimingPersist, dispatchGsapOpAndPersist (every GSAP op) and sdkDeletePersist guarded only on `!sdkSession` — and useSdkSession opens a session by default for shadow/selection, so timing/GSAP/keyframe/delete cutover was ALWAYS live regardless of the flag. Flipping the flag OFF could not disable it, so the data-loss bugs in those paths (single-prop wipe, wrong-keyframe match, tween collapse, arc strip) ship LIVE on merge instead of being dark-launched. Added the flag guard at all three chokepoints → flag OFF returns false → callers fall back to the legacy server path. Makes the stack genuinely dark-launchable: merge is now a no-op in prod, and the remaining cutover correctness bugs become flip-prerequisites rather than merge-blockers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core,sdk): correct 8 GSAP write-path review findings (#1539) Eight correctness bugs from the SDK-cutover review. Several were cases where BOTH writers were identically wrong, so the recast-vs-acorn parity suite stayed green; the new tests assert the real-world-correct result, not agreement. - #2 findKfPropByPct: match the CLOSEST keyframe within tolerance, not the first within 2% — removing/updating 50% on 0/49/50/100 no longer hits 49%. - #3 handleSetTiming: shift each tween by the start DELTA and scale duration by the clip-duration RATIO per-tween, instead of writing absolute newStart/ newDuration onto every tween (which collapsed staggers and blew durations). - #4 enableArcPath: insert motionPath via appendRight at the object start so the insertion can't collide with the x/y remove-range end (which made MagicString discard the append and emit '{}'). - #5 splitAnimationsInScript: compute the inherited baseline in a forward pre-pass so the split-spanning midpoint sees earlier tweens (the reverse write loop is kept for stable count-suffixed ids). - #9 unrollDynamicAnimations: preserve non-target loop-body statements (e.g. tl.set initial-state) per iteration instead of overwriting the whole loop. - #10 buildMotionPathObjectCode (both writers): emit the cubic form when segment curviness varies so per-segment curviness survives, not just segments[0]. - #11 readLastWaypointXY: handle UnaryExpression so negative destination coords are recovered when disabling an arc path. - #15 no-bang: removed every `!` non-null assertion in the touched files, replaced with guards/fallbacks. Tests: gsapWriter.reviewFixes.test.ts (#2/#4/#5/#9/#10/#11) and mutate.gsap.test.ts setTiming GSAP-sync block (#3). All fail on the base and pass after the fix; tsc + full core/sdk suites + parity stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): SDK cutover review fixes — merge tween props, stabilize debounce, serialize gsap writes, on-disk undo baseline, self-write identity Addresses 5 SDK-cutover review findings (studio-only): - #1 useGsapPropertyDebounce: editing one GSAP tween property no longer drops the tween's other animated props. setGsapTween REPLACES the property set, so merge the single edit into the tween's CURRENT properties (read from the SDK doc) before dispatching, mirroring the legacy server merge. - #7 useGsapPropertyDebounce: stabilize the flush callback by reading sdk deps from a ref instead of an unmemoized literal, so a parent re-render mid-edit no longer tears down + flushes the debounce (one commit/undo entry per render). - #8 sdkCutover/useGsapScriptCommits: route SDK gsap-write persists through the same per-file keyed serializer the legacy commitMutation uses, so concurrent same-file read-modify-writes can't interleave and lose an edit. - #12 sdkCutover/useTimelineEditing: capture the exact on-disk bytes as the undo 'before' for timing/GSAP persists (matching the style/delete paths) instead of a normalized SDK serialize() re-emit that reformatted the whole file on undo. - #14 useSdkSession/sdkSelfWriteRegistry: discriminate a cutover echo from an undo write by CONTENT identity (registered self-write hash), not just the 2 s timestamp window — an undo write always reloads the SDK session. Tests: useGsapPropertyDebounce(.test), useGsapPropertyDebounceFlush.test, sdkSelfWriteRegistry.test, and new sdkCutover.test cases; each reproduces the review scenario and asserts the corrected behavior (verified red before fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): extract split/collapse helpers to satisfy no-fallow-ignore rule The #5 (split) and #15 (no-bang guards) fixes pushed splitAnimationsInScript and removeAllKeyframesFromScript over fallow's complexity threshold, and a fallow-ignore had been added to splitAnimationsInScript. Per the hard rule (never ignore — fix), extracted buildSpanningSplit + applyTweenSplit (split) and buildCollapsedFlatVars (collapse), and removed the ignore. Both functions now under threshold; fallow new-only gate reports 0 new findings. Behavior unchanged — core 1811 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(studio): pin dark-launch flag-gate contract (review #1539, Rames/Via) flag OFF ⇒ sdkTimingPersist / sdkGsapTweenPersist (GSAP-op chokepoint) / sdkDeletePersist all return false even with a valid session → legacy fallback. The prod flag-flip rests on this contract; sdkCutover.test.ts only mocks the flag TRUE, so a future gate refactor could silently re-enable cutover on flag-off without failing CI. This sibling file mocks it FALSE and locks the three guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(studio): leading flag-gate on sdkGsapTweenPersist (review #1539 nit, Via) The add-op getElement existence check ran before the inner gate, so flag-off did an SDK touch before falling back. Lead with the flag guard to match the other three chokepoints — flag-off is now a clean no-op at every entry point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): unroll-preservation regressions — non-for loops + AST index substitution (review R2) The #9 unroll-preservation fix had two confirmed regressions: - Non-for loops (forEach/for-of/for-in/while): loopIndexVarName returns null, so substitution no-op'd and preserved siblings kept a now-undefined loop variable (e.g. `item`) → ReferenceError at render. Now returns null for those forms → caller falls back to the blanket loop overwrite (drops siblings, valid code). The #9 fixture only used `for(let i…)` so it never caught this. - substituteLoopIndex did a \bvar\b regex over raw source including string literals, corrupting selectors like ".row-i" → ".row-0". Now AST-based: substitutes only real Identifier uses, skipping string literals and non-computed member/key positions (extracted isIndexBindingPosition helper to stay under the fallow complexity threshold — no ignore added). Two regression tests added (forEach no-dangling-var; for-loop string-literal intact). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk,core): unrollDynamicAnimations rejects empty element list (R1 #1501b) An empty `elements` array has no unrolled form — the writer would overwrite the loop/statement with zero tween calls, silently deleting the animation. - gsapWriterAcorn: unrollDynamicAnimations returns the script verbatim on an empty list (no-op instead of a destructive overwrite). - validateOp: reject unrollDynamicAnimations with empty elements as E_INVALID_ARGS so callers get a clean error rather than silent corruption. - Tests: writer no-op on []; validateOp E_INVALID_ARGS on []. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(sdk): cache draft element in applyDraft, drop HTMLElement casts (R1 #1490a) applyDraft runs at 60fps during a drag but re-ran doc.querySelector on every call — the _draftEl/_draftId fields were only consumed by commit/cancel, never to skip the query. Reuse the tracked element when the id matches and the node is still connected; re-query only on id change or detach (iframe reload). Retypes _draftEl to HTMLElement | null (only ever set from querySelector<HTMLElement>), which removes the `as HTMLElement` casts in commitPreview / _clearDraft. Test asserts a repeated same-id drag queries once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk,core): round-3 correctness — unroll AST safety, single-dispatch undo, empty-arg guards, persist decouple Addresses the highest-severity round-3 review findings: - gsapWriterAcorn unroll (R3 #1/#2/#9): the round-2 AST-substitution fix emitted invalid GSAP for object shorthand `{ i }` (→ `{ 0 }`) and shadowed inner bindings (→ `for(let i=0;0<3;0++)`), and silently dropped sibling statements on non-`for` loops (forEach/for-of). The unroll now REFUSES (no-ops, leaving the dynamic loop intact) whenever siblings can't be safely reproduced — a non-`for` loop, an unmodeled statement, or an unsafe index use — instead of dropping or corrupting. Plain `for` loops with safe siblings still unroll. - session single-dispatch undo (R3 #5/#11): _dispatch now reverses the inverse patch list (parity with batch()). A single op emitting order-dependent inverse patches — a nested parent+child removeElement, an aliased multi-target — undid forward and dropped the child subtree / landed on an intermediate value. - materializeKeyframes empty-array (R3 #10): the unguarded twin of the just-fixed unrollDynamicAnimations. Writer no-ops on an empty keyframe list; validateOp rejects it as E_INVALID_ARGS (shared gsapScriptMissing helper). - history:false persist decouple (R3 #4): persist (auto-save) no longer lives inside the history-enable block, so opting out of SDK undo no longer silently disables all disk writes (data-loss trap for #1496's flag consumers). Tests: unroll refuse cases (shorthand/shadow/forEach) + safe-for-loop regression; nested removeElement undo; materializeKeyframes writer no-op + validateOp reject; history:false-still-persists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): stripGsapForId re-parses per removal so all tweens for a deleted element are stripped (R3 #3) Animation ids are count-based (positional), so removing one tween renumbers the survivors. stripGsapForId captured every matching id from a single up-front parse then removed against the mutating script — after the first removal the later ids were stale and silently no-op'd, leaving an orphaned tl.to() referencing the just-deleted element. Now re-parse after each removal and strip the first still-matching animation until none remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): gsap writer — keyframe ease routing, convert preserves delay, addLabel dedup (R3 #7/#8/#12) - #7: updateAnimationInScript routes an ease update on a keyframe tween to keyframes.easeEach (per-keyframe), not a top-level ease that GSAP ignores — the user's keyframe-easing edit was silently a no-op. - #8: convertToKeyframesFromScript now preserves every non-editable vars key (delay/callbacks/stagger/yoyo/…) verbatim via preservedVarsEntries instead of rebuilding from the GsapAnimation object, which had no `delay` field and dropped it — shifting the tween's start time. - #12: addLabelToScript moves an existing same-named label (overwrites its position) instead of appending a duplicate; duplicates made removeLabel over-remove (it deletes every match, including a pre-existing label). Tests: easeEach routing, delay preservation, addLabel move-not-duplicate + hand-authored-dup removal. Updated the old "no dedup contract" corpus test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): handleSetTiming #domId + data-duration sync; validateOp resolves ids + arc/selector (R3 #6/#13, CF2 #15/#16) CF2 #15: handleSetTiming re-synced GSAP tweens only when the selector matched the element's hf-id. The common #domId-targeted tween (authored by the Studio panel) never matched, so moving/resizing a clip via the SDK timing path left its animations unsynced. Now match the tween selector against the DOM id too. CF2 #16: handleSetTiming read/wrote only data-end. Clips authored with data-duration (what the runtime prefers) got a fresh data-end beside a stale data-duration (no playback change) and oldDuration=null collapsed the GSAP duration-scale ratio to 1. Now read duration preferring data-duration, and write back to whichever attribute the clip uses (timingPath gains a "duration" field). R3 #13b: deleteAllForSelector compared selectors with strict === and missed the alternate quote style ([data-hf-id='x'] vs "x"); now quote-insensitive. R3 #6/#13a: validateOp now resolves the animationId for id-bearing GSAP ops (E_TARGET_NOT_FOUND instead of a misleading ok that no-ops at apply), and updateArcSegment validates the arc is enabled + the segment index is in range. Tests: #domId move sync, data-duration resize + scale, quote-insensitive delete, unresolved-id rejection, arc-segment preconditions. Updated the loose-can() test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core,sdk): name the acorn-node type alias; keyToPath round-trips timing.duration (R3 #14) - gsapWriterAcorn: replace the bare `: any` AST-node annotations with the named `type Node = any` alias, matching the established convention in gsapParserAcorn.ts / gsapInline.ts ("acorn ESTree nodes are structurally untyped"). Documents intent and is greppable; type-identical (zero runtime change). A full ESTree typing is a deliberate architecture decision the codebase has not taken and is out of scope here. - patches: keyToPath/timingPath now include the "duration" timing field added for the data-duration resize fix, so a timing.duration override round-trips on T3 replay instead of being dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): cascadeRemoveAnimations re-parses per removal (R4 — SDK twin of #3) cascadeRemoveAnimations captured every matching animation id from a single up-front parse, then removed against the mutating script — the SDK-side twin of the stripGsapForId bug (R3 #3). Animation ids are positional, so removing the first tween for an element renumbered the survivors and the stale later ids no-op'd, orphaning those tweens on the just-removed element. Now re-parse after each removal and strip the first still-matching animation until none remain. Also adds the reviewer's defense-in-depth test: an aliased multi-target setStyle (same id twice) undoes to the original, not the intermediate (exercises the single-dispatch inverse reversal from R3 #5/#11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
649 lines
24 KiB
TypeScript
649 lines
24 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
/**
|
|
* Recast-vs-acorn GSAP-writer differential suite (WS-3.F cutover gate).
|
|
*
|
|
* The SDK's browser-safe acorn writer (gsapWriterAcorn.ts) must produce output
|
|
* equivalent to the server's recast writer (gsapParser.ts) for every write op,
|
|
* so that making the acorn writer authoritative (retiring recast) is behavior-
|
|
* preserving. The two formatters differ (recast pretty-prints, acorn splices),
|
|
* so we never compare bytes — we apply each op via BOTH writers, parse both
|
|
* outputs with the shared `parseGsapScriptAcorn`, and assert the resulting
|
|
* animation models match structurally.
|
|
*
|
|
* Until this suite, only `addKeyframeToScript` had a true differential test
|
|
* (gsapWriterParity.acorn.test.ts). This file extends parity coverage to the
|
|
* five previously standalone-only ops:
|
|
* updateAnimationInScript, addAnimationToScript, removeAnimationFromScript,
|
|
* updateKeyframeInScript, removeKeyframeFromScript.
|
|
* Plus correctness tests for the acorn-only label ops (addLabelToScript /
|
|
* removeLabelFromScript), which have no recast oracle to diff against.
|
|
*
|
|
* The harness (`runParity`, `modelOf`) is exported so the follow-up WS-3 op-PR
|
|
* workflow can reuse it to gate each cut-over op.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
updateAnimationInScript as updateAnimAcorn,
|
|
addAnimationToScript as addAnimAcorn,
|
|
removeAnimationFromScript as removeAnimAcorn,
|
|
updateKeyframeInScript as updateKfAcorn,
|
|
removeKeyframeFromScript as removeKfAcorn,
|
|
addLabelToScript,
|
|
removeLabelFromScript,
|
|
} from "./gsapWriterAcorn.js";
|
|
import {
|
|
updateAnimationInScript as updateAnimRecast,
|
|
addAnimationToScript as addAnimRecast,
|
|
removeAnimationFromScript as removeAnimRecast,
|
|
updateKeyframeInScript as updateKfRecast,
|
|
removeKeyframeFromScript as removeKfRecast,
|
|
parseGsapScript,
|
|
} from "./gsapParser.js";
|
|
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
|
import type { GsapAnimation } from "./gsapSerialize.js";
|
|
|
|
// ── Reusable differential harness (exported for the WS-3 op-PR workflow) ───────
|
|
|
|
/**
|
|
* Fields that are incidental metadata — derived per-parse rather than authored —
|
|
* and so must be excluded from a structural comparison: stable ids are content-
|
|
* derived (and recast's addAnimation id is a `Date.now()` placeholder), and the
|
|
* rest are computed analysis (resolved start, group classification, provenance).
|
|
*/
|
|
const IGNORED_FIELDS = new Set<keyof GsapAnimation | string>([
|
|
"id",
|
|
"resolvedStart",
|
|
"implicitPosition",
|
|
"propertyGroup",
|
|
"provenance",
|
|
"hasUnresolvedKeyframes",
|
|
"hasUnresolvedSelector",
|
|
]);
|
|
|
|
type NormalizedAnimation = Record<string, unknown>;
|
|
|
|
/**
|
|
* Parse a GSAP script and reduce each animation to its authored shape: target,
|
|
* method, position, properties, fromProperties, duration, ease, extras,
|
|
* keyframes — dropping per-parse metadata. Both writers' outputs go through this
|
|
* SAME parser, so any model difference is a genuine writer divergence, not a
|
|
* parser artifact.
|
|
*/
|
|
export function modelOf(script: string): NormalizedAnimation[] {
|
|
return parseGsapScriptAcorn(script).animations.map((anim) => {
|
|
const out: NormalizedAnimation = {};
|
|
for (const [key, value] of Object.entries(anim)) {
|
|
if (IGNORED_FIELDS.has(key) || value === undefined) continue;
|
|
out[key] = value;
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply an op via BOTH writers and assert the parsed animation models match.
|
|
* `recast`/`acorn` each receive the original script and must return the rewritten
|
|
* script. Returns the recast-written script so callers can chain ops.
|
|
*/
|
|
export function runParity(
|
|
script: string,
|
|
recast: (s: string) => string,
|
|
acorn: (s: string) => string,
|
|
): string {
|
|
const recastOut = recast(script);
|
|
const acornOut = acorn(script);
|
|
expect(modelOf(acornOut), "acorn model must equal recast model").toEqual(modelOf(recastOut));
|
|
return recastOut;
|
|
}
|
|
|
|
/** The id of the i-th animation in a script (recast parser — the op oracle). */
|
|
function idAt(script: string, index = 0): string {
|
|
const id = parseGsapScript(script).animations[index]?.id;
|
|
if (!id) throw new Error(`no animation at index ${index} in fixture`);
|
|
return id;
|
|
}
|
|
|
|
// ── Corpus ─────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Real registry scripts (literal-tween portions extracted verbatim from
|
|
// registry/blocks and registry/components, trimmed to the editable tweens) plus
|
|
// synthetic scripts covering breadth the single-op test lacks: to/from/fromTo,
|
|
// multi-tween, keyframes, labels, numeric + label-relative + symbolic positions,
|
|
// stagger/repeat/yoyo extras, and sub-composition selectors.
|
|
|
|
// REAL — registry/blocks/macos-notification: two plain .to() tweens, numeric pos.
|
|
const REAL_MACOS = `\
|
|
window.__timelines = window.__timelines || {};
|
|
var tl = gsap.timeline({ paused: true });
|
|
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;`;
|
|
|
|
// REAL — registry/blocks/flowchart: multi .to() with strokeDashoffset + back ease.
|
|
const REAL_FLOWCHART = `\
|
|
window.__timelines = window.__timelines || {};
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#node-root", { scale: 1, duration: 0.4, ease: "back.out(2)" }, 0.2);
|
|
tl.to("#node-yes", { scale: 1, duration: 0.3, ease: "back.out(2)" }, 0.5);
|
|
tl.to("#path-1-L", { strokeDashoffset: 0, duration: 0.6, ease: "power2.inOut" }, 0.8);
|
|
window.__timelines["flowchart"] = tl;`;
|
|
|
|
// REAL — registry/components/caption-kinetic-slam: .fromTo() entrances + .to() exit.
|
|
const REAL_CAPTION = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.fromTo("#kt-w-0", { y: -120, opacity: 0 }, { y: 0, opacity: 1, duration: 0.22, ease: "back.out(1.7)" }, 0.5);
|
|
tl.fromTo("#kt-w-1", { x: -300, opacity: 0 }, { x: 0, opacity: 1, duration: 0.2, ease: "expo.out" }, 1.2);
|
|
tl.to("#kt-w-0", { opacity: 0, duration: 0.1, ease: "power2.in" }, 2.5);
|
|
window.__timelines["caption-kinetic-slam"] = tl;`;
|
|
|
|
// SYNTH — single plain to().
|
|
const SYN_SINGLE = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#hero", { opacity: 1, x: 100, duration: 0.5, ease: "power3.out" }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — to + from + fromTo, mixed positions (numeric, symbolic "<", numeric).
|
|
const SYN_MIXED_METHODS = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from("#title", { opacity: 0, y: 30, duration: 0.6, ease: "power2.out" }, 0);
|
|
tl.to("#title", { opacity: 0, duration: 0.4 }, 1.2);
|
|
tl.fromTo(".card", { scale: 0.8, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.5 }, "<");
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — extras: stagger / repeat / yoyo / repeatDelay must survive every op.
|
|
const SYN_EXTRAS = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to(".dot", { y: -20, duration: 0.3, stagger: 0.08, repeat: 2, yoyo: true, repeatDelay: 0.1 }, 0);
|
|
tl.from("#panel", { opacity: 0, duration: 0.5, ease: "sine.out" }, 0.4);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — labels + label-relative positions ("intro", "intro+=0.3").
|
|
const SYN_LABELED = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.addLabel("intro", 0);
|
|
tl.to("#a", { opacity: 1, duration: 0.5 }, "intro");
|
|
tl.to("#b", { opacity: 1, duration: 0.5 }, "intro+=0.3");
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — sub-composition / nested selectors (scoped descendant + attribute).
|
|
const SYN_NESTED = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#scene-2 .headline", { y: 0, opacity: 1, duration: 0.5 }, 0);
|
|
tl.from('[data-hf-id="scene-2"] .sub', { opacity: 0, duration: 0.4 }, 0.3);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — percentage keyframes (3 kfs).
|
|
const SYN_KF3 = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — percentage keyframes (2 kfs) — removal collapses to flat.
|
|
const SYN_KF2 = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "100%": { opacity: 1, x: 40 } }, duration: 0.5, delay: 0.1 }, 0.2);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — keyframes carrying per-keyframe ease + easeEach.
|
|
const SYN_KF_EASE = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { keyframes: { "0%": { y: 0 }, "50%": { y: 30, ease: "power2.in" }, "100%": { y: 0 }, easeEach: "power1.inOut" }, duration: 0.8 }, 0);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// SYNTH — chained tweens (tl.from(...).from(...)) for chain-link removal.
|
|
const SYN_CHAIN = `\
|
|
var tl = gsap.timeline({ paused: true });
|
|
tl.from(".a", { opacity: 0, duration: 0.5 }, 0)
|
|
.from(".b", { opacity: 0, duration: 0.3 }, 0.5)
|
|
.to(".a", { x: 10, duration: 0.2 }, 1);
|
|
window.__timelines["t"] = tl;`;
|
|
|
|
// ── 1. updateAnimationInScript ──────────────────────────────────────────────────
|
|
|
|
describe("parity — updateAnimationInScript", () => {
|
|
it("updates duration + ease on a real two-tween block (macos)", () => {
|
|
const id = idAt(REAL_MACOS, 1);
|
|
const u = { duration: 0.9, ease: "power1.in" };
|
|
runParity(
|
|
REAL_MACOS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("updates duration on a real .to() with a back.out ease (flowchart)", () => {
|
|
const id = idAt(REAL_FLOWCHART, 0);
|
|
const u = { duration: 0.7 };
|
|
runParity(
|
|
REAL_FLOWCHART,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("REPLACES the editable property set (props absent from update are dropped)", () => {
|
|
const id = idAt(SYN_MIXED_METHODS, 0);
|
|
const u = { properties: { x: 50, rotation: 10 } };
|
|
runParity(
|
|
SYN_MIXED_METHODS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("REPLACES fromTo from-vars (props absent from update are dropped)", () => {
|
|
const id = idAt(SYN_MIXED_METHODS, 2);
|
|
const u = { fromProperties: { scale: 0.5 } };
|
|
runParity(
|
|
SYN_MIXED_METHODS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("replaces props while preserving extras (stagger/repeat/yoyo)", () => {
|
|
const id = idAt(SYN_EXTRAS, 0);
|
|
const u = { properties: { y: -40 } };
|
|
runParity(
|
|
SYN_EXTRAS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("updates props + duration + ease together in one op", () => {
|
|
const id = idAt(SYN_SINGLE, 0);
|
|
const u = { properties: { x: 9 }, duration: 1.1, ease: "sine.in" };
|
|
runParity(
|
|
SYN_SINGLE,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("changes numeric position to a label-relative string position", () => {
|
|
const id = idAt(SYN_MIXED_METHODS, 0);
|
|
const u = { position: "intro+=0.5" };
|
|
runParity(
|
|
SYN_MIXED_METHODS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("changes a symbolic '<' position to a numeric position", () => {
|
|
const id = idAt(SYN_MIXED_METHODS, 2);
|
|
const u = { position: 3 };
|
|
runParity(
|
|
SYN_MIXED_METHODS,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("updates a tween whose position is a label name (labeled fixture)", () => {
|
|
const id = idAt(SYN_LABELED, 1);
|
|
const u = { duration: 0.9 };
|
|
runParity(
|
|
SYN_LABELED,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
|
|
it("inserts ease when absent on a nested-selector tween", () => {
|
|
const id = idAt(SYN_NESTED, 0);
|
|
const u = { ease: "power3.out" };
|
|
runParity(
|
|
SYN_NESTED,
|
|
(s) => updateAnimRecast(s, id, u),
|
|
(s) => updateAnimAcorn(s, id, u),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 2. addAnimationToScript ──────────────────────────────────────────────────────
|
|
|
|
describe("parity — addAnimationToScript", () => {
|
|
const add =
|
|
(animation: Omit<GsapAnimation, "id">) =>
|
|
(writer: (s: string, a: Omit<GsapAnimation, "id">) => { script: string }) =>
|
|
(s: string) =>
|
|
writer(s, animation).script;
|
|
|
|
it("appends a plain to() after the last real tween (macos)", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: "#new",
|
|
method: "to",
|
|
position: 5,
|
|
duration: 0.3,
|
|
properties: { x: 100 },
|
|
ease: "sine.in",
|
|
};
|
|
const build = add(anim);
|
|
runParity(REAL_MACOS, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("appends a fromTo() with extras (repeat/yoyo)", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: "#x",
|
|
method: "fromTo",
|
|
position: 2,
|
|
duration: 0.4,
|
|
properties: { opacity: 1 },
|
|
fromProperties: { opacity: 0 },
|
|
extras: { repeat: 2, yoyo: true },
|
|
};
|
|
const build = add(anim);
|
|
runParity(SYN_MIXED_METHODS, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("appends a from() with a symbolic '<' position", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: ".card",
|
|
method: "from",
|
|
position: "<",
|
|
duration: 0.5,
|
|
properties: { y: 20, opacity: 0 },
|
|
};
|
|
const build = add(anim);
|
|
runParity(SYN_EXTRAS, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("appends a tween with a label-relative position", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: "#c",
|
|
method: "to",
|
|
position: "intro+=0.6",
|
|
duration: 0.4,
|
|
properties: { opacity: 1 },
|
|
};
|
|
const build = add(anim);
|
|
runParity(SYN_LABELED, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("appends a tween onto a chained-tween timeline", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: ".c",
|
|
method: "to",
|
|
position: 1.5,
|
|
duration: 0.3,
|
|
properties: { scale: 1.2 },
|
|
};
|
|
const build = add(anim);
|
|
runParity(SYN_CHAIN, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("appends with a nested sub-composition selector", () => {
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: "#scene-2 .footer",
|
|
method: "to",
|
|
position: 0.8,
|
|
duration: 0.3,
|
|
properties: { opacity: 1 },
|
|
};
|
|
const build = add(anim);
|
|
runParity(SYN_NESTED, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
|
|
it("inserts after the timeline decl when the script has no tweens", () => {
|
|
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: "#hero",
|
|
method: "to",
|
|
position: 0,
|
|
duration: 0.5,
|
|
properties: { opacity: 1 },
|
|
};
|
|
const build = add(anim);
|
|
runParity(empty, build(addAnimRecast), build(addAnimAcorn));
|
|
});
|
|
});
|
|
|
|
// ── 3. removeAnimationFromScript ─────────────────────────────────────────────────
|
|
|
|
describe("parity — removeAnimationFromScript", () => {
|
|
it("removes a standalone tween statement (real macos, first tween)", () => {
|
|
const id = idAt(REAL_MACOS, 0);
|
|
runParity(
|
|
REAL_MACOS,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes the middle standalone tween of a 3-tween block (flowchart)", () => {
|
|
const id = idAt(REAL_FLOWCHART, 1);
|
|
runParity(
|
|
REAL_FLOWCHART,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes the only tween (timeline left empty)", () => {
|
|
const id = idAt(SYN_SINGLE, 0);
|
|
runParity(
|
|
SYN_SINGLE,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes a fromTo tween (real caption block)", () => {
|
|
const id = idAt(REAL_CAPTION, 1);
|
|
runParity(
|
|
REAL_CAPTION,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes the inner-most chain link", () => {
|
|
const id = idAt(SYN_CHAIN, 0);
|
|
runParity(
|
|
SYN_CHAIN,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes the outer-most chain link", () => {
|
|
const id = idAt(SYN_CHAIN, 2);
|
|
runParity(
|
|
SYN_CHAIN,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes a labeled-position tween (label statement preserved)", () => {
|
|
const id = idAt(SYN_LABELED, 0);
|
|
runParity(
|
|
SYN_LABELED,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
|
|
it("removes a tween carrying extras", () => {
|
|
const id = idAt(SYN_EXTRAS, 0);
|
|
runParity(
|
|
SYN_EXTRAS,
|
|
(s) => removeAnimRecast(s, id),
|
|
(s) => removeAnimAcorn(s, id),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 4. updateKeyframeInScript ────────────────────────────────────────────────────
|
|
|
|
describe("parity — updateKeyframeInScript", () => {
|
|
it("replaces the 50% keyframe value", () => {
|
|
const id = idAt(SYN_KF3, 0);
|
|
runParity(
|
|
SYN_KF3,
|
|
(s) => updateKfRecast(s, id, 50, { opacity: 0.5 }),
|
|
(s) => updateKfAcorn(s, id, 50, { opacity: 0.5 }),
|
|
);
|
|
});
|
|
|
|
it("replaces a keyframe value AND sets a per-keyframe ease", () => {
|
|
const id = idAt(SYN_KF3, 0);
|
|
runParity(
|
|
SYN_KF3,
|
|
(s) => updateKfRecast(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
|
(s) => updateKfAcorn(s, id, 50, { opacity: 0.4 }, "power2.in"),
|
|
);
|
|
});
|
|
|
|
it("replaces the endpoint (100%) keyframe value", () => {
|
|
const id = idAt(SYN_KF3, 0);
|
|
runParity(
|
|
SYN_KF3,
|
|
(s) => updateKfRecast(s, id, 100, { opacity: 0.9 }),
|
|
(s) => updateKfAcorn(s, id, 100, { opacity: 0.9 }),
|
|
);
|
|
});
|
|
|
|
it("replaces a keyframe that carried a per-keyframe ease (ease dropped when omitted)", () => {
|
|
const id = idAt(SYN_KF_EASE, 0);
|
|
runParity(
|
|
SYN_KF_EASE,
|
|
(s) => updateKfRecast(s, id, 50, { y: 25 }),
|
|
(s) => updateKfAcorn(s, id, 50, { y: 25 }),
|
|
);
|
|
});
|
|
|
|
it("replaces with multiple props on a keyframes-with-easeEach fixture", () => {
|
|
const id = idAt(SYN_KF_EASE, 0);
|
|
runParity(
|
|
SYN_KF_EASE,
|
|
(s) => updateKfRecast(s, id, 100, { y: 5, opacity: 1 }),
|
|
(s) => updateKfAcorn(s, id, 100, { y: 5, opacity: 1 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 5. removeKeyframeFromScript ──────────────────────────────────────────────────
|
|
|
|
describe("parity — removeKeyframeFromScript", () => {
|
|
it("removes the interior keyframe, leaving ≥2 (no collapse)", () => {
|
|
const id = idAt(SYN_KF3, 0);
|
|
runParity(
|
|
SYN_KF3,
|
|
(s) => removeKfRecast(s, id, 50),
|
|
(s) => removeKfAcorn(s, id, 50),
|
|
);
|
|
});
|
|
|
|
it("collapses to a flat tween when removal leaves a single keyframe", () => {
|
|
const id = idAt(SYN_KF2, 0);
|
|
runParity(
|
|
SYN_KF2,
|
|
(s) => removeKfRecast(s, id, 0),
|
|
(s) => removeKfAcorn(s, id, 0),
|
|
);
|
|
});
|
|
|
|
it("collapses to flat preserving the remaining keyframe's props (remove 100%)", () => {
|
|
const id = idAt(SYN_KF2, 0);
|
|
runParity(
|
|
SYN_KF2,
|
|
(s) => removeKfRecast(s, id, 100),
|
|
(s) => removeKfAcorn(s, id, 100),
|
|
);
|
|
});
|
|
|
|
it("collapses a keyframes-with-easeEach fixture (drops easeEach + per-kf ease)", () => {
|
|
// SYN_KF_EASE has 3 kfs; remove two so only one remains → collapse.
|
|
const id1 = idAt(SYN_KF_EASE, 0);
|
|
const afterFirst = runParity(
|
|
SYN_KF_EASE,
|
|
(s) => removeKfRecast(s, id1, 50),
|
|
(s) => removeKfAcorn(s, id1, 50),
|
|
);
|
|
const id2 = idAt(afterFirst, 0);
|
|
runParity(
|
|
afterFirst,
|
|
(s) => removeKfRecast(s, id2, 0),
|
|
(s) => removeKfAcorn(s, id2, 0),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 6. Label correctness (acorn-only ops — no recast oracle to diff) ─────────────
|
|
//
|
|
// addLabelToScript / removeLabelFromScript exist only on the acorn writer, so we
|
|
// cannot parity-test them. Instead we verify the source-level contract directly.
|
|
|
|
describe("correctness — addLabelToScript / removeLabelFromScript", () => {
|
|
function labelCallCount(script: string, name: string): number {
|
|
const re = new RegExp(`\\.addLabel\\(\\s*"${name}"`, "g");
|
|
return (script.match(re) ?? []).length;
|
|
}
|
|
|
|
it("adds an addLabel(name, pos) call after the last located tween", () => {
|
|
const out = addLabelToScript(SYN_SINGLE, "mid", 1.5);
|
|
expect(out).toContain('tl.addLabel("mid", 1.5);');
|
|
// The label sits after the hero tween and before the postamble.
|
|
expect(out.indexOf('addLabel("mid"')).toBeGreaterThan(out.indexOf('tl.to("#hero"'));
|
|
expect(out.indexOf('addLabel("mid"')).toBeLessThan(out.indexOf("window.__timelines"));
|
|
});
|
|
|
|
it("adds a label to an empty (tween-less) timeline after the declaration", () => {
|
|
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
|
const out = addLabelToScript(empty, "start", 0.8);
|
|
expect(out).toContain('tl.addLabel("start", 0.8);');
|
|
expect(out.indexOf('addLabel("start"')).toBeGreaterThan(out.indexOf("gsap.timeline"));
|
|
});
|
|
|
|
it("removes a previously-added label (round-trip back to original)", () => {
|
|
const empty = `var tl = gsap.timeline({ paused: true });\nwindow.__timelines["t"] = tl;`;
|
|
const added = addLabelToScript(empty, "start", 0.8);
|
|
const removed = removeLabelFromScript(added, "start");
|
|
expect(removed).toBe(empty);
|
|
});
|
|
|
|
it("removes a hand-authored label, leaving the tweens intact", () => {
|
|
const out = removeLabelFromScript(SYN_LABELED, "intro");
|
|
expect(labelCallCount(out, "intro")).toBe(0);
|
|
expect(out).toContain('tl.to("#a"');
|
|
expect(out).toContain('tl.to("#b"');
|
|
});
|
|
|
|
it("is idempotent: removing an absent label is a no-op", () => {
|
|
expect(removeLabelFromScript(SYN_SINGLE, "nope")).toBe(SYN_SINGLE);
|
|
});
|
|
|
|
it("adding the same label twice MOVES it instead of duplicating (dedup contract)", () => {
|
|
// A second addLabel for an existing name must not append a duplicate —
|
|
// duplicates make removeLabel over-remove. It moves the label's position.
|
|
const once = addLabelToScript(SYN_SINGLE, "mid", 1.0);
|
|
const twice = addLabelToScript(once, "mid", 2.0);
|
|
expect(labelCallCount(twice, "mid")).toBe(1);
|
|
expect(twice).toContain('tl.addLabel("mid", 2)');
|
|
});
|
|
|
|
it("removeLabel deletes ALL matching addLabel calls for the name (hand-authored dups)", () => {
|
|
const dup = `var tl = gsap.timeline({ paused: true });\ntl.addLabel("mid", 1);\ntl.addLabel("mid", 2);\nwindow.__timelines["t"] = tl;`;
|
|
expect(labelCallCount(dup, "mid")).toBe(2);
|
|
expect(labelCallCount(removeLabelFromScript(dup, "mid"), "mid")).toBe(0);
|
|
});
|
|
|
|
it("the added label is observable by the parser when a tween references it", () => {
|
|
// Tween at numeric 0; add a label at 1.0; a follow-up tween positioned at the
|
|
// label parses without error and is located alongside the others.
|
|
const withLabel = addLabelToScript(SYN_SINGLE, "beat", 1.0);
|
|
const parsed = parseGsapScriptAcorn(withLabel);
|
|
expect(parsed.animations.length).toBe(1);
|
|
expect(withLabel).toContain('tl.addLabel("beat", 1);');
|
|
});
|
|
|
|
it("returns the script unchanged when there is no timeline to anchor to", () => {
|
|
const noTl = `console.log("no timeline here");`;
|
|
expect(addLabelToScript(noTl, "x", 1)).toBe(noTl);
|
|
expect(removeLabelFromScript(noTl, "x")).toBe(noTl);
|
|
});
|
|
});
|