diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 40b7caad211d..0a522b5f2fc9 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "4d45755fed4aeef19e95beb90d3d0c884a2812bd"; +export const WEBKIT_VERSION = "autobuild-preview-pr-950-c5dabb29"; /** * WebKit (JavaScriptCore) — the JS engine, with WTF and bmalloc. diff --git a/src/aot/lower/class/constructor.rs b/src/aot/lower/class/constructor.rs index 4fb6751af888..ed8a215d4a3a 100644 --- a/src/aot/lower/class/constructor.rs +++ b/src/aot/lower/class/constructor.rs @@ -28,6 +28,11 @@ impl FileLowering<'_, '_, '_, '_> { } else { header.name = name; header.flags = header.flags.without(FunctionFlags::HAS_EXPLICIT_NAME); + // `export default class {}` has the name `default` from the start, as `export default function () {}` has. + let is_declaration = matches!(self.bound.class_owner[class.idx()], ClassOwner::Stmt(_)); + if is_declaration && self.is_default_export_name_its_own(node.start) { + header.flags |= FunctionFlags::HAS_EXPLICIT_NAME; + } } let end = match self.bound.class_owner[class.idx()] { ClassOwner::Expr(expression) => hir[expression].end, diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 81597bb610cc..7f021c5fd3a8 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -5404,13 +5404,7 @@ BunString JSC__JSValue__getNameProperty(JSC::EncodedJSValue JSValue0, JSC::JSGlo #endif if (JSC::JSFunction* function = dynamicDowncast(obj)) { - - WTF::String actualName = function->name(vm); - if (!actualName.isEmpty() || function->isHostOrBuiltinFunction()) { - return toStringAdopt(WTF::move(actualName)); - } - - return Bun::toStringRef(function->jsExecutable()->name().string()); + return toStringAdopt(function->name(vm)); } if (JSC::InternalFunction* function = dynamicDowncast(obj)) { diff --git a/test/aot/ir/lower/classes/anonymous-class-names.ts b/test/aot/ir/lower/classes/anonymous-class-names.ts index 57d40af9c0b8..1e443a464714 100644 --- a/test/aot/ir/lower/classes/anonymous-class-names.ts +++ b/test/aot/ir/lower/classes/anonymous-class-names.ts @@ -15,7 +15,9 @@ // RUN(INFERRED): lower // INFERRED-NOT: name="A"{{*}}HasExplicitName -// INFERRED-NOT: name="default"{{*}}HasExplicitName + +// RUN(DECLARED): lower +// DECLARED: name="default"{{*}}HasExplicitName declare const k: string; diff --git a/test/aot/parallel/errors/names-of-functions-behind-export-default/derived-class.mjs b/test/aot/parallel/errors/names-of-functions-behind-export-default/derived-class.mjs new file mode 100644 index 000000000000..d43466423c26 --- /dev/null +++ b/test/aot/parallel/errors/names-of-functions-behind-export-default/derived-class.mjs @@ -0,0 +1,6 @@ +export default class extends Object { + constructor() { + super(); + return 1; + } +} diff --git a/test/aot/parallel/errors/names-of-functions-behind-export-default/main.mjs b/test/aot/parallel/errors/names-of-functions-behind-export-default/main.mjs index 56aee7972827..3d84151b14d1 100644 --- a/test/aot/parallel/errors/names-of-functions-behind-export-default/main.mjs +++ b/test/aot/parallel/errors/names-of-functions-behind-export-default/main.mjs @@ -2,6 +2,7 @@ import arrow from "./arrow.mjs"; import asyncFunction from "./async-function.mjs"; import asyncGenerator from "./async-generator.mjs"; +import DerivedClass from "./derived-class.mjs"; import index from "./folder/index.mjs"; import generator from "./generator.mjs"; import named from "./named.mjs"; @@ -15,6 +16,21 @@ console.log("async function |", asyncFunction.name, "|", await asyncFunction()); console.log("generator |", generator.name, "|", generator().next().value); console.log("async generator |", asyncGenerator.name, "|", (await asyncGenerator().next()).value); console.log("class |", SomeClass.name, "|", new SomeClass().place); +const messageOf = run => { + try { + run(); + } catch (error) { + return error.message; + } +}; +console.log( + "class that is called without new |", + messageOf(() => SomeClass()), +); +console.log( + "derived class whose constructor returns a number |", + messageOf(() => new DerivedClass()), +); console.log("arrow function |", arrow.name, "|", arrow()); console.log("function in parentheses |", parenthesized.name, "|", parenthesized()); console.log("function in a TypeScript file |", typed.name, "|", typed()); @@ -32,6 +48,8 @@ async function | default | at default (async-function.mjs:3:14) generator | default | at default (generator.mjs:2:13) async generator | default | at default (async-generator.mjs:3:13) class | default | at new default (some-class.mjs:3:22) +class that is called without new | Cannot call a class constructor default without |new| +derived class whose constructor returns a number | Cannot return a non-object type in the constructor of a derived class default. arrow function | default | at default (arrow.mjs:2:7) function in parentheses | default | at default (parenthesized.mjs:2:14) function in a TypeScript file | default | at default (typed.ts:2:14) diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 0e3e7eb58fb1..3467bcef96b4 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -671,3 +671,137 @@ test("LifecycleReporter.getModuleGraph reports an empty argv when process.argv i expect(child.exitCode).toBe(null); webSocket.close(); }); + +// JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is +// "starDefault". The debugger shows the name of the function or class, "default". +describe("the debugger names an anonymous default export default", () => { + type Scope = { type: string; name?: string; object: { objectId: string } }; + type CallFrame = { functionName: string; scopeChain: Scope[] }; + + // Runs entry.mjs until a `debugger` statement pauses it. + async function pause(files: Record) { + const dir = tempDir("inspect-default-export", files); + const child = spawn({ + cmd: [bunExe(), "--inspect-wait=ws://127.0.0.1:0/", "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + + let stderr = ""; + let url: string | undefined; + const decoder = new TextDecoder(); + for await (const chunk of child.stderr as ReadableStream) { + stderr += decoder.decode(chunk); + url = stderr.match(/(ws:\/\/[^\s]+)/)?.[1]; + if (url) break; + } + if (!url) throw new Error(`Unable to find the inspector URL in:\n${stderr}`); + + const pending = new Map void>(); + const closed = Promise.withResolvers(); + closed.promise.catch(() => {}); + const opened = Promise.withResolvers(); + const paused = Promise.withResolvers<{ callFrames: CallFrame[] }>(); + const webSocket = new WebSocket(url); + webSocket.addEventListener("open", () => opened.resolve()); + webSocket.addEventListener("close", () => closed.reject(new Error("the inspector closed the socket"))); + webSocket.addEventListener("error", () => closed.reject(new Error("the inspector socket failed"))); + webSocket.addEventListener("message", ({ data }) => { + const message = JSON.parse(data.toString()); + if (message.method === "Debugger.paused") paused.resolve(message.params); + pending.get(message.id)?.(message.result); + pending.delete(message.id); + }); + + let nextId = 1; + function send(method: string, params: object = {}): Promise { + const id = nextId++; + const { promise, resolve } = Promise.withResolvers(); + pending.set(id, resolve); + webSocket.send(JSON.stringify({ id, method, params })); + return Promise.race([promise, closed.promise]); + } + + await Promise.race([opened.promise, closed.promise]); + await send("Inspector.enable"); + await send("Debugger.enable"); + await send("Debugger.setBreakpointsActive", { active: true }); + await send("Debugger.setPauseOnDebuggerStatements", { enabled: true }); + await send("Inspector.initialized"); + + const { callFrames } = await Promise.race([paused.promise, closed.promise]); + return { + callFrames, + send, + async [Symbol.asyncDispose]() { + webSocket.close(); + child.kill(); + await child.exited; + dir[Symbol.dispose](); + }, + }; + } + + test("the call frame and the scopes of its variables", async () => { + await using session = await pause({ + "paused.mjs": ` + export default () => { + let captured = 1; + const read = () => captured; + debugger; + return read(); + }; + `, + "entry.mjs": ` + import paused from "./paused.mjs"; + paused(); + `, + }); + const frame = session.callFrames[0]; + expect({ + functionName: frame.functionName, + // A scope of a function has the name of the function. The scope of the module has none. + scopeNames: frame.scopeChain.flatMap(scope => (scope.name ? [scope.name] : [])), + }).toEqual({ functionName: "default", scopeNames: ["default", "default"] }); + }); + + // The constructor that JavaScriptCore makes for a class with none is a function expression with the name of + // the class. Under a debugger it binds itself to that name in a scope of its own, as a class with a written + // name does. For `export default class {}` the name is "default". + test("the scope that holds the constructor of a class under its name", async () => { + await using session = await pause({ + "paused.mjs": ` + export default class { + field = (() => { + debugger; + return 1; + })(); + } + `, + "entry.mjs": ` + import Paused from "./paused.mjs"; + new Paused(); + `, + }); + const names: string[][] = []; + for (const frame of session.callFrames) { + for (const scope of frame.scopeChain) { + if (scope.type !== "functionName") continue; + const { properties } = await session.send("Runtime.getProperties", { + objectId: scope.object.objectId, + ownProperties: true, + }); + names.push(properties.map((property: { name: string }) => property.name)); + } + } + expect({ + functionNames: session.callFrames.map(frame => frame.functionName), + namesInFunctionNameScopes: names, + }).toEqual({ + functionNames: expect.arrayContaining(["default"]), + namesInFunctionNameScopes: [["default"]], + }); + }); +}); diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index af167b9bca7f..95386675cd29 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -228,6 +228,86 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + // The DFG inlines the default export into caller(). A frame of an inlined function has no function + // object, so the profiler names it from its code. JavaScriptCore binds an anonymous default export + // to the private name `*default*`, whose text is "starDefault". The name of the function is "default". + test("names an inlined anonymous default export default", async () => { + const files = { + "inlined.mjs": `export default work => work() + 1;`, + "main.mjs": ` + import { noInline, numberOfDFGCompiles } from "bun:jsc"; + import inlined from "./inlined.mjs"; + + function work() { + let sum = 0; + for (let i = 0; i < 1000; i++) sum += i % 7; + return sum; + } + noInline(work); + + function caller() { + return inlined(work) + 1; + } + noInline(caller); + + while (numberOfDFGCompiles(caller) === 0) caller(); + const end = performance.now() + 100; + while (performance.now() < end) caller(); + `, + }; + + async function profileOfOneRun() { + using dir = tempDir("cpu-prof-default-export", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-md", "main.mjs"], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + const written = readdirSync(String(dir)); + const profile = JSON.parse( + readFileSync( + join( + String(dir), + written.find(f => f.endsWith(".cpuprofile"))!, + ), + "utf-8", + ), + ); + const markdown = readFileSync( + join( + String(dir), + written.find(f => f.endsWith(".md"))!, + ), + "utf-8", + ); + + const nodes = new Map(profile.nodes.map((node: any) => [node.id, node])); + const callersOfWork = new Set(); + for (const node of profile.nodes) { + for (const child of node.children ?? []) { + if (nodes.get(child).callFrame.functionName === "work") callersOfWork.add(node.callFrame.functionName); + } + } + return { + callersOfWork: [...callersOfWork], + markdown: ["`default`", "`starDefault`"].filter(name => markdown.includes(name)), + exitCode, + }; + } + + // The profiler can miss work() in one window of 100 ms (see the comment at the top of this file). + // A run that has a sample in work() decides. + let result = await profileOfOneRun(); + for (let run = 1; run < 5 && result.exitCode === 0 && result.callersOfWork.length === 0; run++) { + result = await profileOfOneRun(); + } + expect(result).toEqual({ callersOfWork: ["default"], markdown: ["`default`"], exitCode: 0 }); + }); + test("--cpu-prof-md generates markdown format profile", async () => { using dir = tempDir("cpu-prof-md", { "test.js": ` diff --git a/test/cli/test/test-aot.test.ts b/test/cli/test/test-aot.test.ts index 7c774e837eca..94710fc76fba 100644 --- a/test/cli/test/test-aot.test.ts +++ b/test/cli/test/test-aot.test.ts @@ -217,6 +217,43 @@ describe.skipIf(!supported)("bun test --aot", () => { ); }); + // JavaScriptCore binds an anonymous default export to the private name `*default*`. The class has the name "default" + // from the start, as a class with a written name has its name, and compiled code says the same. + test.concurrent("an anonymous default class is named, mocked and snapshotted as without --aot", async () => { + const files = { + "anonymous-class.ts": `export default class { m() {} }`, + "derived-class.ts": `export default class extends Object { constructor() { super(); return 1 as any; } }`, + "names.test.ts": ` + import { expect, mock, test } from "bun:test"; + import AnonymousClass from "./anonymous-class"; + import DerivedClass from "./derived-class"; + const element = (type: unknown) => ({ $$typeof: Symbol.for("react.element"), type, key: null, ref: null, props: {} }); + const messageOf = (run: () => unknown) => { try { run(); } catch (error) { return (error as Error).message; } }; + test("the name property", () => { expect(AnonymousClass.name).toBe("default"); }); + test("a mock", () => { const mocked = mock(AnonymousClass as unknown as () => unknown); console.log(JSON.stringify([mocked.name, mocked.getMockName()])); }); + test("the errors that name the class", () => { console.log(messageOf(() => (AnonymousClass as any)()) + " | " + messageOf(() => new DerivedClass())); }); + test("a snapshot", () => { expect({ AnonymousClass, instance: new AnonymousClass(), element: element(AnonymousClass) }).toMatchSnapshot(); }); + `, + }; + using expectedDir = tempDir("test-aot", files); + using actualDir = tempDir("test-aot", files); + const [expected, actual] = await Promise.all([ + runTests(String(expectedDir), false, ["--update-snapshots"]), + runTests(String(actualDir), true, ["--update-snapshots"]), + ]); + expect(actual).toEqual(expected); + expect(actual.stdout).toContain(`["default","default"]`); + expect(actual.stdout).toContain( + "Cannot call a class constructor default without |new| | Cannot return a non-object type in the constructor of a derived class default.", + ); + expect(actual.stderr).toContain(" 4 pass"); + const snapshot = join("__snapshots__", "names.test.ts.snap"); + expect(readFileSync(join(String(actualDir), snapshot), "utf8")).toBe( + readFileSync(join(String(expectedDir), snapshot), "utf8"), + ); + expect(readFileSync(join(String(actualDir), snapshot), "utf8")).toContain("[class default]"); + }); + test.concurrent("other options work the same as without --aot", async () => { const { stderr } = await expectSameAsBunTest( { diff --git a/test/js/bun/test/describe.test.ts b/test/js/bun/test/describe.test.ts index c8d9dfb83370..1f071c9a7da6 100644 --- a/test/js/bun/test/describe.test.ts +++ b/test/js/bun/test/describe.test.ts @@ -163,6 +163,36 @@ describe("shows first arg name correctly in test output", () => { expect(fullOutput).toInclude("MyClass > should pass"); expect(fullOutput).not.toInclude("[object Object] > should pass"); }); + + // JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is + // "starDefault". The name of the class is "default". + test("describe block shows an anonymous default class as default in test output", async () => { + await using test_dir = tempDir(".", { + "anonymous-default-class.js": `export default class {}`, + "describe-test.test.js": ` + import { describe, test, expect } from "bun:test"; + import AnonymousDefaultClass from "./anonymous-default-class.js"; + + describe(AnonymousDefaultClass, () => { + test("should pass", () => { + expect(true).toBe(true); + }); + }); + `, + }); + const { stdout, stderr } = spawnSync({ + cmd: [bunExe(), "test", "describe-test.test.js"], + cwd: test_dir, + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + const fullOutput = stdout.toString() + stderr.toString(); + + expect(fullOutput).toInclude("default > should pass"); + expect(fullOutput).not.toInclude("starDefault > should pass"); + }); }); describe("passing arrow function as args", () => { diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 76e14c699481..bacc24360aa0 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -326,6 +326,25 @@ describe("mock()", () => { const fn = jest.fn(); expect(fn.name).toBe("mockConstructor"); }); + if (isBun) { + // JavaScriptCore binds an anonymous default export to the private name `*default*`. Its name is "default". + test(".name of an anonymous default class or function declaration is default", async () => { + const { default: Class } = await import("data:text/javascript,export default class {}"); + // The text of a SourceTextModule reaches JavaScriptCore as it is written. + const module = new vm.SourceTextModule("export default function () {}"); + await module.link(() => {}); + await module.evaluate(); + const declaration = module.namespace.default; + + // jest.fn() has the mock name "jest.fn()" whatever it wraps. mock() takes the name of the function. + expect([ + jest.fn(Class).name, + mock(Class).getMockName(), + jest.fn(declaration).name, + mock(declaration).getMockName(), + ]).toEqual(["default", "default", "default", "default"]); + }); + } test(".name throwing doesnt segfault", () => { function baddie() { return this; diff --git a/test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts b/test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts index 6e2a53d0093f..618a282f713d 100644 --- a/test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts +++ b/test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts @@ -118,3 +118,38 @@ test("test snapshots with Boolean and Number", () => { }, }); }); + +// JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is +// "starDefault". The name of the class or function is "default", and a snapshot has that name. +test("an anonymous default export is named default in a snapshot", async () => { + const { default: Class } = await import("data:text/javascript,export default class {}"); + const { default: ClassWithField } = await import("data:text/javascript,export default class { a = 1 }"); + const { default: arrow } = await import("data:text/javascript,export default () => {}"); + // The text of a SourceTextModule reaches JavaScriptCore as it is written. + const { SourceTextModule } = await import("node:vm"); + const module = new SourceTextModule("export default function () {}"); + await module.link(() => {}); + await module.evaluate(); + + expect({ + class: Class, + instance: new Class(), + instanceWithField: new ClassWithField(), + arrow, + declaration: module.namespace.default, + element: { $$typeof: Symbol.for("react.element"), type: Class, props: {}, key: null, ref: null }, + any: expect.any(Class), + }).toMatchInlineSnapshot(` + { + "any": Any, + "arrow": [Function], + "class": [class default], + "declaration": [Function: default], + "element": , + "instance": default {}, + "instanceWithField": default { + "a": 1, + }, + } + `); +}); diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 5445e109bf8e..b58f22776719 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness"; import { join } from "node:path"; test("name property is used for function calls in Error.stack", () => { @@ -175,3 +175,184 @@ test("Async functions frame should be included in stack trace", async () => { at async (file:NN:NN)" `); }); + +// JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is +// "starDefault". A frame of such a function or class has the name "default", as in node, whether or +// not its `.name` was read. +describe("a frame of an anonymous default export is named default", () => { + const modules = { + "class.mjs": `export default class { constructor() { this.stack = new Error("class").stack; } }`, + "subclass.mjs": ` + class Base { constructor() { this.stack = new Error("subclass").stack; } } + export default class extends Base {} + `, + "arrow.mjs": `export default () => new Error("arrow").stack;`, + "expression.mjs": `export default (function () { return new Error("expression").stack; });`, + // A call in tail position that throws leaves no function object for its frame. + "tail-call.mjs": ` + const notAFunction = 1; + export default () => notAFunction(); + `, + // A function that is really named starDefault keeps its name. + "named-star-default.mjs": `export default function starDefault() { return new Error("starDefault").stack; }`, + "throws.mjs": `export default class { constructor() { throw new Error("thrown by an anonymous default export"); } }`, + "derived.mjs": `export default class extends Object { constructor() { super(); return 1; } }`, + // A file that starts with this comment is not transpiled: the declaration reaches JavaScriptCore as it is written. + "already-transpiled.mjs": `// @bun +export default function () { return new Error("already transpiled").stack; }`, + "uncaught.mjs": ` + import Thrower from "./throws.mjs"; + new Thrower(); + `, + "unhandled-rejection.mjs": ` + import Thrower from "./throws.mjs"; + Promise.resolve().then(() => new Thrower()); + `, + "main.mjs": ` + import { SourceTextModule } from "node:vm"; + import Class from "./class.mjs"; + import Subclass from "./subclass.mjs"; + import arrow from "./arrow.mjs"; + import expression from "./expression.mjs"; + import tailCall from "./tail-call.mjs"; + import starDefault from "./named-star-default.mjs"; + import Derived from "./derived.mjs"; + import alreadyTranspiled from "./already-transpiled.mjs"; + + // The text of a SourceTextModule reaches JavaScriptCore as it is written. + async function defaultExportOf(source) { + const module = new SourceTextModule(source); + await module.link(() => {}); + await module.evaluate(); + return module.namespace.default; + } + const declaration = await defaultExportOf('export default function () { return new Error("declaration").stack; }'); + const generator = await defaultExportOf('export default function* () { yield new Error("generator").stack; }'); + const asyncDeclaration = await defaultExportOf( + 'export default async function () { await null; return new Error("async declaration").stack; }', + ); + const asyncGenerator = await defaultExportOf( + 'export default async function* () { await null; yield new Error("async generator").stack; }', + ); + + const frameNames = (stack, count) => + stack + .split("\\n") + .slice(1, 1 + count) + .map(line => line.trim().replace(/^at /, "").replace(/ \\(.*$/, "")); + + const stackOfThrown = fn => { + try { + fn(); + } catch (error) { + return error.stack; + } + }; + + const messageOf = fn => { + try { + fn(); + } catch (error) { + return error.message; + } + }; + + const topCallSite = getStack => { + const previous = Error.prepareStackTrace; + Error.prepareStackTrace = (_, callSites) => [callSites[0].getFunctionName(), callSites[0].isConstructor()]; + try { + return getStack(); + } finally { + Error.prepareStackTrace = previous; + } + }; + + const observe = async () => ({ + class: frameNames(new Class().stack, 1), + subclass: frameNames(new Subclass().stack, 2), + arrow: frameNames(arrow(), 1), + expression: frameNames(expression(), 1), + tailCall: frameNames(stackOfThrown(tailCall), 1), + declaration: frameNames(declaration(), 1), + alreadyTranspiled: frameNames(alreadyTranspiled(), 1), + generator: frameNames(generator().next().value, 1), + asyncDeclaration: frameNames(await asyncDeclaration(), 1), + asyncGenerator: frameNames((await asyncGenerator().next()).value, 1), + starDefault: frameNames(starDefault(), 1), + callSites: [topCallSite(() => new Class().stack), topCallSite(arrow), topCallSite(declaration)], + // The text of these two errors has the name of a class declaration. + messages: [messageOf(() => Class()), messageOf(() => new Derived())], + }); + + const beforeReadingName = await observe(); + const names = [Class, Subclass, arrow, expression, tailCall, declaration, alreadyTranspiled, generator, asyncDeclaration, asyncGenerator, starDefault].map(fn => fn.name); + const afterReadingName = await observe(); + console.log(JSON.stringify({ beforeReadingName, names, afterReadingName })); + `, + }; + + const expected = { + class: ["new default"], + subclass: ["new Base", "new default"], + arrow: ["default"], + expression: ["default"], + tailCall: ["default"], + declaration: ["default"], + alreadyTranspiled: ["default"], + generator: ["default"], + asyncDeclaration: ["default"], + asyncGenerator: ["default"], + starDefault: ["starDefault"], + callSites: [ + ["default", true], + ["default", false], + ["default", false], + ], + messages: [ + "Cannot call a class constructor default without |new|", + "Cannot return a non-object type in the constructor of a derived class default.", + ], + }; + + test.concurrent("error.stack and CallSite#getFunctionName", async () => { + using dir = tempDir("export-default-frame-name", modules); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ result: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + result: { + beforeReadingName: expected, + names: [...Array(10).fill("default"), "starDefault"], + afterReadingName: expected, + }, + stderr: "", + exitCode: 0, + }); + }); + + // Bun's error printer names the frames of an error that nothing caught. + test.concurrent.each([ + ["uncaught.mjs", ["at new default (file:NN:NN)", "at /uncaught.mjs"]], + ["unhandled-rejection.mjs", ["at new default (file:NN:NN)", "at (file:NN:NN)"]], + ])("frames printed for an uncaught error: %s", async (entry, frames) => { + using dir = tempDir("export-default-frame-name", modules); + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const printedFrames = normalizeBunSnapshot(stderr, String(dir)) + .split("\n") + .map(line => line.trim().replace(/:\d+:\d+$/, "")) + .filter(line => line.startsWith("at ")); + expect({ stdout, printedFrames, exitCode }).toEqual({ stdout: "", printedFrames: frames, exitCode: 1 }); + }); +}); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 8e502eb2bab0..dc3f616e3ca9 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -1788,3 +1788,79 @@ it.skipIf(!isASAN)("prints a substring-rope jsx type after a prop's inspect.cust expect(stdout).toEndWith("\n"); expect(exitCode).toBe(0); }); + +// JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is +// "starDefault". The name of the class or function is "default", as in node. +it("an anonymous default export and its instances inspect as default", async () => { + using dir = tempDir("inspect-export-default", { + "class.mjs": `export default class {}`, + "explicit-constructor.mjs": `export default class { constructor() { this.a = 1; } }`, + "class-expression.mjs": `export default (class {});`, + "subclass.mjs": ` + export class Base {} + export default class extends Base {} + `, + "number.mjs": `export default class extends Number {}`, + "boolean.mjs": `export default class extends Boolean {}`, + "arrow.mjs": `export default () => {};`, + // A class that is really named starDefault keeps its name. + "named-star-default.mjs": `export default class starDefault {}`, + "main.mjs": ` + import { SourceTextModule } from "node:vm"; + import Class from "./class.mjs"; + import ExplicitConstructor from "./explicit-constructor.mjs"; + import ClassExpression from "./class-expression.mjs"; + import Subclass from "./subclass.mjs"; + import Num from "./number.mjs"; + import Bool from "./boolean.mjs"; + import arrow from "./arrow.mjs"; + import StarDefault from "./named-star-default.mjs"; + + // The text of a SourceTextModule reaches JavaScriptCore as it is written. + const declaration = new SourceTextModule("export default function () {}"); + await declaration.link(() => {}); + await declaration.evaluate(); + + console.log(Class, Subclass, arrow, declaration.namespace.default); + console.log({ $$typeof: Symbol.for("react.element"), type: Class, props: {}, key: null }); + console.log(new Class()); + console.log({ instance: new Class() }); + console.log(new ExplicitConstructor()); + console.log(new ClassExpression()); + console.log(new Num(1)); + console.log(new Bool(true)); + console.log(Class.prototype); + class Sub extends Class {} + console.log(new Sub()); + console.log(StarDefault, new StarDefault()); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toMatchInlineSnapshot(` + "[class default] [class default extends Base] [Function: default] [Function: default] + + default {} + { + instance: default {}, + } + default { + a: 1, + } + default {} + [Number (default): 1] + [Boolean (default): true] + default {} + Sub {} + [class starDefault] starDefault {} + " + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/util/v8-heap-snapshot.test.ts b/test/js/bun/util/v8-heap-snapshot.test.ts index 8141ce96d21a..2c0e74dee683 100644 --- a/test/js/bun/util/v8-heap-snapshot.test.ts +++ b/test/js/bun/util/v8-heap-snapshot.test.ts @@ -111,3 +111,51 @@ test("v8 heap snapshot labels Web Streams internal edges", async () => { expect.arrayContaining(["source", "destination", "reader", "writer", "promise"]), ); }); + +// JavaScriptCore binds an anonymous default export to the private name `*default*`, whose text is +// "starDefault". A snapshot of either format names the class and its instances "default". +test("heap snapshots name an anonymous default class and its instances default", async () => { + using dir = tempDir("heap-snapshot-default-export", { + "class.mjs": `export default class {}`, + "main.mjs": ` + import Class from "./class.mjs"; + const instance = new Class(); + + const { nodeClassNames } = Bun.generateHeapSnapshot(); + + // A node of the V8 format is a run of numbers. Two of them index its type and its name. + const v8 = JSON.parse(Bun.generateHeapSnapshot("v8")); + const { node_fields: fields, node_types: types } = v8.snapshot.meta; + const namesOfV8Nodes = type => { + const names = new Set(); + for (let i = 0; i < v8.nodes.length; i += fields.length) { + if (types[0][v8.nodes[i + fields.indexOf("type")]] === type) names.add(v8.strings[v8.nodes[i + fields.indexOf("name")]]); + } + return names; + }; + const found = names => ["default", "starDefault"].filter(name => names.has(name)); + + console.log( + JSON.stringify({ + jsc: found(new Set(nodeClassNames)), + v8Objects: found(namesOfV8Nodes("object")), + v8Closures: found(namesOfV8Nodes("closure")), + }), + ); + if (!(instance instanceof Class)) throw new Error("the instance is gone"); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ names: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + names: { jsc: ["default"], v8Objects: ["default"], v8Closures: ["default"] }, + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 71bf87f2d501..d8a8d4c317c1 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -3,7 +3,7 @@ import { noInline } from "bun:jsc"; import { afterEach, expect, mock, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { totalmem } from "node:os"; -import { createContext, runInContext } from "node:vm"; +import { createContext, runInContext, SourceTextModule } from "node:vm"; const origPrepareStackTrace = Error.prepareStackTrace; afterEach(() => { Error.prepareStackTrace = origPrepareStackTrace; @@ -1342,6 +1342,49 @@ test("Error.captureStackTrace with caller not in stack clears async frames too", expect(err.stack).toBe("Error: async test"); }); +// The text of a SourceTextModule reaches JavaScriptCore as it is written. There an anonymous default +// function declaration is bound to the private name `*default*`, and its name is "default". +async function defaultExportOf(source) { + const module = new SourceTextModule(source); + await module.link(() => {}); + await module.evaluate(); + return module.namespace.default; +} + +const namesOfFrames = stack => + stack + .split("\n") + .slice(1) + .map(line => + line + .trim() + .replace(/^at /, "") + .replace(/ \(.*$/, ""), + ); + +test("Error.captureStackTrace finds a resumed anonymous default function whether or not its name was read", async () => { + const resumed = await defaultExportOf(` + export default async function (self) { + await null; + const error = new Error("resumed"); + Error.captureStackTrace(error, self); + return error.stack; + } + `); + async function caller() { + return await resumed(resumed); + } + noInline(caller); + + const beforeReadingName = namesOfFrames(await caller())[0]; + expect(resumed.name).toBe("default"); + const afterReadingName = namesOfFrames(await caller())[0]; + expect({ beforeReadingName, afterReadingName }).toEqual({ + beforeReadingName: "async caller", + afterReadingName: "async caller", + }); +}); + test("Error.captureStackTrace applies stackTraceLimit after caller removal", () => { // Build a deep call chain so the caller sits beyond stackTraceLimit. // If the limit were applied before removal, all collected frames would diff --git a/test/v8/v8-module/anonymous-default-class.mjs b/test/v8/v8-module/anonymous-default-class.mjs new file mode 100644 index 000000000000..a6e68e983892 --- /dev/null +++ b/test/v8/v8-module/anonymous-default-class.mjs @@ -0,0 +1 @@ +export default class {} diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index d2f265e8c5c9..5537529cbc49 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -297,6 +297,12 @@ describe.skipIf(!canBuildNodeAddons()).todoIf(isBroken && isMusl)("node:v8", () await checkSameOutput("call_function_with_weird_this_values"); }); + // JavaScriptCore binds an anonymous default export to the private name `*default*`. Its name is "default". + it("GetName of an anonymous default class is default", async () => { + const out = await checkSameOutput("print_values_from_js", "[require('./anonymous-default-class.mjs').default]"); + expect(out).toContain("argument 0 = function default()"); + }); + it("receives globalThis as this when called bare through a closure", async () => { const output = await checkSameOutput("call_function_bare_through_closure"); expect(output).toContain("bare call returned globalThis: true");