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()
This commit is contained in:
Miguel Ángel
2026-05-27 01:44:31 -04:00
committed by GitHub
parent 8ecef4b939
commit f19d6fd471
21 changed files with 460 additions and 160 deletions
+28 -16
View File
@@ -60,17 +60,19 @@ export interface UseDomSelectionReturn {
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
) => Promise<DomEditSelection | null>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
buildDomSelectionForTimelineElement: (element: TimelineElement) => DomEditSelection | null;
handleTimelineElementSelect: (element: TimelineElement | null) => void;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
buildDomSelectionForTimelineElement: (
element: TimelineElement,
) => Promise<DomEditSelection | null>;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
}
// ── Hook ──
@@ -193,24 +195,34 @@ export function useDomSelection({
}, [applyDomSelection]);
const buildDomSelectionFromTarget = useCallback(
(target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
(
target: HTMLElement,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
return resolveDomEditSelection(target, {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
projectId,
});
},
[activeCompPath, isMasterView],
[activeCompPath, isMasterView, projectId],
);
const resolveDomSelectionFromPreviewPoint = useCallback(
(clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
async (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
const iframe = previewIframeRef.current;
if (!iframe || captionEditMode) return null;
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
if (!target) return null;
return buildDomSelectionFromTarget(target, {
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
});
},
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
@@ -223,7 +235,7 @@ export function useDomSelection({
}, []);
const buildDomSelectionForTimelineElement = useCallback(
(element: TimelineElement): DomEditSelection | null => {
async (element: TimelineElement): Promise<DomEditSelection | null> => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -248,21 +260,21 @@ export function useDomSelection({
);
const handleTimelineElementSelect = useCallback(
(element: TimelineElement | null) => {
async (element: TimelineElement | null) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const selection = buildDomSelectionForTimelineElement(element);
const selection = await buildDomSelectionForTimelineElement(element);
if (selection) applyDomSelection(selection);
},
[applyDomSelection, buildDomSelectionForTimelineElement],
);
const refreshDomEditSelectionFromPreview = useCallback(
(selection: DomEditSelection) => {
async (selection: DomEditSelection) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -275,7 +287,7 @@ export function useDomSelection({
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) return;
const nextSelection = buildDomSelectionFromTarget(element);
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) {
applyDomSelection(nextSelection, {
revealPanel: false,
@@ -287,7 +299,7 @@ export function useDomSelection({
);
const refreshDomEditGroupSelectionsFromPreview = useCallback(
(selections: DomEditSelection[]) => {
async (selections: DomEditSelection[]) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
@@ -301,7 +313,7 @@ export function useDomSelection({
for (const selection of selections) {
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) continue;
const nextSelection = buildDomSelectionFromTarget(element);
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) nextGroup.push(nextSelection);
}
if (nextGroup.length === 0) return;