fix(core,sdk): rebuild acorn GSAP keyframe writer for recast parity (#1520)

The acorn addKeyframeToScript mixed ms.overwrite + ms.appendLeft on the
same _auto endpoint node, crashing MagicString ("Cannot split a chunk
that has already been edited") whenever an interior keyframe adjacent to
an _auto 0/100 endpoint introduced a new backfilled prop — the common SDK
path. It also replaced (not merged) existing keyframes, dropped ease and
_auto markers, corrupted commas on multi-prop backfill into empty {},
used a <0.001 percentage tolerance instead of recast's PCT_TOLERANCE=2,
and silently no-op'd on flat (non-keyframe) tweens.

Rebuild the node model to mirror recast: compute the FINAL property record
for every changed keyframe value node (target merge, _auto endpoint sync,
backfilled siblings) against the original AST, then emit exactly one
ms.overwrite per changed node (one insert for a brand-new key). No node is
ever both overwritten and appended into, so splices can never overlap.

- Merge: re-touching an existing keyframe merges new props over the
  existing record, preserving untouched props, existing ease, and _auto.
- Convert-flat: first keyframe-add on a flat to()/from()/fromTo() tween
  rebuilds its vars object to percentage keyframes (ease->easeEach,
  ease:"none", from/fromTo->to) matching recast, then re-locates via the
  -from-/-fromTo- -> -to- id fallback.
- Tolerance: PCT_TOLERANCE=2 for existing-keyframe detection.
- Shared serializeValue/safeJsKey for keyframe values (recast parity); the
  tween-statement path keeps its local serializer for object/boolean extras.
- keyframeBackfill: only backfill props with a real numeric default; skip
  unknown/string props so color:0 / filter:0 are never emitted.
- setGsapKeyframe move-path threads the same backfill defaults as the add
  path so both entry points behave identically.

Differential tests (acorn vs recast parsed keyframe arrays) cover the
crash (2-endpoint + 0/25/100), empty-{} multi-prop backfill, merge with
extra props + ease, flat to()/fromTo() convert, "50.0%" non-byte-equal
key, near-% tolerance, and _auto-marker preservation onto an endpoint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-16 17:54:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0ba52fc130
commit b5ad518957
5 changed files with 720 additions and 46 deletions
+390 -44
View File
@@ -7,7 +7,7 @@
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
*/
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import { serializeValue, safeJsKey, type GsapAnimation } from "./gsapSerialize.js";
import {
parseGsapScriptAcornForWrite,
type ParsedGsapAcornForWrite,
@@ -17,6 +17,10 @@ import * as acornWalk from "acorn-walk";
// ── Code generation helpers ──────────────────────────────────────────────────
// Local serializer for the tween-statement path, which may carry boolean/object
// extras (stagger config). serializeValue stringifies objects to "[object
// Object]", so keep this richer JSON fallback for that path. Keyframe values are
// always number|string and use the shared serializeValue (recast parity).
function valueToCode(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
@@ -264,31 +268,248 @@ export function removeAnimationFromScript(script: string, animationId: string):
return ms.toString();
}
// ── Flat-tween → keyframes conversion ──────────────────────────────────────────
//
// Mirror recast's convertToKeyframesInScript: when the first keyframe op lands
// on a flat to()/from()/fromTo() tween, rewrite its vars object to
// `{ keyframes: { "0%": {from}, "100%": {to} }, <preserved non-editable keys>,
// ease: "none"? }` and convert from()/fromTo() to to(). We rebuild the whole
// vars ObjectExpression in one ms.overwrite (single-edit-per-node), so the next
// keyframe-add re-parses cleanly.
// Identity value for an editable transform/style prop (recast's CSS_IDENTITY).
const CSS_IDENTITY: Record<string, number> = {
opacity: 1,
autoAlpha: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
};
function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}
// Keys NOT in the editable set — preserved verbatim on the converted vars object
// (matches the parser's classification: builtin/dropped/extras keys).
const NON_EDITABLE_VAR_KEYS = new Set([
"duration",
"delay",
"onComplete",
"onStart",
"onUpdate",
"onRepeat",
"stagger",
"yoyo",
"repeat",
"repeatDelay",
"snap",
"overwrite",
"immediateRender",
]);
/** The CSS-identity counterpart of a props record (numbers → identity value). */
function identityProps(
properties: Record<string, number | string>,
): Record<string, number | string> {
const identity: Record<string, number | string> = {};
for (const [k, v] of Object.entries(properties)) {
if (v != null) identity[k] = typeof v === "number" ? cssIdentityValue(k) : v;
}
return identity;
}
/** Resolve the 0%/100% endpoint records for a tween being converted. */
function conversionEndpoints(animation: GsapAnimation): {
fromProps: Record<string, number | string>;
toProps: Record<string, number | string>;
} {
if (animation.method === "from") {
return { fromProps: { ...animation.properties }, toProps: identityProps(animation.properties) };
}
if (animation.method === "fromTo") {
return {
fromProps: { ...(animation.fromProperties ?? {}) },
toProps: { ...animation.properties },
};
}
// to(): 0% is the CSS identity state, 100% is the authored props.
return { fromProps: identityProps(animation.properties), toProps: { ...animation.properties } };
}
/** Collect preserved (non-editable) `key: value` entries from the original vars node. */
function preservedVarsEntries(varsNode: any, source: string): string[] {
const entries: string[] = [];
if (varsNode?.type !== "ObjectExpression") return entries;
for (const prop of varsNode.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (typeof key !== "string" || !NON_EDITABLE_VAR_KEYS.has(key)) continue;
entries.push(`${safeKey(key)}: ${source.slice(prop.value.start, prop.value.end)}`);
}
return entries;
}
/** Build the rebuilt vars-object code for a converted flat tween. */
function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: string): string {
const { fromProps, toProps } = conversionEndpoints(animation);
const easeEach = animation.ease;
const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
const kfCode = `{ "0%": ${recordToCode(fromProps)}, "100%": ${recordToCode(toProps)}${easeEachEntry} }`;
const entries = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)];
if (easeEach) entries.push(`ease: "none"`);
return `{ ${entries.join(", ")} }`;
}
/** Rename a from()/fromTo() call to to(), dropping fromTo's leading from-vars arg. */
function convertMethodToTo(
ms: MagicString,
animation: GsapAnimation,
call: any,
varsNode: any,
): void {
if (animation.method !== "from" && animation.method !== "fromTo") return;
const calleeProp = call.node.callee?.property;
if (calleeProp) ms.overwrite(calleeProp.start, calleeProp.end, "to");
// Remove the from-vars arg and its trailing separator up to the to-vars arg.
if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start);
}
function convertFlatTweenToKeyframes(script: string, target: any): string {
const animation: GsapAnimation = target.animation;
if (animation.keyframes || animation.method === "set") return script;
const call = target.call;
const varsNode = call.varsArg;
if (varsNode?.type !== "ObjectExpression") return script;
const ms = new MagicString(script);
ms.overwrite(varsNode.start, varsNode.end, buildConvertedVarsCode(animation, varsNode, script));
convertMethodToTo(ms, animation, call, varsNode);
return ms.toString();
}
// ── Keyframe write ops ────────────────────────────────────────────────────────
//
// Design: mirror the recast writer's rebuild-the-node model. The recast writer
// mutates AST nodes in place and re-prints, so it never has an offset-overlap
// problem. Here we instead compute the FINAL property record for every keyframe
// value node that must change (the target merge, `_auto` endpoint sync, and
// backfilled siblings) against the ORIGINAL parsed AST, then emit exactly ONE
// `ms.overwrite(valueNode.start, valueNode.end, code)` per changed node (and a
// single insert for a brand-new key). No node is ever both overwritten and
// appended into, so the splices can never overlap.
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
// Matches recast's PCT_TOLERANCE: percentages within 2 of an existing key are
// treated as the same keyframe (merge), not a new insert.
const PCT_TOLERANCE = 2;
function percentageFromKey(key: string): number {
const m = PERCENTAGE_KEY_RE.exec(key);
return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN;
}
function buildKeyframeValueCode(
properties: Record<string, number | string>,
ease?: string,
): string {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
/** Serialize a final keyframe property record (number|string values) to code. */
function recordToCode(record: Record<string, number | string>): string {
const entries = Object.entries(record).map(([k, v]) => `${safeJsKey(k)}: ${serializeValue(v)}`);
return `{ ${entries.join(", ")} }`;
}
/** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */
function percentagePropsOf(kfNode: any): any[] {
return (kfNode.properties ?? []).filter((p: any) => {
if (!isObjectProperty(p)) return false;
const key = propKeyName(p);
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
});
}
const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral"]);
/** Read one value node: a number/string literal, a negative number, or raw source. */
// fallow-ignore-next-line complexity
function readValueNode(v: any, source: string): number | string {
if (
LITERAL_NODE_TYPES.has(v?.type) &&
(typeof v.value === "number" || typeof v.value === "string")
) {
return v.value;
}
if (
v?.type === "UnaryExpression" &&
v.operator === "-" &&
typeof v.argument?.value === "number"
) {
return -v.argument.value;
}
return `__raw:${source.slice(v.start, v.end)}`;
}
/**
* Read a keyframe value ObjectExpression into a record, mirroring the parser's
* `objectExpressionToRecord`: literals resolve to their value; anything else is
* preserved as `__raw:<source>` so serializeValue round-trips it verbatim.
* Keyframe values are literals in practice, so the raw fallback is rarely hit.
*/
function valueNodeToRecord(valueNode: any, source: string): Record<string, number | string> {
const record: Record<string, number | string> = {};
if (valueNode?.type !== "ObjectExpression") return record;
for (const prop of valueNode.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (typeof key !== "string") continue;
record[key] = readValueNode(prop.value, source);
}
return record;
}
/** True when a keyframe value record carries the synthetic `_auto` marker. */
function recordHasAuto(record: Record<string, number | string>): boolean {
return "_auto" in record;
}
/**
* Compute `_auto` endpoint overwrites: when the new keyframe is the immediate
* neighbor of an `_auto` 0% or 100% endpoint, that endpoint is rewritten to
* `{ ...newProps, _auto: 1 }`. Only fires for interior keyframes. Returns the
* percentage→overwrite map so the caller can fold these into the per-node final
* records (never a separate splice).
*/
function autoEndpointOverwrites(
kfNode: any,
source: string,
percentage: number,
properties: Record<string, number | string>,
): Map<any, Record<string, number | string>> {
const result = new Map<any, Record<string, number | string>>();
if (percentage <= 0 || percentage >= 100) return result;
const pctProps = percentagePropsOf(kfNode);
const allPcts = pctProps
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
.sort((a: number, b: number) => a - b);
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
const rightNeighbor = allPcts.find((p: number) => p > percentage);
for (const endPct of [0, 100]) {
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
if (!isNeighbor) continue;
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
if (!endProp) continue;
const rec = valueNodeToRecord(endProp.value, source);
if (!recordHasAuto(rec)) continue;
result.set(endProp, { ...properties, _auto: 1 });
}
return result;
}
function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null {
const props = kfNode.properties ?? [];
for (let i = 0; i < props.length; i++) {
const prop = props[i];
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) < 0.001) {
if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) <= PCT_TOLERANCE) {
return { prop, idx: i };
}
}
@@ -313,62 +534,187 @@ export function updateKeyframeInScript(
const match = findKfPropByPct(kfPropNode.value, percentage);
if (!match) return script;
const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
const ms = new MagicString(script);
ms.overwrite(
match.prop.value.start,
match.prop.value.end,
buildKeyframeValueCode(properties, ease),
);
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
return ms.toString();
}
// fallow-ignore-next-line complexity
/**
* Build the final property record for the keyframe at `percentage`. If a
* keyframe already exists there, MERGE the new props over the existing record
* (preserve untouched props, preserve `_auto`, preserve the existing per-keyframe
* ease when the op omits one); otherwise it's just the new props.
*/
function buildTargetRecord(
existing: { prop: any; idx: number } | null,
source: string,
properties: Record<string, number | string>,
ease: string | undefined,
): Record<string, number | string> {
if (!existing || existing.prop.value?.type !== "ObjectExpression") {
const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
return record;
}
const existingRecord = valueNodeToRecord(existing.prop.value, source);
const existingEase = typeof existingRecord.ease === "string" ? existingRecord.ease : undefined;
const merged: Record<string, number | string> = { ...existingRecord };
for (const [k, v] of Object.entries(properties)) merged[k] = v;
const finalEase = ease ?? existingEase;
if (finalEase) merged.ease = finalEase;
else delete merged.ease;
return merged;
}
/**
* Compute the backfilled final record for one sibling keyframe: append any of
* `newPropKeys` it's missing, using the backfill default. Returns null when
* nothing changes (so the caller emits no overwrite for it).
*/
function backfilledSiblingRecord(
valueNode: any,
source: string,
newPropKeys: string[],
backfillDefaults: Record<string, number | string>,
): Record<string, number | string> | null {
if (valueNode?.type !== "ObjectExpression") return null;
const record = valueNodeToRecord(valueNode, source);
let changed = false;
for (const pk of newPropKeys) {
const defaultVal = backfillDefaults[pk];
if (pk in record || defaultVal == null) continue;
record[pk] = defaultVal;
changed = true;
}
return changed ? record : null;
}
/** A located tween whose varsArg has a static keyframes ObjectExpression, or null. */
function locateWithKeyframes(
script: string,
animationId: string,
): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return null;
// Converting from()/fromTo() to to() rewrites the content-derived id; match
// recast's locateAnimationWithFallback by remapping the method segment.
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
const target =
parsed.located.find((l) => l.id === animationId) ??
parsed.located.find((l) => l.id === convertedId);
if (!target) return null;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null;
return { script, parsed, target, kfNode: kfPropNode.value };
}
/** Locate a tween's keyframes object, converting a flat tween first if absent. */
function ensureKeyframesNode(
script: string,
animationId: string,
): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null {
const direct = locateWithKeyframes(script, animationId);
if (direct) return direct;
// No static keyframes object — convert the flat tween, then re-locate.
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((l) => l.id === animationId);
if (!target) return null;
const converted = convertFlatTweenToKeyframes(script, target);
if (converted === script) return null;
return locateWithKeyframes(converted, animationId);
}
/**
* Compute the sibling keyframe nodes that need a backfilled prop, excluding the
* target keyframe and any node already being overwritten as an `_auto` endpoint.
*/
function collectBackfillOverwrites(
kfNode: any,
src: string,
properties: Record<string, number | string>,
backfillDefaults: Record<string, number | string> | undefined,
skip: { existingProp: any; endpoints: Map<any, unknown> },
): Map<any, Record<string, number | string>> {
const result = new Map<any, Record<string, number | string>>();
if (!backfillDefaults) return result;
const newPropKeys = Object.keys(properties);
for (const prop of percentagePropsOf(kfNode)) {
if (prop === skip.existingProp || skip.endpoints.has(prop)) continue;
const rec = backfilledSiblingRecord(prop.value, src, newPropKeys, backfillDefaults);
if (rec) result.set(prop, rec);
}
return result;
}
export function addKeyframeToScript(
script: string,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
backfillDefaults?: Record<string, number | string>,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
const kfNode = kfPropNode.value;
const ms = new MagicString(script);
const pctKey = `${percentage}%`;
const valueCode = buildKeyframeValueCode(properties, ease);
const located = ensureKeyframesNode(script, animationId);
if (!located) return script;
const { script: src, kfNode } = located;
const existing = findKfPropByPct(kfNode, percentage);
// Final record for the target keyframe (merge if it already exists).
const targetRecord = buildTargetRecord(existing, src, properties, ease);
// `_auto` endpoint syncs fire only on new inserts; a merge landing ON an
// endpoint already preserves `_auto` via buildTargetRecord.
const endpointOverwrites = existing
? new Map<any, Record<string, number | string>>()
: autoEndpointOverwrites(kfNode, src, percentage, properties);
// Backfilled siblings (each node changes at most once).
const backfillOverwrites = collectBackfillOverwrites(kfNode, src, properties, backfillDefaults, {
existingProp: existing?.prop,
endpoints: endpointOverwrites,
});
// Emit exactly one overwrite per changed node, plus one insert for a new key.
const ms = new MagicString(src);
if (existing) {
ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode);
ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord));
} else {
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
let insertBeforeProp: any = null;
for (const prop of allProps) {
const key = propKeyName(prop);
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertBeforeProp = prop;
break;
}
}
if (insertBeforeProp) {
// Insert `"pct%": {...}, ` before the next higher-percentage prop
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
} else {
// Append at end of kfNode properties
const sep = allProps.length > 0 ? ", " : "";
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
}
insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord));
}
for (const [prop, rec] of [...endpointOverwrites, ...backfillOverwrites]) {
ms.overwrite(prop.value.start, prop.value.end, recordToCode(rec));
}
return ms.toString();
}
/** Insert a brand-new `"pct%": {...}` property in sorted order. */
function insertNewKeyframe(
ms: MagicString,
kfNode: any,
percentage: number,
pctKey: string,
valueCode: string,
): void {
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
let insertBeforeProp: any = null;
for (const prop of allProps) {
const key = propKeyName(prop);
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertBeforeProp = prop;
break;
}
}
if (insertBeforeProp) {
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
} else {
const sep = allProps.length > 0 ? ", " : "";
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
}
}
export function removeKeyframeFromScript(
script: string,
animationId: string,
@@ -0,0 +1,210 @@
// fallow-ignore-file code-duplication
/**
* Differential parity test: acorn writer vs recast writer for addKeyframeToScript.
*
* The SDK uses the acorn (magic-string) writer; the server uses the recast
* writer. An SDK-written keyframe op must produce a GSAP timeline whose parsed
* keyframe array matches the recast-written one, otherwise newly-added props
* snap instead of tween and stale `_auto` endpoints persist.
*
* We compare the *parsed keyframe arrays* (not byte-for-byte source) because the
* two writers format differently (recast pretty-prints, acorn splices).
*/
import { describe, expect, it } from "vitest";
import { addKeyframeToScript as addAcorn } from "./gsapWriterAcorn.js";
import { addKeyframeToScript as addRecast, parseGsapScript } from "./gsapParser.js";
// These fixtures hold exactly one tween. We look it up by index rather than by
// id because the stable id is content-derived: adding/backfilling a property
// changes the id, so a hardcoded lookup would spuriously return null.
function keyframesOf(script: string) {
const parsed = parseGsapScript(script);
const anim = parsed.animations[0];
const kf = anim?.keyframes;
if (!kf || kf.format !== "percentage") return null;
return kf.keyframes
.slice()
.sort((a, b) => a.percentage - b.percentage)
.map((k) => ({ percentage: k.percentage, properties: k.properties, ease: k.ease }));
}
// Script whose 0% / 100% endpoints carry the synthetic `_auto: 1` marker the
// parser emits for auto-derived endpoints.
const AUTO_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
// Script with three plain keyframes (no _auto), used for the backfill case.
const PLAIN_SCRIPT = `\
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;`;
// _auto endpoints with an interior 25% plain keyframe (0/25/100). Exercises the
// "interior keyframe adjacent to a 100% _auto endpoint" path that crashed.
const AUTO_THREE_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "25%": { opacity: 0.5 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
// An existing keyframe carrying extra props + a per-keyframe ease. Re-touching
// one prop must MERGE (preserve the others + the ease), not replace wholesale.
const MERGE_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "50%": { opacity: 0.7, x: 30, scale: 2, ease: "power2.in" }, "100%": { opacity: 1, x: 60 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
// An existing keyframe keyed "50.0%" (not byte-equal to "50%").
const DECIMAL_KEY_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50.0%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
// Flat tweens (no keyframes object) — the first keyframe-add must convert them.
const FLAT_TO_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0.5, x: 100, duration: 0.5, ease: "power2.out" }, 0.2);
window.__timelines["t"] = tl;`;
const FLAT_FROMTO_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
tl.fromTo("#box", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
function animId(script: string): string {
const id = parseGsapScript(script).animations[0]?.id;
if (!id) throw new Error("no animation in fixture");
return id;
}
describe("acorn↔recast addKeyframeToScript parity", () => {
it("rewrites an _auto 100% endpoint when the inserted keyframe is its left neighbor", () => {
const id = animId(AUTO_SCRIPT);
const props = { opacity: 0.3, x: 50 };
const recast = addRecast(AUTO_SCRIPT, id, 60, props);
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("rewrites an _auto 0% endpoint when the inserted keyframe is its right neighbor", () => {
const id = animId(AUTO_SCRIPT);
const props = { opacity: 0.8, scale: 2 };
const recast = addRecast(AUTO_SCRIPT, id, 40, props);
const acorn = addAcorn(AUTO_SCRIPT, id, 40, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("backfills a NEW property into the other keyframes with its default value", () => {
const id = animId(PLAIN_SCRIPT);
const props = { opacity: 0.3, x: 120 };
const backfill = { opacity: 1, x: 0 };
const recast = addRecast(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("no backfill arg → matches recast with no backfill (new prop left absent)", () => {
const id = animId(PLAIN_SCRIPT);
const props = { opacity: 0.3, x: 120 };
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("plain insert in sorted order stays at parity", () => {
const id = animId(PLAIN_SCRIPT);
const props = { opacity: 0.3 };
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 1: crash — _auto endpoint sync + backfill of a new prop together ──────
it("syncs an _auto 100% endpoint AND backfills a new prop (2-endpoint, the crash)", () => {
const id = animId(AUTO_SCRIPT);
const props = { opacity: 0.3, x: 50 };
const backfill = { opacity: 1, x: 0 };
const recast = addRecast(AUTO_SCRIPT, id, 60, props, undefined, backfill);
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("syncs _auto endpoint AND backfills a new prop (0/25/100 interior, the crash)", () => {
const id = animId(AUTO_THREE_SCRIPT);
const props = { opacity: 0.4, x: 80 };
const backfill = { opacity: 1, x: 0 };
const recast = addRecast(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
const acorn = addAcorn(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 2: no-comma corruption — backfill ≥2 props into an empty {} keyframe ──
it("backfills ≥2 new props into an empty {} keyframe without dropping the comma", () => {
const EMPTY_KF = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": {}, "100%": { x: 100, y: 50 } }, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
const id = animId(EMPTY_KF);
const props = { x: 40, y: 20 };
const backfill = { x: 0, y: 0 };
const recast = addRecast(EMPTY_KF, id, 50, props, undefined, backfill);
const acorn = addAcorn(EMPTY_KF, id, 50, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 4 + 7: merge — preserve untouched props AND existing ease ──────────────
it("merges new props over an existing keyframe, preserving its other props + ease", () => {
const id = animId(MERGE_SCRIPT);
const props = { opacity: 0.9 };
const recast = addRecast(MERGE_SCRIPT, id, 50, props);
const acorn = addAcorn(MERGE_SCRIPT, id, 50, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 5: convert-flat — first keyframe-add on a flat tween ──────────────────
it("converts a flat to() tween to keyframes on the first keyframe add", () => {
const id = animId(FLAT_TO_SCRIPT);
const props = { opacity: 0.8 };
const backfill = { opacity: 1 };
const recast = addRecast(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
const acorn = addAcorn(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
it("converts a flat fromTo() tween to keyframes on the first keyframe add", () => {
const id = animId(FLAT_FROMTO_SCRIPT);
const props = { y: 10 };
const backfill = { y: 0 };
const recast = addRecast(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
const acorn = addAcorn(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 3: existing "50.0%" key, add at 50 (non-byte-equal % key) ─────────────
it("merges into a non-byte-equal '50.0%' key when adding at 50", () => {
const id = animId(DECIMAL_KEY_SCRIPT);
const props = { opacity: 0.9 };
const recast = addRecast(DECIMAL_KEY_SCRIPT, id, 50, props);
const acorn = addAcorn(DECIMAL_KEY_SCRIPT, id, 50, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 8: %-tolerance — existing 50, add 51 should MERGE (PCT_TOLERANCE=2) ────
it("treats a near-coincident percentage (50 vs 51) as the same keyframe (merge)", () => {
const id = animId(PLAIN_SCRIPT);
const props = { opacity: 0.9 };
const recast = addRecast(PLAIN_SCRIPT, id, 51, props);
const acorn = addAcorn(PLAIN_SCRIPT, id, 51, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
// ── Bug 7: adding ONTO a 0/100 _auto endpoint preserves the _auto marker ──────
it("preserves the _auto marker when adding a prop directly onto a 0% _auto endpoint", () => {
const id = animId(AUTO_SCRIPT);
const props = { x: 25 };
const recast = addRecast(AUTO_SCRIPT, id, 0, props);
const acorn = addAcorn(AUTO_SCRIPT, id, 0, props);
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
});
});