diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..11e2a6c40899 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -251,6 +251,8 @@ class V8StackTraceIterator { bool isConstructor = false; bool isGlobalCode = false; bool isAsync = false; + // Printed as "name (url)" or " (url)", not as a bare "url". + bool isFunction = false; }; WTF::StringView stack; @@ -400,6 +402,8 @@ class V8StackTraceIterator { functionName = functionName.substring(4); } + frame.isFunction = !functionName.isEmpty(); + if (functionName == ""_s) { functionName = StringView(); } @@ -619,6 +623,8 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, current.code_type = ZigStackFrameCodeConstructor; } else if (frame.isGlobalCode) { current.code_type = ZigStackFrameCodeGlobal; + } else if (frame.isFunction) { + current.code_type = ZigStackFrameCodeFunction; } except.stack.frames_len += 1; diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 04719466ad3e..d791068a0d56 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -596,10 +596,9 @@ test("error.stack doesnt lose frames", () => { at middle (/inspect.test.ts::) at IGNORE_ME_BEFORE_THIS_LINE (/inspect.test.ts::) at accessErrorStackProperty (/inspect.test.ts::) - at /inspect.test.ts:: + at (/inspect.test.ts::) " `); - // We allow it to differ by the existence of as a string. But that's it. - expect(no.split("\n").slice(0, -2).join("\n").trim()).toBe(yes.split("\n").slice(0, -2).join("\n").trim()); + expect(yes).toBe(no); }); diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 63b28630a3f6..1c9a40eb675d 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,5 +1,5 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; import { join } from "node:path"; @@ -149,3 +149,103 @@ test("Async functions frame should be included in stack trace", async () => { at async (file:NN:NN)" `); }); + +// Once error.stack has been read (or assigned), JSC no longer has the frames of +// the error, and Bun.inspect / console.error / the uncaught error output print +// the frames parsed back out of that string instead. +describe("printing the frames parsed back out of error.stack", () => { + const printedFrames = (text: string) => + text + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("at ")); + + // "at async outer (/x.js:1:2)" -> "at async outer": where the frames point is + // not what is being tested here. + const withoutLocation = (line: string) => line.replace(/ \(.*$/, ""); + + test("async, , new and bare frames print as they do in error.stack", () => { + const err = new Error("boom"); + err.stack = [ + "Error: boom", + " at async fetchUser (/fake/api.js:10:9)", + " at async (/fake/app.js:4:3)", + " at (/fake/app.js:9:1)", + " at new Client (/fake/client.js:2:11)", + " at run (/fake/main.js:7:5)", + " at global code (/fake/main.js:12:1)", + " at unknown", + ].join("\n"); + + const expected = [ + "at async fetchUser (/fake/api.js:10:9)", + "at async (/fake/app.js:4:3)", + "at (/fake/app.js:9:1)", + "at new Client (/fake/client.js:2:11)", + "at run (/fake/main.js:7:5)", + "at /fake/main.js:12:1", + expect.stringMatching(/^at unknown\b/), + ]; + expect(printedFrames(Bun.inspect(err, { colors: false }))).toEqual(expected); + expect(printedFrames(Bun.stripANSI(Bun.inspect(err, { colors: true })))).toEqual(expected); + }); + + test("Bun.inspect prints the same frames before and after error.stack has been read", async () => { + async function inner() { + await 1; + throw new Error("boom"); + } + async function outer() { + await inner(); + } + const err: Error = await (async () => { + await outer(); + })().catch(e => e); + + const ownFrames = (text: string) => + printedFrames(text) + .filter(line => line.includes("stack.test.ts")) + .map(withoutLocation); + const before = ownFrames(Bun.inspect(err)); + const stack = ownFrames(err.stack!); + const after = ownFrames(Bun.inspect(err)); + + expect(before).toEqual(["at inner", "at async outer", "at async "]); + expect({ stack, after }).toEqual({ stack: before, after: before }); + }); + + test("console.error and the unhandled rejection output keep the async frames", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + async function inner() { + await 1; + throw new Error("boom"); + } + async function outer() { + await inner(); + } + (async () => { + await outer(); + })().catch(e => { + e.stack; // e.g. a logger reading it + console.error(e); + throw e; + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Printed once by console.error(e) and once as an unhandled rejection. + const expected = ["at inner", "at async outer", "at async "]; + expect(printedFrames(stderr).map(withoutLocation)).toEqual([...expected, ...expected]); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); +});