From cc97cb582f6c5ffd27ff88266a7e954078cec472 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:03:09 +0000 Subject: [PATCH 1/5] test: run the type-export fixtures through one compile and assert their output type-export.test.ts built 18 standalone executables, one per fixture, and the skipped "run" test next to each of them split the concurrent batch, so the compiles ran one after another (53s on the slowest CI lane). All 18 fixtures now go into one temp tree and a generated runner imports them one by one, reporting every result keyed by fixture name. The runner is executed from source (still skipped, #7384), from one Bun.build of all the entry points, and as one --compile --bytecode --format=esm --splitting executable, where every fixture is its own chunk with its own module record. The remaining tests assert the exact stdout/stderr/exit code, or the exact error line, instead of stopping at the exit code. --- test/js/bun/typescript/type-export.test.ts | 651 ++++++++++----------- 1 file changed, 322 insertions(+), 329 deletions(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 3cf51510070b..f9013414aeec 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -1,53 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; - -const ext = isWindows ? ".exe" : ""; +import { bunEnv, bunExe, isCI, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; async function run(cmd: string[], cwd: string) { await using proc = Bun.spawn({ cmd, env: bunEnv, cwd, - stdio: ["inherit", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout, stderr, exitCode }; } -// Cap in-flight `--compile` builds: each one reads + rewrites a full standalone -// executable, and running all of them at once exhausts CI memory/IO -// (see the note at the top of test/bundler/bundler_compile.test.ts). -const maxConcurrentCompiles = 4; -let activeCompiles = 0; -const compileWaiters: (() => void)[] = []; -async function withCompileSlot(fn: () => Promise): Promise { - while (activeCompiles >= maxConcurrentCompiles) { - const { promise, resolve } = Promise.withResolvers(); - compileWaiters.push(resolve); - await promise; - } - activeCompiles++; - try { - return await fn(); - } finally { - activeCompiles--; - compileWaiters.shift()?.(); - } +/** The `error:` / `SomeError:` line of a failed run, with the fixture directory replaced by ``. */ +function errorLine(stderr: string, dir: string) { + const normalized = normalizeBunSnapshot(stderr, dir); + return normalized.split("\n").find(line => /^(?:\w*Error|error):/.test(line)) ?? normalized; } -async function compileAndRun(dir: string, entrypoint: string) { - const outfile = dir + `/compiled${ext}`; - return await withCompileSlot(async () => { - const buildResult = await run( - [bunExe(), "build", "--compile", "--bytecode", "--format=esm", entrypoint, "--outfile", outfile], - dir, - ); - expect(buildResult.stderr).toBe(""); - expect(buildResult.exitCode).toBe(0); - - return run([outfile], dir); - }); -} +// Fixtures whose entry point prints one JSON line when type-only exports are handled correctly. Each one is run +// three ways (see the describe block below) by a generated runner that imports every fixture in turn and reports +// all of the results keyed by fixture name, so one process (and, for `compile`, one standalone executable) +// covers all of them while a failure still names the fixture that produced it. +type Fixture = { files: Record; entry: string; expected: unknown }; +const fixtures: Record = {}; const a_file = ` export type my_string = "1"; @@ -69,179 +45,292 @@ const a_with_value = ` export const my_value = "2"; `; -const b_files = [ - { - name: "export from", - value: `export { my_string, my_value, my_only } from "./a.ts";`, - }, - { - name: "import then export", - value: ` - import { my_string, my_value, my_only } from "./a.ts"; - export { my_string, my_value, my_only }; +// How `b` re-exports `a`. +const b_files = { + "export-from": `export { my_string, my_value, my_only } from "./a.ts";`, + "import-then-export": ` + import { my_string, my_value, my_only } from "./a.ts"; + export { my_string, my_value, my_only }; + `, + "export-star": `export * from "./a.ts";`, + "export-merge": `export * from "./a_no_value.ts"; export * from "./a_with_value.ts"`, +}; + +// How `c` imports `b`. +const c_files = { + "require": `console.log(JSON.stringify(require("./b")));`, + "import-star": `import * as b from "./b"; console.log(JSON.stringify(b));`, + "await-import": `console.log(JSON.stringify(await import("./b")));`, + "import-individual": ` + import { my_string, my_value, my_only } from "./b"; + console.log(JSON.stringify({ my_only, my_value })); + `, +}; + +for (const [b_name, b_file] of Object.entries(b_files)) { + for (const [c_name, c_file] of Object.entries(c_files)) { + fixtures[`${b_name}/${c_name}`] = { + files: { + "a.ts": a_file, + "a_no_value.ts": a_no_value, + "a_with_value.ts": a_with_value, + "b.ts": b_file, + "c.ts": c_file, + }, + entry: "c.ts", + expected: { my_value: "2", my_only: "3" }, + }; + } +} + +fixtures["ownkeys-of-star-import"] = { + files: { + "main.ts": ` + import * as ns from './a'; + console.log(JSON.stringify({ + keys: Object.keys(ns).sort(), + ns, + has_sometype: Object.hasOwn(ns, 'sometype'), + })); `, + "a.ts": "export * from './b'; export {sometype} from './b';", + "b.ts": "export const value = 'b'; export const anotherValue = 'another'; export type sometype = 'sometype';", }, - { - name: "export star", - value: `export * from "./a.ts";`, - }, - { - name: "export merge", - value: `export * from "./a_no_value.ts"; export * from "./a_with_value.ts"`, + entry: "main.ts", + expected: { + keys: ["anotherValue", "value"], + ns: { anotherValue: "another", value: "b" }, + has_sometype: false, }, -]; - -const c_files = [ - { name: "require", value: `console.log(JSON.stringify(require("./b")));` }, - { name: "import star", value: `import * as b from "./b"; console.log(JSON.stringify(b));` }, - { name: "await import", value: `console.log(JSON.stringify(await import("./b")));` }, - { - name: "import individual", - value: ` - import { my_string, my_value, my_only } from "./b"; - console.log(JSON.stringify({ my_only, my_value })); +}; + +// https://github.com/oven-sh/bun/issues/8439: an interface imported without `import type`, used only in +// emitDecoratorMetadata output and re-exported. tsc emits `design:type` metadata of `Object` for it. +fixtures["import-only-used-in-decorator"] = { + files: { + "index.ts": ` + import { TestInterface } from "./interface.ts"; + + const metadata = {}; + Reflect.metadata = (key, value) => () => { + metadata[key] = value; + }; + + function Decorator(): PropertyDecorator { + return () => {}; + } + + class TestClass { + @Decorator() + test?: TestInterface; + } + class OtherClass { + other?: TestInterface; + } + + export { TestInterface }; + + console.log(JSON.stringify({ design_type: metadata["design:type"] === Object ? "Object" : metadata })); `, + "interface.ts": "export interface TestInterface {};", + "tsconfig.json": JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + }), }, -]; - -for (const b_file of b_files) { - describe(`re-export with ${b_file.name}`, () => { - for (const c_file of c_files) { - describe(`import with ${c_file.name}`, () => { - const dir = tempDirWithFiles("type-export", { - "a.ts": a_file, - "b.ts": b_file.value, - "c.ts": c_file.value, - "a_no_value.ts": a_no_value, - "a_with_value.ts": a_with_value, - }); - - describe.each(["run", "compile", "build"])("%s", mode => { - // TODO: "run" is skipped until ESM module_info is enabled in the runtime transpiler. - // Currently module_info is only generated for standalone ESM bytecode (--compile). - // Once enabled, flip this to include "run". - const testFn = mode === "run" ? test.skip : test.concurrent; - testFn("works", async () => { - let result: { stdout: string; stderr: string; exitCode: number }; - if (mode === "compile") { - result = await compileAndRun(dir, dir + "/c.ts"); - } else if (mode === "build") { - const build_result = await Bun.build({ - entrypoints: [dir + "/c.ts"], - outdir: dir + "/dist", - }); - expect(build_result.success).toBe(true); - result = await run([bunExe(), "run", dir + "/dist/c.js"], dir); - } else { - result = await run([bunExe(), "run", "c.ts"], dir); - } - - const parsedOutput = JSON.parse(result.stdout.trim()); - expect(parsedOutput).toEqual({ my_value: "2", my_only: "3" }); - expect(result.exitCode).toBe(0); - }); - }); - }); + entry: "index.ts", + expected: { design_type: "Object" }, +}; + +const fixtureFiles = Object.fromEntries( + Object.entries(fixtures).flatMap(([name, { files }]) => + Object.entries(files).map(([file, contents]) => [`fixtures/${name}/${file}`, contents]), + ), +); +const sourceEntries = Object.fromEntries( + Object.entries(fixtures).map(([name, { entry }]) => [name, `./fixtures/${name}/${entry}`]), +); +const builtEntries = Object.fromEntries( + Object.entries(fixtures).map(([name, { entry }]) => [name, `./dist/${name}/${entry.replace(/\.ts$/, ".js")}`]), +); +const allFixturesPass = { + results: Object.fromEntries(Object.entries(fixtures).map(([name, { expected }]) => [name, expected])), + stderr: "", + exitCode: 0, +}; + +// One `import()` with a literal specifier per fixture, so that `bun build --compile --splitting` gives every +// fixture its own chunk, bytecode and module record. +function runnerSource(entries: Record) { + const imports = Object.entries(entries).map( + ([name, specifier]) => `[${JSON.stringify(name)}, () => import(${JSON.stringify(specifier)})],`, + ); + return ` + const results = {}; + const log = console.log; + for (const [name, load] of [ + ${imports.join("\n")} + ]) { + const lines = []; + console.log = (...args) => lines.push(args.join(" ")); + try { + await load(); + results[name] = lines.length === 1 ? JSON.parse(lines[0]) : { lines }; + } catch (error) { + results[name] = { error: String(error) }; + } finally { + console.log = log; + } } - }); + console.log(JSON.stringify(results)); + `; } -describe("import not found", () => { - for (const [ccase, target_value, name] of [ - [``, /SyntaxError: Export named 'not_found' not found in module '[^']+?'\./, "none"], - [ - `export default function not_found() {};`, - /SyntaxError: Export named 'not_found' not found in module '[^']+?'\. Did you mean to import default\?/, - "default with same name", - ], - [ - `export type not_found = "not_found";`, - /SyntaxError: Export named 'not_found' not found in module '[^']+?'\./, - "type", - ], - ] as const) - test.concurrent(`${name}`, async () => { - await using dir = tempDir("type-export", { - "a.ts": ccase, - "b.ts": /*js*/ ` - import { not_found } from "./a"; - console.log(not_found); - `, - "nf.ts": "", - }); - - const result = await run([bunExe(), "run", "b.ts"], dir); - - expect(result.stderr.trim()).toMatch(target_value); - expect({ - exitCode: result.exitCode, - stdout: result.stdout.trim(), - }).toEqual({ - exitCode: 1, - stdout: "", - }); - }); -}); +async function runFixtures(cmd: string[], cwd: string) { + const { stdout, stderr, exitCode } = await run(cmd, cwd); + let results: unknown = stdout; + try { + results = JSON.parse(stdout); + } catch { + // The process died before the runner printed; the raw output is the best report there is. + } + return { results, stderr, exitCode }; +} -test.concurrent("js file type export", async () => { - await using dir = tempDir("type-export", { - "a.js": "export {not_found};", +// describe.concurrent rather than test.concurrent: a non-concurrent test (a skipped one included) ends the batch +// of tests that run together, which is what used to serialize every compile in this file. +describe.concurrent("type-only exports through re-exports", () => { + // The runtime transpiler does not hand JSC a module record for the modules it transpiles yet (only the bundler + // does, for --compile ESM bytecode), so JSC's own linker still rejects the re-exported types (#7384). + test.skip("run", async () => { + await using dir = tempDir("type-export-run", { ...fixtureFiles, "runner.ts": runnerSource(sourceEntries) }); + + expect(await runFixtures([bunExe(), "runner.ts"], dir)).toEqual(allFixturesPass); }); - const result = await run([bunExe(), "a.js"], dir); + test("build", async () => { + await using dir = tempDir("type-export-build", { ...fixtureFiles, "runner.ts": runnerSource(builtEntries) }); - expect(result.stderr.trim()).toInclude('error: "not_found" is not declared in this file'); - expect(result.exitCode).toBe(1); -}); + // One build of every entry point. Without splitting each of them still gets its own self-contained bundle, + // written to dist//.js, which is where the runner imports it from. + const { logs } = await Bun.build({ + entrypoints: Object.entries(fixtures).map(([name, { entry }]) => `${dir}/fixtures/${name}/${entry}`), + root: `${dir}/fixtures`, + outdir: `${dir}/dist`, + throw: false, + }); + expect(logs.map(String)).toEqual([]); -test.concurrent("js file type import", async () => { - await using dir = tempDir("type-import", { - "b.js": "import {type_only} from './ts.ts';", - "ts.ts": "export type type_only = 'type_only';", + expect(await runFixtures([bunExe(), "runner.ts"], dir)).toEqual(allFixturesPass); }); - const result = await run([bunExe(), "b.js"], dir); + // --compile copies the whole executable (about 1 GB for a debug build), which takes a few seconds on its own and + // longer than the default timeout on a busy machine. Outside CI (which sets its own --timeout) give it the same + // ceiling itBundled gives compile tests. + test("compile", { timeout: isCI ? undefined : 30_000 }, async () => { + await using dir = tempDir("type-export-compile", { ...fixtureFiles, "runner.ts": runnerSource(sourceEntries) }); + const outfile = `${dir}/runner${isWindows ? ".exe" : ""}`; - expect(result.stderr.trim()).toInclude("Export named 'type_only' not found in module '"); - expect(result.stderr.trim()).not.toInclude("Did you mean to import default?"); - expect(result.exitCode).toBe(1); -}); + const build = await run( + [bunExe(), "build", "--compile", "--bytecode", "--format=esm", "--splitting", "runner.ts", "--outfile", outfile], + dir, + ); + expect({ stderr: build.stderr, exitCode: build.exitCode }).toEqual({ stderr: "", exitCode: 0 }); -test.concurrent("js file type import with default export", async () => { - await using dir = tempDir("type-import", { - "b.js": "import {type_only} from './ts.ts';", - "ts.ts": "export type type_only = 'type_only'; export default function type_only() {};", + expect(await runFixtures([outfile], dir)).toEqual(allFixturesPass); }); - - const result = await run([bunExe(), "b.js"], dir); - - expect(result.stderr.trim()).toInclude("Export named 'type_only' not found in module '"); - expect(result.stderr.trim()).toInclude("Did you mean to import default?"); - expect(result.exitCode).toBe(1); }); -test.concurrent("js file with through export", async () => { - await using dir = tempDir("type-import", { - "b.js": "export {type_only} from './ts.ts';", - "ts.ts": "export type type_only = 'type_only'; export default function type_only() {};", - }); +describe.concurrent("importing a name that is not exported as a value", () => { + const import_not_found = ` + import { not_found } from "./a"; + console.log(not_found); + `; + const type_only = "export type type_only = 'type_only';"; + const type_only_and_default = "export type type_only = 'type_only'; export default function type_only() {};"; + + const cases: Record; entry: string; error: string }> = { + "import not found": { + files: { "a.ts": "", "b.ts": import_not_found }, + entry: "b.ts", + error: "SyntaxError: Export named 'not_found' not found in module '/a.ts'.", + }, + "import not found, default export with the same name": { + files: { "a.ts": "export default function not_found() {};", "b.ts": import_not_found }, + entry: "b.ts", + error: "SyntaxError: Export named 'not_found' not found in module '/a.ts'. Did you mean to import default?", + }, + "import not found, type with the same name": { + files: { "a.ts": `export type not_found = "not_found";`, "b.ts": import_not_found }, + entry: "b.ts", + error: "SyntaxError: Export named 'not_found' not found in module '/a.ts'.", + }, + "js file type import": { + files: { "b.js": "import {type_only} from './ts.ts';", "ts.ts": type_only }, + entry: "b.js", + error: "SyntaxError: Export named 'type_only' not found in module '/ts.ts'.", + }, + "js file type import with default export": { + files: { "b.js": "import {type_only} from './ts.ts';", "ts.ts": type_only_and_default }, + entry: "b.js", + error: "SyntaxError: Export named 'type_only' not found in module '/ts.ts'. Did you mean to import default?", + }, + "js file with through export": { + files: { "b.js": "export {type_only} from './ts.ts';", "ts.ts": type_only_and_default }, + entry: "b.js", + error: "SyntaxError: export 'type_only' not found in './ts.ts'", + }, + "js file with through export 2": { + files: { "b.js": "import {type_only} from './ts.ts'; export {type_only};", "ts.ts": type_only_and_default }, + entry: "b.js", + error: "SyntaxError: export 'type_only' not found in './ts.ts'", + }, + "ambiguous export star merge": { + files: { + "main.ts": "import {value} from './a'; console.log(value);", + "a.ts": "export * from './b'; export * from './c';", + "b.ts": "export const value = 'b';", + "c.ts": "export const value = 'c';", + }, + entry: "main.ts", + error: + "SyntaxError: Export named 'value' cannot be resolved due to ambiguous multiple bindings in module '/a.ts'.", + }, + }; + + for (const [name, { files, entry, error }] of Object.entries(cases)) { + test(name, async () => { + await using dir = tempDir("type-export", files); - const result = await run([bunExe(), "b.js"], dir); + const result = await run([bunExe(), entry], dir); - expect(result.stderr.trim()).toInclude("SyntaxError: export 'type_only' not found in './ts.ts'"); - expect(result.exitCode).toBe(1); + expect({ + error: errorLine(result.stderr, dir), + stdout: result.stdout, + exitCode: result.exitCode, + }).toEqual({ error, stdout: "", exitCode: 1 }); + }); + } }); -test.concurrent("js file with through export 2", async () => { - await using dir = tempDir("type-import", { - "b.js": "import {type_only} from './ts.ts'; export {type_only};", - "ts.ts": "export type type_only = 'type_only'; export default function type_only() {};", +test.concurrent("js file type export", async () => { + await using dir = tempDir("type-export", { + "a.js": "export {not_found};", }); - const result = await run([bunExe(), "b.js"], dir); + const result = await run([bunExe(), "a.js"], dir); + + expect(normalizeBunSnapshot(result.stderr, dir)).toMatchInlineSnapshot(` + "1 | export {not_found}; + ^ + error: "not_found" is not declared in this file + at /a.js:1:9 - expect(result.stderr.trim()).toInclude("SyntaxError: export 'type_only' not found in './ts.ts'"); - expect(result.exitCode).toBe(1); + Bun v" + `); + expect({ stdout: result.stdout, exitCode: result.exitCode }).toEqual({ stdout: "", exitCode: 1 }); }); describe("through export merge", () => { @@ -272,7 +361,10 @@ describe("through export merge", () => { : "SyntaxError: Cannot export a duplicate name 'value'.\n", // jsc's syntax error ); - expect(result.exitCode).toBe(1); + if (file === "a." + fmt) { + expect(normalizeBunSnapshot(result.stderr, dir)).toContain(`\n at /a.${fmt}:1:`); + } + expect({ stdout: result.stdout, exitCode: result.exitCode }).toEqual({ stdout: "", exitCode: 1 }); }); } }); @@ -281,163 +373,64 @@ describe("through export merge", () => { } }); -describe("check ownkeys from a star import", () => { - const dir = tempDirWithFiles("ownkeys-star-import", { - ["main.ts"]: ` - import * as ns from './a'; - console.log(JSON.stringify({ - keys: Object.keys(ns).sort(), - ns, - has_sometype: Object.hasOwn(ns, 'sometype'), - })); - `, - ["a.ts"]: "export * from './b'; export {sometype} from './b';", - ["b.ts"]: "export const value = 'b'; export const anotherValue = 'another'; export type sometype = 'sometype';", - }); - - const expected = { - keys: ["anotherValue", "value"], - ns: { - anotherValue: "another", - value: "b", - }, - has_sometype: false, - }; - - describe.each(["run", "compile"] as const)("%s", mode => { - const testFn = mode === "run" ? test.skip : test.concurrent; - - testFn("works", async () => { - const result = - mode === "compile" ? await compileAndRun(dir, dir + "/main.ts") : await run([bunExe(), "main.ts"], dir); - - expect(result.stderr.trim()).toBe(""); - expect(JSON.parse(result.stdout.trim())).toEqual(expected); - expect(result.exitCode).toBe(0); - }); - }); -}); - test.concurrent("check commonjs", async () => { await using dir = tempDir("commonjs", { ["main.ts"]: "const {my_value, my_type} = require('./a'); console.log(my_value, my_type);", ["a.ts"]: "module.exports = require('./b');", ["b.ts"]: "export const my_value = 'my_value'; export type my_type = 'my_type';", }); - const result = await run([bunExe(), "main.ts"], dir); - expect(result.stderr.trim()).toBe(""); - expect(result.stdout.trim()).toBe("my_value undefined"); - expect(result.exitCode).toBe(0); -}); -test.concurrent("check merge", async () => { - await using dir = tempDir("merge", { - ["main.ts"]: "import {value} from './a'; console.log(value);", - ["a.ts"]: "export * from './b'; export * from './c';", - ["b.ts"]: "export const value = 'b';", - ["c.ts"]: "export const value = 'c';", - }); - const result = await run([bunExe(), "main.ts"], dir); - expect(result.stderr.trim()).toInclude( - "SyntaxError: Export named 'value' cannot be resolved due to ambiguous multiple bindings in module", - ); - expect(result.exitCode).toBe(1); + expect(await run([bunExe(), "main.ts"], dir)).toEqual({ stdout: "my_value undefined\n", stderr: "", exitCode: 0 }); }); -describe("export * from './module'", () => { - for (const fmt of ["js", "ts"]) { - describe(fmt, () => { - const dir = tempDirWithFiles("export-star", { - ["main." + fmt]: "import {value} from './a'; console.log(value);", - ["a." + fmt]: "export * from './b';", - ["b." + fmt]: "export const value = 'b';", - }); - for (const file of ["main." + fmt, "a." + fmt]) { - test.concurrent(file, async () => { - const result = await run([bunExe(), file], dir); - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); - }); - } - }); - } -}); - -describe("export * as ns from './module'", () => { - for (const fmt of ["js", "ts"]) { - describe(fmt, () => { - const dir = tempDirWithFiles("export-star-as", { - ["main." + fmt]: "import {ns} from './a'; console.log(ns.value);", - ["a." + fmt]: "export * as ns from './b';", - ["b." + fmt]: "export const value = 'b';", - }); - for (const file of ["main." + fmt, "a." + fmt]) { - test.concurrent(file, async () => { - const result = await run([bunExe(), file], dir); - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); - }); - } - }); - } -}); +// Each re-export form is run both through an importer (`main`, which prints what it imported) and as the entry +// point itself (`a`, which prints nothing). +describe.concurrent("re-export forms", () => { + const forms = [ + { + name: "export * from './module'", + fmts: ["js", "ts"], + main: "import {value} from './a'; console.log(value);", + a: "export * from './b';", + b: "export const value = 'b';", + stdout: "b\n", + }, + { + name: "export * as ns from './module'", + fmts: ["js", "ts"], + main: "import {ns} from './a'; console.log(ns.value);", + a: "export * as ns from './b';", + b: "export const value = 'b';", + stdout: "b\n", + }, + { + name: "export type {Type} from './module'", + fmts: ["ts"], + main: "import {Type} from './a'; const x: Type = 'test'; console.log(x);", + a: "export type {Type} from './b';", + b: "export type Type = string;", + stdout: "test\n", + }, + ]; + + for (const form of forms) { + describe(form.name, () => { + for (const fmt of form.fmts) { + for (const [file, stdout] of [ + [`main.${fmt}`, form.stdout], + [`a.${fmt}`, ""], + ]) { + test(file, async () => { + await using dir = tempDir("re-export", { + [`main.${fmt}`]: form.main, + [`a.${fmt}`]: form.a, + [`b.${fmt}`]: form.b, + }); -describe("export type {Type} from './module'", () => { - for (const fmt of ["ts"]) { - describe(fmt, () => { - const dir = tempDirWithFiles("export-type", { - ["main." + fmt]: "import {Type} from './a'; const x: Type = 'test'; console.log(x);", - ["a." + fmt]: "export type {Type} from './b';", - ["b." + fmt]: "export type Type = string;", - }); - for (const file of ["main." + fmt, "a." + fmt]) { - test.concurrent(file, async () => { - const result = await run([bunExe(), file], dir); - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); - }); + expect(await run([bunExe(), file], dir)).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + } } }); } }); - -describe("import only used in decorator (#8439)", () => { - const dir = tempDirWithFiles("import-only-used-in-decorator", { - ["index.ts"]: /*js*/ ` - import { TestInterface } from "./interface.ts"; - - function Decorator(): PropertyDecorator { - return () => {}; - } - - class TestClass { - @Decorator() - test?: TestInterface; - } - class OtherClass { - other?: TestInterface; - } - - export {TestInterface}; - `, - ["interface.ts"]: "export interface TestInterface {};", - "tsconfig.json": JSON.stringify({ - compilerOptions: { - experimentalDecorators: true, - emitDecoratorMetadata: true, - }, - }), - }); - - describe.each(["run", "compile"] as const)("%s", mode => { - const testFn = mode === "run" ? test.skip : test.concurrent; - - testFn("works", async () => { - const result = - mode === "compile" ? await compileAndRun(dir, dir + "/index.ts") : await run([bunExe(), "index.ts"], dir); - - expect(result.stderr.trim()).toBe(""); - expect(result.exitCode).toBe(0); - }); - }); -}); From be2577f3c1df4647d3ccff5c4cf6cc193639a59e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:13:34 +0000 Subject: [PATCH 2/5] test: table-drive the through-export-merge expectations --- test/js/bun/typescript/type-export.test.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index f9013414aeec..ec5c33f5cbac 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -351,19 +351,16 @@ describe("through export merge", () => { ["c." + fmt]: "export const value = 'c';", }); - for (const file of ["main." + fmt, "a." + fmt]) { + for (const [file, expected_stderr] of [ + // jsc's syntax error, from linking a while running main + ["main." + fmt, "SyntaxError: Cannot export a duplicate name 'value'.\n"], + // bun's syntax error, which points at the duplicate + ["a." + fmt, `error: Multiple exports with the same name "value"\n at /a.${fmt}:1:`], + ]) { test.concurrent(file, async () => { const result = await run([bunExe(), file], dir); - expect(result.stderr.trim()).toInclude( - file === "a." + fmt - ? 'error: Multiple exports with the same name "value"\n' // bun's syntax error - : "SyntaxError: Cannot export a duplicate name 'value'.\n", // jsc's syntax error - ); - - if (file === "a." + fmt) { - expect(normalizeBunSnapshot(result.stderr, dir)).toContain(`\n at /a.${fmt}:1:`); - } + expect(normalizeBunSnapshot(result.stderr, dir)).toContain(expected_stderr); expect({ stdout: result.stdout, exitCode: result.exitCode }).toEqual({ stdout: "", exitCode: 1 }); }); } From 0ff33b33b4e25e0c4c2ff4803bce5abdb0b04f2d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:33:39 +0000 Subject: [PATCH 3/5] test: keep a fixture's output next to its error in the runner report --- test/js/bun/typescript/type-export.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index ec5c33f5cbac..9126c40dc098 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -179,7 +179,7 @@ function runnerSource(entries: Record) { await load(); results[name] = lines.length === 1 ? JSON.parse(lines[0]) : { lines }; } catch (error) { - results[name] = { error: String(error) }; + results[name] = { error: String(error), lines }; } finally { console.log = log; } From cec3fdc113a62b4deb83d0b94eac88e0ff9ec51e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:49:41 +0000 Subject: [PATCH 4/5] test: drop the compile timeout and re-enable LeakSanitizer for type-export.test.ts The file was listed in no-validate-leaksan.txt only because it was slow; with one compile it passes with leak checking in about 9s on a debug build. --- test/js/bun/typescript/type-export.test.ts | 7 ++----- test/no-validate-leaksan.txt | 3 --- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 9126c40dc098..110b8b85b260 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isCI, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness"; async function run(cmd: string[], cwd: string) { await using proc = Bun.spawn({ @@ -226,10 +226,7 @@ describe.concurrent("type-only exports through re-exports", () => { expect(await runFixtures([bunExe(), "runner.ts"], dir)).toEqual(allFixturesPass); }); - // --compile copies the whole executable (about 1 GB for a debug build), which takes a few seconds on its own and - // longer than the default timeout on a busy machine. Outside CI (which sets its own --timeout) give it the same - // ceiling itBundled gives compile tests. - test("compile", { timeout: isCI ? undefined : 30_000 }, async () => { + test("compile", async () => { await using dir = tempDir("type-export-compile", { ...fixtureFiles, "runner.ts": runnerSource(sourceEntries) }); const outfile = `${dir}/runner${isWindows ? ".exe" : ""}`; diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index e4f53b8bfa48..d31db3cc4c8d 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -393,6 +393,3 @@ test/js/node/tls/node-tls-connect.test.ts # ("TODO: think about the finalizer here"), so the meta is never freed. All 18 # tests pass; only the exit check fails. Remove once that TODO is resolved. test/bundler/native-plugin.test.ts - -# Slow -test/js/bun/typescript/type-export.test.ts From 3b8e6f632db0e66fa853bcd065657a796933496a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:10:00 +0000 Subject: [PATCH 5/5] test: report each fixture as it runs and keep the decorator fixture self-contained A fixture that takes the runner down now shows up as the first missing key instead of emptying the whole report. --- test/js/bun/typescript/type-export.test.ts | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/js/bun/typescript/type-export.test.ts b/test/js/bun/typescript/type-export.test.ts index 110b8b85b260..6a37e01905d9 100644 --- a/test/js/bun/typescript/type-export.test.ts +++ b/test/js/bun/typescript/type-export.test.ts @@ -20,8 +20,8 @@ function errorLine(stderr: string, dir: string) { // Fixtures whose entry point prints one JSON line when type-only exports are handled correctly. Each one is run // three ways (see the describe block below) by a generated runner that imports every fixture in turn and reports -// all of the results keyed by fixture name, so one process (and, for `compile`, one standalone executable) -// covers all of them while a failure still names the fixture that produced it. +// each result under the fixture's name as it goes, so one process (and, for `compile`, one standalone executable) +// covers all of them while a failure, or a crash part way through, still names the fixture responsible. type Fixture = { files: Record; entry: string; expected: unknown }; const fixtures: Record = {}; @@ -127,6 +127,7 @@ fixtures["import-only-used-in-decorator"] = { class OtherClass { other?: TestInterface; } + delete Reflect.metadata; export { TestInterface }; @@ -168,33 +169,39 @@ function runnerSource(entries: Record) { ([name, specifier]) => `[${JSON.stringify(name)}, () => import(${JSON.stringify(specifier)})],`, ); return ` - const results = {}; const log = console.log; for (const [name, load] of [ ${imports.join("\n")} ]) { const lines = []; console.log = (...args) => lines.push(args.join(" ")); + let result; try { await load(); - results[name] = lines.length === 1 ? JSON.parse(lines[0]) : { lines }; + result = lines.length === 1 ? JSON.parse(lines[0]) : { lines }; } catch (error) { - results[name] = { error: String(error), lines }; + result = { error: String(error), lines }; } finally { console.log = log; } + console.log(JSON.stringify([name, result])); } - console.log(JSON.stringify(results)); `; } +/** The runner's output as `{ [fixture]: result }`; a fixture that took the process down is simply missing. */ async function runFixtures(cmd: string[], cwd: string) { const { stdout, stderr, exitCode } = await run(cmd, cwd); let results: unknown = stdout; try { - results = JSON.parse(stdout); + results = Object.fromEntries( + stdout + .trim() + .split("\n") + .map(line => JSON.parse(line)), + ); } catch { - // The process died before the runner printed; the raw output is the best report there is. + // Whatever was printed instead is the best report there is. } return { results, stderr, exitCode }; }