Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/js/node/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,10 +665,22 @@ function createTest(arg0: unknown, arg1: unknown, arg2: unknown) {
checkNotInsideTest(ctx, "test");
const context = new TestContext(true, name, Bun.main, ctx);

// Node's node:test passes a second `done` callback argument when the test
// function declares two or more parameters, e.g. `test('name', (t, done) => ...)`.
// Detect that here so we can both forward `done` to the user and avoid
// auto-completing the test synchronously while it waits on the callback.
// https://nodejs.org/api/test.html#testname-options-fn
const useCallback = typeof fn === "function" && fn.length >= 2;

const runTest = (done: (error?: unknown) => void) => {
const originalContext = ctx;
ctx = context;
let ended = false;
const endTest = (error?: unknown) => {
// Calling done() and also returning a value (or throwing) must not
// complete the test twice. Make endTest idempotent.
if (ended) return;
ended = true;
try {
done(error);
} finally {
Expand All @@ -678,16 +690,18 @@ function createTest(arg0: unknown, arg1: unknown, arg2: unknown) {

let result: unknown;
try {
result = fn(context);
result = useCallback ? fn(context, endTest) : fn(context);
} catch (error) {
endTest(error);
return;
}
if (result instanceof Promise) {
(result as Promise<unknown>).then(() => endTest()).catch(error => endTest(error));
} else {
} else if (!useCallback) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
endTest();
}
// useCallback + sync return: wait for the user to invoke done().
// bun:test enforces the test timeout if done is never called.
};

return { name, options, fn: runTest };
Expand Down Expand Up @@ -756,7 +770,7 @@ function createHook(arg0: unknown, arg1: unknown) {
return { options, fn: runHook };
}

type TestFn = (ctx: TestContext) => unknown | Promise<unknown>;
type TestFn = (ctx: TestContext, done?: (error?: unknown) => void) => unknown | Promise<unknown>;
type HookFn = () => unknown | Promise<unknown>;

type TestOptions = {
Expand Down
39 changes: 39 additions & 0 deletions test/js/node/test_runner/fixtures/06-callback-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { test } = require("node:test");
const assert = require("node:assert");

let callCount = 0;

// Sync callback-style test: invokes done() to signal completion.
test("callback test resolves with done()", (t, done) => {
assert.equal(typeof done, "function");
callCount++;
done();
});

// Async-but-callback-style test: schedules done() in a setTimeout.
test("callback test resolves with done() asynchronously", (t, done) => {
setTimeout(() => {
callCount++;
done();
}, 5);
});

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make the async callback case observable instead of timer-based.

This fixture still passes if runTest incorrectly auto-completes the test before done() is called, because the timer fires later and callCount still reaches 4. Please make the async path assert completion ordering directly (for example with a teardown/sentinel that only flips inside the callback) so it actually exercises the new callback-waiting branch.

As per coding guidelines, "Do not use setTimeout in tests; instead, await the actual condition to be met rather than testing time passing."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/test_runner/fixtures/06-callback-tests.js` around lines 13 - 19,
The callback-style async fixture in the test is still timer-based, so it does
not reliably prove that runTest waits for done() before completing. Update the
test in 06-callback-tests.js to use an observable completion sentinel or
teardown assertion tied to the callback path in callback test resolves with
done() asynchronously, so the test fails if completion happens before done()
runs. Remove the setTimeout-based delay and make the assertion depend directly
on the callback ordering instead.

Source: Coding guidelines

Comment on lines +19 to +28

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace setTimeout with a timer-free macrotask deferral.

Line 22 uses setTimeout(..., 5) to force an async gap between promise resolution and done(). As per path instructions, test/**: "CRITICAL: Do not use setTimeout in tests... you are not testing the TIME PASSING, you are testing the CONDITION." setImmediate achieves the same macrotask boundary without an arbitrary timer.

♻️ Proposed fix
   return Promise.resolve().then(() => {
-    setTimeout(() => {
+    setImmediate(() => {
       callCount++;
       sawDone = true;
       done();
-    }, 5);
+    });
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/test_runner/fixtures/06-callback-tests.js` around lines 19 - 28,
Replace the setTimeout call in the callback test with setImmediate, preserving
the delayed callback body that increments callCount, sets sawDone, and invokes
done(). Keep the promise resolution and macrotask boundary behavior unchanged
without relying on elapsed time.

Source: Path instructions


// Callback-style test that signals failure via done(err) must still report
// failure, but we cannot easily inspect that from inside the same suite.
// The shape is exercised by the next two tests instead.

// Existing (t) => {...} sync-style signature must keep working.
test("sync test without done() still passes", t => {
assert.equal(typeof t.name, "string");
callCount++;
});

// Existing async-function signature must keep working.
test("async test without done() still passes", async t => {
await Promise.resolve();
callCount++;
});

process.on("exit", () => {
assert.equal(callCount, 4, `expected 4 test invocations, saw ${callCount}`);
});
8 changes: 8 additions & 0 deletions test/js/node/test_runner/node-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe("node:test", () => {
stderr: expect.stringContaining("0 fail"),
});
});

test("should pass a done() callback to tests whose function takes two parameters", async () => {
const { exitCode, stderr } = await runTests(["06-callback-tests.js"]);
expect({ exitCode, stderr }).toMatchObject({
exitCode: 0,
stderr: expect.stringContaining("0 fail"),
});
Comment on lines +56 to +61

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add end-to-end coverage for the new callback error/idempotency paths.

This only checks the all-pass case. The runtime change also introduced new callback-specific behavior for done(error) and duplicate completion suppression, but those branches are still untested here. A tiny companion fixture for done(new Error("boom")) and one mixed-completion case would lock down the new ended guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/test_runner/node-test.test.ts` around lines 56 - 61, The current
node test runner coverage only exercises the successful done() path, so add
end-to-end tests for the new callback error and idempotency behavior. Extend the
existing node-test suite around runTests and the 06-callback-tests.js fixture
with a case where a test calls done(new Error("boom")) and asserts the failure
output, plus a mixed-completion case that calls done more than once or combines
done with another completion path to verify the ended guard suppresses duplicate
completion handling. Use the existing node-test.test.ts runTests helper and the
callback fixture names to keep the new cases alongside the current done()
callback coverage.

});
});

async function runTests(filenames: string[]) {
Expand Down