diff --git a/src/bun.js/ConsoleObject.zig b/src/bun.js/ConsoleObject.zig index 7ad178704fe..1a2ee798029 100644 --- a/src/bun.js/ConsoleObject.zig +++ b/src/bun.js/ConsoleObject.zig @@ -2326,16 +2326,28 @@ pub const Formatter = struct { ); }, .Class => { - var printable = ZigString.init(&name_buf); - try value.getClassName(this.globalThis, &printable); - this.addForNewLine(printable.len); + // Prefer the constructor's own `.name` property over + // `getClassName` / `calculatedClassName`. For DOM / WebCore + // InternalFunction constructors like `ReadableStreamBYOBReader`, + // `calculatedClassName` walks the prototype chain and hits + // `Function.prototype.constructor === Function`, returning + // "Function". The `.name` property is set to the real class + // name on the constructor itself. See #29225. + var printable = try value.getName(this.globalThis); + defer printable.deref(); + this.addForNewLine(printable.length()); + // Only report `extends` when the parent is itself a class + // (i.e. `class Foo extends Bar`). Built-in and DOM constructors + // have `Function.prototype` as their prototype, which would + // render as `[class X extends Function]` and is noise. const proto = value.getPrototype(this.globalThis); - var printable_proto = ZigString.init(&name_buf); - try proto.getClassName(this.globalThis, &printable_proto); - this.addForNewLine(printable_proto.len); + const proto_is_class = !proto.isEmptyOrUndefinedOrNull() and proto.isCell() and proto.isClass(this.globalThis); + var printable_proto: bun.String = if (proto_is_class) try proto.getName(this.globalThis) else bun.String.empty; + defer printable_proto.deref(); + this.addForNewLine(printable_proto.length()); - if (printable.len == 0) { + if (printable.isEmpty()) { if (printable_proto.isEmpty()) { writer.print(comptime Output.prettyFmt("[class (anonymous)]", enable_ansi_colors), .{}); } else { diff --git a/test/regression/issue/29225.test.ts b/test/regression/issue/29225.test.ts new file mode 100644 index 00000000000..d7e72638a39 --- /dev/null +++ b/test/regression/issue/29225.test.ts @@ -0,0 +1,137 @@ +// https://github.com/oven-sh/bun/issues/29225 + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { ReadableStreamBYOBReader } from "node:stream/web"; + +const streamWebClasses = [ + "ByteLengthQueuingStrategy", + "CompressionStream", + "CountQueuingStrategy", + "DecompressionStream", + "ReadableByteStreamController", + "ReadableStream", + "ReadableStreamBYOBReader", + "ReadableStreamBYOBRequest", + "ReadableStreamDefaultController", + "ReadableStreamDefaultReader", + "TextDecoderStream", + "TextEncoderStream", + "TransformStream", + "TransformStreamDefaultController", + "WritableStream", + "WritableStreamDefaultController", + "WritableStreamDefaultWriter", +]; + +test.concurrent("node:stream/web classes inspect as [class X], not [class Function]", async () => { + const source = ` + const sw = require("node:stream/web"); + const names = ${JSON.stringify(streamWebClasses)}; + for (const name of names) { + const klass = sw[name]; + if (typeof klass !== "function") { + console.log(name + ": MISSING"); + continue; + } + // Bun.inspect() uses the same formatter as console.log. + console.log(name + ": " + Bun.inspect(klass)); + } + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout.trim().split("\n"); + expect(lines.length).toBe(streamWebClasses.length); + + for (let i = 0; i < streamWebClasses.length; i++) { + const name = streamWebClasses[i]; + // Must not be "MISSING" (sanity check for the test itself) and + // must report the real class name, not "Function". + expect(lines[i]).not.toContain("MISSING"); + expect(lines[i]).toBe(`${name}: [class ${name}]`); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("other DOM / WebCore constructors inspect as [class X]", async () => { + // Sanity: the inspect formatter should work for any `isConstructor` + // InternalFunction exposed as a global. Keep this list small — it's + // a regression guard, not an audit. + const code = ` + console.log("URL: " + Bun.inspect(URL)); + console.log("Request: " + Bun.inspect(Request)); + console.log("Response: " + Bun.inspect(Response)); + console.log("Blob: " + Bun.inspect(Blob)); + console.log("Event: " + Bun.inspect(Event)); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe( + "URL: [class URL]\n" + + "Request: [class Request]\n" + + "Response: [class Response]\n" + + "Blob: [class Blob]\n" + + "Event: [class Event]\n", + ); + expect(exitCode).toBe(0); +}); + +test.concurrent("user-defined classes and extends still render correctly", async () => { + const code = ` + class Foo {} + class Bar extends Foo {} + const Anon = class {}; + + console.log("Foo: " + Bun.inspect(Foo)); + console.log("Bar: " + Bun.inspect(Bar)); + console.log("Anon: " + Bun.inspect(Anon)); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // `Anon` picks up the "Anon" name from the variable binding, matching + // JSC's naming inference for `const Anon = class {};`. + expect(stdout).toBe("Foo: [class Foo]\n" + "Bar: [class Bar extends Foo]\n" + "Anon: [class Anon]\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("instanceof and prototype identity still work", async () => { + // Functional behavior must not regress — the fix is cosmetic only. + const stream = new ReadableStream({ + type: "bytes", + start(c) { + c.enqueue(new Uint8Array([1, 2, 3])); + c.close(); + }, + }); + const reader = stream.getReader({ mode: "byob" }); + expect(reader).toBeInstanceOf(ReadableStreamBYOBReader); + expect(Object.getPrototypeOf(reader)).toBe(ReadableStreamBYOBReader.prototype); + reader.releaseLock(); + + class Sub extends ReadableStreamBYOBReader {} + expect(Object.getPrototypeOf(Sub.prototype)).toBe(ReadableStreamBYOBReader.prototype); +});