Repository navigation
Conversation
… anything
TestBuilder silently accepted two patterns that never asserted anything:
- Callbacks passed to .stdout()/.stderr()/.exitCode() had their return
value discarded, so predicate-style callbacks such as
`s => s.includes("x")` or `c => c !== 0` checked nothing.
- Calling .stdout() (or .stderr()/.exitCode()/.error()) twice on one
builder overwrote the earlier expectation, so only the last one in a
chain ran.
The builder now throws when an expectation is set twice and when a
callback returns a value. run() also fails when .error() was set but the
command completed, and awaits the checks on the ShellError path instead
of letting a failure escape as an unhandled rejection.
Fix the call sites this uncovers: the cd-in-pipeline tests in
pipeline_stack.test.ts, the exit-code table, the nested pipeline stderr
check and three todo'd condexpr tests in bunshell.test.ts, the two
bunshell-default.test.ts stderr checks, and four chained .stdout()
chains in ls.test.ts. `export` is removed from the exit-code table
because `export` with no arguments succeeds (as in bash); a test for
that behaviour is added instead. test_builder.test.ts pins the new
builder behaviour.
|
Warning Review limit reached
Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: ready for review. Reproduced on
|
There was a problem hiding this comment.
LGTM — test-only hardening of the shell TestBuilder helper plus fixes for the vacuous call sites it exposed.
What was reviewed:
_setExpectationguard andcheckCallbackReturnedNothing: verified allbun:testmatchers returnundefined, so existings => expect(s).toBe(...)chains keep working; grep confirms no existing chain calls.error()twice.run()restructure:doChecksmoved out of the try so assertion failures aren't swallowed by the catch, and now awaited on the ShellError path; the new.error()-but-completed check correctly excludes.error(false).- Rewritten
ls -R level1expectation: checkedsrc/runtime/shell/builtin/ls.rs—print_directoryis false for a single path arg and the header is written only after the dir fd opens, so the["file1","file2","file3","level2"]exact match is correct. exportremoved from the failing-commands table with a replacement test pinning its actual behavior; condexpr double-.stdout()chains insidedescribe.todowere fixed so construction-time throws don't break the file.
Extended reasoning...
Overview
Test-only PR touching six files under test/js/bun/shell/. It hardens test_builder.ts (the shared helper used by ~24 shell test files) so two classes of vacuous assertion fail loudly: (1) setting the same expectation twice on one builder now throws at chain-construction time, and (2) an expectation callback that returns a value (predicate style) now fails the run instead of being silently discarded. run() is restructured so doChecks runs outside the try (assertion failures no longer land in the catch), is awaited on the ShellError path, and a set .error() now fails when the command completes instead of throwing. Every call site that tripped the new guards is rewritten with a real assertion, and a new test_builder.test.ts pins each guard.
Security risks
None. No runtime code is touched — only test files and a test helper.
Level of scrutiny
Medium-low. The change is test-only, so the worst-case regression is a CI failure rather than a shipped bug. The parts warranting a closer look were the tightened exact-match assertions (which could be platform- or implementation-sensitive) and the shared-helper API change (which affects 24 files). I checked:
- Double-set guard reach: grepped for
.error(acrosstest/js/bun/shell/— no chain calls it twice outside the new test file. The three condexpr double-.stdout()chains sit insidedescribe.todo, whose body still runs at collection time, so they had to be fixed (and were). ls -R level1exact assertion: readsrc/runtime/shell/builtin/ls.rs. Thelevel1:header is printed only whenprint_directoryis true, which is set fromtask_count > 1for the initial task (false here — single arg) and forced true only for recursive subtasks; thelevel1/level2:subtask header is written afterfdis opened, which fails with EACCES, so it never reaches stdout.sortedLsOutputtherefore yields exactly["file1","file2","file3","level2"].run()restructure: the newexpected_error !== undefined && expected_error !== falsecheck correctly leaves.error(false)(explicit expect-no-error) on the success path, anddoChecksbeing awaited on the ShellError branch fixes the previously fire-and-forget async.
Other factors
- The PR description documents exhaustive verification: all 24
TestBuilder-based files were run with the guards in place and only the sites rewritten here tripped;test_builder.test.tsfails 9/14 againstmainand passes 14/14 with the change. - The
exportremoval fromfailing_cmdsfollows REVIEW.md's rule that every deletion needs a stated reason or replacement — bareexportexits 0, and a new test pins that it prints the exported vars sorted. - The bug-hunting system found nothing.
- No prior human reviews or outstanding comments on the PR.
|
Updated 3:14 AM PT - Aug 12th, 2026
✅ @robobun, your commit 2a0be52f45b4c0db61f67b8ca48ec750c789af78 passed in 🧪 To try this PR locally: bunx bun-pr 37737That installs a local version of the PR into your bun-37737 --bun |
Problem
main,echo hiwith.stdout(s => s.includes("definitely-not-present"))passes..stdout()in a chain replaced the first and only the last one ran.cd-in-pipeline tests, two stderr checks, fourlschains and threedescribe.todochains. The table hid that bareexporthas always exited 0, as in bash..error(...)on a command that completed ran the output checks instead of failing, and on the ShellError path a failing expectation escaped as an unhandled rejection instead of failingrun().Fix
undefinedfails the run.bun:testmatchers returnundefined, so existings => expect(s)...callbacks are unaffected.run()fails when.error()was set but the command completed, and awaits the checks on the ShellError path, so every failing expectation now rejectsrun().exportleaves the failing table and gets a test pinning its output and exit 0; a case for the cwd after acdpipeline is added. The new expectations match current shell behaviour, so none papers over a shell bug.mainand pass here. All 24 files that use the builder were run with the guards on and only the chains rewritten here tripped. Twolspermission-denied tests fail as root before and after, andleak.test.tstimes out under a debug build regardless of this change.Background
TestBuilder(test/js/bun/shell/test_builder.ts) is the helper behind most shell tests:TestBuilder.command`...`followed by.stdout(),.stderr(),.exitCode()or.error(), then.run()or.runAsTest(name).expect()itself; the builder never looked at what it returned.$TEMP_DIRin a string expectation is replaced with the test's temp directory. It is not replaced for callbacks, which is why thecdpredicates could never have matched..error()declares that the command should throw (for example a parse error) rather than complete with an exit code.Bun.$in throwing mode,run()catches aShellErrorcarrying stdout, stderr and the exit code and checks the expectations against those.Original description
What was wrong
Several shell tests built with
TestBuilder(test/js/bun/shell/test_builder.ts) looked like they asserted something about the output but did not. Two builder behaviours made this possible:.stdout(fn)/.stderr(fn)/.exitCode(fn)discarded the callback's return value, so a predicate-style callback asserted nothing.doChecks()calledthis.expected_stdout(...)and ignored the result..stdout()(and.stderr()/.exitCode()/.error()) assign a single field, so calling one of them twice in a chain silently replaced the earlier expectation and only the last one ran.Both are easy to demonstrate against
main; these pass:Call sites affected (found by temporarily instrumenting the builder and running every file that uses it, plus a scan of the chains):
bunshell.test.ts"exit codes":.exitCode(c => c !== 0)on 14 commands, so none of them checked the exit code. One entry, bareexport, has in fact always exited 0 (it prints the exported variables, like bash), which the no-op assertion hid.pipeline_stack.test.ts"cd builtin in pipelines": three.stdout(s => s.includes("$TEMP_DIR"))-style predicates. Doubly inert, since$TEMP_DIRis only substituted into string expectations and the third predicate expected two lines frompwd | cd / | pwd, whose firstpwdgoes into the pipe.bunshell.test.ts"nested pipeline mixed success failure":.stderr(s => s.includes("command not found")).bunshell-default.test.ts: two.stderr(s => s.includes("1 pass")).commands/ls.test.ts: four chains with 2 to 4.stdout(...)calls (-A,-Ra,-RA, permission denied recursive). The-Atest, for example, only rantoContain("regular"); the./..exclusion it is named for was never checked.bunshell.test.tscondexprs (insidedescribe.todo): three chains with.stdout("1").stdout("0")-style pairs.Fix
test_builder.ts:runAsTestchain fails the file immediately..stdout()/.stderr()/.exitCode()callback that returns anything other thanundefinedfails the run with a message saying the callback has to callexpect()itself. Allbun:testmatchers returnundefined, so the existings => expect(s).toBe(...)style keeps working.run()now fails when.error(...)was set but the command completed (previously the output checks ran instead, and with.error()and no argument any failure was swallowed), and it awaitsdoChecks()on the ShellError path, where a failing expectation used to escape as an unhandled rejection instead of rejectingrun(). The output checks also moved out of thetry, so an assertion failure can no longer be reinterpreted as the thrown error.Call sites: the predicates are replaced with real assertions (exact
$TEMP_DIR\nstrings for thecdpipeline tests,expect(c).not.toBe(0)for the exit code table, exact stderr for the nested pipeline test) and the chained.stdout()calls are folded into single exacttoEqualassertions, matching the neighbouringlstests.exportis dropped from the failing-commands table andenv variablesgets a test pinning that bareexportprints the exported variables sorted and exits 0. Acd foo | pwd; pwdcase is added to the pipeline describe since none of the existing cases covered the cwd after the pipeline.test_builder.test.tspins the builder behaviour: each of the set-twice guards, each return-value guard,.error()with and without a throw, and the ShellError path both passing and failing.Why this is the right fix
Both broken patterns were written independently several times (seven predicate callbacks across four files, seven chains with repeated calls across two files), so the API itself invites them; fixing only the call sites would leave the next one just as silent. The guards reject exactly the two shapes that can never assert anything, and nothing in the suite relies on either: with the guards in place, every file that uses
TestBuilderwas run and the only chains that tripped are the ones rewritten here. The rewritten expectations were checked against the current shell behaviour (cdin a pipeline stage does not change the cwd of the other stages or of the commands after the pipeline, same as bash), so none of them is papering over a shell bug. The two shell bugs the exit-code table happens to exercise (echo hi > /dev/fullexiting with 65508, andexport foo-barsucceeding) already have open PRs (#32278, #32298); the table only asserts non-zero, so it is unaffected by either.Verification
test_builder.test.tswith the builder frommain: 9 of 14 fail (the four set-twice cases, the three return-value cases,.error()without a throw, and the failing ShellError-path expectation). With this change: 14 pass.TestBuilder-based files undertest/js/bun/shellwith a debug build: everything passes except the twolspermission-denied tests, which fail as root both before and after this change (they pass when run as an unprivileged user, including the rewritten recursive one), and theleak.test.tsiteration tests, which time out under the debug build here regardless of this change and pass with a release build.