Files
hyperframes/packages/cli/src/utils/studioSelectionClient.test.ts
T
Miguel Ángel 0a9555a0f7 fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)
* feat(player,studio): favicon-blade play icon with pause<->play morph

Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.

* fix(cli): discover local-studio (Vite) preview over IPv6 loopback

The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.

Adds an IPv6-only discovery regression test.

* fix(studio): wire the Add-keyframe (K) shortcut

The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.

* fix(studio): clear orphaned GSAP transforms on soft reload

A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.

Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.

* fix(studio-server): bust thumbnail cache on composition edits

The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.

Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.

* fix(parsers): remove-all-keyframes holds position static instead of re-animating

removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.

Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.

* fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation

The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.

* fix(studio): timeline 'Delete All Keyframes' holds position too

The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.

* fix(studio): one position write per element + clean remove-all-keyframes

Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.

- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
  recast, in parity); findExistingPositionWrite matches degenerate duration:0
  holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
  removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
  parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
  static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).

Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
2026-06-29 11:23:46 -07:00

237 lines
7.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { resolve } from "node:path";
import {
AmbiguousPreviewServerError,
fetchStudioLint,
fetchStudioSelection,
studioApiUrl,
findPreviewServerForProject,
PreviewServerPortMismatchError,
studioSelectionUrl,
} from "./studioSelectionClient";
import type { ActiveServer } from "../server/portUtils";
const servers: ActiveServer[] = [
{
port: 3002,
projectName: "other",
projectDir: "/tmp/other",
version: "0.7.17",
pid: null,
},
{
port: 3003,
projectName: "demo project",
projectDir: "/tmp/demo",
version: "0.7.17",
pid: "123",
},
];
function mockProjectsFetch(port = 5190): typeof fetch {
return vi.fn(async (url: string | URL | Request) => {
expect(String(url)).toBe(`http://127.0.0.1:${port}/api/projects`);
return new Response(
JSON.stringify({
projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
}
describe("studioSelectionClient", () => {
it("finds the active preview server for a project directory", async () => {
const scan = vi.fn(async () => servers);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan);
expect(server?.port).toBe(3003);
expect(scan).toHaveBeenCalledWith(3002);
});
it("matches by project directory when multiple projects are open", async () => {
const scan = vi.fn(async () => [
...servers,
{
port: 3004,
projectName: "third",
projectDir: "/tmp/third",
version: "0.7.17",
pid: null,
},
]);
const server = await findPreviewServerForProject(resolve("/tmp/third"), 3002, scan);
expect(server?.port).toBe(3004);
});
it("rejects ambiguous duplicate servers for the same project", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan),
).rejects.toMatchObject({
name: "AmbiguousPreviewServerError",
ports: [3003, 3004],
} satisfies Partial<AmbiguousPreviewServerError>);
});
it("uses an explicit preferred port to disambiguate duplicate project servers", async () => {
const scan = vi.fn(async () => [servers[1]!, { ...servers[1]!, port: 3004, pid: "456" }]);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, undefined, {
preferredPort: 3004,
});
expect(server?.port).toBe(3004);
});
it("rejects an explicit preferred port that does not match the only project server", async () => {
const scan = vi.fn(async () => [servers[1]!]);
const fetchImpl = vi.fn(async () => new Response("missing", { status: 404 }));
await expect(
findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 3999,
}),
).rejects.toMatchObject({
name: "PreviewServerPortMismatchError",
requestedPort: 3999,
ports: [3003],
} satisfies Partial<PreviewServerPortMismatchError>);
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:3999/api/projects");
});
it("falls back to Vite Studio project discovery on port 5190", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch();
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
expect(server).toEqual({
port: 5190,
host: "127.0.0.1",
projectName: "demo project",
projectDir: "/tmp/demo",
version: "studio-dev",
pid: null,
});
});
it("discovers a Vite Studio that only binds IPv6 loopback ([::1])", async () => {
const scan = vi.fn(async () => []);
// Vite binds ::1 only: the IPv4 probe is refused, the IPv6 probe succeeds.
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
const u = String(url);
if (u === "http://127.0.0.1:5190/api/projects") throw new Error("ECONNREFUSED");
expect(u).toBe("http://[::1]:5190/api/projects");
return new Response(
JSON.stringify({ projects: [{ id: "demo project", dir: "/tmp/demo", title: "Demo" }] }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as unknown as typeof fetch;
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl);
expect(server).toEqual({
port: 5190,
host: "[::1]",
projectName: "demo project",
projectDir: "/tmp/demo",
version: "studio-dev",
pid: null,
});
// Follow-up API calls must target the IPv6 host the server was found on.
expect(studioSelectionUrl(server!)).toBe(
"http://[::1]:5190/api/projects/demo%20project/selection",
);
});
it("checks an explicit preferred port for Vite Studio discovery", async () => {
const scan = vi.fn(async () => []);
const fetchImpl = mockProjectsFetch(5191);
const server = await findPreviewServerForProject(resolve("/tmp/demo"), 3002, scan, fetchImpl, {
preferredPort: 5191,
});
expect(server?.port).toBe(5191);
expect(fetchImpl).not.toHaveBeenCalledWith("http://127.0.0.1:5190/api/projects");
});
it("builds a URL to the existing preview server's selection endpoint", () => {
expect(studioSelectionUrl(servers[1]!)).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/selection",
);
});
it("builds URLs to other preview server API routes", () => {
expect(studioApiUrl(servers[1]!, "lint")).toBe(
"http://127.0.0.1:3003/api/projects/demo%20project/lint",
);
});
it("fetches the current selection snapshot from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
selection: {
schemaVersion: 1,
projectId: "demo project",
compositionPath: "index.html",
sourceFile: "index.html",
currentTime: 2,
target: { hfId: "cta" },
label: "CTA",
tagName: "button",
boundingBox: { x: 0, y: 0, width: 10, height: 10 },
textContent: "Go",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: { canSelect: true },
thumbnailUrl: "/api/projects/demo%20project/thumbnail/index.html?t=2&format=png",
},
updatedAt: "2026-06-28T16:00:00.000Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioSelection(servers[1]!, fetchImpl);
expect(result.selection?.target.hfId).toBe("cta");
expect(result.updatedAt).toBe("2026-06-28T16:00:00.000Z");
expect(fetchImpl).toHaveBeenCalledWith(studioSelectionUrl(servers[1]!));
});
it("throws when the preview server returns a failed response", async () => {
await expect(
fetchStudioSelection(
servers[1]!,
vi.fn(async () => new Response("missing", { status: 404 })),
),
).rejects.toThrow("selection endpoint returned 404");
});
it("fetches lint findings from a preview server", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
findings: [{ severity: "error", message: "Missing timeline", file: "index.html" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await fetchStudioLint(servers[1]!, fetchImpl);
expect(result.findings).toHaveLength(1);
expect(result.findings[0]?.message).toBe("Missing timeline");
expect(fetchImpl).toHaveBeenCalledWith(studioApiUrl(servers[1]!, "lint"));
});
});