Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
102 changes: 93 additions & 9 deletions src/js/node/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1559,6 +1559,10 @@ class TestNode {
firstSubtestError: unknown = undefined;
// First failure from a before hook created while this test was running.
hookFailure: unknown = undefined;
// While executeTestNode() runs this node: ends the wait it is in with an error thrown outside its promise.
failFromOutside: ((err: unknown) => void) | undefined = undefined;
// The one subtest executeTestNode() is running; these links lead from a top-level test to the innermost one.
activeSubtest: TestNode | undefined = undefined;
#ctx: TestContext | undefined;
#suiteCtx: SuiteContext | undefined;
#tags: readonly string[] | undefined;
Expand Down Expand Up @@ -1639,6 +1643,8 @@ const fileGeneration = $newRustFunction("jest.rs", "jsFileGeneration", 0);
// `done` binds the intended sequence so a late call after the bun:test watchdog
// moved on cannot write onto the currently-running test.
const markCurrentResult = $newRustFunction("jest.rs", "jsNodeTestMarkResult", 2);
// bun:test calls handler(error) once it has failed `done`'s entry for an uncaught error; `true` keeps the entry open until done().
const onUncaughtError = $newRustFunction("jest.rs", "jsNodeTestOnUncaught", 2);

let rootNode: TestNode | undefined;
let rootGeneration = -1;
Expand Down Expand Up @@ -2282,21 +2288,42 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
node.plan = new TestPlan(planOption);
}

// Node fails and aborts the test an uncaught error belongs to: give up the one wait in progress, then wind down in order.
let outsideError: unknown;
let interruptWait: ((err: unknown) => void) | undefined;
node.failFromOutside = err => {
outsideError ??= err;
interruptWait?.(err);
};
// Armed before `start` runs: a listener that throws inside dispatchEvent() is reported from its synchronous part.
const untilInterrupted = (start: () => unknown, stop?: Promise<never>) => {
const interrupted = Promise.withResolvers<never>();
interrupted.promise.catch(() => {});
interruptWait = interrupted.reject;
const awaited = start();
return Promise.race(stop === undefined ? [interrupted.promise, awaited] : [stop, interrupted.promise, awaited]);
};
node.activeSubtest = undefined;
const parentTest = enclosingTest(node);
if (parentTest !== undefined) parentTest.activeSubtest = node;

try {
for (const ancestor of ancestors) {
for (const hook of ancestor.hooks.beforeEach) {
await runHook(hook, ancestor, ctx);
await untilInterrupted(() => runHook(hook, ancestor, ctx));
}
}
} catch (err) {
failure = err;
}
failure ??= outsideError;

