Files
hyperframes/packages/studio/src/components/editor/motionPathCommit.test.ts
T
Miguel Ángel cdf9c817e1 refactor: extract @hyperframes/parsers from core (#1755)
## Summary

Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package.

This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on.

**Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch.

## What moves

| | |
|---|---|
| Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) |
| Total lines removed from core (incl. tests + goldens) | ~19,600 |
| Files relocated | 39 |
| Tests carried over | **660 passing** (5 skipped, 3 todo) |

The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus.

## Bundle footprint of the new package

| Artifact | Size |
|---|---|
| `dist/` (unpacked) | 1.7 MB |
| npm tarball (packed) | 409 KB |
| `dist/index.js` | 90 KB (**~21 KB gzipped**) |
| Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB |

Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers.

## How `@hyperframes/core` changes

The interesting part: **core sheds its entire AST toolchain.**

| core `dependencies` | before | after |
|---|---|---|
| count | 9 | 6 |
| removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` |
| added | — | `@hyperframes/parsers`, `linkedom` |

Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks.

## Design notes

- **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable.
- `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack.

## Test plan

- [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass
- [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass
- [x] `bun run build` — full monorepo build succeeds
- [x] Fallow audit passes on CI
2026-06-27 00:46:26 -04:00

131 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import { editableAnimationId } from "./motionPathSelection";
import {
commitNode,
commitAddWaypoint,
commitAddKeyframe,
commitRemoveWaypoint,
commitCreatePath,
} from "./motionPathCommit";
const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
({
id: "a1",
targetSelector: "#el",
method: "to",
position: 0,
properties: {},
...over,
}) as GsapAnimation;
describe("editableAnimationId", () => {
it("picks the arc animation for an arc path", () => {
const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
});
it("picks a position-keyframe animation for a linear path", () => {
const kf = anim({
id: "kf1",
propertyGroup: "position",
keyframes: {
format: "percentage",
keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
} as never,
});
expect(editableAnimationId([kf], "linear")).toBe("kf1");
});
it("returns null for dynamic (unresolved) tweens — read-only", () => {
const dyn = anim({
id: "dyn",
arcPath: { enabled: true, autoRotate: false, segments: [] },
hasUnresolvedKeyframes: true,
});
expect(editableAnimationId([dyn], "arc")).toBeNull();
});
it("returns null for non-literal (helper) provenance — read-only", () => {
const helper = anim({
id: "h",
arcPath: { enabled: true, autoRotate: false, segments: [] },
provenance: { kind: "helper" } as never,
});
expect(editableAnimationId([helper], "arc")).toBeNull();
});
it("returns null when nothing matches", () => {
expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
});
});
describe("commitNode", () => {
it("routes a keyframe node to update-keyframe by percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "keyframe", pct: 50 }, 120, 30, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-keyframe", animationId: "a1", percentage: 50, properties: { x: 120, y: 30 } },
expect.objectContaining({ softReload: true }),
);
});
it("routes a waypoint node to update-motion-path-point by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitNode({ type: "waypoint", index: 2 }, 80, 40, "a1", commit);
expect(commit).toHaveBeenCalledWith(
{ type: "update-motion-path-point", animationId: "a1", pointIndex: 2, x: 80, y: 40 },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitAddWaypoint / commitRemoveWaypoint", () => {
it("adds a waypoint at an index with coordinates", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddWaypoint("a1", 1, 120, -40, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-motion-path-point", animationId: "a1", index: 1, x: 120, y: -40 },
expect.objectContaining({ softReload: true }),
);
});
it("removes a waypoint by index", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitRemoveWaypoint("a1", 2, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "remove-motion-path-point", animationId: "a1", index: 2 },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitAddKeyframe", () => {
it("inserts an x/y keyframe at a tween-relative percentage", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitAddKeyframe("a1", 42.5, 80, -20, commit);
expect(commit).toHaveBeenCalledWith(
{ type: "add-keyframe", animationId: "a1", percentage: 42.5, properties: { x: 80, y: -20 } },
expect.objectContaining({ softReload: true }),
);
});
});
describe("commitCreatePath", () => {
it("authors a new motionPath to a destination at a given time", async () => {
const commit = vi.fn().mockResolvedValue(undefined);
await commitCreatePath("#title", 2.0, 300, -120, commit);
expect(commit).toHaveBeenCalledWith(
{
type: "add-motion-path",
targetSelector: "#title",
position: 2.0,
duration: 1.5,
x: 300,
y: -120,
},
expect.objectContaining({ softReload: true }),
);
});
});