fix(cli): never print "[object Object]" from validate/inspect errors (#1810)

* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"

The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.

Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.

Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.

* fix(cli): route remaining browser/process error sites through normalizeErrorMessage

The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].

Route those sites through the shared helper:
  snapshot.ts (the closest sibling to validate/inspect, same bug class),
  render.ts (Chrome launch + Docker build), capture/index.ts and
  commands/capture.ts (page-driven extraction), auth/browser.ts,
  browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
  paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
  lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
  error objects.

Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
This commit is contained in:
Miguel Ángel
2026-06-30 10:47:54 -07:00
committed by GitHub
parent 23adfdc496
commit a5c2636e8c
16 changed files with 60 additions and 47 deletions
+2 -1
View File
@@ -5,6 +5,7 @@
*/
import { c } from "../ui/colors.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export interface OpenBrowserResult {
/** True when we successfully invoked the platform "open" command. */
@@ -21,7 +22,7 @@ export async function openBrowser(url: string): Promise<OpenBrowserResult> {
await open(url);
return { opened: true };
} catch (err) {
printManualInstructions(url, err instanceof Error ? err.message : String(err));
printManualInstructions(url, normalizeErrorMessage(err));
return { opened: false };
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
import { basename } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
type PuppeteerBrowsers = typeof import("@puppeteer/browsers");
@@ -11,7 +12,7 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
try {
return await import("@puppeteer/browsers");
} catch (err) {
const cause = err instanceof Error ? err.message : String(err);
const cause = normalizeErrorMessage(err);
throw new Error(
`Failed to load @puppeteer/browsers: ${cause}\n` +
`Fix: run \`npm install\` or \`bun install\` to restore missing packages, then retry.`,
@@ -268,7 +269,7 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
try {
return await downloadBrowser();
} catch (err) {
const cause = err instanceof Error ? err.message : String(err);
const cause = normalizeErrorMessage(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
+4 -5
View File
@@ -18,6 +18,7 @@ import { extractTokens } from "./tokenExtractor.js";
import { extractDesignStyles } from "./designStyleExtractor.js";
import { downloadAssets, downloadAndRewriteFonts } from "./assetDownloader.js";
import { extractFontMetadata } from "./fontMetadataExtractor.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
// briefGenerator.ts, visual-style, capture-summary removed — DESIGN.md replaces them
import {
setupAnimationCapture,
@@ -353,7 +354,8 @@ export async function captureWebsite(
`${designStyles.typography.length} typography roles, ${designStyles.buttons.length} button styles, ${designStyles.shadows.length} shadow values extracted`,
);
} catch (err) {
const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
const errMsg =
err instanceof Error ? `${err.message}\n${err.stack}` : normalizeErrorMessage(err);
console.error(` ⚠ Design style extraction failed: ${errMsg}`);
warnings.push(`Design style extraction failed: ${errMsg}`);
}
@@ -480,10 +482,7 @@ export async function captureWebsite(
}
}
} catch (err) {
console.warn(
"Font metadata extraction failed (non-fatal):",
err instanceof Error ? err.message : err,
);
console.warn("Font metadata extraction failed (non-fatal):", normalizeErrorMessage(err));
}
// Save animation catalog — lean version for the agent (not 745 raw CSS declarations)
+2 -1
View File
@@ -22,6 +22,7 @@
*/
import { readFileSync } from "node:fs";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export type SupportedAspectRatio = "16:9" | "9:16" | "1:1";
@@ -87,7 +88,7 @@ export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDet
try {
html = readFileSync(entryHtmlPath, "utf-8");
} catch (err) {
return { kind: "read-error", error: err instanceof Error ? err.message : String(err) };
return { kind: "read-error", error: normalizeErrorMessage(err) };
}
return detectAspectRatioFromHtmlString(html);
}
+3 -9
View File
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";
import { c } from "../ui/colors.js";
import { errorBox } from "../ui/format.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import {
loadProjectVariableSchema,
reportVariableIssues,
@@ -90,10 +91,7 @@ function parseJson(raw: string, source: string): unknown {
try {
return JSON.parse(raw);
} catch (error: unknown) {
throw new BatchRenderInputError(
"Invalid JSON in --batch",
`${source}: ${error instanceof Error ? error.message : String(error)}`,
);
throw new BatchRenderInputError("Invalid JSON in --batch", `${source}: ${errorMessage(error)}`);
}
}
@@ -239,7 +237,7 @@ export function prepareBatchRender(options: PrepareBatchRenderOptions): Prepared
} catch (error: unknown) {
throw new BatchRenderInputError(
"Could not read --batch",
`${batchPath}: ${error instanceof Error ? error.message : String(error)}`,
`${batchPath}: ${errorMessage(error)}`,
);
}
@@ -310,10 +308,6 @@ function emitJsonEvent(event: Record<string, unknown>, json: boolean): void {
if (json) console.log(JSON.stringify(event));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function renderBatchRow(
row: PreparedBatchRow,
manifest: BatchManifest,
+2 -1
View File
@@ -1,6 +1,7 @@
import { defineCommand } from "citty";
import { resolve } from "node:path";
import type { Example } from "./_examples.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export const examples: Example[] = [
["Capture a website into ./capture/", "hyperframes capture https://stripe.com"],
@@ -213,7 +214,7 @@ export default defineCommand({
console.log();
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const errMsg = normalizeErrorMessage(err);
// Write BLOCKED.md so the user/agent knows the capture failed
try {
const { mkdirSync, writeFileSync } = await import("node:fs");
+3 -2
View File
@@ -33,6 +33,7 @@ import {
import { c } from "../../ui/colors.js";
import { errorBox, formatBytes, formatDuration } from "../../ui/format.js";
import { resolveProject } from "../../utils/project.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { createPublishArchive } from "../../utils/publishProject.js";
import {
reportVariableIssues,
@@ -541,7 +542,7 @@ async function maybeUploadProject(
try {
archive = createPublishArchive(project.dir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = normalizeErrorMessage(err);
errorBox("Zip failed", msg, "Check the project for missing files or unreadable permissions.");
process.exit(1);
}
@@ -733,7 +734,7 @@ async function streamVideo(
}
return { bytes: result.bytes };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = normalizeErrorMessage(err);
errorBox(
"Download failed",
message,
+2 -1
View File
@@ -30,6 +30,7 @@ import {
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../utils/variables.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
export const examples: Example[] = [
["Deploy the Cloud Run render stack", "hyperframes cloudrun deploy --project my-gcp-project"],
@@ -670,7 +671,7 @@ async function runRenderBatch(args: Record<string, unknown>): Promise<void> {
} catch (err) {
return {
outputKey: entry.outputKey,
error: err instanceof Error ? err.message : String(err),
error: normalizeErrorMessage(err),
};
}
}),
+2 -1
View File
@@ -22,6 +22,7 @@
import { readFileSync } from "node:fs";
import { c } from "../../ui/colors.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
export type PoliciesVerb = "role" | "user" | "validate";
@@ -247,7 +248,7 @@ export async function runPolicies(args: PoliciesArgs): Promise<void> {
try {
result = validatePolicy(args.inputPath);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = normalizeErrorMessage(err);
if (args.json) {
console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
process.exitCode = 1;
@@ -36,6 +36,7 @@ import {
reportVariableIssues,
validateVariablesAgainstSchema,
} from "../../utils/variables.js";
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
import { warnOnDimensionMismatch } from "./_dimensions.js";
import { requireStack } from "./state.js";
@@ -294,7 +295,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
outputKey: entry.outputKey,
executionArn: null,
status: "failed-to-start",
error: err instanceof Error ? err.message : String(err),
error: normalizeErrorMessage(err),
};
}
};
@@ -371,10 +372,7 @@ export function parseBatchFile(path: string): Array<{ entry: BatchEntry; lineNum
try {
parsed = JSON.parse(line);
} catch (err) {
errorBox(
`Invalid JSON in batch file on line ${i + 1}`,
err instanceof Error ? err.message : String(err),
);
errorBox(`Invalid JSON in batch file on line ${i + 1}`, normalizeErrorMessage(err));
process.exit(1);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+2 -1
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { withMeta } from "../utils/updateCheck.js";
import {
@@ -634,7 +635,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
process.exit(ok ? 0 : 1);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = normalizeErrorMessage(err);
if (args.json) {
console.log(
JSON.stringify(
+2 -9
View File
@@ -26,6 +26,7 @@ import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
import {
@@ -307,10 +308,6 @@ function printSelectionFailure(code: string, message: string, json: boolean): vo
process.exitCode = 1;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function previewServerPayload(server: {
port: number;
host?: string;
@@ -471,11 +468,7 @@ async function printCurrentContext(
try {
fields = parseContextFields(options.fields);
} catch (err) {
printSelectionFailure(
"invalid-context-fields",
err instanceof Error ? err.message : String(err),
options.json,
);
printSelectionFailure("invalid-context-fields", errorMessage(err), options.json);
return;
}
const fullDetail = options.detail === "full";
+3 -3
View File
@@ -753,7 +753,7 @@ export default defineCommand({
browserSpinner?.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
err instanceof Error ? err.message : String(err),
normalizeErrorMessage(err),
"Run: npx hyperframes browser ensure",
);
process.exit(1);
@@ -1076,7 +1076,7 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s
{ stdio: quiet ? "pipe" : "inherit", timeout: 600_000 },
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const message = normalizeErrorMessage(error);
throw new Error(`Failed to build Docker image: ${message}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
@@ -1149,7 +1149,7 @@ async function renderDocker(
try {
imageTag = ensureDockerImage(dockerVersion, platform, options.quiet);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const message = normalizeErrorMessage(error);
const isDockerMissing = /connect|not found|ENOENT/i.test(message);
errorBox(
isDockerMissing ? "Docker not available" : "Docker image build failed",
+4 -4
View File
@@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { resolve, join, relative, isAbsolute, basename } from "node:path";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
@@ -594,8 +595,7 @@ export default defineCommand({
descriptions.push(`## ${result.value.filename}`, `${result.value.desc}`, ``);
} else {
// Log first failure so Gemini issues are visible rather than silent
const errMsg =
result.reason instanceof Error ? result.reason.message : String(result.reason);
const errMsg = normalizeErrorMessage(result.reason);
descriptions.push(`## (error)`, `Gemini call failed: ${errMsg.slice(0, 120)}`, ``);
}
}
@@ -605,12 +605,12 @@ export default defineCommand({
console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`);
}
} catch (descErr) {
const msg = descErr instanceof Error ? descErr.message : String(descErr);
const msg = normalizeErrorMessage(descErr);
console.log(` ${c.dim(`--describe failed: ${msg.slice(0, 80)}`)}`);
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const msg = normalizeErrorMessage(err);
console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
process.exit(1);
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveProject } from "../utils/project.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
import { c } from "../ui/colors.js";
import { withMeta } from "../utils/updateCheck.js";
@@ -226,7 +227,7 @@ async function validateInBrowser(
});
page.on("pageerror", (err) => {
const text = err instanceof Error ? err.message : String(err);
const text = normalizeErrorMessage(err);
// CDN scripts (e.g. GSAP from jsdelivr) returning HTML error pages
// instead of JS produce "Unexpected token '<'" SyntaxErrors. These
// are network failures, not composition authoring errors.
@@ -394,7 +395,7 @@ Examples:
const exitCode = printValidationResult(result, asJson);
process.exit(exitCode);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
const message = normalizeErrorMessage(err);
emitFailureReport(message, asJson);
process.exit(1);
}
@@ -46,6 +46,26 @@ describe("normalizeErrorMessage", () => {
expect(normalizeErrorMessage(hostile)).toBe("[object Object]");
});
it("never yields '[object Object]' for a no-message object (the reported validate/inspect bug)", () => {
const out = normalizeErrorMessage({ code: 42 });
expect(out).not.toBe("[object Object]");
expect(out).toContain("42");
});
it("surfaces a Puppeteer-style protocol error object via its message", () => {
expect(normalizeErrorMessage({ name: "ProtocolError", message: "Target closed" })).toBe(
"Target closed",
);
});
it("surfaces a structured CDP error object (no message) instead of '[object Object]'", () => {
// The shape snapshot/render can receive when a CDP/protocol rejection is a
// plain object rather than an Error: no string `message`, only code + data.
const out = normalizeErrorMessage({ code: -32000, data: { reason: "navigation timeout" } });
expect(out).not.toBe("[object Object]");
expect(out).toContain("navigation timeout");
});
it("returns 'unknown error' for null and undefined", () => {
expect(normalizeErrorMessage(null)).toBe("unknown error");
expect(normalizeErrorMessage(undefined)).toBe("unknown error");