Skip to content

test(init): initialize each react template once and skip the npm bun package's postinstall - #38476

Open
robobun wants to merge 3 commits into
mainfrom
farm/d53211a7/speed-up-init-test
Open

robobun wants to merge 3 commits into
mainfrom
farm/d53211a7/speed-up-init-test

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/cli/init/init.test.ts is a serial-phase file that took 25s on alpine x64, 20s on alpine aarch64, 23s on windows x64 and 29s on windows aarch64 in build 95391, against 3.5-6s on the debian, ubuntu and darwin lanes.
  • The alpine and windows x64 time is the postinstall of the npm bun package. --react=tailwind and --react=shadcn depend on bun-plugin-tailwind, whose bun peer dependency installs that package, and bun is in src/install/default-trusted-dependencies.txt, so every init of those templates ran its install.js. The file ran it five times per run (cache prime, two works cases, two typecheck/build cases).
    • On musl, the published bun@1.3.14 postinstall tries the glibc build first (@oven/bun-linux-x64), fails to execute it, runs npm install of that same package against registry.npmjs.org, fails again, and only then tries the musl build. In the alpine x64 log the tailwind and shadcn installs took 5.9s and 7.4s after the cache was primed, against 0.2s for the blank template on the same lane and 0.17s / 0.65s for the same two installs on debian; the prime itself took 13.4s against 2.0s on debian.
    • On windows x64 the same postinstall probes for AVX2 by having PowerShell compile a C# snippet. (Both behaviors are already gone from packages/bun-release since npm(bun-release): prefer musl package first on musl hosts #36283, but the test installs whatever is published, and nothing in the file asserts on lifecycle scripts.)
  • The windows aarch64 time is structural: the describe block was describe instead of describe.concurrent on Windows, and the shadcn template (60 packages, 5555 files, 4090 of them in lucide-react) was installed three times per run at about 6s per warm install on that lane.

