Files
2026-08-27 21:09:14 +00:00

1214 lines
40 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// ES standard decorators are used for .js files (always) and for .ts files
// when experimentalDecorators is NOT set in tsconfig.
// We test using .js files in temp directories to avoid inheriting
// the root tsconfig's experimentalDecorators: true setting.
function filterStderr(stderr: string) {
// Filter out ASAN warnings that only appear in debug builds
return stderr
.split("\n")
.filter(line => !line.startsWith("WARNING: ASAN"))
.join("\n")
.trim();
}
async function runDecorator(code: string) {
using dir = tempDir("es-dec", {
"test.js": code,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr: filterStderr(rawStderr), exitCode };
}
describe("ES Decorators", () => {
describe("class decorators", () => {
test("basic class decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) {
ctx.addInitializer(function() {
this.initialized = true;
});
return cls;
}
@dec class Foo {}
const f = new Foo();
console.log(Foo.initialized);
`);
expect(stderr).toBe("");
expect(stdout).toBe("true\n");
expect(exitCode).toBe(0);
});
test("class decorator receives correct context", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) {
console.log(ctx.kind);
console.log(ctx.name);
console.log(typeof ctx.addInitializer);
return cls;
}
@dec class MyClass {}
`);
expect(stderr).toBe("");
expect(stdout).toBe("class\nMyClass\nfunction\n");
expect(exitCode).toBe(0);
});
test("class decorator can replace class", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) {
return class extends cls {
extra = true;
};
}
@dec class Foo {
original = true;
}
const f = new Foo();
console.log(f.original, f.extra);
`);
expect(stderr).toBe("");
expect(stdout).toBe("true true\n");
expect(exitCode).toBe(0);
});
test("multiple class decorators apply in reverse order", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const order = [];
function dec1(cls, ctx) { order.push("dec1"); return cls; }
function dec2(cls, ctx) { order.push("dec2"); return cls; }
function dec3(cls, ctx) { order.push("dec3"); return cls; }
@dec1 @dec2 @dec3 class Foo {}
console.log(order.join(","));
`);
expect(stderr).toBe("");
expect(stdout).toBe("dec3,dec2,dec1\n");
expect(exitCode).toBe(0);
});
});
describe("method decorators", () => {
test("instance method decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function log(fn, ctx) {
return function(...args) {
console.log("before", ctx.name);
const result = fn.call(this, ...args);
console.log("after", ctx.name);
return result;
};
}
class Foo {
@log greet() { console.log("hello"); }
}
new Foo().greet();
`);
expect(stderr).toBe("");
expect(stdout).toBe("before greet\nhello\nafter greet\n");
expect(exitCode).toBe(0);
});
test("static method decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(fn, ctx) {
console.log(ctx.kind, ctx.name, ctx.static);
return fn;
}
class Foo {
@dec static bar() { return 42; }
}
console.log(Foo.bar());
`);
expect(stderr).toBe("");
expect(stdout).toBe("method bar true\n42\n");
expect(exitCode).toBe(0);
});
test("method decorator context has correct access", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
let savedAccess;
function dec(fn, ctx) {
savedAccess = ctx.access;
return fn;
}
class Foo {
@dec bar() { return 42; }
}
const f = new Foo();
console.log(savedAccess.has(f));
console.log(savedAccess.get(f)());
`);
expect(stderr).toBe("");
expect(stdout).toBe("true\n42\n");
expect(exitCode).toBe(0);
});
});
describe("getter decorators", () => {
test("getter decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(fn, ctx) {
console.log(ctx.kind, ctx.name);
return fn;
}
class Foo {
@dec get x() { return 42; }
}
console.log(new Foo().x);
`);
expect(stderr).toBe("");
expect(stdout).toBe("getter x\n42\n");
expect(exitCode).toBe(0);
});
});
describe("setter decorators", () => {
test("setter decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(fn, ctx) {
console.log(ctx.kind, ctx.name);
return fn;
}
class Foo {
_val = 0;
@dec set x(v) { this._val = v; }
}
const f = new Foo();
f.x = 99;
console.log(f._val);
`);
expect(stderr).toBe("");
expect(stdout).toBe("setter x\n99\n");
expect(exitCode).toBe(0);
});
});
describe("field decorators", () => {
test("field decorator receives undefined value", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) {
console.log(ctx.kind, ctx.name, value);
return undefined;
}
class Foo {
@dec x = 42;
}
console.log(new Foo().x);
`);
expect(stderr).toBe("");
expect(stdout).toBe("field x undefined\n42\n");
expect(exitCode).toBe(0);
});
test("multiple field decorators", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const order = [];
function dec1(value, ctx) { order.push("dec1:" + ctx.name); }
function dec2(value, ctx) { order.push("dec2:" + ctx.name); }
class Foo {
@dec1 @dec2 x = 1;
@dec1 y = 2;
}
console.log(order.join(","));
`);
expect(stderr).toBe("");
expect(stdout).toBe("dec2:x,dec1:x,dec1:y\n");
expect(exitCode).toBe(0);
});
test("static field decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) {
console.log(ctx.kind, ctx.name, ctx.static);
return undefined;
}
class Foo {
@dec static x = 10;
}
console.log(Foo.x);
`);
expect(stderr).toBe("");
expect(stdout).toBe("field x true\n10\n");
expect(exitCode).toBe(0);
});
});
describe("non-ASCII string-literal keys", () => {
// Supra-BMP code points are stored as UTF-16 in the AST; the lowering must
// not reinterpret those bytes as UTF-8 when it builds `this[key]`.
const key = "\u{20BB7}\u{91BB6}";
test("Bun.Transpiler output preserves the key", () => {
const t = new Bun.Transpiler({ loader: "js", target: "node", minifyWhitespace: true });
const out = t.transformSync(`class A{@(() => {})\n"\\u{20BB7}\\u{91BB6}"\n}`);
// The key appears twice in the lowered output (constructor assignment and
// __decorateElement call) and must be the same string both times, either
// as literal UTF-8 or as \uXXXX escapes of the correct surrogate pair.
const normalized = out.replace(/\\uD842\\uDFB7\\uDA06\\uDFB6/gi, key);
expect(normalized.split(key).length - 1).toBe(2);
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(out)).not.toThrow();
});
test("decorated instance field with supra-BMP key is assigned correctly", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) {
console.log(ctx.kind, JSON.stringify(ctx.name));
return (init) => init * 2;
}
class Foo {
@dec "\\u{20BB7}\\u{91BB6}" = 21;
}
const f = new Foo();
console.log(f[${JSON.stringify(key)}]);
console.log(Object.getOwnPropertyNames(f).map(n => JSON.stringify(n)).join(","));
`);
expect(stderr).toBe("");
expect(stdout).toBe(`field ${JSON.stringify(key)}\n42\n${JSON.stringify(key)}\n`);
expect(exitCode).toBe(0);
});
test("decorated static field with supra-BMP key is assigned correctly", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return (init) => init + 1; }
class Foo {
@dec static "\\u{20BB7}\\u{91BB6}" = 9;
}
console.log(Foo[${JSON.stringify(key)}]);
`);
expect(stderr).toBe("");
expect(stdout).toBe("10\n");
expect(exitCode).toBe(0);
});
test("decorated accessor with supra-BMP key works", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(target, ctx) {
console.log(ctx.kind, JSON.stringify(ctx.name));
return target;
}
class Foo {
@dec accessor "\\u{20BB7}\\u{91BB6}" = 7;
}
const f = new Foo();
console.log(f[${JSON.stringify(key)}]);
f[${JSON.stringify(key)}] = 99;
console.log(f[${JSON.stringify(key)}]);
`);
expect(stderr).toBe("");
expect(stdout).toBe(`accessor ${JSON.stringify(key)}\n7\n99\n`);
expect(exitCode).toBe(0);
});
test("undecorated accessor with supra-BMP key in a decorated class works", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) { return cls; }
@dec class Foo {
accessor "\\u{20BB7}\\u{91BB6}" = 3;
}
const f = new Foo();
console.log(f[${JSON.stringify(key)}]);
`);
expect(stderr).toBe("");
expect(stdout).toBe("3\n");
expect(exitCode).toBe(0);
});
});
describe("decorator ordering", () => {
test("decorators on different elements evaluate in source order", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const order = [];
function track(name) {
return function(value, ctx) {
order.push(name + ":" + ctx.kind + ":" + ctx.name);
return value;
};
}
@track("cls")
class Foo {
@track("method") foo() {}
@track("field") x = 1;
@track("getter") get y() { return 2; }
@track("setter") set y(v) {}
}
console.log(order.join("\\n"));
`);
expect(stderr).toBe("");
expect(stdout).toContain("method:method:foo");
expect(stdout).toContain("field:field:x");
expect(stdout).toContain("getter:getter:y");
expect(stdout).toContain("setter:setter:y");
expect(stdout).toContain("cls:class:Foo");
expect(exitCode).toBe(0);
});
});
describe("decorator expressions", () => {
test("member expression decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const decorators = {
log: function(fn, ctx) {
console.log("decorated", ctx.name);
return fn;
}
};
class Foo {
@decorators.log bar() {}
}
console.log("done");
`);
expect(stderr).toBe("");
expect(stdout).toBe("decorated bar\ndone\n");
expect(exitCode).toBe(0);
});
test("call expression decorator", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function withTag(tag) {
return function(fn, ctx) {
console.log(tag, ctx.name);
return fn;
};
}
class Foo {
@withTag("hello") bar() {}
}
console.log("done");
`);
expect(stderr).toBe("");
expect(stdout).toBe("hello bar\ndone\n");
expect(exitCode).toBe(0);
});
async function runDecoratorTS(code: string) {
using dir = tempDir("es-dec-ts", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"test.ts": code,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr: filterStderr(rawStderr), exitCode };
}
test("non-null assertion in decorator member expression", async () => {
const { stdout, stderr, exitCode } = await runDecoratorTS(`
const ns = {
dec(cls: any, ctx: any) {
console.log(ctx.kind, ctx.name);
return cls;
},
};
@ns!.dec
class Foo {}
@ns!.dec!
class Bar {}
console.log("done");
`);
expect(stderr).toBe("");
expect(stdout).toBe("class Foo\nclass Bar\ndone\n");
expect(exitCode).toBe(0);
});
test("type arguments in decorator member expression are stripped", async () => {
const { stdout, stderr, exitCode } = await runDecoratorTS(`
function dec<T>(cls: any, ctx: any) {
console.log(ctx.kind, ctx.name);
return cls;
}
const ns = {
dec: function<T>(tag: string) {
return function(cls: any, ctx: any) {
console.log(tag, ctx.name);
return cls;
};
},
};
@dec<string>
class A {}
@ns.dec<string>("hello")
class B {}
@ns<string>.dec<number>("bye")
class C {}
console.log("done");
`);
expect(stderr).toBe("");
expect(stdout).toBe("class A\nhello B\nbye C\ndone\n");
expect(exitCode).toBe(0);
});
test.each(["ts", "js"])("private name in decorator member expression (.%s)", async ext => {
const run = ext === "ts" ? runDecoratorTS : runDecorator;
const { stdout, stderr, exitCode } = await run(`
class Outer {
static #dec(cls, ctx) {
console.log(ctx.kind, ctx.name);
return cls;
}
static Inner = @Outer.#dec class Inner {};
}
new Outer.Inner();
console.log("done");
`);
expect(stderr).toBe("");
expect(stdout).toBe("class Inner\ndone\n");
expect(exitCode).toBe(0);
});
test.each(["ts", "js"])("export before decorator (.%s)", async ext => {
using dir = tempDir("es-dec-export", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
[`dep.${ext}`]: `
function dec(cls, ctx) {
console.log(ctx.kind, ctx.name);
return cls;
}
export @dec class Foo {}
`,
[`test.${ext}`]: `
import { Foo } from "./dep";
console.log(typeof Foo);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), `test.${ext}`],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("class Foo\nfunction\n");
expect(exitCode).toBe(0);
});
test("non-null assertion in decorator is rejected in JavaScript", async () => {
const { stderr, exitCode } = await runDecorator(`
const ns = { dec(cls, ctx) { return cls; } };
@ns!.dec class Foo {}
`);
expect(stderr).toContain("error: Unexpected !");
expect(exitCode).not.toBe(0);
});
test("optional chaining in decorator is rejected with a hint", async () => {
const { stderr, exitCode } = await runDecoratorTS(`
@x?.y class Foo {}
`);
expect(stderr).toContain("Optional chaining is not allowed in decorator expressions");
expect(stderr).toContain("wrap the expression in parentheses");
expect(exitCode).not.toBe(0);
});
test("property access after call in decorator is rejected", async () => {
const { stderr, exitCode } = await runDecoratorTS(`
@x().y class Foo {}
`);
expect(stderr).toContain("wrap the expression in parentheses");
expect(exitCode).not.toBe(0);
});
test("decorators on both sides of export are rejected", async () => {
const { stderr, exitCode } = await runDecoratorTS(`
@x export @y class Foo {}
`);
expect(stderr).toContain('Expected "class" but found "@"');
expect(exitCode).not.toBe(0);
});
test("repeated export around a decorator is rejected", async () => {
const { stderr, exitCode } = await runDecoratorTS(`
export @dec export class Foo {}
`);
expect(stderr).toContain('Expected "class" but found "export"');
expect(exitCode).not.toBe(0);
});
});
describe("metadata", () => {
test("Symbol.metadata is set on decorated class", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
// Symbol.metadata may not exist natively, use the same fallback as the runtime
const metadataKey = Symbol.metadata || Symbol.for("Symbol.metadata");
function dec(cls, ctx) { return cls; }
@dec class Foo {}
console.log(typeof Foo[metadataKey]);
console.log(Foo[metadataKey] !== null);
`);
expect(stderr).toBe("");
expect(stdout).toBe("object\ntrue\n");
expect(exitCode).toBe(0);
});
test("metadata inherits from parent class", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const metadataKey = Symbol.metadata || Symbol.for("Symbol.metadata");
function dec(cls, ctx) {
ctx.metadata.decorated = true;
return cls;
}
@dec class Base {}
@dec class Child extends Base {}
console.log(Base[metadataKey].decorated);
console.log(Child[metadataKey].decorated);
console.log(Object.getPrototypeOf(Child[metadataKey]) === Base[metadataKey]);
`);
expect(stderr).toBe("");
expect(stdout).toBe("true\ntrue\ntrue\n");
expect(exitCode).toBe(0);
});
});
describe("addInitializer", () => {
test("class addInitializer runs after class is created", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
const order = [];
function dec(cls, ctx) {
ctx.addInitializer(function() {
order.push("initializer");
});
return cls;
}
order.push("before");
@dec class Foo {}
order.push("after");
console.log(order.join(","));
`);
expect(stderr).toBe("");
expect(stdout).toBe("before,initializer,after\n");
expect(exitCode).toBe(0);
});
});
describe("standard vs experimental mode switching", () => {
test("JS files use standard decorators by default", async () => {
// JS files always use standard decorators, even when
// experimentalDecorators is set in tsconfig
using dir = tempDir("es-dec-js", {
"tsconfig.json": JSON.stringify({
compilerOptions: { experimentalDecorators: true },
}),
"test.js": `
function dec(cls, ctx) {
console.log(ctx.kind);
return cls;
}
@dec class Foo {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("class\n");
expect(exitCode).toBe(0);
});
test("TS files use experimental decorators when experimentalDecorators is set", async () => {
using dir = tempDir("es-dec-ts-exp", {
"tsconfig.json": JSON.stringify({
compilerOptions: { experimentalDecorators: true },
}),
"test.ts": `
function dec(target: any) {
target.decorated = true;
}
@dec class Foo {}
console.log((Foo as any).decorated);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("true\n");
expect(exitCode).toBe(0);
});
test("TS files use standard decorators when experimentalDecorators is not set", async () => {
using dir = tempDir("es-dec-ts-std", {
"tsconfig.json": JSON.stringify({
compilerOptions: {},
}),
"test.ts": `
function dec(cls: any, ctx: any) {
console.log(ctx.kind, ctx.name);
return cls;
}
@dec class Foo {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("class Foo\n");
expect(exitCode).toBe(0);
});
});
describe("extends clause", () => {
test("decorator on class with extends", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) {
console.log(ctx.kind, ctx.name);
return cls;
}
class Base {
base = true;
}
@dec class Child extends Base {
child = true;
}
const c = new Child();
console.log(c.base, c.child);
`);
expect(stderr).toBe("");
expect(stdout).toBe("class Child\ntrue true\n");
expect(exitCode).toBe(0);
});
});
describe("export default class", () => {
test("export default class with method decorator", async () => {
using dir = tempDir("es-dec-export-default", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.foo());
`,
"mod.js": `
function dec(target, ctx) { return target; }
export default class {
@dec foo() { return 42; }
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("42\n");
expect(exitCode).toBe(0);
});
test("export default class with class decorator", async () => {
using dir = tempDir("es-dec-export-default-cls", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.value);
`,
"mod.js": `
function addValue(cls, ctx) {
return class extends cls { value = "decorated"; };
}
@addValue export default class {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("decorated\n");
expect(exitCode).toBe(0);
});
test("export default named class with decorator", async () => {
using dir = tempDir("es-dec-export-default-named", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.foo());
`,
"mod.js": `
function dec(target, ctx) { return target; }
export default class MyClass {
@dec foo() { return "named"; }
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("named\n");
expect(exitCode).toBe(0);
});
test("export default anonymous decorated class expression", async () => {
using dir = tempDir("es-dec-export-default-anon-expr", {
"entry.js": `
import Cls from "./mod.js";
console.log(Cls.name);
console.log(globalThis.decoratorContextName);
`,
"mod.js": `
function dec(cls, ctx) { globalThis.decoratorContextName = ctx.name; }
export default (@dec class {});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("default\ndefault\n");
expect(exitCode).toBe(0);
});
test("export default anonymous class with class decorator", async () => {
using dir = tempDir("es-dec-export-default-anon-dec", {
"entry.js": `
import Cls from "./mod.js";
console.log(Cls.name);
console.log(globalThis.decoratorContextName);
`,
"mod.js": `
function dec(cls, ctx) { globalThis.decoratorContextName = ctx.name; }
export default @dec class {}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("default\ndefault\n");
expect(exitCode).toBe(0);
});
test("export default anonymous class expression with method decorator", async () => {
using dir = tempDir("es-dec-export-default-anon-method", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.foo());
`,
"mod.js": `
function dec(fn, ctx) { console.log("decorated", ctx.name); return fn; }
export default (class {
@dec foo() { return 42; }
});
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("decorated foo\n42\n");
expect(exitCode).toBe(0);
});
test("export default anonymous class with auto-accessor and no decorators", async () => {
using dir = tempDir("es-dec-export-default-anon-accessor", {
"entry.js": `
import Cls from "./mod.js";
const c = new Cls();
console.log(c.op);
c.op = 42;
console.log(c.op);
const desc = Object.getOwnPropertyDescriptor(Cls.prototype, "op");
console.log(typeof desc.get, typeof desc.set);
`,
"mod.js": `
export default class {
accessor op;
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("undefined\n42\nfunction function\n");
expect(exitCode).toBe(0);
});
test("export default anonymous TypeScript class with auto-accessor and no decorators", async () => {
using dir = tempDir("es-dec-export-default-anon-accessor-ts", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"entry.ts": `
import Cls from "./mod.ts";
const c = new Cls();
c.op = "hello";
console.log(c.op);
`,
"mod.ts": `
export default class {
accessor op: string | undefined;
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "entry.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("hello\n");
expect(exitCode).toBe(0);
});
test("Bun.build bundles export default anonymous class with auto-accessor", async () => {
using dir = tempDir("es-dec-build-anon-accessor", {
"build.js": `
const result = await Bun.build({
entrypoints: ["./mod.ts"],
target: "bun",
minify: true,
sourcemap: "external",
throw: false,
});
console.log(result.success);
`,
"mod.ts": `
export default class {
accessor op;
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build.js"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("true\n");
expect(exitCode).toBe(0);
});
});
describe("anonymous class expressions with reserved-word inferred names", () => {
test("decorated anonymous class as value of a reserved-word object key", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(cls, ctx) { console.log("ctx.name:", ctx.name); }
const obj = { default: (@dec class {}) };
console.log(obj.default.name);
`);
expect(stderr).toBe("");
expect(stdout).toBe("ctx.name: default\ndefault\n");
expect(exitCode).toBe(0);
});
test("Bun.Transpiler output for decorated anonymous default export reparses", () => {
const transpiler = new Bun.Transpiler({ loader: "ts", target: "node", deadCodeElimination: true });
const output = transpiler.transformSync("export default(@c class{})");
// "default" is a keyword, so it must not be printed as the class binding name
expect(output).not.toContain("class default");
// the lowered output must still be valid syntax
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow();
});
});
describe("private member calls in lowered classes", () => {
// When a class is lowered for standard decorators, `recv.#m(...)` becomes
// `__privateGet(recv, _m).call(recv, ...)`. The receiver must be evaluated
// exactly once: duplicating it re-runs side effects and makes the printed
// output grow exponentially for chains like `o.#m().#m().#m()`.
test("chained optional private calls do not explode the transpiled output size", () => {
const chain = "?.Foo.#m()".repeat(20);
const source = `class Foo {
static #x = -0;
static #m = function() {};
@decorator() est() {
return [o${chain}];
}
}`;
const transpiler = new Bun.Transpiler({ loader: "js", target: "bun" });
const output = transpiler.transformSync(source);
// Exponential duplication produced ~47 MB for a 20-call chain; the
// single-evaluation lowering stays in the kilobytes.
expect(output.length).toBeLessThan(50_000);
// The lowered output must still be valid syntax.
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow();
});
test("double-call private chains in decorated static field initializers stay linear", () => {
// Fuzzer-minimized variant: each `.#method()()` link re-lowers the whole
// receiver, so duplicating it doubles the printed output per link
// (~30 links allocated multiple GB before aborting).
const chain = ".#method()()".repeat(20);
const source = `class C {
@decorator() static s = new C()${chain.slice(0, -2)};
#method() { return 1e999; }
}`;
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun", deadCodeElimination: true });
const output = transpiler.transformSync(source);
// Exponential duplication produced ~64 MB for 20 links; the
// single-evaluation lowering stays in the kilobytes.
expect(output.length).toBeLessThan(50_000);
// The lowered output must still be valid syntax.
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(output)).not.toThrow();
});
test("calling the result of a private method call evaluates each link once", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
let evals = 0;
class C {
@dec static s = new C().#method()().#method()().#method()();
#method() { evals++; const self = this; return () => self; }
}
console.log(C.s instanceof C, evals);
`);
expect(stderr).toBe("");
expect(stdout).toBe("true 3\n");
expect(exitCode).toBe(0);
});
test("receiver temps are scoped per invocation, not shared across reentrant calls", async () => {
// A private getter runs user code inside __privateGet, between the
// `_obj = recv` write and the `.call(_obj)` read. If the getter reenters
// the same call site, a temp hoisted outside the method would be
// clobbered and the outer call would see the inner receiver. Declaring
// the temp inside the method body gives each invocation its own binding.
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
let nextId = 0;
let depth = 0;
const order = [];
class C {
get #g() {
if (depth++ === 0) make().run();
const self = this;
return function () { order.push(self.id + ":" + this.id); };
}
@dec run() { make().#g(); }
}
function make() { const c = new C(); c.id = ++nextId; return c; }
make().run();
console.log(JSON.stringify(order));
`);
expect(stderr).toBe("");
// Each entry pairs the receiver seen at getter time with the receiver
// the returned function was invoked on; they must always match.
expect(stdout).toBe('["4:4","2:2"]\n');
expect(exitCode).toBe(0);
});
test("private method call receiver is evaluated exactly once", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
let receiverEvals = 0;
class Counter {
static #m = function (x) { return [this === Counter, x]; };
@dec test() {
return getCounter().#m(42);
}
}
function getCounter() { receiverEvals++; return Counter; }
console.log(JSON.stringify(new Counter().test()), receiverEvals);
`);
expect(stderr).toBe("");
expect(stdout).toBe("[true,42] 1\n");
expect(exitCode).toBe(0);
});
test("chained optional private method calls return the right value", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
class Chain {
#tag;
constructor(tag) { this.#tag = tag; }
#next() { return { Chain: new Chain(this.#tag + 1) }; }
@dec run(o) {
return o?.Chain.#next()?.Chain.#next()?.Chain.#next()?.Chain.tag();
}
tag() { return this.#tag; }
}
console.log(new Chain(0).run({ Chain: new Chain(10) }));
`);
expect(stderr).toBe("");
expect(stdout).toBe("13\n");
expect(exitCode).toBe(0);
});
test("private method calls through `this` and identifier receivers still work", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
class Fast {
#p(n) { return "p" + n; }
@dec viaThis() { return this.#p(1); }
@dec viaIdent(other) { return other.#p(2); }
}
const f = new Fast();
console.log(f.viaThis(), f.viaIdent(new Fast()));
`);
expect(stderr).toBe("");
expect(stdout).toBe("p1 p2\n");
expect(exitCode).toBe(0);
});
// Covers both temp placements in a decorated class expression: the method
// body receiver gets a per-invocation `var` inside the method, while the
// field initializer receiver is rewritten outside any function body, so
// its temp is hoisted to the nearest statement list through the
// class-expression path.
test("decorated class expression evaluates chained private call receivers once", async () => {
const { stdout, stderr, exitCode } = await runDecorator(`
function dec(value, ctx) { return value; }
let evals = 0;
let initEvals = 0;
function pick(x) { initEvals++; return x; }
const C = class Foo {
static #m = function (tag) { return { Foo, tag }; };
#p(tag) { return "i" + tag; }
@dec r = pick(this).#p("0");
@dec test(o) {
return o.effectful()?.Foo.#m("a")?.Foo.#m("b");
}
};
const o = { Foo: C, effectful() { evals++; return { Foo: C }; } };
const inst = new C();
console.log(inst.r, inst.test(o).tag, evals, initEvals);
`);
expect(stderr).toBe("");
expect(stdout).toBe("i0 b 1 1\n");
expect(exitCode).toBe(0);
});
});
describe("accessor with TypeScript annotations", () => {
test("accessor with definite assignment assertion (!)", async () => {
using dir = tempDir("es-dec-accessor-bang", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"test.ts": `
function dec(target: any, ctx: any) { return target; }
class Foo {
@dec accessor child!: string;
}
const f = new Foo();
f.child = "hello";
console.log(f.child);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("hello\n");
expect(exitCode).toBe(0);
});
test("accessor with optional marker (?)", async () => {
using dir = tempDir("es-dec-accessor-optional", {
"tsconfig.json": JSON.stringify({ compilerOptions: {} }),
"test.ts": `
function dec(target: any, ctx: any) { return target; }
class Foo {
@dec accessor child?: string;
}
const f = new Foo();
console.log(f.child);
f.child = "world";
console.log(f.child);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, rawStderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(filterStderr(rawStderr)).toBe("");
expect(stdout).toBe("undefined\nworld\n");
expect(exitCode).toBe(0);
});
});
});