Files
hyperframes/packages/studio/tests/e2e/motion-path-home.mjs
Miguel Ángel b45818f69d fix(studio): the motion path draws on every load, not only when the layer's node is the frame's own (#4957)
* fix(studio): the motion path draws on every load, not only when the layer's node is the frame's own

useMotionPathData found the selected layer with `instanceof` against the preview
frame's HTMLElement. In the Studio preview the body's nodes often carry the editor
window's prototypes (see core's runtime/domRealm.ts), so the check failed, the path
never got its anchor and drew nothing. Core's realm-free isHtmlElement owns that
question; both motion-path call sites now use it.

* fix(studio): every preview node check asks what a node is, not which window built it

Studio checked preview nodes against their document window's own constructors in
seek reapply, studio motion, timeline z-order commits, the manual offset drag and
the z-order menu. On loads where body nodes carry the editor window's prototypes
those checks fail silently, as the motion path did. They now use core's realm-free
predicates, and a source test fails on a new window-bound check.

* fix(studio): the webmcp preview helper asks what a node is, and both realm tests stub the frame

* fix(studio): the last preview node checks ask what a node is, and the guard sees any constructor read

The selector walk read ancestor ids only from editor-realm nodes and the audio group
observer never saw a group the preview added. Both use core's predicates now. The guard
flags any DOM interface read off an object, the spellings it missed included, and the
window gates the old constructor needed are gone.

* test(studio): cover the main seek filter across windows and split member reads

* fix(studio): a press on the motion path's line inside the selected layer moves the layer

* fix(studio): a press inside the selected layer moves it, and path nodes sit where the layer is

* refactor(studio): one helper places a path point, for keyframes and the start

* fix(studio): a handle over a path node takes its press; nodes sit right on centred layers

* fix(studio): reading a layer's path anchor leaves its CSS translate alone

* chore(studio): keep the motion path home file under the comment share

* fix(studio): a new motion path ends where you click on a CSS-centred layer

* fix(studio): a path made on a transform-centred layer ends where you click

* style(studio): keep a motion path test comment within 120 columns
2026-10-04 22:08:29 +00:00

124 lines
5.1 KiB
JavaScript

// Run with: node packages/studio/tests/e2e/motion-path-home.mjs
// Real Chromium and real GSAP: a layer's motion path home leaves its CSS translate alone (GSAP's
// transform parse folds it) and puts a created path's end on the click; happy-dom computes no translate.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import puppeteer from "puppeteer-core";
import { resolveChromeExecutable } from "./chrome-executable.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
const output = mkdtempSync(join(tmpdir(), "motion-path-home-"));
const studio = createRequire(join(root, "packages/studio/package.json"));
const gsapSource = readFileSync(studio.resolve("gsap/dist/gsap.js"), "utf8");
let browser;
try {
execFileSync(
"bun",
[
"build",
"packages/studio/src/components/editor/motionPathHome.ts",
"--target",
"browser",
"--outdir",
output,
],
{ cwd: root },
);
const homeUrl = `data:text/javascript;base64,${readFileSync(join(output, "motionPathHome.js")).toString("base64")}`;
browser = await puppeteer.launch({
executablePath: resolveChromeExecutable(),
headless: true,
pipe: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const page = await browser.newPage();
await page.setContent(
`<style>.centred{transform:translate(-50%,-50%)}</style><body style="margin:0"><div id="stage" style="position:relative;width:1920px;height:1080px"></div></body>`,
);
await page.addScriptTag({ content: gsapSource });
await page.addScriptTag({
content: readFileSync(studio.resolve("gsap/dist/MotionPathPlugin.js"), "utf8"),
});
const got = await page.evaluate(async (url) => {
const { elementHome } = await import(url);
window.gsap.registerPlugin(window.MotionPathPlugin);
const layer = (css) => {
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:10px;top:10px;width:240px;height:160px;${css}`;
document.getElementById("stage").append(el);
return el;
};
const centre = (el) => {
const r = el.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
};
// Create mode: a path to (click - home), as the overlay commits it, must end on the click.
const create = (css, setup = () => {}) => {
const el = layer(css);
setup(el);
const home = elementHome(el);
const style = [el.style.translate, el.style.transform];
const [cx, cy] = [700, 400];
const path = [
{ x: 0, y: 0 },
{ x: cx - home.x, y: cy - home.y },
];
window.gsap.timeline().to(el, { motionPath: { path }, duration: 1 }).progress(1);
return { style, end: centre(el) };
};
// A drag's offset on a layer GSAP already moves composes with its transform.
const dragged = layer("");
window.gsap.set(dragged, { x: 20 });
dragged.style.cssText += ";--hf-studio-offset-x:40px;--hf-studio-offset-y:30px";
dragged.style.translate = "var(--hf-studio-offset-x) var(--hf-studio-offset-y)";
const draggedHome = elementHome(dragged);
const owned = layer("left: 50%; top: 50%");
window.gsap.set(owned, { xPercent: -50, yPercent: -50 });
return {
plain: create("translate: 40px 30px"),
centred: create("left: 50%; top: 50%; translate: -50% -50%"),
halfPx: create("left: 50%; top: 50%; translate: -120px -80px"),
none: create(""),
transformed: create("left: 50%; top: 50%; transform: translate(-50%, -50%)"),
classed: create("left: 50%; top: 50%", (el) => el.classList.add("centred")),
mixed: create("left: 50%; top: 50%; transform: translate(-50%, -50%); translate: 10px 0"),
// A fade's cache holds no transform yet: GSAP's next parse still folds the drag offset into x.
faded: create(
"--hf-studio-offset-x: 40px; --hf-studio-offset-y: 30px; translate: var(--hf-studio-offset-x) var(--hf-studio-offset-y)",
(el) => window.gsap.set(el, { opacity: 0.9 }),
),
dragged: { home: [draggedHome.x, draggedHome.y], centre: centre(dragged) },
owned: elementHome(owned),
};
}, homeUrl);
assert.deepEqual(got.plain.style, ["40px 30px", ""]);
assert.deepEqual(got.centred.style, ["-50% -50%", ""]);
for (const name of [
"plain",
"centred",
"halfPx",
"none",
"transformed",
"classed",
"mixed",
"faded",
]) {
assert.deepEqual(got[name].end.map(Math.round), [700, 400], name);
}
assert.deepEqual(got.dragged.home, [got.dragged.centre[0] - 20, got.dragged.centre[1]]);
const { x, y, ax, ay } = got.owned;
assert.deepEqual([x, y, ax, ay], [960, 540, 0, 0]);
console.log(
"motion-path home: a created path ends on the click; translates kept; drag offsets count",
);
} finally {
await browser?.close();
rmSync(output, { recursive: true, force: true });
}