Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
df2cc28125 fix(console): improve Error.cause and AggregateError logging
- Add [cause] label when printing Error.cause property to show the
  relationship between parent and cause errors
- Print AggregateError's own message and stack trace before printing
  its errors array
- Add [errors] label before AggregateError's errors to clearly show
  the aggregated errors section
- Handle AggregateError's cause property properly

This makes error output more similar to Node.js, where the relationship
between errors is clearly labeled with [cause] and [errors] markers.

Fixes #1352

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:04:56 +00:00
2 changed files with 237 additions and 10 deletions

View File

@@ -2409,6 +2409,22 @@ pub fn printErrorlikeObject(
}
if (value.isAggregateError(this.global)) {
// First, print the AggregateError itself (name, message, stack, cause)
this.printAggregateErrorInstance(
value,
exception,
exception_list,
formatter,
Writer,
writer,
allow_ansi_color,
allow_side_effects,
) catch return;
// Print the [errors] label
writer.writeAll(comptime Output.prettyFmt("\n<cyan>[errors]<r>\n", allow_ansi_color)) catch return;
// Now iterate through the errors array
const AggregateErrorIterator = struct {
writer: Writer,
current_exception_list: ?*ExceptionList = null,
@@ -2517,6 +2533,91 @@ fn printErrorFromMaybePrivateData(
return false;
}
/// Prints an AggregateError's name, message, stack trace, and cause.
/// Does NOT print the `errors` property - that should be handled separately.
fn printAggregateErrorInstance(
this: *VirtualMachine,
error_instance: JSValue,
exception: ?*Exception,
exception_list: ?*ExceptionList,
formatter: *ConsoleObject.Formatter,
comptime Writer: type,
writer: Writer,
comptime allow_ansi_color: bool,
comptime allow_side_effects: bool,
) !void {
var exception_holder = ZigException.Holder.init();
var zig_exception = exception_holder.zigException();
defer exception_holder.deinit(this);
defer error_instance.ensureStillAlive();
var source_code_slice: ?ZigString.Slice = null;
defer if (source_code_slice) |slice| slice.deinit();
this.remapZigException(
zig_exception,
error_instance,
exception_list,
&exception_holder.need_to_clear_parser_arena_on_deinit,
&source_code_slice,
formatter.error_display_level != .warn,
);
const prev_had_errors = this.had_errors;
this.had_errors = true;
defer this.had_errors = prev_had_errors;
if (allow_side_effects) {
if (this.debugger) |*debugger| {
debugger.lifecycle_reporter_agent.reportError(zig_exception);
}
}
defer if (allow_side_effects and Output.is_github_action)
printGithubAnnotation(zig_exception);
// Print the error name and message
try this.printErrorNameAndMessage(
zig_exception.name,
zig_exception.message,
!zig_exception.browser_url.isEmpty(),
null,
Writer,
writer,
allow_ansi_color,
formatter.error_display_level,
);
// Print the stack trace
try printStackTrace(@TypeOf(writer), writer, zig_exception.stack, allow_ansi_color);
// Handle cause property (it's not enumerable, so we need to check it explicitly)
if (error_instance.getOwn(this.global, "cause") catch null) |cause| {
if (cause.jsType() == .ErrorInstance) {
try writer.writeAll(comptime Output.prettyFmt("\n<cyan>[cause]<r>\n", allow_ansi_color));
try this.printErrorInstance(.js, cause, exception_list, formatter, Writer, writer, allow_ansi_color, allow_side_effects);
}
}
// Also include any exception info from the wrapper if available
if (exception) |ex| {
if (exception_list) |list| {
var holder = ZigException.Holder.init();
var ex_exception: *ZigException = holder.zigException();
holder.deinit(this);
ex.getStackTrace(this.global, &ex_exception.stack);
if (ex_exception.stack.frames_len > 0) {
if (allow_ansi_color) {
printStackTrace(Writer, writer, ex_exception.stack, true) catch {};
} else {
printStackTrace(Writer, writer, ex_exception.stack, false) catch {};
}
}
ex_exception.addToErrorList(list, this.transpiler.fs.top_level_dir, &this.origin) catch {};
}
}
}
pub fn reportUncaughtException(globalObject: *JSGlobalObject, exception: *Exception) JSValue {
var jsc_vm = globalObject.bunVM();
_ = jsc_vm.uncaughtException(globalObject, exception.value(), false);
@@ -3112,10 +3213,11 @@ fn printErrorInstance(
}
// This is usually unsafe to do, but we are protecting them each time first
var errors_to_append = std.array_list.Managed(JSValue).init(this.allocator);
const LabeledError = struct { err: JSValue, label: ?[]const u8 };
var errors_to_append = std.array_list.Managed(LabeledError).init(this.allocator);
defer {
for (errors_to_append.items) |err| {
err.unprotect();
for (errors_to_append.items) |item| {
item.err.unprotect();
}
errors_to_append.deinit();
}
@@ -3151,11 +3253,12 @@ fn printErrorInstance(
// avoid infinite recursion
!prev_had_errors)
{
if (field.eqlComptime("cause")) {
const is_cause = field.eqlComptime("cause");
if (is_cause) {
saw_cause = true;
}
value.protect();
try errors_to_append.append(value);
try errors_to_append.append(.{ .err = value, .label = if (is_cause) "cause" else null });
} else if (kind.isObject() or kind.isArray() or value.isPrimitive() or kind.isStringLike()) {
var bun_str = bun.String.empty;
defer bun_str.deref();
@@ -3234,7 +3337,7 @@ fn printErrorInstance(
if (try error_instance.getOwn(this.global, "cause")) |cause| {
if (cause.jsType() == .ErrorInstance) {
cause.protect();
try errors_to_append.append(cause);
try errors_to_append.append(.{ .err = cause, .label = "cause" });
}
}
}
@@ -3272,7 +3375,7 @@ fn printErrorInstance(
);
}
for (errors_to_append.items) |err| {
for (errors_to_append.items) |item| {
// Check for circular references to prevent infinite recursion in cause chains
if (formatter.map_node == null) {
formatter.map_node = ConsoleObject.Formatter.Visited.Pool.get(default_allocator);
@@ -3280,7 +3383,7 @@ fn printErrorInstance(
formatter.map = formatter.map_node.?.data;
}
const entry = formatter.map.getOrPut(err) catch unreachable;
const entry = formatter.map.getOrPut(item.err) catch unreachable;
if (entry.found_existing) {
try writer.writeAll("\n");
try writer.writeAll(comptime Output.prettyFmt("<r><cyan>[Circular]<r>", allow_ansi_color));
@@ -3288,8 +3391,11 @@ fn printErrorInstance(
}
try writer.writeAll("\n");
try this.printErrorInstance(.js, err, exception_list, formatter, Writer, writer, allow_ansi_color, allow_side_effects);
_ = formatter.map.remove(err);
if (item.label) |label| {
try writer.print(comptime Output.prettyFmt("<cyan>[{s}]<r>\n", allow_ansi_color), .{label});
}
try this.printErrorInstance(.js, item.err, exception_list, formatter, Writer, writer, allow_ansi_color, allow_side_effects);
_ = formatter.map.remove(item.err);
}
}

View File

@@ -0,0 +1,121 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("Error.cause should display with [cause] label", async () => {
using dir = tempDir("error-cause-test", {
"test.js": `
const err = new Error("Main error");
err.cause = new Error("Cause error");
console.error(err);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The output should contain "[cause]" label
expect(stderr).toContain("[cause]");
expect(stderr).toContain("Main error");
expect(stderr).toContain("Cause error");
expect(exitCode).toBe(0);
});
test("AggregateError should display message and [errors] label", async () => {
using dir = tempDir("aggregate-error-test", {
"test.js": `
const aggregate = new AggregateError(
[new Error('Error 1'), new Error('Error 2')],
'Aggregate error message.'
);
throw aggregate;
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The output should contain the AggregateError message
expect(stderr).toContain("AggregateError");
expect(stderr).toContain("Aggregate error message.");
// The output should contain "[errors]" label
expect(stderr).toContain("[errors]");
expect(stderr).toContain("Error 1");
expect(stderr).toContain("Error 2");
expect(exitCode).not.toBe(0); // throw causes non-zero exit
});
test("AggregateError with cause should display [cause] label", async () => {
using dir = tempDir("aggregate-error-cause-test", {
"test.js": `
const aggregate = new AggregateError(
[new Error('Error 1')],
'Aggregate error message.',
{ cause: new Error('Cause') }
);
throw aggregate;
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The output should contain [cause] label
expect(stderr).toContain("[cause]");
expect(stderr).toContain("Cause");
// The output should contain [errors] label
expect(stderr).toContain("[errors]");
expect(stderr).toContain("Error 1");
expect(exitCode).not.toBe(0); // throw causes non-zero exit
});
test("Nested Error.cause chain should display properly", async () => {
using dir = tempDir("nested-cause-test", {
"test.js": `
const err3 = new Error("Third level");
const err2 = new Error("Second level", { cause: err3 });
const err1 = new Error("First level", { cause: err2 });
console.error(err1);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should show cause labels for nested errors
expect(stderr).toContain("First level");
expect(stderr).toContain("Second level");
expect(stderr).toContain("Third level");
// Should have multiple [cause] labels
const causeMatches = stderr.match(/\[cause\]/g);
expect(causeMatches).not.toBeNull();
expect(causeMatches!.length).toBeGreaterThanOrEqual(2);
expect(exitCode).toBe(0);
});