From 0bf71d21094612cfca4a1982b74ef8c14a488282 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 11:24:25 -0700 Subject: [PATCH 1/6] test(studio): add T5b rotation+motion build-patches characterization Extends manualEditsDomPatches.test.ts with rotation and motion pairs. Same 4-pattern structure: populated, empty, clear restores originals, build/clear symmetry. Merges duplicate manualEditsTypes import block. --- .../editor/manualEditsDomPatches.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 028c2600d..3cff4e7a0 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -7,8 +7,11 @@ import { STUDIO_OFFSET_Y_PROP, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, + STUDIO_ROTATION_PROP, STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, + STUDIO_ROTATION_ATTR, + STUDIO_ROTATION_DRAFT_ATTR, STUDIO_ORIGINAL_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, @@ -24,13 +27,26 @@ import { STUDIO_ORIGINAL_SCALE_ATTR, STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, STUDIO_ORIGINAL_DISPLAY_ATTR, + STUDIO_ORIGINAL_ROTATE_ATTR, + STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, } from "./manualEditsTypes"; +import { + STUDIO_MOTION_ATTR, + STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, + STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, +} from "./studioMotionTypes"; import { buildPathOffsetPatches, buildClearPathOffsetPatches, buildBoxSizePatches, buildClearBoxSizePatches, + buildRotationPatches, + buildClearRotationPatches, + buildMotionPatches, + buildClearMotionPatches, } from "./manualEditsDomPatches"; /* ── helpers ── */ @@ -210,3 +226,117 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { assertClearCoversKeys(buildBoxSizePatches(e), buildClearBoxSizePatches(e)); }); }); + +/* ── Rotation ────────────────────────────────────────────────────────────── */ + +describe("buildRotationPatches / buildClearRotationPatches", () => { + function populatedRotEl(): HTMLElement { + const e = div(); + e.style.setProperty(STUDIO_ROTATION_PROP, "45"); + e.style.setProperty("rotate", "45deg"); + e.style.setProperty("transform-origin", "left center"); + e.style.setProperty("display", "block"); + e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg"); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg"); + e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center"); + e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex"); + return e; + } + + it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => { + const ops = buildRotationPatches(populatedRotEl()); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" }, + { type: "inline-style", property: "rotate", value: "45deg" }, + { type: "inline-style", property: "transform-origin", value: "left center" }, + { type: "inline-style", property: "display", value: "block" }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" }, + { + type: "attribute", + property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + value: "center center", + }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" }, + ]); + }); + + it("empty: bare element yields only the rotation marker", () => { + expect(buildRotationPatches(div())).toEqual([ + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }, + ]); + }); + + it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg"); + e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "top left"); + e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid"); + const ops = buildClearRotationPatches(e); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null }, + { type: "inline-style", property: "rotate", value: "30deg" }, + { type: "inline-style", property: "transform-origin", value: "top left" }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null }, + { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null }, + { type: "inline-style", property: "display", value: "grid" }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }, + ]); + }); + + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { + const e = populatedRotEl(); + assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e)); + }); +}); + +/* ── Motion ──────────────────────────────────────────────────────────────── */ + +describe("buildMotionPatches / buildClearMotionPatches", () => { + const MOTION_JSON = '{"kind":"gsap-motion","start":0,"duration":1}'; + + function populatedMotionEl(): HTMLElement { + const e = div(); + e.setAttribute(STUDIO_MOTION_ATTR, MOTION_JSON); + e.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, "translateX(0)"); + e.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, "1"); + e.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, "visible"); + return e; + } + + it("populated: captures motion JSON and all three original attrs when motion attr is present", () => { + const ops = buildMotionPatches(populatedMotionEl()); + expect(ops).toEqual([ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: MOTION_JSON }, + { + type: "attribute", + property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + value: "translateX(0)", + }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: "1" }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: "visible" }, + ]); + }); + + it("empty: returns [] when STUDIO_MOTION_ATTR is absent", () => { + expect(buildMotionPatches(div())).toEqual([]); + }); + + it("clear: always nulls all four motion attrs regardless of element state", () => { + expect(buildClearMotionPatches(div())).toEqual([ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null }, + ]); + }); + + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { + const e = populatedMotionEl(); + assertClearCoversKeys(buildMotionPatches(e), buildClearMotionPatches(e)); + }); +}); From e79343e7305651d3421f44ea57d8ef1858cdd6d0 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 11:31:52 -0700 Subject: [PATCH 2/6] test(studio): add T5c review-fix gaps in manualEditsDomPatches characterization Fixes four gaps identified in max-setting code review: - Box-size clear: replace arrayContaining with full ordered toEqual (30 ops) - Box-size / pathOffset / rotation clear: add empty-string coercion tests (origVal||null must produce null, not set property to "") - Rotation clear: add test for absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR - Motion clear: prove input-independence by calling with both empty and populated element and asserting identical output --- .../editor/manualEditsDomPatches.test.ts | 85 +++++++++++++++---- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 3cff4e7a0..428315829 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -118,6 +118,13 @@ describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => { ]); }); + it("clear: empty STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR coerces to null (translate not set to empty string)", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, ""); + const ops = buildClearPathOffsetPatches(e); + expect(ops.find((o) => o.property === "translate")?.value).toBeNull(); + }); + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { const e = populatedPathEl(); assertClearCoversKeys(buildPathOffsetPatches(e), buildClearPathOffsetPatches(e)); @@ -203,22 +210,54 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { ]); }); - it("clear: restores width and height from orig attrs, nulls all orig attrs", () => { + it("clear(populated): ops follow interleaved restore-then-null order for every orig attr", () => { + const ops = buildClearBoxSizePatches(populatedBoxEl()); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, + { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, + { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, + { type: "inline-style", property: "width", value: "250px" }, + { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "height", value: "150px" }, + { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "min-width", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "min-height", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "max-width", value: "none" }, + { type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "max-height", value: "none" }, + { type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "flex-basis", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: null }, + { type: "inline-style", property: "flex-grow", value: "0" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: null }, + { type: "inline-style", property: "flex-shrink", value: "1" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: null }, + { type: "inline-style", property: "box-sizing", value: "content-box" }, + { type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: null }, + { type: "inline-style", property: "scale", value: "1" }, + { type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: null }, + { type: "inline-style", property: "transform-origin", value: "50% 50%" }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: null }, + { type: "inline-style", property: "display", value: "flex" }, + { type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }, + ]); + }); + + it("clear: empty orig attr coerces to null (style is removed rather than set to empty string)", () => { const e = div(); - e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "200px"); - e.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, "100px"); + e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, ""); const ops = buildClearBoxSizePatches(e); - expect(ops).toEqual( - expect.arrayContaining([ - { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, - { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, - { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, - { type: "inline-style", property: "width", value: "200px" }, - { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null }, - { type: "inline-style", property: "height", value: "100px" }, - { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null }, - ]), - ); + expect(ops.find((o) => o.property === "width")?.value).toBeNull(); + }); + + it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => { + const ops = buildClearBoxSizePatches(div()); + // 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR) + expect(ops).toHaveLength(17); + expect(ops.every((op) => op.value === null)).toBe(true); }); it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { @@ -288,6 +327,18 @@ describe("buildRotationPatches / buildClearRotationPatches", () => { ]); }); + it("clear: absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR yields null for transform-origin", () => { + const ops = buildClearRotationPatches(div()); + expect(ops.find((o) => o.property === "transform-origin")?.value).toBeNull(); + }); + + it("clear: empty STUDIO_ORIGINAL_INLINE_ROTATE_ATTR coerces to null (rotate not set to empty string)", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, ""); + const ops = buildClearRotationPatches(e); + expect(ops.find((o) => o.property === "rotate")?.value).toBeNull(); + }); + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { const e = populatedRotEl(); assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e)); @@ -327,12 +378,14 @@ describe("buildMotionPatches / buildClearMotionPatches", () => { }); it("clear: always nulls all four motion attrs regardless of element state", () => { - expect(buildClearMotionPatches(div())).toEqual([ + const expected = [ { type: "attribute", property: STUDIO_MOTION_ATTR, value: null }, { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null }, { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null }, { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null }, - ]); + ]; + expect(buildClearMotionPatches(div())).toEqual(expected); + expect(buildClearMotionPatches(populatedMotionEl())).toEqual(expected); }); it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { From 8cf631f0f2275e1cbaae692b4087bd1860f8dd17 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 11:50:39 -0700 Subject: [PATCH 3/6] refactor(core): extract maxEndTime+serialize to parsers/test-utils.ts (TU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicate helpers shared by T1 (htmlParser.roundtrip.test.ts) and T2 (stableIds.test.ts). Both files inline identical implementations; extract to test-utils.ts so future parser tests (T6a…) import one copy. Also fix lefthook fallow command to unset GIT_DIR+GIT_INDEX_FILE before running — those vars are set by git in worktree hook context and block fallow’s internal temp-worktree creation. --- lefthook.yml | 5 +++- .../src/parsers/htmlParser.roundtrip.test.ts | 19 +------------ packages/core/src/parsers/stableIds.test.ts | 18 +----------- packages/core/src/parsers/test-utils.ts | 28 +++++++++++++++++++ packages/core/tsconfig.json | 9 +++++- 5 files changed, 42 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/parsers/test-utils.ts diff --git a/lefthook.yml b/lefthook.yml index e6d83e199..05e77d2da 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,7 +19,10 @@ pre-commit: # fails on issues introduced by the branch, not inherited findings. fallow: glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" - run: bunx fallow audit --base origin/main --fail-on-issues + # Unset git worktree env vars: fallow creates a temp worktree internally and + # GIT_DIR/GIT_INDEX_FILE (set by git in worktree hook context) break that. + # env -u is safe in non-worktree contexts (no-op when var is unset). + run: env -u GIT_DIR -u GIT_INDEX_FILE -u GIT_WORK_TREE bunx fallow audit --base origin/main --fail-on-issues filesize: # Scoped to packages/studio — the 600 LOC limit is a studio architecture # standard enforced as part of the App.tsx decomposition work. Player and diff --git a/packages/core/src/parsers/htmlParser.roundtrip.test.ts b/packages/core/src/parsers/htmlParser.roundtrip.test.ts index a1df46c29..975ee6db3 100644 --- a/packages/core/src/parsers/htmlParser.roundtrip.test.ts +++ b/packages/core/src/parsers/htmlParser.roundtrip.test.ts @@ -9,25 +9,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseHtml } from "./htmlParser.js"; +import { maxEndTime, serialize } from "./test-utils.js"; import { generateHyperframesHtml } from "../generators/hyperframes.js"; -import type { ParsedHtml } from "./htmlParser.js"; - -function maxEndTime(elements: ParsedHtml["elements"]): number { - if (elements.length === 0) return 0; - return Math.max(...elements.map((e) => e.startTime + e.duration)); -} - -function serialize(parsed: ParsedHtml): string { - // Fixed compositionId prevents Date.now() churn from masking structural instability. - // The compositionId generation instability itself is tracked as R1 (stable hf- ids). - return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { - compositionId: "test-comp", - resolution: parsed.resolution, - styles: parsed.styles ?? undefined, - keyframes: parsed.keyframes, - stageZoomKeyframes: parsed.stageZoomKeyframes, - }); -} describe("T1 — parse→serialize round-trip (DOM/timing)", () => { it("preserves element count and ids through one round-trip", () => { diff --git a/packages/core/src/parsers/stableIds.test.ts b/packages/core/src/parsers/stableIds.test.ts index 757cb23e0..a3887f04f 100644 --- a/packages/core/src/parsers/stableIds.test.ts +++ b/packages/core/src/parsers/stableIds.test.ts @@ -13,23 +13,7 @@ */ import { describe, expect, it } from "vitest"; import { parseHtml } from "./htmlParser.js"; -import { generateHyperframesHtml } from "../generators/hyperframes.js"; -import type { ParsedHtml } from "./htmlParser.js"; - -function maxEndTime(elements: ParsedHtml["elements"]): number { - if (elements.length === 0) return 0; - return Math.max(...elements.map((e) => e.startTime + e.duration)); -} - -function serialize(parsed: ParsedHtml): string { - return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { - compositionId: "test-comp", - resolution: parsed.resolution, - styles: parsed.styles ?? undefined, - keyframes: parsed.keyframes, - stageZoomKeyframes: parsed.stageZoomKeyframes, - }); -} +import { serialize } from "./test-utils.js"; describe("T2 — stable element ids (spec for R1)", () => { // --- Spec (red until R1) --- diff --git a/packages/core/src/parsers/test-utils.ts b/packages/core/src/parsers/test-utils.ts new file mode 100644 index 000000000..568b55004 --- /dev/null +++ b/packages/core/src/parsers/test-utils.ts @@ -0,0 +1,28 @@ +/** + * Shared test utilities for parser test suites (T1, T2, T6…). + * Import from here rather than duplicating helpers across test files. + * + * Not part of the public package exports — consumed only by *.test.ts files. + */ +import { generateHyperframesHtml } from "../generators/hyperframes.js"; +import type { ParsedHtml } from "./htmlParser.js"; + +export function maxEndTime(elements: ParsedHtml["elements"]): number { + if (elements.length === 0) return 0; + return Math.max(...elements.map((e) => e.startTime + e.duration)); +} + +/** + * Round-trip serialize helper. + * Fixed compositionId prevents Date.now() churn from masking structural instability. + * The compositionId generation instability itself is tracked as R1 (stable hf- ids). + */ +export function serialize(parsed: ParsedHtml): string { + return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { + compositionId: "test-comp", + resolution: parsed.resolution, + styles: parsed.styles ?? undefined, + keyframes: parsed.keyframes, + stageZoomKeyframes: parsed.stageZoomKeyframes, + }); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index cdd189fde..e1ef706c1 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,5 +15,12 @@ }, "files": ["src/runtime/mediaVolumeEnvelope.ts"], "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/tests", "src/runtime", "**/*.test.ts"] + "exclude": [ + "node_modules", + "dist", + "src/tests", + "src/runtime", + "**/*.test.ts", + "src/parsers/test-utils.ts" + ] } From 2146fea1b3a2c9b87d75618eddfd0984272d7d50 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 11:51:38 -0700 Subject: [PATCH 4/6] test(core): add T10 PreviewAdapter contract stubs (spec for R7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 14 tests are it.todo, following the T4 pattern. The stubs define the full createPreviewAdapter interface — elementAtPoint (root exclusion, hf-id ancestor walk, opacity filter), applyDraft/revertDraft (draft marker lifecycle), commitPreview (patch derivation), and getElementTimings (data-start/data-end reader). createPreviewAdapter does not exist yet; R7 implements it and converts these stubs to real assertions. --- .../studio-api/helpers/previewAdapter.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/core/src/studio-api/helpers/previewAdapter.test.ts diff --git a/packages/core/src/studio-api/helpers/previewAdapter.test.ts b/packages/core/src/studio-api/helpers/previewAdapter.test.ts new file mode 100644 index 000000000..7b5480628 --- /dev/null +++ b/packages/core/src/studio-api/helpers/previewAdapter.test.ts @@ -0,0 +1,51 @@ +/** + * T10 — PreviewAdapter contract (spec for R7). + * + * `createPreviewAdapter` does not exist yet. These stubs define the expected + * interface so R7 has a concrete target. Convert from it.todo to real + * assertions in the R7 PR. + * + * Hit-testing (elementAtPoint) in both linkedom and jsdom returns null for + * all geometry calls — the real tests must inject a position-resolver stub + * or mock elementFromPoint. The contract tested is filtering logic (root + * exclusion, data-hf-id ancestor walk, opacity-at-playhead), not geometry. + */ +import { describe, it } from "vitest"; + +describe("T10 — PreviewAdapter contract (spec for R7)", () => { + describe("elementAtPoint", () => { + it.todo("returns null for the stage root (data-hf-root)"); + + it.todo("returns the nearest ancestor with data-hf-id"); + + it.todo("returns null when the hit element has no data-hf-id ancestor"); + + it.todo("skips elements whose computed opacity is 0 at the given playhead time"); + }); + + describe("applyDraft / revertDraft", () => { + it.todo("applyDraft writes --hf-studio-* CSS props and sets the gesture marker"); + + it.todo("applyDraft accepts both move (dx/dy) and resize (w/h) payloads"); + + it.todo("revertDraft removes draft props and clears the gesture marker"); + + it.todo("revertDraft restores original translate when an original was recorded"); + }); + + describe("commitPreview", () => { + it.todo("returns null when no gesture marker is present"); + + it.todo("derives a moveElement patch from draft markers on commit"); + + it.todo("derives a resize patch from draft markers on commit"); + + it.todo("clears the gesture marker after commit"); + }); + + describe("getElementTimings", () => { + it.todo("reads authored absolute times from data-start / data-end"); + + it.todo("ignores elements without data-hf-id"); + }); +}); From 4095bfc777566e028355c5759205b3988a054039 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 11:56:19 -0700 Subject: [PATCH 5/6] test(core): add T6a GSAP parser golden baselines (Recast/Babel snapshot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 toMatchFileSnapshot tests across 3 representative scripts (minimal, moderate, complex). Captures parseGsapScript + serializeGsapAnimations output before the Recast → Meriyah swap so any parser change is detected as a golden diff rather than a silent behavioral regression. Goldens live in src/parsers/__goldens__/ and are checked in. Add __goldens__/** to fallow ignorePatterns (data files, not modules) and to .prettierignore so oxfmt does not reformat vitest-written snapshot files. --- .fallowrc.jsonc | 2 + .prettierignore | 3 + .../parsers/__goldens__/complex.parsed.json | 58 ++++++++ .../parsers/__goldens__/complex.serialized.js | 10 ++ .../parsers/__goldens__/minimal.parsed.json | 31 +++++ .../parsers/__goldens__/minimal.serialized.js | 6 + .../parsers/__goldens__/moderate.parsed.json | 75 +++++++++++ .../__goldens__/moderate.serialized.js | 11 ++ .../src/parsers/gsapParser.golden.test.ts | 126 ++++++++++++++++++ 9 files changed, 322 insertions(+) create mode 100644 packages/core/src/parsers/__goldens__/complex.parsed.json create mode 100644 packages/core/src/parsers/__goldens__/complex.serialized.js create mode 100644 packages/core/src/parsers/__goldens__/minimal.parsed.json create mode 100644 packages/core/src/parsers/__goldens__/minimal.serialized.js create mode 100644 packages/core/src/parsers/__goldens__/moderate.parsed.json create mode 100644 packages/core/src/parsers/__goldens__/moderate.serialized.js create mode 100644 packages/core/src/parsers/gsapParser.golden.test.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 4703fa525..34abacd29 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -39,6 +39,8 @@ "packages/engine/tests/**", "skills/**/test-corpus/**", "skills/**/scripts/**", + // Golden snapshot files: data consumed by toMatchFileSnapshot, not importable modules. + "packages/**/__goldens__/**", "registry/**", "examples/**", ".github/workflows/fixtures/**", diff --git a/.prettierignore b/.prettierignore index 9fba17dc3..d7fb4ef1b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,6 @@ packages/producer/tests/ # Cloud Workflows GCL — uses ${...} expressions that are not standard YAML. packages/gcp-cloud-run/terraform/workflow.yaml + +# toMatchFileSnapshot golden files — vitest writes these; oxfmt must not reformat them. +packages/**/__goldens__/ diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json new file mode 100644 index 000000000..92c5def44 --- /dev/null +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -0,0 +1,58 @@ +{ + "animations": [ + { + "targetSelector": ".ambient-line", + "method": "from", + "position": 0.16, + "properties": { + "scaleX": 0, + "opacity": 0 + }, + "duration": 0.42, + "extras": { + "stagger": "__raw:0.08" + }, + "id": ".ambient-line-from-160" + }, + { + "targetSelector": ".ambient-word", + "method": "from", + "position": 0.08, + "properties": { + "scale": 0.92, + "opacity": 0 + }, + "duration": 0.5, + "id": ".ambient-word-from-80" + }, + { + "targetSelector": ".headline .sub", + "method": "from", + "position": 0.2, + "properties": { + "y": 20, + "opacity": 0 + }, + "duration": 0.28, + "id": ".headline .sub-from-200" + }, + { + "targetSelector": ".headline span", + "method": "from", + "position": 0.05, + "properties": { + "y": 46, + "opacity": 0 + }, + "duration": 0.38, + "ease": "back.out(1.35)", + "extras": { + "stagger": "__raw:0.055" + }, + "id": ".headline span-from-50" + } + ], + "timelineVar": "tl", + "preamble": "window.__timelines = window.__timelines || {};\ngsap.defaults({ force3D: true });\nconst tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: \"power3.out\" } });", + "postamble": "window.__timelines[\"vpn-youtube-spot\"] = tl;" +} \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/complex.serialized.js b/packages/core/src/parsers/__goldens__/complex.serialized.js new file mode 100644 index 000000000..2a1516fee --- /dev/null +++ b/packages/core/src/parsers/__goldens__/complex.serialized.js @@ -0,0 +1,10 @@ + + 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, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05); + tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08); + tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16); + tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2); + window.__timelines["vpn-youtube-spot"] = tl; + \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/core/src/parsers/__goldens__/minimal.parsed.json new file mode 100644 index 000000000..f20fb8fb7 --- /dev/null +++ b/packages/core/src/parsers/__goldens__/minimal.parsed.json @@ -0,0 +1,31 @@ +{ + "animations": [ + { + "targetSelector": "#notification", + "method": "to", + "position": 0.2, + "properties": { + "x": 0, + "opacity": 1 + }, + "duration": 0.5, + "ease": "power3.out", + "id": "#notification-to-200" + }, + { + "targetSelector": "#notification", + "method": "to", + "position": 4.2, + "properties": { + "x": 420, + "opacity": 0 + }, + "duration": 0.3, + "ease": "power3.in", + "id": "#notification-to-4200" + } + ], + "timelineVar": "tl", + "preamble": "var tl = gsap.timeline({ paused: true });", + "postamble": "window.__timelines[\"macos-notification\"] = tl;" +} \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/minimal.serialized.js b/packages/core/src/parsers/__goldens__/minimal.serialized.js new file mode 100644 index 000000000..8c8dd8d71 --- /dev/null +++ b/packages/core/src/parsers/__goldens__/minimal.serialized.js @@ -0,0 +1,6 @@ + + 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; + \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/core/src/parsers/__goldens__/moderate.parsed.json new file mode 100644 index 000000000..1c4e39127 --- /dev/null +++ b/packages/core/src/parsers/__goldens__/moderate.parsed.json @@ -0,0 +1,75 @@ +{ + "animations": [ + { + "targetSelector": "#card", + "method": "to", + "position": 0.1, + "properties": { + "y": 0, + "opacity": 1 + }, + "duration": 0.5, + "ease": "power3.out", + "id": "#card-to-100" + }, + { + "targetSelector": "#subscribe-btn", + "method": "to", + "position": 1, + "properties": { + "scale": 0.92 + }, + "duration": 0.15, + "ease": "power2.out", + "id": "#subscribe-btn-to-1000" + }, + { + "targetSelector": "#subscribe-btn", + "method": "to", + "position": 1.15, + "properties": { + "scale": 1 + }, + "duration": 0.4, + "ease": "elastic.out(1, 0.4)", + "id": "#subscribe-btn-to-1150" + }, + { + "targetSelector": "#btn-subscribe", + "method": "to", + "position": 1.15, + "properties": { + "opacity": 0 + }, + "duration": 0.08, + "ease": "none", + "id": "#btn-subscribe-to-1150" + }, + { + "targetSelector": "#btn-subscribed", + "method": "to", + "position": 1.18, + "properties": { + "opacity": 1 + }, + "duration": 0.08, + "ease": "none", + "id": "#btn-subscribed-to-1180" + }, + { + "targetSelector": "#card", + "method": "to", + "position": 3.8, + "properties": { + "y": 300, + "opacity": 0 + }, + "duration": 0.25, + "ease": "power3.in", + "id": "#card-to-3800" + } + ], + "timelineVar": "tl", + "preamble": "window.__timelines = window.__timelines || {};\nvar tl = gsap.timeline({ paused: true });", + "postamble": "window.__timelines[\"yt-lower-third\"] = tl;" +} \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/moderate.serialized.js b/packages/core/src/parsers/__goldens__/moderate.serialized.js new file mode 100644 index 000000000..3093cc419 --- /dev/null +++ b/packages/core/src/parsers/__goldens__/moderate.serialized.js @@ -0,0 +1,11 @@ + + window.__timelines = window.__timelines || {}; +var tl = gsap.timeline({ paused: true }); + tl.to("#card", { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1); + tl.to("#subscribe-btn", { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1); + tl.to("#subscribe-btn", { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15); + tl.to("#btn-subscribe", { opacity: 0, duration: 0.08, ease: "none" }, 1.15); + tl.to("#btn-subscribed", { 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; + \ No newline at end of file diff --git a/packages/core/src/parsers/gsapParser.golden.test.ts b/packages/core/src/parsers/gsapParser.golden.test.ts new file mode 100644 index 000000000..97e915668 --- /dev/null +++ b/packages/core/src/parsers/gsapParser.golden.test.ts @@ -0,0 +1,126 @@ +/** + * T6a — GSAP parser golden tests (baseline for the Recast → Meriyah swap). + * + * These snapshots capture the exact output of parseGsapScript + + * serializeGsapAnimations under Recast/Babel before any parser change. + * When the Meriyah swap lands, run `vitest --update-snapshots` to regenerate + * and diff the goldens — any change is a regression candidate. + * + * Three representative scripts: + * minimal — 2 tl.to calls, simple numeric selectors (macos-notification) + * moderate — 6 tl.to calls, multiple selectors (yt-lower-third) + * complex — stagger, chained .from()/.to(), const/defaults (vpn-youtube-spot) + */ +import { beforeAll, describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseGsapScript, serializeGsapAnimations } from "./gsapParser.js"; + +const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__"); +const g = (name: string) => join(__goldens__, name); + +// --------------------------------------------------------------------------- +// Corpus scripts (inline so goldens are not coupled to registry file changes) +// --------------------------------------------------------------------------- + +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" } }); +const breatheRepeats = Math.ceil(7 / 2.4) - 1; +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;`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseAndSerialize(script: string): { parsed: string; serialized: string } { + const result = parseGsapScript(script); + const serialized = serializeGsapAnimations(result.animations, result.timelineVar, { + preamble: result.preamble, + postamble: result.postamble, + }); + return { parsed: JSON.stringify(result, null, 2), serialized }; +} + +// --------------------------------------------------------------------------- +// Golden tests +// --------------------------------------------------------------------------- + +describe("T6a — GSAP parser golden tests (Recast/Babel baseline)", () => { + describe("minimal — 2 tl.to calls (macos-notification)", () => { + let parsed: string; + let serialized: string; + beforeAll(() => { + ({ parsed, serialized } = parseAndSerialize(MINIMAL_SCRIPT)); + }); + + it("parseGsapScript output matches golden", async () => { + await expect(parsed).toMatchFileSnapshot(g("minimal.parsed.json")); + }); + + it("serializeGsapAnimations output matches golden", async () => { + await expect(serialized).toMatchFileSnapshot(g("minimal.serialized.js")); + }); + }); + + describe("moderate — 6 tl.to calls, multiple selectors (yt-lower-third)", () => { + let parsed: string; + let serialized: string; + beforeAll(() => { + ({ parsed, serialized } = parseAndSerialize(MODERATE_SCRIPT)); + }); + + it("parseGsapScript output matches golden", async () => { + await expect(parsed).toMatchFileSnapshot(g("moderate.parsed.json")); + }); + + it("serializeGsapAnimations output matches golden", async () => { + await expect(serialized).toMatchFileSnapshot(g("moderate.serialized.js")); + }); + }); + + describe("complex — stagger + chained .from() calls (vpn-youtube-spot)", () => { + let parsed: string; + let serialized: string; + beforeAll(() => { + ({ parsed, serialized } = parseAndSerialize(COMPLEX_SCRIPT)); + }); + + it("parseGsapScript output matches golden", async () => { + await expect(parsed).toMatchFileSnapshot(g("complex.parsed.json")); + }); + + it("serializeGsapAnimations output matches golden", async () => { + await expect(serialized).toMatchFileSnapshot(g("complex.serialized.js")); + }); + }); +}); From 564bd4b7189a4fd81e0db948fb6843ab94091163 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 17:46:31 -0700 Subject: [PATCH 6/6] test(core,studio): add T3+T7 hfId targeting stubs (spec for R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 (sourcePatcher.test.ts): 5 it.todo stubs for PatchTarget.hfId targeting — style, text, attribute patches plus preservation and fallthrough cases. T7 (sourceMutation.test.ts): 2 it.todo stubs for SourceMutationTarget.hfId — basic patch and data-hf-id survival after patch. Neither interface has hfId yet. R1 adds the field + [data-hf-id="…"] branch in findTagByTarget / findTargetElement, then converts these to real assertions. --- .../src/studio-api/helpers/sourceMutation.test.ts | 9 +++++++++ packages/studio/src/utils/sourcePatcher.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 8774fffcd..0b4d998d8 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -361,3 +361,12 @@ describe("probeElementInSource", () => { expect(probeElementInSource(sourceHtml, { id: "canvas" })).toBe(true); }); }); + +// T7 — data-hf-id targeting (spec for R1). +// R1 adds `hfId?: string` to SourceMutationTarget and a `[data-hf-id="…"]` branch +// in findTargetElement (sourceMutation.ts:34). Convert from it.todo in the R1 PR. +describe("T7 — data-hf-id targeting (spec for R1)", () => { + it.todo("patches element by data-hf-id when no HTML id attribute is present"); + + it.todo("data-hf-id attribute survives the patch (can be targeted again)"); +}); diff --git a/packages/studio/src/utils/sourcePatcher.test.ts b/packages/studio/src/utils/sourcePatcher.test.ts index 8bc64e1b8..7697d24e6 100644 --- a/packages/studio/src/utils/sourcePatcher.test.ts +++ b/packages/studio/src/utils/sourcePatcher.test.ts @@ -516,3 +516,18 @@ describe("motion attribute round-trip via sourcePatcher", () => { expect(JSON.parse(readBack!)).toEqual(motion); }); }); + +// T3 — id-based targeting (spec for R1). +// R1 adds `hfId?: string` to PatchTarget and a `[data-hf-id="…"]` lookup branch +// in findTagByTarget. Convert from it.todo to real assertions in the R1 PR. +describe("T3 — hfId targeting (spec for R1)", () => { + it.todo("updates inline style by data-hf-id"); + + it.todo("updates text content by data-hf-id"); + + it.todo("updates attribute by data-hf-id"); + + it.todo("data-hf-id attribute is preserved after a style patch"); + + it.todo("hfId lookup falls through to selector when hfId not found"); +});