if (failure === undefined) {
// Node arms one stopPromise (timeout + signal) and races both the body
// AND the plan wait against it. Arm timeout once here so plan({wait:true})
// is bounded by the same test timeout, not left unbounded.
const stop = createStopController(node.options.timeout);
const untilStopped = (start: () => unknown) => untilInterrupted(start, stop?.promise);
try {
const runBody = async () => {
await runWithNode(node, () => invokeTestFn(fn, ctx));
Expand All @@ -2306,7 +2333,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
};

try {
await (stop === undefined ? runBody() : Promise.race([stop.promise, runBody()]));
await untilStopped(runBody);
} catch (err) {
// A body that throws or rejects with a nullish value must still fail.
failure = err ?? makeTestFailure("test failed");
Expand All @@ -2315,6 +2342,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
// A before hook created while the test was running failed (Node fails the
// test with the hook's error).
failure ??= node.hookFailure;
failure ??= outsideError;

const { plan } = node;
if (failure === undefined && plan !== null) {
Expand All @@ -2324,12 +2352,11 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
// Defuse: if stop wins the race, plan's own wait-timeout may still
// reject `pending` afterward with no one listening.
pending.catch(() => {});
await (stop === undefined ? pending : Promise.race([stop.promise, pending]));
await untilStopped(() => pending);
// A t.test() that fulfilled the plan from an async callback was
// scheduled onto subtestChain during the wait; drain again so its
// failure reaches failedSubtests below (Node fails the parent).
const drain = drainSubtestChain(node);
await (stop === undefined ? drain : Promise.race([stop.promise, drain]));
await untilStopped(() => drainSubtestChain(node));
}
} catch (err) {
failure = err;
Expand All @@ -2354,7 +2381,8 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
}

const bodyFailure = failure;
failure = applyExpectFailure(node, failure);
// bun:test has already failed the entry for an outside error, so expectFailure cannot accept it.
if (failure === undefined || failure !== outsideError) failure = applyExpectFailure(node, failure);
const acceptedXfail = bodyFailure !== undefined && failure === undefined;

// Node sets passed/error before running afterEach/after so hooks can
Expand All @@ -2370,7 +2398,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
const ancestor = ancestors[i];
for (const hook of ancestor.hooks.afterEach) {
try {
await runHook(hook, ancestor, ctx);
await untilInterrupted(() => runHook(hook, ancestor, ctx));
} catch (err) {
if (!acceptedXfail) failure ??= err;
}
Expand All @@ -2379,7 +2407,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {

for (const hook of node.hooks.after) {
try {
await runHook(hook, node, ctx);
await untilInterrupted(() => runHook(hook, node, ctx));
} catch (err) {
if (!acceptedXfail) failure ??= err;
}
Expand All @@ -2391,12 +2419,45 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise<unknown> {
if (!acceptedXfail) failure ??= err;
}

failure ??= outsideError;
node.failFromOutside = undefined;
Comment thread
robobun marked this conversation as resolved.
if (parentTest !== undefined && parentTest.activeSubtest === node) parentTest.activeSubtest = undefined;

node.passed = failure === undefined;
node.error = failure ?? (acceptedXfail ? bodyFailure : null);
reportNodeToRunParent(node, started);
return failure;
}

// The test a subtest runs under, past any inline suites in between.
function enclosingTest(node: TestNode): TestNode | undefined {
if (!node.isExecutionPhase) return undefined;
let parent = node.parent;
while (parent?.isSuite) parent = parent.parent;
return parent;
}

// Ends the wait of the innermost running test under `top`; false when `top` is not running.
function failInnermostTest(top: TestNode, err: unknown): boolean {
let fail = top.failFromOutside;
if (fail === undefined) return false;
for (let node = top.activeSubtest; node?.failFromOutside !== undefined; node = node.activeSubtest) {
fail = node.failFromOutside;
}
fail(err);
Comment thread
robobun marked this conversation as resolved.
return true;
}

// Whether `failure` is one of `errors` or executeTestNode()'s "subtests failed" roll-up of one.
function reportsOneOf(failure: unknown, errors: unknown[]): boolean {
const seen = new Set<unknown>();
while ((failure as { failureType?: string } | undefined)?.failureType === "subtestsFailed" && !seen.has(failure)) {
seen.add(failure);
failure = (failure as { cause?: unknown }).cause;
}
return errors.includes(failure);
}

function scheduleSubtest(parent: TestNode, child: TestNode, fn: TestFn, ownTodo: boolean): Promise<undefined> {
const run = async () => {
if (child.options.skip) {
Expand Down Expand Up @@ -2536,17 +2597,40 @@ function currentCollectionParent(): TestNode {
return getRootNode();
}

function createTopLevelTestRunner(node: TestNode, fn: TestFn, declaredTodo = false) {
function createTopLevelTestRunner(declared: TestNode, fn: TestFn, declaredTodo = false) {
const { todoFlag } = declared;
let ran = false;
// bun:test invokes this with a `done` callback because the function declares
// one parameter.
return (done: (error?: unknown) => void) => {
// A retry calls this again, and a node keeps its run's state (finished, failed subtests, added hooks).
let node = declared;
if (ran) {
node = new TestNode(declared.name, declared.parent, declared.options, false, false);
node.filePath = declared.filePath;
node.ownTags = declared.ownTags;
node.todoFlag = todoFlag;
}
ran = true;
// Under plain bun:test a describe.todo scope already handles its children's
// todo verdict (FailBecauseTodoPassed under --todo), so don't override when
// the flag was only inherited; under a run() child the suite registers as a
// plain describe so bun:test has no todo scope to consult.
const todoBefore = node.todoFlag;
const outsideErrors: unknown[] = [];
onUncaughtError(done, (err: unknown) => {
err ??= makeTestFailure("test failed");
if (!failInnermostTest(node, err)) return false;
outsideErrors.push(err);
return true;
});
executeTestNode(node, fn).then(
failure => {
// bun:test has failed this entry for these and printed them.
if (outsideErrors.length > 0 && (failure === undefined || reportsOneOf(failure, outsideErrors))) {
Comment thread
robobun marked this conversation as resolved.
done(undefined);
return;
}
// A runtime t.skip()/t.todo() overrides bun:test's pass/fail accounting
// (Node counts these as skip/todo even when the body threw); a declared
// todo body's failure must reach bun:test's own todo accounting instead.
Expand Down
59 changes: 58 additions & 1 deletion src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,8 @@ pub(crate) struct BunTest {
/// Only the Box header may be freed in `Drop` — fields alias `DescribeScope` originals.
pub(crate) cloned_hook_entries: Vec<*mut ExecutionEntry>,
pub(crate) wants_wakeup: bool,
/// See [`BunTest::offer_uncaught_to_node_test`].
pub(crate) node_test_uncaught: Option<NodeTestUncaught>,

pub(crate) phase: Phase,
pub(crate) collection: Collection,
Expand Down Expand Up @@ -677,6 +679,7 @@ impl BunTest {
// `next = EPOCH, state = PENDING`.
timer: EventLoopTimer::init_paused(EventLoopTimerTag::BunTest),
wants_wakeup: false,
node_test_uncaught: None,
}
}

Expand Down Expand Up @@ -1260,6 +1263,42 @@ impl BunTest {
Some(cfg_data)
}

/// `true`: `node:test` runs the failed entry's hooks and subtests (all inside this one callback) and then calls its `done`, so do not advance yet.
pub(crate) fn offer_uncaught_to_node_test(
this_strong: &BunTestPtr,
global_this: &JSGlobalObject,
current: &RefDataValue,
error: JSValue,
) -> bool {
// The call re-enters JS: copy the handler out so that no borrow of the `BunTest` is live across it.
let handler = match &this_strong.get().node_test_uncaught {
Some(claim) if claim.entry.is_same_entry(current) => claim.handler.get(),
_ => return false,
};
if global_this.has_exception() {
return false;
}
// JS gets the thrown value, not the `JSC::Exception` cell around it.
let thrown_value = error.to_error().unwrap_or(error);
let taken = match handler.call(global_this, JSValue::UNDEFINED, &[thrown_value]) {
Ok(taken) => taken.to_boolean(),
Err(e) => {
// As `on_unhandled_rejection` does for `run`: a termination is left where it is.
if !global_this.has_pending_termination_exception() {
let thrown = global_this.take_exception(e);
this_strong.get().on_uncaught_exception(global_this, Some(thrown), false, current);
}
false
}
};
bun_core::scoped_log!(bun_test_group, "offerUncaughtToNodeTest -> taken: {}", taken);
if taken {
// The handler queued promise reactions, possibly after this turn's last drain or under a live JS frame: run them next turn.
this_strong.get().wants_wakeup = true;
}
taken
}

/// called from the uncaught exception handler, or if a test callback rejects or throws an error
pub(crate) fn on_uncaught_exception(
&mut self,
Expand Down Expand Up @@ -1399,13 +1438,20 @@ bun_jsc::jsc_host_abi! {

// Clone/Copy: bitwise OK — `entry` is a non-owning erased borrow of an
// `ExecutionEntry` owned by `BunTest::execution`.
#[derive(Copy, Clone)]
#[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) struct EntryData {
pub(crate) sequence_index: usize,
pub(crate) entry: *const (),
pub(crate) remaining_repeat_count: i64,
}

/// Registered through `jest::js_node_test_on_uncaught` each time bun:test invokes a `node:test` test.
pub(crate) struct NodeTestUncaught {
/// The entry whose callback registered `handler`.
pub(crate) entry: RefDataValue,
pub(crate) handler: Strong,
}

// Clone/Copy: bitwise OK — `active_scope` is a non-owning borrow of a
// `DescribeScope` whose lifetime spans the async boundary (see field note);
// `EntryData.entry` likewise borrows.
Expand All @@ -1427,6 +1473,17 @@ pub(crate) enum RefDataValue {
}

impl RefDataValue {
/// Same execution entry in the same repeat (a retry of it compares equal).
pub(crate) fn is_same_entry(&self, other: &RefDataValue) -> bool {
match (self, other) {
(
RefDataValue::Execution { group_index: a_group, entry_data: Some(a) },
RefDataValue::Execution { group_index: b_group, entry_data: Some(b) },
) => a_group == b_group && a == b,
_ => false,
}
}

pub(crate) fn sequence<'a>(&self, buntest: &'a mut BunTest) -> Option<&'a mut Execution::ExecutionSequence> {
let RefDataValue::Execution { group_index, entry_data } = self else { return None };
let entry_data = (*entry_data)?;
Expand Down
Loading
Loading