Fix

  • initEnv points XDG_CONFIG_HOME at a global bunfig with [install] ignoreScripts = true, so the bun install that bun init spawns skips the bun package's postinstall. peer = false was tried first and rejected: it still downloads the @oven/* tarballs and it also drops the project's own typescript peer dependency, which the TypeScript 6 cases exist to check.
  • Each template is initialized at most once per run (initTemplate, memoized per flag). The works case and the "installs TypeScript 6, typechecks, and builds" case for a template share the result; the file listing is captured right after init, before the build case writes dist/. Shared directories are removed in afterAll.
  • The beforeAll prime is now the shadcn init itself rather than two throwaway installs. shadcn's closure is a superset of every other template's, including the blank one: after a shadcn-only init into a fresh cache, -y, --react, --react=tailwind and --react=shadcn each report Resolved, downloaded and extracted [0].
  • describe.concurrent on every platform. The Windows exemption came from the bulk test.concurrent conversion in Start using test.concurrent in our tests #22823 with no recorded reason; 7 runs on a windows aarch64 machine (one cold cache, six warm) were all green.
  • Assertions, while in there:
    • the three react works cases now snapshot the exact set of files bun init created, compare every file from src/runtime/cli/init/<template>/ against what was written, check the per-template README title and bun version, and check that every dependency, devDependency and peerDependency declared in the written package.json is present in node_modules. This replaces the toHaveProperty and existsSync checks.
    • every consumer of an init checks the installed packages first, in one assertion that also carries the captured stdout/stderr (expectInstalled): the react inits now use pipes instead of inherited stdio, and bun init exits 0 when the nested install fails (init: exit 1 when the nested bun install fails #38474), so this is what puts the install's error lines in the failure output instead of a bare listing diff or ENOENT. Verified with a dead registry (BUN_CONFIG_REGISTRY=http://127.0.0.1:1/): all seven template cases fail at that assertion with the error: ConnectionRefused ... lines shown. There is deliberately no blanket "stderr has no error/warn lines" check, since warn: lines there depend on registry state (peer ranges during a publish window, optional dependency fetch failures).
    • the typecheck/build cases assert that the blank template has no scripts and the react templates have a build script, so the build branch cannot silently stop running, and that the build produced dist/index.html.
    • "error rather than overwriting file" asserts the exact message (Failed to change directory to mydir: ENOTDIR, identical on Windows), empty stdout and exit code 1 instead of not.toBe(0).
    • "bun init works" and the no-TTY case assert the exact directory listing instead of three or four existsSync calls.
  • Timings, cold install cache in every run:
    • CI, this PR's build 95949 against build 95391 (same file, all 15 cases passing on every lane): alpine x64 24.9s to 13.1s, alpine aarch64 19.6s to 9.4s, windows x64 23.0s to 8.0s, windows aarch64 29.0s to 10.6s, debian x64 6.1s to 4.9s, debian x64-asan 6.2s to 4.9s, debian aarch64 3.5s to 4.3s. On alpine x64 the remaining time is 6.0s for the one cold shadcn install in beforeAll and about 7s for the concurrent phase (four tsc runs and three builds).
    • windows aarch64 machine, release canary, bun test: 43.9s before, 16.6s after (11.2-12.7s with a warm cache). Of the 16.6s, about 11s is the one cold shadcn install in beforeAll.
    • linux x64 glibc (this container), bun bd test test/cli/init/init.test.ts: 35.2s before, 34.4s and 34.9s after. Flat as expected: glibc never took the postinstall detour, and under a debug+ASAN binary the file is dominated by the three template bun run build steps (5-24s each), which are unchanged. Release binary on the same box: 4.9s before, 4.4-5.0s after.
    • What remains per run is one real install of each template; the bun package still pulls one to four @oven/bun-* platform tarballs into the cache once per run (four on x64 linux, since bun install does not filter optional dependencies by libc yet, install: filter optionalDependencies by libc (glibc/musl) #31123). The only way to avoid that is peer = false, rejected above.
  • Not a shared cause with test/cli/install/bunx.test.ts (test(bunx): install fixture packages from a local registry instead of real ones #38467): that file does not install bun-plugin-tailwind. One observation from the same logs that does apply to any install-heavy file: even fully cached, the 5-package blank installs took 200-250ms on alpine and windows aarch64 against 14-30ms on debian.
  • Rebase notes for the open PRs touching this file: bun init: add react + @types/react to the blank scaffold so .tsx works #36607, bun init --react: don't overwrite existing README.md #35164, bun init: point the agent rule at the bun-types docs under both linkers #38419 and init: exit 1 when the nested bun install fails #38474 add cases or edit the blank-template cases, which this PR leaves in place (bun init --react: don't overwrite existing README.md #35164 inserts right after the --react works case, whose body changed). bun init: put typescript in devDependencies, not peerDependencies #35445's edits to the three react works cases are superseded, since those now compare package.json against the template source.

Background

  • bun init writes the template files and then spawns bun install in the new directory; bun install reads $XDG_CONFIG_HOME/.bunfig.toml (falling back to $HOME/.bunfig.toml) on every platform before the project's bunfig, which is how a test can configure the nested install without adding files to the scaffold it is asserting on. test/cli/install/minimum-release-age.test.ts uses the same mechanism.
  • bun only runs lifecycle scripts of packages listed in trustedDependencies or in bun's built-in default trusted list; bun is on that list. ignoreScripts disables them for the whole install.
  • In CI each test file gets a fresh BUN_INSTALL_CACHE_DIR (scripts/runner.node.mjs), so every run of this file starts from a cold cache; bun dedupes downloads within one install but not across concurrently running installs, which is why one install has to run alone before the rest start.
Per-lane timeline of the file in build 95391 (from the raw job logs)
alpine x64        prime 13.4s | post-prime installs: blank 0.20-0.26s, tailwind 5.88s, shadcn 7.39s | total 24.9s
alpine aarch64    prime 12.2s | blank 0.01-0.05s, tailwind 3.93s, shadcn 5.27s                      | total 19.6s
windows x64       prime  6.2s | blank 0.05s, tailwind 2.35s, shadcn 2.67s, then 10.3s serial tsc/builds | total 23.0s
windows aarch64   prime  9.3s | blank 0.25s, tailwind 0.79s, shadcn 3.03s, then 11.4s serial tsc/builds | total 29.0s
debian x64        prime  2.0s | blank 0.01-0.03s, tailwind 0.17s, shadcn 0.65s                      | total  6.1s
debian x64-asan   prime  2.6s | blank 0.08-0.10s, tailwind 0.13s, shadcn 0.21s                      | total  6.2s
debian aarch64    prime  1.2s | blank 0.01-0.03s, tailwind 0.05s, shadcn 0.15s                      | total  3.5s

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

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

Debug/ASAN (expected pass):
$ bun bd test 'test/cli/init/init.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/cli/init/init.test.ts
bun test v1.4.0 (7f8f5d3cf)

test/cli/init/init.test.ts:
 + .gitignore
 + index.ts
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:

    bun run index.ts

 + .gitignore
 + index.ts
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:

    bun run index.ts

bun install v1.4.0 (7f8f5d3cf)
Resolving dependencies
Resolved, downloaded and extracted [0]
(pass) bun init > bun init error rather than overwriting file [281.57ms]
Saved lockfile

+ @types/bun@1.3.14
+ typescript@6.0.3 (v7.0.2 available)

5 packages installed [220.00ms]
bun install v1.4.0 (7f8f5d3cf)

bun install v1.4.0 (7f8f5d3cf)
Resolving dependencies
Resolved, downloaded and extracted [0]
Resolving dependencies
Resolved, downloaded and extracted [0]
 + .gitignore
 + index.ts
 + tsconfig.json (for editor autocomplete)
 + README.md

To get started, run:

    bun run index.ts

Saved lockfile

+ @types/bun@1.3.14
+ typescript@6.0.3 (v7.0.2 available)

5 packages installed [222.00ms]
(pass) bun init > bun init works [518.17ms]
Saved lockfile

+ @types/bun@1.3.14
+ typescript@6.0.3 (v7.0.2 available)

5 packages installed [295.00ms]


(pass) bun init > bun init falls back to --yes when stdin is not a TTY [574.10ms]
bun install v1.4.0 (7f8f5d3cf)
(pass) bun init > bun init in folder [582.93ms]
Resolving dependencies
Resolved, downloaded and extracted [0]
(pass) bun init > bun init --react=shadcn works [83.02ms]
Saved lockfile

+ @types/bun@1.3.14
+ typescript@6.0.3 (v7.0.2 available)

5 packages installed [211.00ms]
(pass) bun init > bun init utf-8 [657.96ms]

(pass) bun init > bun init --react works [666.40ms]
(pass) bun init > bun init twice [835.71ms]
(pass) bun init > bun init --react=tailwind works [636.62ms]
(pass) bun init > nested `bun install` output is inherited [636.34ms]
 + tsconfig.json (for editor autocomplete)
bun install v1.4.0 (7
... (truncated)
Exit: 0
diff hotspot
test/cli/init/init.test.ts | 383 ++++++++++++++++++++++++++++++---------------
 1 file changed, 254 insertions(+), 129 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                        reads  edits  tests
test/cli/init/init.test.ts      7     20      0

…package's postinstall

The tailwind and shadcn templates depend on bun-plugin-tailwind, whose bun
peer dependency installs the npm bun package. That package is in the default
trusted list, so every init of those templates ran its postinstall, which on
musl shells out to npm install for a glibc build it cannot execute and on
Windows x64 compiles a PowerShell snippet to probe for AVX2. The file ran it
five times per run. Point the nested bun install at a global bunfig with
ignoreScripts, initialize each template at most once per run and share the
result between the "works" case and the typecheck/build case, use the shadcn
init itself as the cache prime, and run the cases concurrently on Windows as
well.

The react cases now compare every scaffolded file against the template
source, assert the exact file set and that every declared dependency was
installed, and check that the build produced dist/index.html. The error case
asserts the exact message and exit code.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a59c514e-4497-416e-adfa-45f54a61dc84

📥 Commits

Reviewing files that changed from the base of the PR and between 032b8db and 7f8f5d3.

📒 Files selected for processing (1)
  • test/cli/init/init.test.ts

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:37 AM PT - Aug 14th, 2026

@robobun, your commit 7f8f5d3 is building: #96341

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (head 7f8f5d3).

Reproduced how: per-lane timelines extracted from the raw test-bun job logs of build 95391 (table in the PR body), plus a cold-cache run of the unmodified file on a windows aarch64 machine (43.9s). With this branch the same machine runs it in 16.6s cold and 11-13s warm, 7 out of 7 runs green; on this PR's first CI build the file went from 20-29s to 8-13s on the alpine and windows lanes, passing everywhere (numbers in the body). bun bd test test/cli/init/init.test.ts passes on linux.

Found while investigating, handled separately: #38474 (bun init exited 0 when the nested install failed).

@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.

I reviewed this PR and didn't find any bugs. It's a substantial test-file restructure (memoized template inits shared across concurrent tests, describe.concurrent re-enabled on Windows, new exact-listing snapshots), so a human look would still be worthwhile.

What was reviewed:

  • The memoized initTemplate + shared-directory pattern: init.files is captured synchronously before any consumer runs bun run build, and expectReactTemplate only reads template files / README / package.json, so the concurrent dist/ write doesn't race with the snapshot or the "works" assertions.
  • XDG_CONFIG_HOME global-bunfig mechanism matches the pattern in test/cli/install/minimum-release-age.test.ts; bunEnv doesn't already set it.
  • Template source paths (src/runtime/cli/init/react-{app,tailwind,shadcn}) exist; the source-dir bun.lock/node_modules filter matches what's actually checked in.
  • readdirSync(temp) on the DisposableString from tempDir — it extends String, and the same call shape already exists elsewhere in the file.
Extended reasoning...

Overview

This PR restructures test/cli/init/init.test.ts to cut CI time on the slow lanes (alpine, windows) from 20-29s down to ~12-17s. The mechanism: (1) point the nested bun install at a global bunfig with ignoreScripts = true so the npm bun package's postinstall never runs, (2) memoize each template's bun init so the "works" and "typechecks/builds" cases share one install instead of running two, (3) replace the throwaway cache-prime with the shadcn init itself, and (4) enable describe.concurrent on Windows. Along the way the assertions are tightened: exact file-listing snapshots replace existsSync spot-checks, template files are byte-compared against src/runtime/cli/init/<template>/, the ENOTDIR error case asserts the exact message and exit code, and the build case now asserts dist/index.html exists so it can't silently become a no-op.

Security risks

None. This touches only a test file; no production code, no auth/crypto/permissions surface.

Level of scrutiny

Medium. It's test-only, so there is no user-facing risk, but it is not a mechanical change: it introduces a shared-directory-across-concurrent-tests pattern (two tests read from and one writes dist/ into the same temp dir), removes a platform exemption (Windows was serial before), and adds several inline snapshots that pin the exact scaffold layout for three templates. The reasoning in the PR body is thorough and I verified the load-bearing assumptions (file capture happens before any build writes; the XDG_CONFIG_HOME override pattern is already used in minimum-release-age.test.ts; DisposableString extends String so readdirSync(temp) is fine; the template source directories and their bun.lock filter match the repo). Still, the concurrent-shared-directory design and the Windows re-enablement are the kind of test-infrastructure decisions a maintainer typically signs off on — if the build script for one of the templates ever starts touching files outside dist/, or if Windows concurrency turns out to have been serial for a reason the author's 7 runs didn't surface, this file will flake.

Other factors

The PR description notes five open PRs touch this same file (#36607, #35164, #38419, #38474, #35445) with rebase implications, which is another reason a human should be in the loop on merge order. No prior reviews from me or other humans; only bot noise in the timeline. The bug-hunting pass found nothing.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points worth spelling out from the review above:

  • Shared directories: the only writer after init is bun run build, and all three templates build into dist/ (react-app's script passes --outdir=dist, the other two build.ts files rm and recreate dist). The works assertions read the template files, README.md, package.json and node_modules, and the file listing is taken inside initTemplate before the result is handed to anyone, so a build would have to rewrite its own sources to interfere, which would be a template bug in its own right.
  • Windows concurrency: the serial block dates from the bulk conversion in Start using test.concurrent in our tests #22823 with no reason recorded, and this file is one of two left with that exemption. The 7 runs on windows aarch64 (the slowest lane) were all green; CI on this PR covers windows x64 as well.

…l fails

The react cases were switched from inherited stdio to pipes, so when the
nested bun install fails the error lines no longer reach the log, and the
first assertion to notice was a file-listing diff or an ENOENT. Check the
installed packages first, with the captured stdout/stderr in the assertion,
and drop the blanket stderr error/warn match, which also fired on registry
state such as peer dependency warnings during a publish window.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 7f8f5d3 after a self-review pass over the diff. Two things it turned up, both in the new react cases:

  • Switching those inits from inherited stdio to pipes meant that if the nested bun install fails, its error lines no longer reach the log, and the first assertion to notice was a file-listing diff or an ENOENT on node_modules/typescript/package.json. Every consumer of an init now runs expectInstalled first, a single assertion over the missing packages plus the captured stdout/stderr, so the install's own errors are what the failure shows. Checked by pointing BUN_CONFIG_REGISTRY at a closed port: all seven template cases fail at that assertion with the ConnectionRefused lines in the output.
  • Dropped the blanket stderr has-no-error/warn-lines check. The error half is covered by the package checks, and the warn lines bun install can print there depend on registry state (for example react-dom pinning its react peer to its own version during a publish window), which would have turned that into a deterministic failure of this file on every lane.

No change to the mechanism or the timings; PR body updated accordingly. This push also re-runs CI, which matters because the previous build's only non-flaky problem was both darwin 14 aarch64 test shards expiring without ever getting an agent (the neighbouring build 95945 had the same thing happen).

@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 refactor that strengthens assertions and cuts CI time on the slow lanes.

What was reviewed:

  • Shared-directory concurrency: init.files is captured synchronously inside the memoized promise before any consumer sees it, and the only post-init writer (bun run build) targets dist/, which none of the works assertions read.
  • initTemplate memoization under describe.concurrent: the check-and-set is synchronous, so two concurrent callers can't both miss the cache.
  • XDG_CONFIG_HOME override: bunEnv doesn't set it, and test/cli/install/minimum-release-age.test.ts already uses the same mechanism to configure the nested install.
  • Verified src/runtime/cli/init/{react-app,react-tailwind,react-shadcn} exist and tempDir returns a DisposableString, so readdirSync(temp) is fine.
Extended reasoning...

Overview

This PR refactors test/cli/init/init.test.ts to reduce its wall-clock time on alpine and Windows CI lanes (25-29s → 8-13s) without weakening coverage. The changes are: (1) point XDG_CONFIG_HOME at a global bunfig with ignoreScripts = true so the nested bun install skips the npm bun package's postinstall; (2) memoize each template's init in initTemplate() so the works and typechecks/builds cases share one directory per template; (3) replace the throwaway cache-prime installs with the shadcn init itself; (4) drop the Windows-only serial-describe exemption; (5) tighten assertions across the board — exact file-listing snapshots, byte-for-byte comparison of every scaffolded file against src/runtime/cli/init/<template>/, exact error message and exit code for the ENOTDIR case, and an expectInstalled helper that surfaces the nested install's stdout/stderr when a declared dependency is missing (working around #38474). One file changed, no production code touched.

Security risks

None. This is a test file that spawns bun init and bun install in temp directories. The new XDG_CONFIG_HOME points at a test-created temp dir and is scoped to the spawned processes' env.

Level of scrutiny

Medium — it is test-only, but it shares mutable temp directories across concurrently running test cases and removes a platform-specific concurrency exemption, both of which are classic flake sources. I traced both:

  • Shared directories: the InitResult.files listing is captured inside the memoized async IIFE before the promise resolves, so no consumer can observe it after bun run build writes dist/. expectReactTemplate reads only the template source files, README, package.json and node_modules/<pkg>/package.json — none of which the build step touches. tsc --noEmit writes nothing to the project.
  • Memoization race: templateInits.get/.set runs synchronously before any await, so under describe.concurrent two callers with the same flag cannot both miss the cache.
  • Windows concurrency: the exemption came from a bulk conversion (#22823) with no recorded reason; the author ran 7 cold/warm runs on Windows aarch64 all green, and this PR's CI build (95949 per the body) passed all 15 cases on every lane including both Windows targets.

Other factors

  • No CODEOWNERS entry for this path.
  • Assertions are strictly stronger than before (exact listings and content comparison replace existsSync/toHaveProperty; not.toBe(0) becomes exact stderr + toBe(1)), matching REVIEW.md's "assert the strongest invariant" guidance.
  • Cleanup: shared directories and the global config dir are removed in afterAll via Promise.allSettled + fs.promises.rm({force:true}), so a failed init doesn't block cleanup of the others.
  • I confirmed src/runtime/cli/init/ contains react-app, react-tailwind, react-shadcn and that react-app/ has a bun.lock (excluded by the filter in expectReactTemplate), and that the harness's tempDir returns a DisposableString so readdirSync(temp) works.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing further has changed since 7f8f5d3. On that head's build the file is green on every lane that has run so far (the only annotations are retry-passed flakes in unrelated files); the darwin 14 aarch64 test shards are the ones still waiting for an agent, as in the previous build.

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