Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@
"file": "packages/studio/src/telemetry/events.ts",
"exports": ["trackStudioRenderStart"],
},
// domEditingLayers: these exports are consumed via the browser iframe
// runtime context (not traceable by static import analysis from the
// studio entry point) or re-exported through the domEditing barrel but
// have no downstream consumers yet.
{
"file": "packages/studio/src/components/editor/domEditingLayers.ts",
"exports": [
"isEditableTextLeaf",
"collectDomEditTextFields",
"buildElementLabel",
"refreshDomEditSelection",
],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
Expand Down
2 changes: 2 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pre-commit:
exclude: "(\\.test\\.(ts|tsx)$|\\.generated\\.)"
run: |
for f in {staged_files}; do
# Skip test and generated files (exclude pattern backup in case lefthook doesn't filter)
case "$f" in *.test.ts|*.test.tsx|*.generated.*) continue ;; esac
lines=$(wc -l < "$f")
if [ "$lines" -gt 600 ]; then
echo "ERROR: $f has $lines lines (max 600)"
Expand Down
57 changes: 55 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,26 @@ const hasJsonFlag = process.argv.includes("--json");
// exit handler is synchronous-only).
let _flush: (() => Promise<void>) | undefined;
let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}) => void)
| undefined;
let _trackCommandResult:
| ((props: { command: string; success: boolean; exitCode: number; durationMs: number }) => void)
| undefined;
let _printUpdateNotice: (() => void) | undefined;

if (!isHelp && command !== "telemetry" && command !== "unknown") {
import("./telemetry/index.js").then((mod) => {
_flush = mod.flush;
_flushSync = mod.flushSync;
_trackCliError = mod.trackCliError;
_trackCommandResult = mod.trackCommandResult;
mod.showTelemetryNotice();
mod.trackCommand(command);
if (mod.shouldTrack()) mod.incrementCommandCount();
Expand All @@ -176,17 +190,56 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade") {
});
}

const commandStart = Date.now();
let commandFailed = false;

// Async flush for normal exit (beforeExit fires when the event loop drains)
process.on("beforeExit", () => {
_flush?.().catch(() => {});
if (!hasJsonFlag) _printUpdateNotice?.();
});

// Sync flush for process.exit() calls (exit event only allows synchronous code)
process.on("exit", () => {
// Sync-only: exit handlers cannot await promises or drain microtasks.
// _trackCommandResult / _trackCliError are captured references resolved
// at init time, so they're callable synchronously here.
process.on("exit", (code) => {
_trackCommandResult?.({
command,
success: code === 0 && !commandFailed,
exitCode: code,
durationMs: Date.now() - commandStart,
});
_flushSync?.();
});

process.on("uncaughtException", (error) => {
commandFailed = true;
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "uncaught_exception",
});
_flushSync?.();
process.exit(1);
});

// unhandledRejection does not call process.exit() — Node may continue
// running if the rejection is non-fatal (e.g. a fire-and-forget promise).
// The exit handler above will still fire with the real exit code.
process.on("unhandledRejection", (reason) => {
commandFailed = true;
const error = reason instanceof Error ? reason : new Error(String(reason));
_trackCliError?.({
error_name: error.name,
error_message: error.message,
stack_trace: error.stack,
command,
kind: "unhandled_rejection",
});
});

// Lazy-load help renderer — avoids allocating help data on non-help invocations
async function showUsage<T extends ArgsDef>(
cmd: CommandDef<T>,
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,33 @@ export function trackInitTemplate(templateId: string, props?: { tailwind?: boole
export function trackBrowserInstall(): void {
trackEvent("browser_install", {});
}

export function trackCliError(props: {
error_name: string;
error_message: string;
stack_trace?: string;
command?: string;
kind: "uncaught_exception" | "unhandled_rejection" | "command_error";
}): void {
trackEvent("cli_error", {
error_name: props.error_name,
error_message: props.error_message.slice(0, 1000),
stack_trace: props.stack_trace?.slice(0, 2000),
command: props.command,
kind: props.kind,
});
}

export function trackCommandResult(props: {
command: string;
success: boolean;
exitCode: number;
durationMs: number;
}): void {
trackEvent("cli_command_result", {
command: props.command,
success: props.success,
exit_code: props.exitCode,
duration_ms: props.durationMs,
});
}
2 changes: 2 additions & 0 deletions packages/cli/src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@ export {
trackRenderError,
trackInitTemplate,
trackBrowserInstall,
trackCliError,
trackCommandResult,
} from "./events.js";
export { getSystemMeta, getShmSizeMb, getFreeDiskMb, bytesToMb } from "./system.js";
71 changes: 70 additions & 1 deletion packages/core/src/studio-api/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading