Files
hyperframes/packages/studio/src/components/editor/DomEditOverlay.test.ts
T
Miguel Ángel f19d6fd471 feat: CLI observability + fix studio save failures on JS-created elements (#1091)
* feat(core): add probeElementInSource for source-existence checks

* feat(core): add probe-element endpoint for source-existence checks

* feat(studio): gate editing capabilities on source existence

* fix(studio): enrich save_failure telemetry with target details

* feat(studio): async selection resolution with source probe

Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").

Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
  `probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
  when `projectId` is supplied and the element has a stable id/selector.
  `existsInSource: false` flows into `resolveDomEditCapabilities`, which
  disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
  `resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
  helpers to eliminate repeated boilerplate across remove/patch/probe handlers.

Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
  `resolveDomSelectionFromPreviewPoint`,
  `buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
  `refreshDomEditSelectionFromPreview`, and
  `refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
  forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
  `buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
  with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
  `handlePreviewCanvasPointerMove` made async (React ignores handler return
  values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
  converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
  `handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
  return type widened to `Promise<DomEditSelection | null>`; pointer-down
  handler falls back to `hoverSelectionRef.current` (always populated by a
  prior hover) instead of awaiting the async move callback inline.

Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
  files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
  not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
  made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
  and `hoverSelection` pre-seeded so pointer-down test works with the new
  hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
  `Promise.resolve()`; seek/selection hydration test made async with
  `await act(async () => { await Promise.resolve(); })` to flush microtasks.

* feat(cli): add global error handlers for crash telemetry

Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.

* feat(cli): track per-command success/failure and duration

* test(core): add integration test for JS-created element probe scenario

* fix: address PR review feedback

- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc

* fix(cli): restore stack_trace in cli_error telemetry

* fix(cli): use captured module refs in exit handlers instead of dead import()
2026-05-27 01:44:31 -04:00

408 lines
11 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { Window } from "happy-dom";
import {
DomEditOverlay,
filterNestedDomEditGroupItems,
focusDomEditOverlayElement,
hasDomEditRotationChanged,
resolveDomEditCoordinateScale,
resolveDomEditGroupOverlayRect,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./DomEditOverlay";
import type { DomEditSelection } from "./domEditing";
// React 19 warns unless the test environment opts into act().
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("./useDomEditOverlayGestures", () => ({
createDomEditOverlayGestureHandlers: () => ({
startGesture: () => true,
startGroupDrag: () => {},
onPointerMove: () => {},
onPointerUp: () => {},
clearPointerState: () => {},
}),
}));
vi.mock("./useDomEditOverlayRects", async () => {
const React = await import("react");
const { rectsEqual } = await import("./domEditOverlayGeometry");
return {
useDomEditOverlayRects: () => {
const [overlayRect, setOverlayRectState] = React.useState(null);
const overlayRectRef = React.useRef(null);
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
const groupOverlayItemsRef = React.useRef([]);
const setOverlayRect = (next: unknown) => {
if (rectsEqual(overlayRectRef.current, next)) return;
overlayRectRef.current = next;
setOverlayRectState(next);
};
const setGroupOverlayItems = (next: unknown[]) => {
groupOverlayItemsRef.current = next;
setGroupOverlayItemsState(next);
};
return {
overlayRect,
overlayRectRef,
setOverlayRect,
hoverRect: null,
hoverRectRef: { current: null },
setHoverRect: () => {},
groupOverlayItems,
groupOverlayItemsRef,
setGroupOverlayItems,
};
},
};
});
vi.mock("./domEditOverlayGeometry", async () => {
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
"./domEditOverlayGeometry",
);
return {
...actual,
toOverlayRect: () => ({
left: 24,
top: 36,
width: 180,
height: 72,
editScaleX: 1,
editScaleY: 1,
}),
};
});
describe("focusDomEditOverlayElement", () => {
it("focuses the canvas overlay without scrolling", () => {
const calls: Array<FocusOptions | undefined> = [];
focusDomEditOverlayElement({
focus: (options?: FocusOptions) => calls.push(options),
});
expect(calls).toEqual([{ preventScroll: true }]);
});
});
describe("DomEditOverlay", () => {
it("renders selected bounds right after clicking a movable selection", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const selection: DomEditSelection = {
element: document.createElement("div"),
id: "hero-title",
selector: ".hero-title",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Hero Title",
textContent: "Hello",
textFields: [],
capabilities: {
canEditText: true,
canEditLayout: true,
canMove: true,
canApplyManualOffset: true,
canApplyManualSize: false,
canApplyManualRotation: false,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: {
display: "block",
position: "absolute",
},
};
let currentSelection: DomEditSelection | null = null;
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
function Harness() {
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
currentSelection = selected;
return React.createElement(DomEditOverlay, {
iframeRef,
activeCompositionPath: null,
selection: selected,
// Simulate the element being hovered before pointer-down (real users always hover first)
hoverSelection: selection,
groupSelections: [],
onCanvasMouseDown: () => {},
onCanvasPointerMove: () => Promise.resolve(selection),
onCanvasPointerLeave: () => {},
onSelectionChange: (next: DomEditSelection) => setSelected(next),
onBlockedMove: () => {},
onPathOffsetCommit: () => {},
onGroupPathOffsetCommit: () => {},
onBoxSizeCommit: () => {},
onRotationCommit: () => {},
});
}
act(() => {
root.render(React.createElement(Harness));
});
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
expect(overlay).toBeTruthy();
act(() => {
overlay.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
clientX: 120,
clientY: 80,
}),
);
});
expect(currentSelection).toBe(selection);
expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy();
act(() => {
root.unmount();
});
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
host.remove();
});
});
describe("resolveDomEditCoordinateScale", () => {
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
}),
).toEqual({
scaleX: 0.5,
scaleY: 0.5,
});
});
it("converts source-local pixels through a scaled nested composition host", () => {
expect(
resolveDomEditCoordinateScale({
rootScaleX: 0.5,
rootScaleY: 0.5,
sourceRectWidth: 960,
sourceRectHeight: 540,
sourceWidth: 1920,
sourceHeight: 1080,
}),
).toEqual({
scaleX: 0.25,
scaleY: 0.25,
});
});
});
describe("resolveDomEditGroupOverlayRect", () => {
it("returns a bounding box that contains every selected element", () => {
expect(
resolveDomEditGroupOverlayRect([
{ left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 },
{ left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 },
{ left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 },
]),
).toEqual({
left: 20,
top: 10,
width: 160,
height: 120,
editScaleX: 1,
editScaleY: 1,
});
});
it("returns null for an empty group", () => {
expect(resolveDomEditGroupOverlayRect([])).toBeNull();
});
});
describe("filterNestedDomEditGroupItems", () => {
it("keeps top-level selected elements so descendants are not moved twice", () => {
const window = new Window();
const parent = window.document.createElement("div");
const child = window.document.createElement("div");
const sibling = window.document.createElement("div");
parent.append(child);
expect(
filterNestedDomEditGroupItems([
{ key: "parent", element: parent },
{ key: "child", element: child },
{ key: "sibling", element: sibling },
]).map((item) => item.key),
).toEqual(["parent", "sibling"]);
});
});
describe("resolveDomEditResizeGesture", () => {
it("resizes width and height independently by default", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 240,
originHeight: 120,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
dx: 30,
dy: 12,
uniform: false,
}),
).toEqual({
overlayWidth: 270,
overlayHeight: 132,
width: 270,
height: 132,
});
});
it("snaps width and height to the same value when Shift is held", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 240,
originHeight: 120,
actualWidth: 240,
actualHeight: 120,
scaleX: 1,
scaleY: 1,
dx: 30,
dy: 12,
uniform: true,
}),
).toEqual({
overlayWidth: 270,
overlayHeight: 270,
width: 270,
height: 270,
});
});
it("uses the dominant pointer delta for uniform shrink", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 300,
originHeight: 180,
actualWidth: 300,
actualHeight: 180,
scaleX: 1,
scaleY: 1,
dx: 8,
dy: -40,
uniform: true,
}),
).toMatchObject({
width: 260,
height: 260,
});
});
it("writes source-local dimensions when the edited source is scaled down in master view", () => {
expect(
resolveDomEditResizeGesture({
originWidth: 100,
originHeight: 50,
actualWidth: 400,
actualHeight: 200,
scaleX: 0.25,
scaleY: 0.25,
dx: 25,
dy: 10,
uniform: false,
}),
).toEqual({
overlayWidth: 125,
overlayHeight: 60,
width: 500,
height: 240,
});
});
});
describe("resolveDomEditRotationGesture", () => {
it("rotates by the pointer angle around the element center", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -10,
currentX: 10,
currentY: 0,
actualAngle: 5,
snap: false,
}),
).toEqual({ angle: 95 });
});
it("uses the shortest delta across the 180 degree boundary", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: -10,
startY: 1.76,
currentX: -10,
currentY: -1.76,
actualAngle: 0,
snap: false,
}).angle,
).toBeCloseTo(20, 1);
});
it("snaps to 15 degree increments when requested", () => {
expect(
resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 10,
startY: 0,
currentX: 10,
currentY: 3.25,
actualAngle: 0,
snap: true,
}),
).toEqual({ angle: 15 });
});
it("allows small pointer movements when the rounded angle changes", () => {
const nextRotation = resolveDomEditRotationGesture({
centerX: 0,
centerY: 0,
startX: 0,
startY: -40,
currentX: 1,
currentY: -40,
actualAngle: 0,
snap: false,
});
expect(nextRotation.angle).toBe(1.4);
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
});
});