Files
hyperframes/packages/studio/src/utils/studioUrlState.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

252 lines
7.4 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildStudioHash,
normalizeStudioCompositionPath,
normalizeStudioUrlPanelTab,
parseStudioUrlStateFromHash,
} from "./studioUrlState";
import { useStudioUrlState } from "../hooks/useStudioUrlState";
import { usePlayerStore } from "../player";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function resetPlayerStore() {
usePlayerStore.setState({
isPlaying: false,
currentTime: 0,
duration: 0,
timelineReady: false,
elements: [],
selectedElementId: null,
requestedSeekTime: null,
});
}
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
window.history.replaceState(null, "", "/");
resetPlayerStore();
});
function renderStudioUrlStateHarness(
props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
projectId: "demo",
activeCompPath: null,
currentTime: 0,
duration: 30,
isPlaying: false,
compositionLoading: false,
refreshKey: 0,
previewIframeRef: { current: null },
rightPanelTab: "renders",
rightCollapsed: true,
timelineVisible: true,
activeCompPathHydrated: true,
domEditSelection: null,
buildDomSelectionFromTarget: () => Promise.resolve(null),
applyDomSelection: () => {},
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: null,
rightCollapsed: null,
timelineVisible: null,
selection: null,
},
};
const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
act(() => {
root.render(
React.createElement(StudioUrlStateHarness, {
...baseProps,
...props,
...nextProps,
}),
);
});
};
render();
return {
rerender: render,
unmount: () =>
act(() => {
root.unmount();
}),
};
}
function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
useStudioUrlState(props);
return null;
}
describe("studio url state", () => {
it("parses persisted studio state from project hash", () => {
const state = parseStudioUrlStateFromHash(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
);
expect(state.activeCompPath).toBe("compositions/title.html");
expect(state.currentTime).toBe(4.25);
expect(state.rightPanelTab).toBe("design");
expect(state.rightCollapsed).toBe(false);
expect(state.timelineVisible).toBe(true);
expect(state.selection).toEqual({
sourceFile: "index.html",
id: "hero",
selector: undefined,
selectorIndex: undefined,
});
});
it("builds a project hash with persisted studio state", () => {
expect(
buildStudioHash("demo", {
activeCompPath: "compositions/title.html",
currentTime: 4.2571,
rightPanelTab: "layers",
rightCollapsed: true,
timelineVisible: false,
selection: {
sourceFile: "index.html",
selector: ".card",
selectorIndex: 2,
},
}),
).toBe(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
);
});
it("falls back cleanly on invalid values", () => {
const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
expect(state.activeCompPath).toBeNull();
expect(state.currentTime).toBeNull();
expect(state.rightPanelTab).toBeNull();
expect(state.rightCollapsed).toBeNull();
expect(state.timelineVisible).toBeNull();
expect(state.selection).toBeNull();
});
it("normalizes stale composition paths to the master composition", () => {
expect(
normalizeStudioCompositionPath("compositions/missing.html", [
"index.html",
"compositions/title.html",
]),
).toBeNull();
expect(
normalizeStudioCompositionPath("compositions/title.html", [
"index.html",
"compositions/title.html",
]),
).toBe("compositions/title.html");
});
it("normalizes url tabs against feature flags", () => {
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
});
it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
const requestSeek = vi.fn();
usePlayerStore.setState({ requestSeek });
const selectedElement = document.createElement("div");
selectedElement.id = "hero";
document.body.append(selectedElement);
const previewDoc = document.implementation.createHTMLDocument("preview");
previewDoc.body.append(selectedElement);
const applyDomSelection = vi.fn();
const restoredSelection = {
element: selectedElement,
id: "hero",
selector: "#hero",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Hero",
textContent: "",
textFields: [],
capabilities: {
canEditText: false,
canEditLayout: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: {
display: "block",
position: "absolute",
},
};
const harness = renderStudioUrlStateHarness({
previewIframeRef: {
current: { contentDocument: previewDoc } as HTMLIFrameElement,
},
rightPanelTab: "design",
rightCollapsed: false,
applyDomSelection,
buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: "design",
rightCollapsed: false,
timelineVisible: true,
selection: { id: "hero" },
},
});
expect(requestSeek).toHaveBeenCalledWith(4.2);
expect(applyDomSelection).not.toHaveBeenCalled();
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("tab=design");
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(applyDomSelection).not.toHaveBeenCalled();
harness.rerender({ currentTime: 4.2 });
await act(async () => {
vi.advanceTimersByTime(250);
// Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
await Promise.resolve();
});
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("selId=hero");
harness.unmount();
});
});