mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* 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()
252 lines
7.4 KiB
TypeScript
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();
|
|
});
|
|
});
|