mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { removeElementFromHtml, patchElementInHtml } from "./sourceMutation.js";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
probeElementInSource,
|
||||
} from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
it("removes a self-closing element by id", () => {
|
||||
@@ -248,3 +252,68 @@ describe("patchElementInHtml", () => {
|
||||
expect(result).not.toContain("dynsrc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeElementInSource", () => {
|
||||
const FIXTURE = `<!doctype html><html><head></head><body>
|
||||
<div id="root" data-composition-id="main">
|
||||
<div class="layer" data-composition-id="overlay" data-composition-src="compositions/overlay.html">
|
||||
<div class="chrome">
|
||||
<span class="brand">HyperFrames</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hero" class="hero-heading" style="font-size: 48px">Hello World</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("returns true for an element found by id", () => {
|
||||
expect(probeElementInSource(FIXTURE, { id: "hero" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for an element found by class selector", () => {
|
||||
expect(probeElementInSource(FIXTURE, { selector: ".hero-heading" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for an element found by data-composition-id selector", () => {
|
||||
expect(probeElementInSource(FIXTURE, { selector: '[data-composition-id="overlay"]' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for an id that does not exist in source", () => {
|
||||
expect(probeElementInSource(FIXTURE, { id: "arrows-svg" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a class selector that does not exist", () => {
|
||||
expect(probeElementInSource(FIXTURE, { selector: ".phone-frame" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when target has neither id nor selector", () => {
|
||||
expect(probeElementInSource(FIXTURE, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for class selector with valid selectorIndex", () => {
|
||||
const html = `<div class="item">A</div><div class="item">B</div>`;
|
||||
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for class selector with out-of-bounds selectorIndex", () => {
|
||||
const html = `<div class="item">A</div><div class="item">B</div>`;
|
||||
expect(probeElementInSource(html, { selector: ".item", selectorIndex: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an element that would only exist after JS execution", () => {
|
||||
const sourceHtml = `<!doctype html><html><head></head><body>
|
||||
<div id="root" data-composition-id="main">
|
||||
<div id="canvas"></div>
|
||||
<script>
|
||||
const svg = document.createElement("div");
|
||||
svg.id = "arrows-svg";
|
||||
document.getElementById("canvas").appendChild(svg);
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
expect(probeElementInSource(sourceHtml, { id: "arrows-svg" })).toBe(false);
|
||||
expect(probeElementInSource(sourceHtml, { id: "canvas" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,3 +199,10 @@ export function patchElementInHtml(
|
||||
|
||||
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
||||
}
|
||||
|
||||
export function probeElementInSource(source: string, target: SourceMutationTarget): boolean {
|
||||
if (!target.id && !target.selector) return false;
|
||||
const { document } = parseSourceDocument(source);
|
||||
const el = findTargetElement(document, target);
|
||||
return el != null && isHTMLElement(el);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isSafePath } from "../helpers/safePath.js";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
probeElementInSource,
|
||||
type PatchOperation,
|
||||
} from "../helpers/sourceMutation.js";
|
||||
|
||||
@@ -38,9 +39,11 @@ interface RouteContext {
|
||||
json: (data: unknown, status?: number) => Response;
|
||||
}
|
||||
|
||||
async function resolveProjectFile(
|
||||
/** Resolve project + safe absolute path for any project-scoped route. */
|
||||
async function resolveProjectPath(
|
||||
c: RouteContext,
|
||||
adapter: StudioApiAdapter,
|
||||
pathPrefix: (projectId: string) => string,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
const id = c.req.param("id");
|
||||
@@ -49,7 +52,7 @@ async function resolveProjectFile(
|
||||
return { error: c.json({ error: "not found" }, 404) } as const;
|
||||
}
|
||||
|
||||
const filePath = decodeURIComponent(c.req.path.replace(`/projects/${project.id}/files/`, ""));
|
||||
const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
|
||||
if (filePath.includes("\0")) {
|
||||
return { error: c.json({ error: "forbidden" }, 403) } as const;
|
||||
}
|
||||
@@ -66,6 +69,48 @@ async function resolveProjectFile(
|
||||
return { project, filePath, absPath } as const;
|
||||
}
|
||||
|
||||
function resolveProjectFile(
|
||||
c: RouteContext,
|
||||
adapter: StudioApiAdapter,
|
||||
opts?: { mustExist?: boolean },
|
||||
) {
|
||||
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
|
||||
}
|
||||
|
||||
function resolveFileMutationContext(c: RouteContext, adapter: StudioApiAdapter, operation: string) {
|
||||
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
|
||||
}
|
||||
|
||||
type MutationTarget = { id?: string | null; selector?: string; selectorIndex?: number };
|
||||
|
||||
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
|
||||
function writeIfChanged(
|
||||
c: RouteContext,
|
||||
absPath: string,
|
||||
original: string,
|
||||
next: string,
|
||||
): Response {
|
||||
if (next === original) {
|
||||
return c.json({ ok: true, changed: false, content: original });
|
||||
}
|
||||
writeFileSync(absPath, next, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the request body and validate that `target` is present.
|
||||
* Returns `{ error }` if missing, or `{ target, body }` for the full parsed body.
|
||||
*/
|
||||
async function parseMutationBody<T extends { target?: MutationTarget }>(
|
||||
c: RouteContext & { req: { json(): Promise<unknown> } },
|
||||
): Promise<{ error: Response } | { target: MutationTarget; body: T }> {
|
||||
const body = (await (c.req as { json(): Promise<unknown> }).json().catch(() => null)) as T | null;
|
||||
if (!body?.target) {
|
||||
return { error: c.json({ error: "target required" }, 400) };
|
||||
}
|
||||
return { target: body.target, body };
|
||||
}
|
||||
|
||||
/** Ensure the parent directory of a path exists. */
|
||||
function ensureDir(filePath: string) {
|
||||
const dir = dirname(filePath);
|
||||
@@ -204,79 +249,68 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const project = await adapter.resolveProject(id);
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const ctx = await resolveFileMutationContext(c, adapter, "remove-element");
|
||||
if ("error" in ctx) return ctx.error;
|
||||
|
||||
const filePath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/file-mutations/remove-element/`, ""),
|
||||
);
|
||||
if (filePath.includes("\0")) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
const absPath = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, absPath)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
if (!existsSync(absPath)) {
|
||||
if (!existsSync(ctx.absPath)) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
target?: { id?: string | null; selector?: string; selectorIndex?: number };
|
||||
} | null;
|
||||
if (!body?.target) {
|
||||
return c.json({ error: "target required" }, 400);
|
||||
}
|
||||
const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
const originalContent = readFileSync(absPath, "utf-8");
|
||||
const patchedContent = removeElementFromHtml(originalContent, body.target);
|
||||
if (patchedContent === originalContent) {
|
||||
return c.json({ ok: true, changed: false, content: originalContent });
|
||||
}
|
||||
|
||||
writeFileSync(absPath, patchedContent, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: patchedContent });
|
||||
const originalContent = readFileSync(ctx.absPath, "utf-8");
|
||||
return writeIfChanged(
|
||||
c,
|
||||
ctx.absPath,
|
||||
originalContent,
|
||||
removeElementFromHtml(originalContent, parsed.target),
|
||||
);
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const project = await adapter.resolveProject(id);
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
|
||||
if ("error" in ctx) return ctx.error;
|
||||
|
||||
const filePath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/file-mutations/patch-element/`, ""),
|
||||
);
|
||||
if (filePath.includes("\0")) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
|
||||
const absPath = resolve(project.dir, filePath);
|
||||
if (!isSafePath(project.dir, absPath)) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
target?: { id?: string | null; selector?: string; selectorIndex?: number };
|
||||
const parsed = await parseMutationBody<{
|
||||
target?: MutationTarget;
|
||||
operations?: PatchOperation[];
|
||||
} | null;
|
||||
if (!body?.target || !Array.isArray(body.operations) || body.operations.length === 0) {
|
||||
}>(c);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
|
||||
return c.json({ error: "target and operations required" }, 400);
|
||||
}
|
||||
|
||||
let originalContent: string;
|
||||
try {
|
||||
originalContent = readFileSync(absPath, "utf-8");
|
||||
originalContent = readFileSync(ctx.absPath, "utf-8");
|
||||
} catch {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
const patchedContent = patchElementInHtml(originalContent, body.target, body.operations);
|
||||
if (patchedContent === originalContent) {
|
||||
return c.json({ ok: true, changed: false, content: originalContent });
|
||||
return writeIfChanged(
|
||||
c,
|
||||
ctx.absPath,
|
||||
originalContent,
|
||||
patchElementInHtml(originalContent, parsed.target, parsed.body.operations),
|
||||
);
|
||||
});
|
||||
|
||||
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
|
||||
const ctx = await resolveFileMutationContext(c, adapter, "probe-element");
|
||||
if ("error" in ctx) return ctx.error;
|
||||
|
||||
const parsed = await parseMutationBody<{ target?: MutationTarget }>(c);
|
||||
if ("error" in parsed) return parsed.error;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(ctx.absPath, "utf-8");
|
||||
} catch {
|
||||
return c.json({ exists: false });
|
||||
}
|
||||
|
||||
writeFileSync(absPath, patchedContent, "utf-8");
|
||||
return c.json({ ok: true, changed: true, content: patchedContent });
|
||||
const exists = probeElementInSource(content, parsed.target);
|
||||
return c.json({ exists });
|
||||
});
|
||||
|
||||
// ── Rename / Move ──
|
||||
|
||||
Reference in New Issue
Block a user