Skip to content

test(bundler): run esbuild/default.test.ts on the cli backend and pin more outputs - #41036

Open
robobun wants to merge 2 commits into
mainfrom
robobun/158a2395/esbuild-default-test-speed
Open

robobun wants to merge 2 commits into
mainfrom
robobun/158a2395/esbuild-default-test-speed

Conversation

@robobun

@robobun robobun commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/bundler/esbuild/default.test.ts takes 13s to 14s on the debian 13 x64-asan lane (build 108747), as a serial step: test/no-validate-leaksan.txt lists it. Inside the file, 128 of 152 live cases build through Bun.build() in-process, which expectBundled registers with it.serial, and every serial case is a barrier for the 24 concurrent ones.
  • 17 run steps had no expected output, two fixtures asserted nothing, 12 cases only checked that the build succeeds, and three *NoBundle cases bundled.

Fix

  • The file defaults itBundled to the cli backend, as bundler_compile.test.ts does. 127 cases move with their options and assertions unchanged. HashbangBannerUseStrictOrder stays on the api backend (the cli harness quotes --banner values) and uses iife, so the hashbang, banner and "use strict" order is observable.
  • The leaksan entry goes: the file is clean under the runner's detect_leaks=1 env. The three *NoBundle cases and VarRelocatingNoBundle get bundling: false like upstream. The last one becomes todo: the harness has no multi-entry --no-bundle, and it was a copy of the bundle case.
  • Assertions, 210 to 392 expect() calls (list in Notes): exact stdout on the 17 run steps, real ExportWildcardFSNode comparisons, four new runs, onAfterBundle checks on 18 cases.
  • Verified: bun bd test test/bundler/esbuild/default.test.ts (debug, ASAN) 53.9s to 54.9s before, 20.4s to 21.0s after, 151 pass. Released bun 1.25s to 0.31s, Windows canary 2.32s to 0.73s. Self-reviewed: 8 concerns, 3 addressed, the harness-level alternative is in Notes.

Background

  • itBundled picks the backend from the options. Without dotenv, production, bundling: false, define, env, bundleWarnings, emitDCEAnnotations or run.validate it calls Bun.build() in the test process, else it spawns bun build. Only the in-process backend is serial: it wraps the build in process.chdir.
  • bun test groups consecutive concurrent tests. A serial test ends the group. Under ASAN a group runs 5 tests at a time, else 20.
  • The cli backend fails a case on a warning it does not declare. No case here emits one.
Notes

Timings on one 16-core container. Debug is bun bd test (debug build with ASAN). Released is USE_SYSTEM_BUN=1 bun test with bun 1.4.1. Windows is the canary bun 1.4.1 on a Windows Server 2019 machine, USE_SYSTEM_BUN=1, first revision of this branch.

version live cases debug ASAN released windows
main (2a0fda9) 152: 128 api, 24 cli 54.5s, 53.9s, 54.9s 1.24s, 1.26s 2.32s
this PR 151: 1 api, 150 cli 20.6s, 20.4s, 20.7s, 20.5s, 20.6s, 21.0s 0.32s, 0.30s 0.71s, 0.73s, 0.76s

The per-test durations on main add up to 54.2s for a 54.5s run. After the change they add up to 77.8s for a 20.6s run: each cli case spawns bun build as well as the run child, and 5 cases overlap. The remaining cases above 1s have four run steps each (ConditionalImport, ConditionalRequire) or import fs in node mode.

Leaksan: the entry dates from #21142 (September 2025, group Zig::SourceProvider::~SourceProvider()). With BUN_DESTRUCT_VM_ON_EXIT=1, ASAN_OPTIONS=...detect_leaks=1:abort_on_error=1 and the test/leaksan.supp suppressions, the debug ASAN build runs the whole file (children included, bunEnv spreads process.env) with exit 0 and no report, twice. scripts/runner.node.mjs (isBucketCandidate) requires shouldValidateLeakSan on the asan lane, so the file now joins the parallel bucket there. The other 14 files of that group are untouched. If the asan lane reports a leak this revision did not see locally, the line comes back.

Self-review: eight concerns. Addressed: HashbangBannerUseStrictOrder never reached the directive branch under esm (bun drops "use strict" there; upstream uses FormatIIFE), the three *NoBundle cases that bundled, the VarRelocatingNoBundle copy, the leaksan entry. Not done here, on scope: the serial barrier lives in expectBundled.ts (process.chdir and the module-global configRef around await Bun.build, then it.serial). A promise-chain mutex around that window plus plain it would let every api case of every describe.concurrent bundler file overlap while it keeps Bun.build() coverage, and a prototype of it ran the unmodified file in 17.0s against 20.2s for this flip. The handoff for this task keeps expectBundled.ts out of this PR (#38454, #40497 and #38471 edit it), so that is a follow-up. With it, the wrapper here and the one in bundler_compile.test.ts become unnecessary. Also a follow-up: run: true could default to stdout: "" in the harness.

Backend counts: the brief counted the 44 option-forced cases as the serial api ones. resolveBackend returns "cli" for those options, so they were the concurrent ones. 24 of them are live, the other 20 are todo or use an option the harness does not implement.

Assertion changes:

  • stdout: "" on the 17 run steps that had no expectation. Their fixtures assert with node:assert and print nothing: NewExpressionCommonJS, ExportFormsES6, ExportFormsWithMinifyIdentifiersAndNoBundle (todo: multi-entry --no-bundle), ImportFormsWithNoBundle, ImportFormsWithMinifyIdentifiersAndNoBundle, ExportFormsCommonJS, ExportChain, AwaitImportInsideTry, NestedScopeBug, ExportFSBrowser, ExportFSNode, ReExportFSNode, ExportFSNodeInCommonJSModule, ExportWildcardFSNodeES6, ExportWildcardFSNodeCommonJS, ThisOutsideFunctionNotRenamed, ThisInsideFunction.
  • ExportWildcardFSNodeES6 and CommonJS: assert(fs, fs2) becomes readFileSync identity plus equal key sets (minus default). The es6 output must hold export * from "fs", the cjs output require("fs").
  • New runs: ArgumentsSpecialCaseNoBundle prints marker and executes its 50 assert.deepEqual calls (as out.cjs: var arguments needs sloppy mode). NamedFunctionExpressionArgumentCollision prints 123. ArrowFnScope runs a test.js that calls the four arrows without defaults (tests[0](1, 2) === 3, ...) and checks that the comma expressions assigned the global x. NonDeterminismESBuildIssue2537 imports aap and checks aap(false, 4) === "teun" and aap(true, 4) === 10.
  • onAfterBundle checks: NewExpressionCommonJS keeps new (require_foo()).Foo. ExportChain exports c as a and has no b binding. AwaitImportInsideTry keeps await import(name). NestedScopeBug keeps b() and var b under one name. HashbangBannerUseStrictOrder starts with #! in file, #! from banner, "use strict"; (before: only the file hashbang). ArrowFnScope: the globals x, y, z keep their names and no arrow has a parameter named like them. ArgumentsSpecialCaseNoBundle: 8 (x = arguments) defaults and 3 var arguments survive minification. ExternalModuleExclusionPackage, ScopedExternalModuleExclusion, ExternalWildcardDoesNotMatchEntryPoint: exact import list via Bun.Transpiler.scanImports. ManyEntryPoints: all 40 outputs hold their own shared_default = 123 and no import. TopLevelAwaitNoBundle keeps await foo and for await. RequireMainCacheCommonJS keeps require.main and require.cache and has no __require. member. VarRelocatingBundle: for (var i = 1 in {}) becomes i = 1; plus the loop, a block-level function l is lowered to var l, the one in a function body stays. EntryNamesNoSlashAfterDir: each output holds its console.log(n). NonDeterminismESBuildIssue2537: the export keeps the name aap, the local is minified.

Found on the way, not asserted here because the case would fail:

  • In bundle mode, the iife output of a CommonJS entry defines require_entry and never calls it (bundler: call the wrapped entry point in iife output #37843), and __require is not defined in iife output (bundler: define __require in iife output #38077). This is why ArgumentsSpecialCaseNoBundle runs as --no-bundle only.
  • ImportNamespaceThisValue: in cjs output the call of a named import from an external is printed as import_external.foo(), so foo runs with this === import_external. esbuild prints (0, import_external.foo)(). A probe with a runtime external package confirms the leak, also under node.
  • ToESMWrapperOmission: bun build --no-bundle --format=cjs leaves the ESM syntax in place, so the case cannot check the wrappers it is named for.
  • WarnCommonJSExportsInESMBundle: the cjs-in-esm.js output references an undeclared module_cjs_in_esm. js_parser: make the ESM/CJS classification and the module/exports bindings agree #40840 works in that area.
  • StrictModeNestedFnDeclKeepNamesVariableInliningESBuildIssue1552: its output changes with bundler: implement --keep-names with __name helper #40503 (--keep-names), so nothing is pinned.
  • MetafileNoBundle, MetafileVariousCases, MetafileVeryLongExternalPaths are todo through harness limits (multi-entry --no-bundle, copy and dataurl loaders).
  • The 43 todo: true cases are test: enable stale todo tests that now pass #39058's job. No case was removed or skipped. timeoutScale is not used in this file. No case spawns a child of its own, except two todo cases that run esbuild on their output.

BUN_BUNDLER_TEST_USE_ESBUILD=1 (first revision): 79 failures on main, 19 here. The esbuild api backend of the harness forwards almost no options, the cli backend does. Seven cases pass on main and fail here with esbuild: five pin bun's printer output or run through the api backend (NestedScopeBug, VarRelocatingBundle, VarRelocatingNoBundle, RequireMainCacheCommonJS, HashbangBannerUseStrictOrder), and InjectDuplicate and RequireResolve now reach esbuild with their real options.

The same four tsc errors exist on main and here (automaticRuntime, copy and dataurl loaders). prettier reports the file clean.

Related open PRs that edit this file (#40840, #40857, #40804, #39058, #38471 and others) add or flip cases in other hunks. expectBundled.ts is not touched (#38454, #40497). #38385 edits test/no-validate-leaksan.txt in other lines.

First revision (ed744b6): the banner case pinned only the first two lines under esm, the three *NoBundle cases bundled, VarRelocatingNoBundle ran the bundle checks, and the leaksan entry stayed. 152 pass, 405 expect() calls.


[stamp-90s] gate passed · iteration 0 · 2 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/bundler/esbuild/default.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/bundler/esbuild/default.test.ts
bun test v1.4.1 (a6c4cc276)

test/bundler/esbuild/default.test.ts:
(pass) bundler > default/SimpleES6 [928.00ms]
(pass) bundler > default/SimpleCommonJS [586.69ms]
(pass) bundler > default/NewExpressionCommonJS [630.91ms]
(pass) bundler > default/CommonJSFromES6 [661.58ms]
(todo) bundler > default/ExportFormsIIFE
(todo) bundler > default/ExportFormsWithMinifyIdentifiersAndNoBundle
(pass) bundler > default/NestedCommonJS [731.75ms]
(pass) bundler > default/ES6FromCommonJS [573.57ms]
(pass) bundler > default/NestedES6FromCommonJS [636.18ms]
(pass) bundler > default/ImportFormsWithNoBundle [662.80ms]
(pass) bundler > default/ExportFormsES6 [730.16ms]
(todo) bundler > default/ExportInfiniteCycle2
(pass) bundler > default/ImportFormsWithMinifyIdentifiersAndNoBundle [664.67ms]
(pass) bundler > default/ExportInfiniteCycle1 [236.03ms]
(pass) bundler > default/JSXSyntaxInJS [232.42ms]
(pass) bundler > default/ExportChain [623.85ms]
(pass) bundler > default/ExportFormsCommonJS [849.50ms]
(pass) bundler > default/NodeModules [627.06ms]
(pass) bundler > default/RequireChildDirES6 [613.33ms]
(pass) bundler > default/RequireChildDirCommonJS [649.86ms]
(pass) bundler > default/ImportMissingES6 [285.32ms]
(todo) bundler > default/ImportMissingNeitherES6NorCommonJS
(pass) bundler > default/RequireParentDirCommonJS [627.74ms]
(pass) bundler > default/ImportMissingUnusedES6 [298.07ms]
(pass) bundler > default/ExportMissingES6 [302.13ms]
(pass) bundler > default/RequireParentDirES6 [699.05ms]
(pass) bundler > default/ImportMissingCommonJS [550.16ms]
(pass) bundler > default/DynamicImportWithExpressionCJS [221.17ms]
(pass) bundler > default/DotImport [680.44ms]
(pass) bundler > default/RequireWithTemplate [631.14ms]
(pass) bundler > default/DynamicImportWithTemplateIIFE [572.50ms]
(pass) bundler > default/MinifiedDynamicImportWithExpressionCJS [183.91ms]
(pass)
... (truncated)
Exit: 0
diff hotspot
test/bundler/esbuild/default.test.ts | 198 +++++++++++++++++++++++++++++++++--
 test/no-validate-leaksan.txt         |   1 -
 2 files changed, 189 insertions(+), 10 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
test/bundler/esbuild/default.test.ts     26     40      0
test/no-validate-leaksan.txt              1      1      0

@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2cd0f48b-9a7f-4c02-a1ae-f5f15b84e032

📥 Commits

Reviewing files that changed from the base of the PR and between ed744b6 and 4f880d3.

📒 Files selected for processing (2)
  • test/bundler/esbuild/default.test.ts
  • test/no-validate-leaksan.txt
💤 Files with no reviewable changes (1)
  • test/no-validate-leaksan.txt

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

Changes

The bundler default tests now use a CLI-default wrapper with API overrides. They add generated-output and runtime assertions for module resolution, exports, dynamic imports, scope handling, entry naming, top-level await, CommonJS globals, and minification.

Bundler test validation

Layer / File(s) Summary
Test harness and output assertions
test/bundler/esbuild/default.test.ts, test/no-validate-leaksan.txt
The tests select the CLI or API backend and validate generated output for transforms, exports, imports, aliases, hashbangs, and scope behavior. The test file is removed from the categorized list.
Module resolution and entry behavior
test/bundler/esbuild/default.test.ts
The tests validate filesystem exports, external package matching, entry handling, duplicated shared code, top-level await, and CommonJS runtime globals.
Scope, relocation, and naming validation
test/bundler/esbuild/default.test.ts
The tests validate identifier renaming, declaration relocation, arguments preservation, function-expression collisions, and public export naming.

Merge Risk: ⚪ Minimal · up to 4f880

This PR reorganizes and strengthens bundler tests while reducing their runtime; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: running the esbuild bundler test on the CLI backend and pinning additional outputs.
Description check ✅ Passed The description explains the problem, implementation, verification commands, test results, performance impact, and scope. It does not use the exact template headings, but it fully covers both required…
Full details: Description check

Explanation

The description explains the problem, implementation, verification commands, test results, performance impact, and scope. It does not use the exact template headings, but it fully covers both required topics.


Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only hardening that turns assertion-light bundler cases into ones that can actually fail, plus a CLI-backend default for concurrency.

Checked the itBundled wrapper: backend is a real BundlerTestInput field, the spread order lets backend: "api" override the default, and the callback form is preserved. The assert(fs, fs2) → strictEqual/deepStrictEqual rewrites now compare real values instead of a truthiness check with a message arg. The new onAfterBundle output-shape checks (scanImports externals, require.main/__require., var-relocation, new (require_foo()).Foo parenthesization) read as correct against the fixtures they cover. No runtime code, no CODEOWNERS on this path.

Extended reasoning...

Overview

This PR touches a single file, test/bundler/esbuild/default.test.ts, which is the esbuild-ported bundler test suite. It makes two kinds of changes: (1) a local itBundled wrapper that defaults every case to backend: "cli" so the describe.concurrent block actually runs concurrently (the API backend uses it.serial because it calls process.chdir), with one case (HashbangBannerUseStrictOrder) explicitly opting back into backend: "api" due to a CLI --banner quoting quirk; and (2) a large number of assertion additions — converting run: true to run: { stdout: "" }, replacing the vacuous assert(fs, fs2) with real strictEqual/deepStrictEqual comparisons, and adding onAfterBundle output-shape checks (parenthesization of new (require_foo()).Foo, export-chain collapse, dynamic-import preservation, scanImports on externals, require.main/require.cache survival, var-relocation hoisting, etc.). No runtime or bundler source is touched.

Security risks

None. This is a test-only change to a bundler regression suite. It adds no network access, no credentials, no file-system writes outside the harness's temp dirs, and does not touch any security-sensitive path (auth, crypto, TLS, package extraction). The Bun.Transpiler().scanImports calls operate on the bundler's own output in-process.

Level of scrutiny

Low-to-moderate. Test-only changes that strengthen assertions carry the risk of pinning to incorrect output (REVIEW.md: "a snapshot captured from buggy code certifies the bug"), so I read each new onAfterBundle check against its fixture. The new (require_foo()).Foo parenthesization, c as a alias collapse, await import(name) preservation, export * from "fs" / require("fs") presence, arguments survival under minification, and __require. absence all match what the bundler should emit for those inputs. The itBundled wrapper was checked against expectBundled.ts — backend?: "cli" | "api" is a real option, the spread order ({ backend: "cli", ...opts }) correctly lets per-case overrides win, and the callback-form branch preserves the metadata argument. The .skip static is forwarded via Object.assign.

Other factors

The bug hunt ran to dry_streak with no findings. No CODEOWNERS entry covers test/bundler/. There are no prior review comments or outstanding objections in the timeline. The PR description reports the full suite passes on debug+ASAN, release, and Windows canary with the same 152 live cases and no new todos/skips, and the change directly implements REVIEW.md's "every assertion must be able to fail" and "prefer concurrent tests" guidance. The one intentional behavior difference — the strengthened HashbangBannerUseStrictOrder assertion now checking both the file hashbang and the banner line — is a strict tightening of the previous startsWith("#! in file") check.

… cases --no-bundle, drop the stale leaksan entry

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/bundler/esbuild/default.test.ts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant