From 9cb1fad90d515900e82339e87034d0d7ca9686d2 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 23 Jun 2026 04:06:56 +0000 Subject: [PATCH 01/18] node:test: subtests, plan, mock timers, getTestContext (+11 tests) Reimplements the node:test shim so that the in-process API matches Node: - t.test()/t.describe() and global test()/describe() called inside a running test now run as inline subtests (previously NotImplementedError); the parent waits for them and fails when a subtest fails. - beforeEach/afterEach are run by the module itself with Node's context arguments and inheritance order; before/after still map to bun:test beforeAll/afterAll but receive the owning suite/root context. - New: getTestContext(), t.plan() (including the wait option), t.waitFor(), runtime t.skip()/t.todo(), test tags (validation, case-insensitive dedup, inheritance, one-shot ExperimentalWarning), SuiteContext, fullName/filePath/passed/attempt, custom assertions via assert.register(), done-callback test functions, and top-level test() returning a promise. - t.mock is now a per-test MockTracker that resets after the test; mock.property() and mock.timers (a port of Node's MockTimers and PriorityQueue, in src/js/internal/test_runner/mock_timers.ts) are added. - Per-test timeouts are enforced by the shim with Node semantics; only timeouts above bun:test's 5s default are forwarded to bun:test. node:net: a custom lookup callback that yields a non-string address now emits ERR_INVALID_IP_ADDRESS like Node instead of connecting to the stringified value. Tests: - Vendors 11 more Node v26.3.0 test_runner tests (18 total now pass) and refreshes test-runner-aliases.js / test-runner-typechecking.js to verbatim upstream copies. - Rewrites the 05-test-in-test.js fixture to assert subtests work instead of asserting they throw NotImplementedError. - Removes test-set-http-max-http-headers.js: it only passed because callback-style node:test functions never waited for their callback; it spawns test-http-max-http-headers.js, which is not vendored, so it cannot pass meaningfully. - Updates the node:test entry in docs/runtime/nodejs-compat.mdx. --- docs/runtime/nodejs-compat.mdx | 2 +- src/js/internal/test_runner/mock_timers.ts | 753 +++++++ src/js/node/net.ts | 2 +- src/js/node/test.ts | 1816 +++++++++++++---- test/expectations.txt | 9 + test/js/node/test/common/index.js | 4 +- .../test-runner-aftereach-runtime-skip.js | 36 + .../node/test/parallel/test-runner-aliases.js | 8 +- .../parallel/test-runner-custom-assertions.js | 63 + .../parallel/test-runner-get-test-context.js | 120 ++ .../parallel/test-runner-mock-timers-date.js | 130 ++ .../test-runner-mock-timers-scheduler.js | 122 ++ .../test/parallel/test-runner-mock-timers.js | 906 ++++++++ .../parallel/test-runner-option-validation.js | 26 + .../parallel/test-runner-tags-inheritance.mjs | 118 ++ .../parallel/test-runner-test-filepath.js | 52 + .../parallel/test-runner-test-fullname.js | 48 + .../test/parallel/test-runner-typechecking.js | 28 +- .../test/parallel/test-runner-wait-for.js | 124 ++ .../test-set-http-max-http-headers.js | 81 - .../test_runner/fixtures/05-test-in-test.js | 67 +- .../test_runner/fixtures/06-hook-semantics.js | 66 + .../test_runner/fixtures/07-failing-hooks.js | 67 + .../test_runner/fixtures/08-only-no-op.js | 21 + .../test_runner/fixtures/09-inline-suites.js | 38 + .../test_runner/fixtures/10-done-callbacks.js | 31 + .../fixtures/11-timeout-overrides.js | 11 + .../12-runtime-todo-and-mock-timers.js | 46 + .../test_runner/fixtures/13-todo-bodies.js | 13 + .../test_runner/fixtures/14-root-hooks-a.js | 22 + .../test_runner/fixtures/14-root-hooks-b.js | 24 + test/js/node/test_runner/node-test.test.ts | 93 +- .../web/workers/structured-clone-2gb.test.ts | 86 + test/js/web/workers/structured-clone.test.ts | 81 - 34 files changed, 4527 insertions(+), 587 deletions(-) create mode 100644 src/js/internal/test_runner/mock_timers.ts create mode 100644 test/js/node/test/parallel/test-runner-aftereach-runtime-skip.js create mode 100644 test/js/node/test/parallel/test-runner-custom-assertions.js create mode 100644 test/js/node/test/parallel/test-runner-get-test-context.js create mode 100644 test/js/node/test/parallel/test-runner-mock-timers-date.js create mode 100644 test/js/node/test/parallel/test-runner-mock-timers-scheduler.js create mode 100644 test/js/node/test/parallel/test-runner-mock-timers.js create mode 100644 test/js/node/test/parallel/test-runner-option-validation.js create mode 100644 test/js/node/test/parallel/test-runner-tags-inheritance.mjs create mode 100644 test/js/node/test/parallel/test-runner-test-filepath.js create mode 100644 test/js/node/test/parallel/test-runner-test-fullname.js create mode 100644 test/js/node/test/parallel/test-runner-wait-for.js delete mode 100644 test/js/node/test/parallel/test-set-http-max-http-headers.js create mode 100644 test/js/node/test_runner/fixtures/06-hook-semantics.js create mode 100644 test/js/node/test_runner/fixtures/07-failing-hooks.js create mode 100644 test/js/node/test_runner/fixtures/08-only-no-op.js create mode 100644 test/js/node/test_runner/fixtures/09-inline-suites.js create mode 100644 test/js/node/test_runner/fixtures/10-done-callbacks.js create mode 100644 test/js/node/test_runner/fixtures/11-timeout-overrides.js create mode 100644 test/js/node/test_runner/fixtures/12-runtime-todo-and-mock-timers.js create mode 100644 test/js/node/test_runner/fixtures/13-todo-bodies.js create mode 100644 test/js/node/test_runner/fixtures/14-root-hooks-a.js create mode 100644 test/js/node/test_runner/fixtures/14-root-hooks-b.js create mode 100644 test/js/web/workers/structured-clone-2gb.test.ts diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index d08cfefc4b90..744d6afb65ff 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -177,7 +177,7 @@ This page is updated regularly and reflects the latest version of Bun's compatib ### [`node:test`](https://nodejs.org/api/test.html) -🟡 Partially implemented. Missing mocks, snapshots, timers. Use [`bun:test`](/test) instead. +🟡 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). Missing `run()`, `node:test/reporters`, snapshot testing, `mock.module()`, code coverage, and Node's `--test` CLI runner mode. Use [`bun:test`](/test) instead. ### [`node:trace_events`](https://nodejs.org/api/tracing.html) diff --git a/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts new file mode 100644 index 000000000000..6aa4b929bbad --- /dev/null +++ b/src/js/internal/test_runner/mock_timers.ts @@ -0,0 +1,753 @@ +// Port of Node.js lib/internal/test_runner/mock/mock_timers.js (v26.3.0) +// and its dependency lib/internal/priority_queue.js. +// API reference: https://nodejs.org/api/test.html#class-mocktimers + +const { + validateAbortSignal, + validateNumber, + validateString, + validateArray, + validateUint32, +} = require("internal/validators"); +const { addAbortListener } = require("internal/abort_listener"); + +const nodeTimers = require("node:timers"); +const nodeTimersPromises = require("node:timers/promises"); +const EventEmitter = require("node:events"); + +const DatePrototypeGetTime = Date.prototype.getTime; +const FunctionPrototypeToString = Function.prototype.toString; + +// require('internal/timers').TIMEOUT_MAX in Node +const TIMEOUT_MAX = 2 ** 31 - 1; + +// The PriorityQueue is a basic implementation of a binary heap that accepts +// a custom sorting function via its constructor. This function is passed +// the two nodes to compare, similar to the native Array#sort. Crucially +// this enables priority queues that are based on a comparison of more than +// just a single criteria. +class PriorityQueue { + #compare = (a, b) => a - b; + #heap: any[] = [undefined, undefined]; + #setPosition; + #size = 0; + + constructor(comparator, setPosition) { + if (comparator !== undefined) this.#compare = comparator; + if (setPosition !== undefined) this.#setPosition = setPosition; + } + + insert(value) { + const heap = this.#heap; + const pos = ++this.#size; + heap[pos] = value; + + this.percolateUp(pos); + } + + peek() { + return this.#heap[1]; + } + + peekBottom() { + return this.#heap[this.#size]; + } + + percolateDown(pos) { + const compare = this.#compare; + const setPosition = this.#setPosition; + const hasSetPosition = setPosition !== undefined; + const heap = this.#heap; + const size = this.#size; + const hsize = size >> 1; + const item = heap[pos]; + + while (pos <= hsize) { + let child = pos << 1; + const nextChild = child + 1; + let childItem = heap[child]; + + if (nextChild <= size && compare(heap[nextChild], childItem) < 0) { + child = nextChild; + childItem = heap[nextChild]; + } + + if (compare(item, childItem) <= 0) break; + + if (hasSetPosition) setPosition(childItem, pos); + + heap[pos] = childItem; + pos = child; + } + + heap[pos] = item; + if (hasSetPosition) setPosition(item, pos); + } + + percolateUp(pos) { + const heap = this.#heap; + const compare = this.#compare; + const setPosition = this.#setPosition; + const hasSetPosition = setPosition !== undefined; + const item = heap[pos]; + + while (pos > 1) { + const parent = pos >> 1; + const parentItem = heap[parent]; + if (compare(parentItem, item) <= 0) break; + heap[pos] = parentItem; + if (hasSetPosition) setPosition(parentItem, pos); + pos = parent; + } + + heap[pos] = item; + if (hasSetPosition) setPosition(item, pos); + } + + removeAt(pos) { + const heap = this.#heap; + let size = this.#size; + heap[pos] = heap[size]; + heap[size] = undefined; + size = --this.#size; + + if (size > 0 && pos <= size) { + if (pos > 1 && this.#compare(heap[pos >> 1], heap[pos]) > 0) this.percolateUp(pos); + else this.percolateDown(pos); + } + } + + shift() { + const heap = this.#heap; + const value = heap[1]; + if (value === undefined) return; + + this.removeAt(1); + + return value; + } +} + +function validateStringArray(value, name) { + validateArray(value, name); + for (let i = 0; i < value.length; i++) { + validateString(value[i], `${name}[${i}]`); + } +} + +// Internal reference to the MockTimers class inside MockDate +let kMock; +// Initial epoch to which #now should be set to +const kInitialEpoch = 0; + +function compareTimersLists(a, b) { + return a.runAt - b.runAt || a.id - b.id; +} + +function setPosition(node, pos) { + node.priorityQueuePosition = pos; +} + +function abortIt(signal) { + return $makeAbortError(undefined, { cause: signal.reason }); +} + +const SUPPORTED_APIS = ["setTimeout", "setInterval", "setImmediate", "Date", "scheduler.wait", "AbortSignal.timeout"]; +const TIMERS_DEFAULT_INTERVAL = { + __proto__: null, + setImmediate: -1, +}; + +class Timeout { + #clear; + id; + callback; + runAt; + interval; + args; + priorityQueuePosition; + + constructor(opts) { + this.id = opts.id; + this.callback = opts.callback; + this.runAt = opts.runAt; + this.interval = opts.interval; + this.args = opts.args; + this.#clear = opts.clear; + } + + hasRef() { + return true; + } + + ref() { + return this; + } + + unref() { + return this; + } + + refresh() { + return this; + } + + close() { + this.#clear(this); + return this; + } + + [Symbol.dispose]() { + this.#clear(this); + } +} + +class MockTimers { + #realSetTimeout; + #realClearTimeout; + #realSetInterval; + #realClearInterval; + #realSetImmediate; + #realClearImmediate; + + #realPromisifiedSetTimeout; + #realPromisifiedSetInterval; + #realTimersPromisifiedSchedulerWait; + + #realTimersSetTimeout; + #realTimersClearTimeout; + #realTimersSetInterval; + #realTimersClearInterval; + #realTimersSetImmediate; + #realTimersClearImmediate; + #realPromisifiedSetImmediate; + + #nativeDateDescriptor; + #realAbortSignalTimeout; + + #timersInContext: string[] = []; + #isEnabled = false; + #currentTimer = 1; + #now = kInitialEpoch; + + #executionQueue = new PriorityQueue(compareTimersLists, setPosition); + + #setTimeout = this.#createTimer.bind(this, false); + #clearTimeout = this.#clearTimer.bind(this); + #setInterval = this.#createTimer.bind(this, true); + #clearInterval = this.#clearTimer.bind(this); + #clearImmediate = this.#clearTimer.bind(this); + + #restoreSetImmediate() { + Object.defineProperty(globalThis, "setImmediate", this.#realSetImmediate); + Object.defineProperty(globalThis, "clearImmediate", this.#realClearImmediate); + Object.defineProperty(nodeTimers, "setImmediate", this.#realTimersSetImmediate); + Object.defineProperty(nodeTimers, "clearImmediate", this.#realTimersClearImmediate); + Object.defineProperty(nodeTimersPromises, "setImmediate", this.#realPromisifiedSetImmediate); + } + + #restoreOriginalSetInterval() { + Object.defineProperty(globalThis, "setInterval", this.#realSetInterval); + Object.defineProperty(globalThis, "clearInterval", this.#realClearInterval); + Object.defineProperty(nodeTimers, "setInterval", this.#realTimersSetInterval); + Object.defineProperty(nodeTimers, "clearInterval", this.#realTimersClearInterval); + Object.defineProperty(nodeTimersPromises, "setInterval", this.#realPromisifiedSetInterval); + } + + #restoreOriginalSchedulerWait() { + nodeTimersPromises.scheduler.wait = this.#realTimersPromisifiedSchedulerWait.bind(this); + } + + #restoreOriginalSetTimeout() { + Object.defineProperty(globalThis, "setTimeout", this.#realSetTimeout); + Object.defineProperty(globalThis, "clearTimeout", this.#realClearTimeout); + Object.defineProperty(nodeTimers, "setTimeout", this.#realTimersSetTimeout); + Object.defineProperty(nodeTimers, "clearTimeout", this.#realTimersClearTimeout); + Object.defineProperty(nodeTimersPromises, "setTimeout", this.#realPromisifiedSetTimeout); + } + + #storeOriginalSetImmediate() { + this.#realSetImmediate = Object.getOwnPropertyDescriptor(globalThis, "setImmediate"); + this.#realClearImmediate = Object.getOwnPropertyDescriptor(globalThis, "clearImmediate"); + this.#realTimersSetImmediate = Object.getOwnPropertyDescriptor(nodeTimers, "setImmediate"); + this.#realTimersClearImmediate = Object.getOwnPropertyDescriptor(nodeTimers, "clearImmediate"); + this.#realPromisifiedSetImmediate = Object.getOwnPropertyDescriptor(nodeTimersPromises, "setImmediate"); + } + + #storeOriginalSetInterval() { + this.#realSetInterval = Object.getOwnPropertyDescriptor(globalThis, "setInterval"); + this.#realClearInterval = Object.getOwnPropertyDescriptor(globalThis, "clearInterval"); + this.#realTimersSetInterval = Object.getOwnPropertyDescriptor(nodeTimers, "setInterval"); + this.#realTimersClearInterval = Object.getOwnPropertyDescriptor(nodeTimers, "clearInterval"); + this.#realPromisifiedSetInterval = Object.getOwnPropertyDescriptor(nodeTimersPromises, "setInterval"); + } + + #storeOriginalSchedulerWait() { + this.#realTimersPromisifiedSchedulerWait = nodeTimersPromises.scheduler.wait.bind(this); + } + + #storeOriginalSetTimeout() { + this.#realSetTimeout = Object.getOwnPropertyDescriptor(globalThis, "setTimeout"); + this.#realClearTimeout = Object.getOwnPropertyDescriptor(globalThis, "clearTimeout"); + this.#realTimersSetTimeout = Object.getOwnPropertyDescriptor(nodeTimers, "setTimeout"); + this.#realTimersClearTimeout = Object.getOwnPropertyDescriptor(nodeTimers, "clearTimeout"); + this.#realPromisifiedSetTimeout = Object.getOwnPropertyDescriptor(nodeTimersPromises, "setTimeout"); + } + + #storeOriginalAbortSignalTimeout() { + this.#realAbortSignalTimeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + } + + #restoreOriginalAbortSignalTimeout() { + Object.defineProperty(AbortSignal, "timeout", this.#realAbortSignalTimeout); + } + + #createTimer(isInterval, callback, delay, ...args) { + if (delay > TIMEOUT_MAX) { + delay = 1; + } + + const timerId = this.#currentTimer++; + const opts = { + __proto__: null, + id: timerId, + callback, + runAt: this.#now + delay, + interval: isInterval ? delay : undefined, + args, + clear: this.#clearTimeout, + }; + + const timer = new Timeout(opts); + this.#executionQueue.insert(timer); + return timer; + } + + #clearTimer(timer) { + if (timer?.priorityQueuePosition !== undefined) { + this.#executionQueue.removeAt(timer.priorityQueuePosition); + timer.priorityQueuePosition = undefined; + timer.interval = undefined; + } + } + + #createDate() { + kMock ??= Symbol("MockTimers"); + const NativeDateConstructor = this.#nativeDateDescriptor.value; + if (NativeDateConstructor.isMock) { + throw $ERR_INVALID_STATE("Date is already being mocked!"); + } + /** + * Function to mock the Date constructor, treats cases as per ECMA-262 + * and returns a Date object with a mocked implementation + */ + function MockDate(year, month, date, hours, minutes, seconds, ms) { + const mockTimersSource = MockDate[kMock]; + const nativeDate = mockTimersSource.#nativeDateDescriptor.value; + + // As of the fake-timers implementation for Sinon + // ref https://github.com/sinonjs/fake-timers/blob/a4c757f80840829e45e0852ea1b17d87a998388e/src/fake-timers-src.js#L456 + // This covers the Date constructor called as a function ref. + // ECMA-262 Edition 5.1 section 15.9.2. + // and ECMA-262 Edition 14 Section 21.4.2.1 + // replaces 'this instanceof MockDate' with a more reliable check + // from ECMA-262 Edition 14 Section 13.3.12.1 NewTarget + if (!new.target) { + return new nativeDate(mockTimersSource.#now).toString(); + } + + // Cases where Date is called as a constructor + // This is intended as a defensive implementation to avoid + // having unexpected returns + switch (arguments.length) { + case 0: + return new nativeDate(MockDate[kMock].#now); + case 1: + return new nativeDate(year); + case 2: + return new nativeDate(year, month); + case 3: + return new nativeDate(year, month, date); + case 4: + return new nativeDate(year, month, date, hours); + case 5: + return new nativeDate(year, month, date, hours, minutes); + case 6: + return new nativeDate(year, month, date, hours, minutes, seconds); + default: + return new nativeDate(year, month, date, hours, minutes, seconds, ms); + } + } + + // Prototype is read-only, and non assignable through Object.defineProperties + // eslint-disable-next-line no-unused-vars -- used to get the prototype out of the object + const { prototype, ...dateProps } = Object.getOwnPropertyDescriptors(NativeDateConstructor); + + // Binds all the properties of Date to the MockDate function + Object.defineProperties(MockDate, dateProps); + + MockDate.now = function now() { + return MockDate[kMock].#now; + }; + + // This is just to print the function { native code } in the console + // when the user prints the function and not the internal code + MockDate.toString = function toString() { + return FunctionPrototypeToString.$call(MockDate[kMock].#nativeDateDescriptor.value); + }; + + Object.defineProperties(MockDate, { + // @ts-ignore + __proto__: null, + [kMock]: { + __proto__: null, + enumerable: false, + configurable: false, + writable: false, + value: this, + }, + + isMock: { + __proto__: null, + enumerable: true, + configurable: false, + writable: false, + value: true, + }, + }); + + MockDate.prototype = NativeDateConstructor.prototype; + MockDate.parse = NativeDateConstructor.parse; + MockDate.UTC = NativeDateConstructor.UTC; + MockDate.prototype.toUTCString = NativeDateConstructor.prototype.toUTCString; + return MockDate; + } + + async *#setIntervalPromisified(interval, result, options) { + const emitter = new EventEmitter(); + + let abortListener; + if (options?.signal) { + validateAbortSignal(options.signal, "options.signal"); + + if (options.signal.aborted) { + throw abortIt(options.signal); + } + + abortListener = addAbortListener(options.signal, () => { + emitter.emit("error", abortIt(options.signal)); + }); + } + + const eventIt = EventEmitter.on(emitter, "data"); + const timer = this.#createTimer(true, () => emitter.emit("data"), interval, options); + + try { + // eslint-disable-next-line no-unused-vars + for await (const event of eventIt) { + yield result; + } + } finally { + abortListener?.[Symbol.dispose](); + this.#clearInterval(timer); + } + } + + #setImmediate(callback, ...args) { + return this.#createTimer(false, callback, TIMERS_DEFAULT_INTERVAL.setImmediate, ...args); + } + + async #promisifyTimer({ timerFn, clearFn, ms, result, options }) { + const { promise, resolve, reject } = Promise.withResolvers(); + + let abortListener; + if (options?.signal) { + validateAbortSignal(options.signal, "options.signal"); + + if (options.signal.aborted) { + throw abortIt(options.signal); + } + + abortListener = addAbortListener(options.signal, () => { + reject(abortIt(options.signal)); + }); + } + + const timer = timerFn(resolve, ms); + + try { + await promise; + return result; + } finally { + abortListener?.[Symbol.dispose](); + clearFn(timer); + } + } + + #setImmediatePromisified(result, options) { + return this.#promisifyTimer({ + __proto__: null, + timerFn: this.#setImmediate.bind(this), + clearFn: this.#clearImmediate.bind(this), + ms: TIMERS_DEFAULT_INTERVAL.setImmediate, + result, + options, + }); + } + + #setTimeoutPromisified(ms, result, options) { + return this.#promisifyTimer({ + __proto__: null, + timerFn: this.#setTimeout.bind(this), + clearFn: this.#clearTimeout.bind(this), + ms, + result, + options, + }); + } + + #assertTimersAreEnabled() { + if (!this.#isEnabled) { + throw $ERR_INVALID_STATE("You should enable MockTimers first by calling the .enable function"); + } + } + + #assertTimeArg(time) { + if (time < 0) { + // Node passes the reason and value swapped here; bun's ERR_INVALID_ARG_VALUE + // requires a string reason, so use the conventional argument order. + throw $ERR_INVALID_ARG_VALUE("time", time, "must be a positive integer"); + } + } + + #isValidDateWithGetTime(maybeDate) { + // Validation inspired on https://github.com/inspect-js/is-date-object/blob/main/index.js#L3-L11 + try { + DatePrototypeGetTime.$call(maybeDate); + return true; + } catch { + return false; + } + } + + #toggleEnableTimers(activate) { + const options = { + __proto__: null, + toFake: { + "__proto__": null, + "scheduler.wait": () => { + this.#storeOriginalSchedulerWait(); + + nodeTimersPromises.scheduler.wait = (delay, options) => + this.#setTimeoutPromisified(delay, undefined, options); + }, + "setTimeout": () => { + this.#storeOriginalSetTimeout(); + + globalThis.setTimeout = this.#setTimeout; + globalThis.clearTimeout = this.#clearTimeout; + + nodeTimers.setTimeout = this.#setTimeout; + nodeTimers.clearTimeout = this.#clearTimeout; + + nodeTimersPromises.setTimeout = this.#setTimeoutPromisified.bind(this); + }, + "setInterval": () => { + this.#storeOriginalSetInterval(); + + globalThis.setInterval = this.#setInterval; + globalThis.clearInterval = this.#clearInterval; + + nodeTimers.setInterval = this.#setInterval; + nodeTimers.clearInterval = this.#clearInterval; + + nodeTimersPromises.setInterval = this.#setIntervalPromisified.bind(this); + }, + "setImmediate": () => { + this.#storeOriginalSetImmediate(); + + // setImmediate functions needs to bind MockTimers + // otherwise it will throw an error when called + // "Receiver must be an instance of MockTimers" + // because #setImmediate is the only function here + // that calls #createTimer and it's not bound to MockTimers + globalThis.setImmediate = this.#setImmediate.bind(this); + globalThis.clearImmediate = this.#clearImmediate; + + nodeTimers.setImmediate = this.#setImmediate.bind(this); + nodeTimers.clearImmediate = this.#clearImmediate; + nodeTimersPromises.setImmediate = this.#setImmediatePromisified.bind(this); + }, + "Date": () => { + this.#nativeDateDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Date"); + globalThis.Date = this.#createDate(); + }, + "AbortSignal.timeout": () => { + this.#storeOriginalAbortSignalTimeout(); + const mock = this; + Object.defineProperty(AbortSignal, "timeout", { + // @ts-ignore + __proto__: null, + configurable: true, + writable: true, + value: function value(delay) { + validateUint32(delay, "delay", false); + const controller = new AbortController(); + // Don't keep an unused binding to the timer; mock tick controls it + mock.#setTimeout(() => { + controller.abort(); + }, delay); + return controller.signal; + }, + }); + }, + }, + toReal: { + "__proto__": null, + "scheduler.wait": () => { + this.#restoreOriginalSchedulerWait(); + }, + "setTimeout": () => { + this.#restoreOriginalSetTimeout(); + }, + "setInterval": () => { + this.#restoreOriginalSetInterval(); + }, + "setImmediate": () => { + this.#restoreSetImmediate(); + }, + "Date": () => { + Object.defineProperty(globalThis, "Date", this.#nativeDateDescriptor); + }, + "AbortSignal.timeout": () => { + this.#restoreOriginalAbortSignalTimeout(); + }, + }, + }; + + const target = activate ? options.toFake : options.toReal; + for (const timer of this.#timersInContext) { + target[timer](); + } + this.#isEnabled = activate; + } + + /** + * Advances the virtual time of MockTimers by the specified duration (in milliseconds). + * This method simulates the passage of time and triggers any scheduled timers that are due. + */ + tick(time = 1) { + this.#assertTimersAreEnabled(); + this.#assertTimeArg(time); + + this.#now += time; + let timer = this.#executionQueue.peek(); + while (timer) { + if (timer.runAt > this.#now) break; + timer.callback.$apply(undefined, timer.args); + + // Check if the timeout was cleared by calling clearTimeout inside its own callback + const afterCallback = this.#executionQueue.peek(); + if (afterCallback?.id === timer.id) { + this.#executionQueue.shift(); + timer.priorityQueuePosition = undefined; + } + + const { interval } = timer; + if (interval !== undefined) { + timer.runAt += interval; + this.#executionQueue.insert(timer); + } + + timer = this.#executionQueue.peek(); + } + } + + /** + * Enables the MockTimers replacing the native timers with the fake ones. + */ + enable(options = { __proto__: null, apis: SUPPORTED_APIS, now: 0 }) { + const internalOptions = { __proto__: null, ...options } as { apis?: string[]; now?: number | Date }; + if (this.#isEnabled) { + throw $ERR_INVALID_STATE("MockTimers is already enabled!"); + } + + const { now } = internalOptions; + if (Number.isNaN(now)) { + throw $ERR_INVALID_ARG_VALUE("now", now, `epoch must be a positive integer received ${now}`); + } + + internalOptions.now ||= 0; + + internalOptions.apis ||= SUPPORTED_APIS; + + // Check that the timers passed are supported + validateStringArray(internalOptions.apis, "options.apis"); + for (const timer of internalOptions.apis) { + if (!SUPPORTED_APIS.includes(timer)) { + throw $ERR_INVALID_ARG_VALUE("options.apis", timer, `option ${timer} is not supported`); + } + } + this.#timersInContext = internalOptions.apis; + + // Checks if the second argument is the initial time + const initialTime = internalOptions.now; + if (this.#isValidDateWithGetTime(initialTime)) { + this.#now = DatePrototypeGetTime.$call(initialTime); + } else if (validateNumber(initialTime, "initialTime") === undefined) { + this.#assertTimeArg(initialTime); + this.#now = initialTime as number; + } + + this.#toggleEnableTimers(true); + } + + /** + * Sets the current time to the given epoch. + */ + setTime(time = kInitialEpoch) { + validateNumber(time, "time"); + this.#assertTimeArg(time); + this.#assertTimersAreEnabled(); + + this.#now = time; + } + + /** + * An alias for `this.reset()`, allowing the disposal of the `MockTimers` instance. + */ + [Symbol.dispose]() { + this.reset(); + } + + /** + * Resets MockTimers, disabling any enabled timers and clearing the execution queue. + * Does nothing if MockTimers are not enabled. + */ + reset() { + // Ignore if not enabled + if (!this.#isEnabled) return; + + this.#toggleEnableTimers(false); + this.#timersInContext = []; + this.#now = kInitialEpoch; + + let timer = this.#executionQueue.peek(); + while (timer) { + this.#executionQueue.shift(); + timer = this.#executionQueue.peek(); + } + } + + /** + * Runs all scheduled timers until there are no more pending timers. + */ + runAll() { + this.#assertTimersAreEnabled(); + const longestTimer = this.#executionQueue.peekBottom(); + if (!longestTimer) return; + this.tick(longestTimer.runAt - this.#now); + } +} + +export default { MockTimers }; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 8d2305b5ef50..040a0f61ad1d 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -2541,7 +2541,7 @@ function lookupAndConnect(self, options) { if (!self.connecting) return; if (err) { process.nextTick(destroyNT, self, err); - } else if (!isIP(ip)) { + } else if (typeof ip !== "string" || !isIP(ip)) { err = $ERR_INVALID_IP_ADDRESS(ip); process.nextTick(destroyNT, self, err); } else if (addressType !== 4 && addressType !== 6) { diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 6a75a9768313..b1472ae89f9b 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1,21 +1,50 @@ // Hardcoded module "node:test" // This follows the Node.js API as described in: https://nodejs.org/api/test.html +// +// Top-level tests and suites are scheduled through bun:test (Bun.jest), while +// subtests created inside a running test are executed inline by this module so +// that Node's TestContext semantics (subtests, hooks, plan, mock tracker, +// getTestContext) are observable without a separate runner process. const { jest } = Bun; const { kEmptyObject, throwNotImplemented } = require("internal/shared"); -const { validateBoolean, validateInteger, validateObject } = require("internal/validators"); +const { + validateBoolean, + validateInteger, + validateObject, + validateNumber, + validateFunction, + validateString, + validateArray, + validateAbortSignal, + validateUint32, +} = require("internal/validators"); const kDefaultName = ""; +const kRootName = ""; const kDefaultFunction = () => {}; +// The runner's own timers must keep working while `mock.timers` replaces the +// globals, so capture them at module load like Node's runner does. +const realSetTimeout = setTimeout; +const realClearTimeout = clearTimeout; const kDefaultOptions = kEmptyObject; +// Matches Node's internal/timers TIMEOUT_MAX. +const kTimeoutMax = 2 ** 31 - 1; +// Matches bun:test's default per-test timeout. +const kBunTestDefaultTimeoutMs = 5_000; +const kJoinSeparator = " > "; function run() { throwNotImplemented("run()", 5090, "Use `bun:test` in the interim."); } +// ----------------------------------------------------------------------------- +// MockTracker +// // Port of Node.js lib/internal/test_runner/mock/mock.js (v26.3.0): // https://github.com/nodejs/node/blob/50c35fea9e64d50ab3bb5f359e8523de89d6c798/lib/internal/test_runner/mock/mock.js // API reference: https://nodejs.org/api/test.html#class-mocktracker +// ----------------------------------------------------------------------------- let trackMockCall: (ctx: MockFunctionContext, thisArg: unknown, args: unknown[], target: unknown) => unknown; class MockFunctionContext { @@ -63,7 +92,7 @@ class MockFunctionContext { const nextCall = this.#calls.length; const call = onCall ?? nextCall; validateInteger(call, "onCall", nextCall); - this.#onceImplementations.set(call, implementation); + this.#onceImplementations.$set(call, implementation); } resetCalls() { @@ -91,9 +120,9 @@ class MockFunctionContext { target: unknown, ) { const callIndex = ctx.#calls.length; - let implementation = ctx.#onceImplementations.get(callIndex); + let implementation = ctx.#onceImplementations.$get(callIndex); if (implementation !== undefined) { - ctx.#onceImplementations.delete(callIndex); + ctx.#onceImplementations.$delete(callIndex); } else { implementation = ctx.#implementation ?? ctx.#original; } @@ -131,177 +160,289 @@ class MockFunctionContext { } } -function createMockFunction( - original: Function, - implementation: Function | undefined, - restore?: () => void, - times: number = Infinity, -) { - const context = new MockFunctionContext(original, implementation, restore, times); - kMockContexts.push(context); - function mockFunction(this: unknown, ...args: unknown[]) { - return trackMockCall(context, this, args, new.target); - } - Object.defineProperty(mockFunction, "mock", { - value: context, - writable: false, - enumerable: false, - }); - Object.defineProperty(mockFunction, "length", { - value: original.length, - configurable: true, - }); - Object.defineProperty(mockFunction, "name", { - value: original.name, - configurable: true, - }); - return mockFunction; -} - -const kMockContexts: MockFunctionContext[] = []; - -function validateTimes(value: unknown, name: string) { - if (value === Infinity) { - return; +class MockPropertyContext { + #object: object; + #propertyName: PropertyKey; + #value: unknown; + #originalValue: unknown; + #descriptor: PropertyDescriptor; + #accesses: unknown[]; + #onceValues: Map; + + constructor(object: object, propertyName: PropertyKey, value?: unknown) { + this.#onceValues = new Map(); + this.#accesses = []; + this.#object = object; + this.#propertyName = propertyName; + this.#originalValue = object[propertyName]; + this.#value = arguments.length > 2 ? value : this.#originalValue; + const descriptor = Object.getOwnPropertyDescriptor(object, propertyName); + if (!descriptor) { + throw $ERR_INVALID_ARG_VALUE("propertyName", propertyName, "is not a property of the object"); + } + this.#descriptor = descriptor; + + const { configurable, enumerable } = descriptor; + Object.defineProperty(object, propertyName, { + configurable, + enumerable, + get: () => { + const nextValue = this.#getAccessValue(this.#value); + this.#accesses.push({ + type: "get", + value: nextValue, + stack: new Error(), + }); + return nextValue; + }, + set: this.mockImplementation.bind(this), + }); } - validateInteger(value, name, 1); -} -function mockFn(original?: Function | object, implementation?: Function | object, options?: object) { - if (original !== null && original !== undefined && !$isCallable(original) && typeof original === "object") { - options = implementation as object; - implementation = original; - original = undefined; + get accesses() { + return this.#accesses.slice(0); } - if ( - implementation !== null && - implementation !== undefined && - !$isCallable(implementation) && - typeof implementation === "object" - ) { - options = implementation as object; - implementation = undefined; - } - if (original !== undefined && !$isCallable(original)) { - throw $ERR_INVALID_ARG_TYPE("original", "function", original); + + accessCount(): number { + return this.#accesses.length; } - if (implementation !== undefined && !$isCallable(implementation)) { - throw $ERR_INVALID_ARG_TYPE("implementation", "function", implementation); + + mockImplementation(value: unknown) { + if (!this.#descriptor.writable) { + throw $ERR_INVALID_ARG_VALUE("propertyName", this.#propertyName, "cannot be set"); + } + const nextValue = this.#getAccessValue(value); + this.#accesses.push({ + type: "set", + value: nextValue, + stack: new Error(), + }); + this.#value = nextValue; } - if (options !== undefined) { - validateObject(options, "options"); + + #getAccessValue(value: unknown) { + const accessIndex = this.#accesses.length; + if (this.#onceValues.$has(accessIndex)) { + const accessValue = this.#onceValues.$get(accessIndex); + this.#onceValues.$delete(accessIndex); + return accessValue; + } + return value; } - const { times = Infinity } = (options ?? kEmptyObject) as { times?: number }; - validateTimes(times, "options.times"); - return createMockFunction( - (original as Function) ?? function () {}, - implementation as Function | undefined, - undefined, - times, - ); -} - -function mockMethod( - objectOrFunction: object | Function, - methodName: PropertyKey, - implementation?: Function | object, - options?: { getter?: boolean; setter?: boolean } | object, -) { - if ( - implementation !== null && - implementation !== undefined && - !$isCallable(implementation) && - typeof implementation === "object" - ) { - options = implementation; - implementation = undefined; + + mockImplementationOnce(value: unknown, onAccess?: number) { + const nextAccess = this.#accesses.length; + const accessIndex = onAccess ?? nextAccess; + validateInteger(accessIndex, "onAccess", nextAccess); + this.#onceValues.$set(accessIndex, value); } - if (implementation !== undefined && !$isCallable(implementation)) { - throw $ERR_INVALID_ARG_TYPE("implementation", "function", implementation); + + resetAccesses() { + this.#accesses = []; } - if ((typeof objectOrFunction !== "object" || objectOrFunction === null) && !$isCallable(objectOrFunction)) { - throw $ERR_INVALID_ARG_TYPE("object", "object", objectOrFunction); + + restore() { + Object.defineProperty(this.#object, this.#propertyName, { + ...this.#descriptor, + value: this.#originalValue, + }); } - if (typeof methodName !== "string" && typeof methodName !== "symbol") { - throw $ERR_INVALID_ARG_TYPE("methodName", ["string", "symbol"], methodName); +} + +function validateTimes(value: unknown, name: string) { + if (value === Infinity) { + return; } - if (options !== undefined) { - validateObject(options, "options"); + validateInteger(value, name, 1); +} + +function validateStringOrSymbol(value: unknown, name: string) { + if (typeof value !== "string" && typeof value !== "symbol") { + throw $ERR_INVALID_ARG_TYPE(name, ["string", "symbol"], value); } - const { - getter = false, - setter = false, - times = Infinity, - } = (options ?? kEmptyObject) as { - getter?: boolean; - setter?: boolean; - times?: number; - }; - validateBoolean(getter, "options.getter"); - validateBoolean(setter, "options.setter"); - validateTimes(times, "options.times"); - if (setter && getter) { - throw $ERR_INVALID_ARG_VALUE("options.setter", setter, "cannot be used with 'options.getter'"); +} + +class MockTracker { + #mocks: { ctx: { restore: () => void } }[] = []; + #timers: unknown; + // Set on the module-level tracker: registering into it from a new file's + // module scope must run the file-boundary reset (getRootNode) first. + #isFileScoped: boolean = false; + + static createFileScoped(): MockTracker { + const tracker = new MockTracker(); + tracker.#isFileScoped = true; + return tracker; } - // Find the descriptor on the object or its prototype chain. - let target: object | null = objectOrFunction; - let descriptor: PropertyDescriptor | undefined; - while (target !== null) { - descriptor = Object.getOwnPropertyDescriptor(target, methodName); - if (descriptor !== undefined) break; - target = Object.getPrototypeOf(target); + // File-scoped registrations must run the file-boundary reset (getRootNode) + // BEFORE capturing any state, or a new file's module-scope mock.method() + // would snapshot the previous file's still-installed mock as the original. + #syncEntryFile(): void { + if (this.#isFileScoped) getRootNode(); } - if (descriptor === undefined) { - throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a method"); + + #createMockFunction( + original: Function, + implementation: Function | undefined, + restore?: () => void, + times: number = Infinity, + ) { + const context = new MockFunctionContext(original, implementation, restore, times); + this.#mocks.push({ ctx: context }); + function mockFunction(this: unknown, ...args: unknown[]) { + return trackMockCall(context, this, args, new.target); + } + Object.defineProperty(mockFunction, "mock", { + value: context, + writable: false, + enumerable: false, + }); + Object.defineProperty(mockFunction, "length", { + value: original.length, + configurable: true, + }); + Object.defineProperty(mockFunction, "name", { + value: original.name, + configurable: true, + }); + return mockFunction; } - let original: Function; - if (getter) { - if (!$isCallable(descriptor.get)) { - throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a getter"); + fn(original?: Function | object, implementation?: Function | object, options?: object) { + this.#syncEntryFile(); + if (original !== null && original !== undefined && !$isCallable(original) && typeof original === "object") { + options = implementation as object; + implementation = original; + original = undefined; + } + if ( + implementation !== null && + implementation !== undefined && + !$isCallable(implementation) && + typeof implementation === "object" + ) { + options = implementation as object; + implementation = undefined; } - original = descriptor.get; - } else if (setter) { - if (!$isCallable(descriptor.set)) { - throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a setter"); + if (original !== undefined && !$isCallable(original)) { + throw $ERR_INVALID_ARG_TYPE("original", "function", original); } - original = descriptor.set; - } else { - if (!$isCallable(descriptor.value)) { - throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a method"); + if (implementation !== undefined && !$isCallable(implementation)) { + throw $ERR_INVALID_ARG_TYPE("implementation", "function", implementation); + } + if (options !== undefined) { + validateObject(options, "options"); } - original = descriptor.value; + const { times = Infinity } = (options ?? kEmptyObject) as { times?: number }; + validateTimes(times, "options.times"); + return this.#createMockFunction( + (original as Function) ?? function () {}, + implementation as Function | undefined, + undefined, + times, + ); } - const restore = function restore() { - Object.defineProperty(objectOrFunction, methodName, descriptor!); - }; - const mocked = createMockFunction(original, implementation as Function | undefined, restore, times); + method( + objectOrFunction: object | Function, + methodName: PropertyKey, + implementation?: Function | object, + options?: { getter?: boolean; setter?: boolean } | object, + ) { + this.#syncEntryFile(); + if ( + implementation !== null && + implementation !== undefined && + !$isCallable(implementation) && + typeof implementation === "object" + ) { + options = implementation; + implementation = undefined; + } + if (implementation !== undefined && !$isCallable(implementation)) { + throw $ERR_INVALID_ARG_TYPE("implementation", "function", implementation); + } + if ((typeof objectOrFunction !== "object" || objectOrFunction === null) && !$isCallable(objectOrFunction)) { + throw $ERR_INVALID_ARG_TYPE("object", "object", objectOrFunction); + } + if (typeof methodName !== "string" && typeof methodName !== "symbol") { + throw $ERR_INVALID_ARG_TYPE("methodName", ["string", "symbol"], methodName); + } + if (options !== undefined) { + validateObject(options, "options"); + } + const { + getter = false, + setter = false, + times = Infinity, + } = (options ?? kEmptyObject) as { + getter?: boolean; + setter?: boolean; + times?: number; + }; + validateBoolean(getter, "options.getter"); + validateBoolean(setter, "options.setter"); + validateTimes(times, "options.times"); + if (setter && getter) { + throw $ERR_INVALID_ARG_VALUE("options.setter", setter, "cannot be used with 'options.getter'"); + } - const mockDescriptor: PropertyDescriptor = { - configurable: descriptor.configurable, - enumerable: descriptor.enumerable, - }; - if (getter || setter) { + // Find the descriptor on the object or its prototype chain. + let target: object | null = objectOrFunction; + let descriptor: PropertyDescriptor | undefined; + while (target !== null) { + descriptor = Object.getOwnPropertyDescriptor(target, methodName); + if (descriptor !== undefined) break; + target = Object.getPrototypeOf(target); + } + if (descriptor === undefined) { + throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a method"); + } + + let original: Function; if (getter) { - mockDescriptor.get = mocked; - mockDescriptor.set = descriptor.set; + if (!$isCallable(descriptor.get)) { + throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a getter"); + } + original = descriptor.get!; + } else if (setter) { + if (!$isCallable(descriptor.set)) { + throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a setter"); + } + original = descriptor.set!; } else { - mockDescriptor.get = descriptor.get; - mockDescriptor.set = mocked; + if (!$isCallable(descriptor.value)) { + throw $ERR_INVALID_ARG_VALUE("methodName", methodName, "must be a method"); + } + original = descriptor.value; } - } else { - mockDescriptor.value = mocked; - mockDescriptor.writable = descriptor.writable; + + const restore = function restore() { + Object.defineProperty(objectOrFunction, methodName, descriptor!); + }; + const mocked = this.#createMockFunction(original, implementation as Function | undefined, restore, times); + + const mockDescriptor: PropertyDescriptor = { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + }; + if (getter || setter) { + if (getter) { + mockDescriptor.get = mocked; + mockDescriptor.set = descriptor.set; + } else { + mockDescriptor.get = descriptor.get; + mockDescriptor.set = mocked; + } + } else { + mockDescriptor.value = mocked; + mockDescriptor.writable = descriptor.writable; + } + Object.defineProperty(objectOrFunction, methodName, mockDescriptor); + return mocked; } - Object.defineProperty(objectOrFunction, methodName, mockDescriptor); - return mocked; -} -const mock = { - fn: mockFn, - method: mockMethod, getter( objectOrFunction: object | Function, methodName: PropertyKey, @@ -309,7 +450,7 @@ const mock = { options?: object, ) { // Shift implementation -> options *before* spreading, or the shift inside - // mockMethod would clobber the getter flag (node does the same). + // method() would clobber the getter flag (node does the same). if ( implementation !== null && implementation !== undefined && @@ -323,11 +464,12 @@ const mock = { if (getter === false) { throw $ERR_INVALID_ARG_VALUE("options.getter", getter, "cannot be false"); } - return mockMethod(objectOrFunction, methodName, implementation as Function | undefined, { + return this.method(objectOrFunction, methodName, implementation as Function | undefined, { ...options, getter, }); - }, + } + setter( objectOrFunction: object | Function, methodName: PropertyKey, @@ -347,26 +489,68 @@ const mock = { if (setter === false) { throw $ERR_INVALID_ARG_VALUE("options.setter", setter, "cannot be false"); } - return mockMethod(objectOrFunction, methodName, implementation as Function | undefined, { + return this.method(objectOrFunction, methodName, implementation as Function | undefined, { ...options, setter, }); - }, + } + + property(object: object, propertyName: PropertyKey, value?: unknown) { + this.#syncEntryFile(); + validateObject(object, "object"); + validateStringOrSymbol(propertyName, "propertyName"); + + const ctx = + arguments.length > 2 + ? new MockPropertyContext(object, propertyName, value) + : new MockPropertyContext(object, propertyName); + this.#mocks.push({ ctx }); + + return new Proxy(object, { + get(target, property, receiver) { + if (property === "mock") { + return ctx; + } + return Reflect.get(target, property, receiver); + }, + }); + } + + get timers() { + this.#syncEntryFile(); + if (this.#timers === undefined) { + const { MockTimers } = require("internal/test_runner/mock_timers"); + this.#timers = new MockTimers(); + } + return this.#timers; + } + reset() { // restoreAll() plus disassociating the mocks from the tracker, like node. - mock.restoreAll(); - kMockContexts.length = 0; - }, + this.restoreAll(); + (this.#timers as { reset: () => void } | undefined)?.reset(); + this.#mocks = []; + } + restoreAll() { // Restores method mocks to their original descriptor and makes bare // mock.fn() mocks call their original function again, like node. Unlike // reset(), the mocks stay associated with the tracker. - for (const ctx of kMockContexts) ctx.restore(); - }, + for (const { ctx } of this.#mocks) ctx.restore(); + } + module() { throwNotImplemented("mock.module()", 5090, "Use `bun:test` in the interim."); - }, -}; + } +} + +// The module-level tracker is reset automatically at each test-file boundary +// (see getRootNode), matching Node's per-process module state. +const mock = MockTracker.createFileScoped(); + +// ----------------------------------------------------------------------------- +// Assertions (t.assert + custom assertion registry) +// ----------------------------------------------------------------------------- function fileSnapshot(_value: unknown, _path: string, _options: { serializers?: Function[] } = kEmptyObject) { throwNotImplemented("fileSnapshot()", 5090, "Use `bun:test` in the interim."); @@ -376,11 +560,27 @@ function snapshot(_value: unknown, _options: { serializers?: Function[] } = kEmp throwNotImplemented("snapshot()", 5090, "Use `bun:test` in the interim."); } +const nodeAssert = require("node:assert"); + +// Custom assertions registered through `require("node:test").assert.register()`. +// They become part of every TestContext's `t.assert` built afterwards. +// Prototype-less so lookups never go through user-reachable Map/Object methods. +let customAssertions: Record = { __proto__: null } as unknown as Record; + +function registerCustomAssertion(name: string, fn: Function) { + validateString(name, "name"); + validateFunction(fn, "fn"); + // Run the file-boundary reset first so a registration made at module scope, + // before the file's first test, is not wiped by that test's registration. + getRootNode(); + customAssertions[name] = fn; +} + const assert = { - ...require("node:assert"), + ...nodeAssert, fileSnapshot, snapshot, - // register, + register: registerCustomAssertion, }; // Delete deprecated methods on assert (required to pass node's tests) @@ -388,28 +588,299 @@ delete assert.AssertionError; delete assert.CallTracker; delete assert.strict; -let checkNotInsideTest: (ctx: TestContext | undefined, fn: string) => void; +function buildContextAssert(node: TestNode, ctx: TestContext) { + // Per-context assert namespace, prototype-less like Node's: node:assert + // methods (minus the uncopied ones), snapshot/fileSnapshot, and custom + // assertions; each call counts the plan and binds the TestContext. + const result: Record = { __proto__: null } as unknown as Record; + const add = (name: string, method: Function) => { + const wrapper = function (...args: unknown[]) { + planCount(node); + return method.$apply(ctx, args); + }; + Object.defineProperty(wrapper, "name", { value: name, configurable: true }); + result[name] = wrapper; + }; + for (const key of Object.keys(nodeAssert)) { + // CallTracker is also excluded: bun's node:assert still ships it (Node 26 + // does not), and copying it would trigger its deprecation accessor. + if (key === "AssertionError" || key === "strict" || key === "CallTracker") continue; + const value = nodeAssert[key]; + if (!$isCallable(value)) continue; + add(key, value); + } + add("snapshot", snapshot); + add("fileSnapshot", fileSnapshot); + for (const name of Object.keys(customAssertions)) { + add(name, customAssertions[name]); + } + return result; +} + +// ----------------------------------------------------------------------------- +// Test plan +// ----------------------------------------------------------------------------- + +function makeTestFailure(message: string) { + const error = new Error(message); + (error as { code?: string }).code = "ERR_TEST_FAILURE"; + return error; +} + +class TestPlan { + expected: number; + actual = 0; + wait: boolean | number; + #pending: + | { resolve: () => void; reject: (err: Error) => void; timer: ReturnType | undefined } + | undefined; + + constructor(count: number, options: { wait?: boolean | number } = kEmptyObject) { + validateUint32(count, "count"); + validateObject(options, "options"); + const { wait = false } = options; + if (typeof wait === "number") { + validateNumber(wait, "options.wait", 0, kTimeoutMax); + } else if (typeof wait !== "boolean" && wait !== undefined) { + throw $ERR_INVALID_ARG_TYPE("options.wait", ["boolean", "number"], wait); + } + this.expected = count; + this.wait = wait ?? false; + } + + count() { + this.actual++; + if (this.#pending !== undefined && this.actual >= this.expected) { + const pending = this.#pending; + this.#pending = undefined; + const { timer } = pending; + if (timer !== undefined) realClearTimeout(timer); + pending.resolve(); + } + } + + check(): undefined | Promise { + const { actual, expected, wait } = this; + if (actual === expected) { + return; + } + if (wait === false || wait === undefined || actual > expected) { + throw makeTestFailure(`plan expected ${expected} assertions but received ${actual}`); + } + return new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + if (typeof wait === "number") { + timer = realSetTimeout(() => { + this.#pending = undefined; + reject( + makeTestFailure(`plan timed out after ${wait}ms with ${this.actual} assertions when expecting ${expected}`), + ); + }, wait); + timer.unref?.(); + } + this.#pending = { resolve, reject, timer }; + }); + } +} + +function planCount(node: TestNode) { + node.plan?.count(); +} + +// ----------------------------------------------------------------------------- +// Tags +// ----------------------------------------------------------------------------- + +const kEmptyTags: string[] = Object.freeze([]) as string[]; +let tagsExperimentalWarningEmitted = false; + +function canonicalizeTags(tags: unknown, name: string): string[] { + validateArray(tags, name); + const seen = new Set(); + for (let i = 0; i < (tags as unknown[]).length; i++) { + const tag = (tags as unknown[])[i]; + validateString(tag, `${name}[${i}]`); + if (tag === "") { + throw $ERR_INVALID_ARG_VALUE(`${name}[${i}]`, tag, "must not be an empty string"); + } + seen.add((tag as string).toLowerCase()); + } + if (seen.size > 0 && !tagsExperimentalWarningEmitted) { + tagsExperimentalWarningEmitted = true; + process.emitWarning("Test tags is an experimental feature and might change at any time", "ExperimentalWarning"); + } + return Array.from(seen); +} + +// ----------------------------------------------------------------------------- +// Async context tracking for getTestContext() +// ----------------------------------------------------------------------------- + +let asyncLocalStorage: { getStore(): TestNode | undefined; run(store: TestNode, fn: () => T): T } | undefined; + +function getAsyncLocalStorage() { + if (asyncLocalStorage === undefined) { + const { AsyncLocalStorage } = require("node:async_hooks"); + asyncLocalStorage = new AsyncLocalStorage(); + } + return asyncLocalStorage; +} + +function currentNode(): TestNode | undefined { + return asyncLocalStorage?.getStore(); +} + +function runWithNode(node: TestNode, fn: () => T): T { + return getAsyncLocalStorage().run(node, fn); +} + +function getTestContext(): TestContext | undefined { + const node = currentNode(); + return node?.getCtx(); +} + +// ----------------------------------------------------------------------------- +// TestNode: internal runner state shared by TestContext/SuiteContext +// ----------------------------------------------------------------------------- + +// `timeout`/`signal` are snapshotted at creation (Node validates and stores them +// on the TestHook). `result` memoizes a before hook's one run, like Node's +// runOnce(): every replay observes the same outcome, including the failure. +type Hook = { fn: Function; timeout: number | undefined; signal: AbortSignal | undefined; result?: Promise }; +type HookSets = { before: Hook[]; after: Hook[]; beforeEach: Hook[]; afterEach: Hook[] }; + +class TestNode { + name: string; + parent: TestNode | undefined; + isSuite: boolean; + // "collection" nodes register with bun:test; "execution" nodes run inline as subtests. + isExecutionPhase: boolean; + filePath: string | undefined; + options: TestOptions; + ownTags: string[] | undefined; + hooks: HookSets = { before: [], after: [], beforeEach: [], afterEach: [] }; + plan: TestPlan | null = null; + mockTracker: MockTracker | null = null; + skipped = false; + todoFlag = false; + started = false; + finished = false; + passed = false; + // Inline subtests are serialized through this chain (Node's default subtest concurrency is 1). + subtestChain: Promise = Promise.resolve(); + failedSubtests = 0; + firstSubtestError: unknown = undefined; + // First failure from a before hook created while this test was running. + hookFailure: unknown = undefined; + #ctx: TestContext | undefined; + #suiteCtx: SuiteContext | undefined; + #tags: string[] | undefined; + + constructor( + name: string, + parent: TestNode | undefined, + options: TestOptions, + isSuite: boolean, + isExecutionPhase: boolean, + ) { + this.name = name; + this.parent = parent; + this.options = options; + this.isSuite = isSuite; + this.isExecutionPhase = isExecutionPhase; + // Direct children of the root capture the entry file at declaration time + // (under `bun test` with multiple files, Bun.main is the file currently + // being collected); nested tests inherit their parent's file. + this.filePath = parent !== undefined && parent.parent !== undefined ? parent.filePath : Bun.main; + this.skipped = !!options.skip; + this.todoFlag = !!options.todo; + } + + get tags(): string[] { + if (this.#tags === undefined) { + const parentTags = this.parent?.tags ?? kEmptyTags; + const own = this.ownTags ?? kEmptyTags; + if (parentTags.length === 0 && own.length === 0) { + this.#tags = kEmptyTags; + } else { + const merged = new Set(parentTags); + for (const tag of own) merged.add(tag); + this.#tags = Object.freeze(Array.from(merged)) as string[]; + } + } + return this.#tags; + } + + get fullName(): string { + const names: string[] = []; + let node: TestNode | undefined = this; + while (node !== undefined && node.parent !== undefined) { + names.unshift(node.name); + node = node.parent; + } + if (names.length === 0) { + return this.name; + } + return names.join(kJoinSeparator); + } + + getCtx(): TestContext { + this.#ctx ??= new TestContext(this); + return this.#ctx; + } + + getSuiteCtx(): SuiteContext { + this.#suiteCtx ??= new SuiteContext(this); + return this.#suiteCtx; + } + + // True while user code reached from this node should treat new tests as + // inline subtests instead of bun:test registrations. + isRunning(): boolean { + return (this.started && !this.finished) || this.isExecutionPhase; + } +} + +let rootNode: TestNode | undefined; + +function getRootNode(): TestNode { + // bun test loads every file into one process: a new entry file gets a fresh + // root so file-level hooks and state never leak into later files (Node runs + // each test file in its own process). + if (rootNode === undefined || rootNode.filePath !== Bun.main) { + const isNewFile = rootNode !== undefined; + // Publish the new root before resetting so re-entrant calls (user code run + // by a mock's restore) see an up-to-date root and don't reset again. + rootNode = new TestNode(kRootName, undefined, kDefaultOptions, true, false); + if (isNewFile) { + // Node also scopes these per process: drop the previous file's + // module-level mocks and assert.register() additions with its root. + mock.reset(); + customAssertions = { __proto__: null } as unknown as Record; + } + } + return rootNode; +} + +// ----------------------------------------------------------------------------- +// Contexts +// ----------------------------------------------------------------------------- + +const contextNode: WeakMap = new WeakMap(); /** * @link https://nodejs.org/api/test.html#class-testcontext */ class TestContext { - #insideTest: boolean; - #name: string | undefined; - #filePath: string | undefined; - #parent?: TestContext; #abortController?: AbortController; + #assert: Record | undefined; - constructor( - insideTest: boolean, - name: string | undefined, - filePath: string | undefined, - parent: TestContext | undefined, - ) { - this.#insideTest = insideTest; - this.#name = name; - this.#filePath = filePath || parent?.filePath || kDefaultFilePath; - this.#parent = parent; + constructor(node: TestNode) { + contextNode.set(this, node); + } + + get #node(): TestNode { + return contextNode.get(this)!; } get signal(): AbortSignal { @@ -420,38 +891,54 @@ class TestContext { } get name(): string { - return this.#name!; + return this.#node.name; } get fullName(): string { - let fullName = this.#name; - let parent = this.#parent; - while (parent && parent.name) { - fullName = `${parent.name} > ${fullName}`; - parent = parent.#parent; - } - return fullName!; + return this.#node.fullName; } get filePath(): string { - return this.#filePath!; + return this.#node.filePath!; + } + + get error(): unknown { + return undefined; + } + + get passed(): boolean { + return this.#node.passed; + } + + get attempt(): number { + return 0; + } + + get tags(): string[] { + return this.#node.tags; } diagnostic(message: string) { console.log(message); } - plan(_count: number, _options: { wait?: boolean } = kEmptyObject) { - throwNotImplemented("plan()", 5090, "Use `bun:test` in the interim."); + plan(count: number, options: { wait?: boolean | number } = kEmptyObject) { + const node = this.#node; + if (node.plan !== null) { + throw makeTestFailure("cannot set plan more than once"); + } + node.plan = new TestPlan(count, options); } get assert() { - return assert; + this.#assert ??= buildContextAssert(this.#node, this); + return this.#assert; } - get mock() { - throwNotImplemented("mock", 5090, "Use `bun:test` in the interim."); - return undefined; + get mock(): MockTracker { + const node = this.#node; + node.mockTracker ??= new MockTracker(); + return node.mockTracker; } runOnly(_value?: boolean) { @@ -459,171 +946,155 @@ class TestContext { } skip(_message?: string) { - throwNotImplemented("skip()", 5090, "Use `bun:test` in the interim."); + this.#node.skipped = true; } todo(_message?: string) { - throwNotImplemented("todo()", 5090, "Use `bun:test` in the interim."); + this.#node.todoFlag = true; } before(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { beforeAll } = bunTest(); - beforeAll(fn); + const hook = createHook(arg0, arg1); + const node = this.#node; + node.hooks.before.push(hook); + if (node.started && !node.finished) { + // Node runs before hooks created on an already-started test immediately. + scheduleImmediateBeforeHook(node, hook, this); + } } after(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { afterAll } = bunTest(); - afterAll(fn); + this.#node.hooks.after.push(createHook(arg0, arg1)); } beforeEach(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { beforeEach } = bunTest(); - beforeEach(fn); + this.#node.hooks.beforeEach.push(createHook(arg0, arg1)); } afterEach(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { afterEach } = bunTest(); - afterEach(fn); + this.#node.hooks.afterEach.push(createHook(arg0, arg1)); } - waitFor(_condition: unknown, _options: { timeout?: number } = kEmptyObject) { - throwNotImplemented("waitFor()", 5090, "Use `bun:test` in the interim."); + waitFor(condition: unknown, options: { interval?: number; timeout?: number } = kEmptyObject) { + validateFunction(condition, "condition"); + validateObject(options, "options"); + const { interval = 50, timeout = 1000 } = options; + validateNumber(interval, "options.interval", 0, kTimeoutMax); + validateNumber(timeout, "options.timeout", 0, kTimeoutMax); + + return new Promise((resolve, reject) => { + let cause: unknown; + let hasCause = false; + let timedOut = false; + const timer = realSetTimeout(() => { + timedOut = true; + const error = new Error("waitFor() timed out"); + if (hasCause) { + (error as { cause?: unknown }).cause = cause; + } + reject(error); + }, timeout); + + const poll = async () => { + try { + const result = await (condition as Function)(); + if (timedOut) return; + realClearTimeout(timer); + resolve(result); + } catch (err) { + if (timedOut) return; + cause = err; + hasCause = true; + realSetTimeout(poll, interval); + } + }; + poll(); + }); } test(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = createTest(arg0, arg1, arg2); - - this.#checkNotInsideTest("test"); - - const { test } = bunTest(); - if (options.only) { - test.only(name, fn); - } else if (options.todo) { - test.todo(name, fn); - } else if (options.skip) { - test.skip(name, fn); - } else { - test(name, fn); - } + const node = this.#node; + planCount(node); + return addTest(arg0, arg1, arg2, node); } describe(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn } = createDescribe(arg0, arg1, arg2); + return addSuite(arg0, arg1, arg2, this.#node); + } +} + +/** + * @link https://nodejs.org/api/test.html#class-suitecontext + */ +class SuiteContext { + #abortController?: AbortController; - this.#checkNotInsideTest("describe"); + constructor(node: TestNode) { + contextNode.set(this, node); + } - const { describe } = bunTest(); - describe(name, fn); + get #node(): TestNode { + return contextNode.get(this)!; } - #checkNotInsideTest(fn: string) { - if (this.#insideTest) { - throwNotImplemented(`${fn}() inside another test()`, 5090, "Use `bun:test` in the interim."); + get signal(): AbortSignal { + if (this.#abortController === undefined) { + this.#abortController = new AbortController(); } + return this.#abortController.signal; } - static { - // expose this function to the rest of this file without exposing it to user JS - checkNotInsideTest = (ctx: TestContext | undefined, fn: string) => { - if (ctx) ctx.#checkNotInsideTest(fn); - }; + get name(): string { + return this.#node.name; } -} -function bunTest() { - return jest(Bun.main); -} + get fullName(): string { + return this.#node.fullName; + } -let ctx: TestContext | undefined = undefined; + get filePath(): string { + return this.#node.filePath!; + } -function describe(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn } = createDescribe(arg0, arg1, arg2); - const { describe } = bunTest(); - describe(name, fn); + get passed(): boolean { + return this.#node.passed; + } + + get attempt(): number { + return 0; + } + + diagnostic(message: string) { + console.log(message); + } } -describe.skip = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn } = createDescribe(arg0, arg1, arg2); - const { describe } = bunTest(); - describe.skip(name, fn); -}; +// ----------------------------------------------------------------------------- +// Option parsing & validation +// ----------------------------------------------------------------------------- -describe.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn } = createDescribe(arg0, arg1, arg2); - const { describe } = bunTest(); - describe.todo(name, fn); -}; +type TestFn = (ctx: TestContext | SuiteContext) => unknown | Promise; +type HookFn = (ctx?: unknown) => unknown | Promise; -describe.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn } = createDescribe(arg0, arg1, arg2); - const { describe } = bunTest(); - describe.only(name, fn); -}; - -function test(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = createTest(arg0, arg1, arg2); - const { test } = bunTest(); - // Node's {only: true} is intentionally not routed to test.only() here: - // in Node it is a no-op unless --test-only is passed, whereas bun:test's - // test.only() unconditionally skips siblings. - if (options.todo) { - test.todo(name, fn, options); - } else if (options.skip) { - test.skip(name, fn, options); - } else { - test(name, fn, options); - } -} - -test.skip = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = createTest(arg0, arg1, arg2); - const { test } = bunTest(); - test.skip(name, fn, options); -}; - -test.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = createTest(arg0, arg1, arg2); - const { test } = bunTest(); - test.todo(name, fn, options); +type TestOptions = { + concurrency?: number | boolean | null; + only?: boolean; + signal?: AbortSignal; + skip?: boolean | string; + todo?: boolean | string; + timeout?: number; + plan?: number; + tags?: string[]; }; -test.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = createTest(arg0, arg1, arg2); - const { test } = bunTest(); - test.only(name, fn, options); +type HookOptions = { + signal?: AbortSignal; + timeout?: number; }; -function before(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { beforeAll } = bunTest(); - beforeAll(fn); -} - -function after(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { afterAll } = bunTest(); - afterAll(fn); -} - -function beforeEach(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { beforeEach } = bunTest(); - beforeEach(fn); -} - -function afterEach(arg0: unknown, arg1: unknown) { - const { fn } = createHook(arg0, arg1); - const { afterEach } = bunTest(); - afterEach(fn); -} - -function parseTestOptions(arg0: unknown, arg1: unknown, arg2: unknown) { +function parseTestArgs(arg0: unknown, arg1: unknown, arg2: unknown) { let name: string; - let options: unknown; + let options: TestOptions; let fn: TestFn; if (typeof arg0 === "function") { @@ -650,130 +1121,700 @@ function parseTestOptions(arg0: unknown, arg1: unknown, arg2: unknown) { fn = kDefaultFunction; options = kDefaultOptions; } + } else if (typeof arg0 === "object" && arg0 !== null) { + options = arg0 as TestOptions; + if (typeof arg1 === "function") { + fn = arg1 as TestFn; + name = fn.name || kDefaultName; + } else { + fn = kDefaultFunction; + name = kDefaultName; + } } else { name = kDefaultName; fn = kDefaultFunction; options = kDefaultOptions; } - return { name, options: options as TestOptions, fn }; + return { name, options, fn }; } -function createTest(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, options, fn } = parseTestOptions(arg0, arg1, arg2); +// Shared by test and hook options: Node validates both the same way. +function validateTimeoutAndSignal(options: TestOptions | HookOptions) { + const { timeout, signal } = options; + if (signal !== undefined) { + validateAbortSignal(signal, "options.signal"); + } + if (timeout != null && timeout !== Infinity) { + validateNumber(timeout, "options.timeout", 0, kTimeoutMax); + } +} - checkNotInsideTest(ctx, "test"); - const context = new TestContext(true, name, Bun.main, ctx); +function validateTestOptions(options: TestOptions): { ownTags: string[] | undefined } { + const { concurrency, tags, plan } = options; - const runTest = (done: (error?: unknown) => void) => { - const originalContext = ctx; - ctx = context; - const endTest = (error?: unknown) => { - try { - done(error); - } finally { - ctx = originalContext; + validateTimeoutAndSignal(options); + if (concurrency != null && typeof concurrency !== "boolean") { + if (typeof concurrency === "number") { + validateUint32(concurrency, "options.concurrency", true); + } else { + throw $ERR_INVALID_ARG_TYPE("options.concurrency", ["boolean", "number"], concurrency); + } + } + if (plan !== undefined) { + validateUint32(plan, "options.plan"); + } + + let ownTags: string[] | undefined; + if (tags !== undefined) { + ownTags = canonicalizeTags(tags, "options.tags"); + } + + return { ownTags }; +} + +function parseHookArgs(arg0: unknown, arg1: unknown) { + let fn: HookFn; + let options: HookOptions; + + if (typeof arg0 === "function") { + fn = arg0 as HookFn; + } else { + fn = kDefaultFunction; + } + + if (typeof arg1 === "object" && arg1 !== null) { + options = arg1 as HookOptions; + } else { + options = kDefaultOptions; + } + + return { fn, options }; +} + +function createHook(arg0: unknown, arg1: unknown): Hook { + const { fn, options } = parseHookArgs(arg0, arg1); + // Node validates hook options in the TestHook constructor and snapshots them. + validateTimeoutAndSignal(options); + const { signal, timeout } = options; + return { fn, timeout, signal, result: undefined }; +} + +// ----------------------------------------------------------------------------- +// Execution engine +// ----------------------------------------------------------------------------- + +function ancestorChain(node: TestNode): TestNode[] { + // Returns [root, ..., parent] (outermost first), excluding `node` itself. + const chain: TestNode[] = []; + let current = node.parent; + while (current !== undefined) { + chain.unshift(current); + current = current.parent; + } + return chain; +} + +function invokeWithDoneCallback(fn: Function, arg: unknown) { + return new Promise((resolve, reject) => { + let returned = false; + let returnedPromise = false; + let doneCalled = false; + let doneError: unknown; + const done = (err?: unknown) => { + if (doneCalled) { + // Node throws into the caller when the callback is invoked again. + throw makeTestFailure("callback invoked multiple times"); } + doneCalled = true; + // A done() call made before the function returned is deferred, and one + // made after a promise was returned is ignored: returning a promise from + // a callback function always fails, like Node. + if (!returned) { + doneError = err; + return; + } + if (returnedPromise) { + return; + } + if (err) reject(err); + else resolve(); }; - - let result: unknown; - try { - result = fn(context); - } catch (error) { - endTest(error); + const result = fn(arg, done); + returned = true; + if ($isPromise(result)) { + // Node fails the test but still awaits the returned promise, so hooks + // and later tests never race a still-running body. + returnedPromise = true; + const fail = () => reject(makeTestFailure("passed a callback but also returned a Promise")); + (result as Promise).then(fail, fail); return; } - if (result instanceof Promise) { - (result as Promise).then(() => endTest()).catch(error => endTest(error)); + if (doneCalled) { + if (doneError) reject(doneError); + else resolve(); + } + }); +} + +// Node passes a `done` callback when a test or hook function declares exactly +// two parameters; completion is then done()'s call, not the returned value. +function invokeTestFn(fn: Function, arg: unknown) { + if (fn.length === 2) { + return invokeWithDoneCallback(fn, arg); + } + return fn(arg); +} + +// Runs `run` racing Node's test timeout; the timer starts before the body so a +// long synchronous prefix counts against the timeout, like Node. +function awaitWithTimeout(run: () => unknown, timeout: number | undefined) { + if (typeof timeout !== "number" || !Number.isFinite(timeout)) { + return run(); + } + return raceWithTimeoutAndSignal(run, timeout, undefined); +} + +let addAbortListener; + +async function raceWithTimeoutAndSignal( + run: () => unknown, + timeout: number | undefined, + signal: AbortSignal | undefined, +): Promise { + let timer: ReturnType | undefined; + let abortListener; + try { + const racers: unknown[] = []; + if (typeof timeout === "number" && Number.isFinite(timeout)) { + racers.push( + new Promise((_, reject) => { + timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); + timer.unref?.(); + }), + ); + } + if (signal !== undefined) { + if (signal.aborted) { + throw signal.reason; + } + addAbortListener ??= require("internal/abort_listener").addAbortListener; + racers.push( + new Promise((_, reject) => { + abortListener = addAbortListener(signal, () => reject(signal.reason)); + }), + ); + } + racers.push(run()); + await Promise.race(racers); + } finally { + // If run() settled first the loser promises stay pending forever, which is + // harmless; only the timer and the abort listener need to be released. + if (timer !== undefined) realClearTimeout(timer); + abortListener?.[Symbol.dispose](); + } +} + +async function runHook(hook: Hook, owner: TestNode, arg: unknown) { + const { timeout, signal } = hook; + const run = () => runWithNode(owner, () => invokeTestFn(hook.fn as Function, arg)); + try { + if (signal === undefined) { + await awaitWithTimeout(run, timeout); } else { - endTest(); + await raceWithTimeoutAndSignal(run, timeout, signal); } - }; + } catch (err) { + // A hook that throws a nullish value must still fail the owning test. + throw err ?? makeTestFailure("hook failed"); + } +} - return { name, options, fn: runTest }; +// Node runs each before hook at most once (runOnce) and memoizes the outcome: +// after a failure, every later subtest observes the same rejection. +function runBeforeHookOnce(hook: Hook, owner: TestNode, arg: unknown): Promise { + return (hook.result ??= runHook(hook, owner, arg)); } -function createDescribe(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, fn, options } = parseTestOptions(arg0, arg1, arg2); +// Failures fail the owning test (Node: hook.error -> test.fail) instead of +// poisoning the subtest chain, so they are reported even when nothing awaits. +function scheduleImmediateBeforeHook(node: TestNode, hook: Hook, arg: unknown) { + node.subtestChain = node.subtestChain.then(async () => { + try { + await runBeforeHookOnce(hook, node, arg); + } catch (err) { + node.hookFailure ??= err; + } + }); +} + +async function runOwnBeforeHooks(node: TestNode) { + const { before } = node.hooks; + if (before.length === 0) return; + const arg = node.isSuite ? node.getSuiteCtx() : node.getCtx(); + for (const hook of before) { + await runBeforeHookOnce(hook, node, arg); + } +} - checkNotInsideTest(ctx, "describe"); - const context = new TestContext(false, name, Bun.main, ctx); +async function executeTestNode(node: TestNode, fn: TestFn): Promise { + // Runs a single test (top-level or subtest): inherited beforeEach hooks, the + // body, pending subtests, the plan check, inherited afterEach hooks, and the + // test's own after hooks. Returns the failure (if any) instead of throwing. + node.started = true; + const ctx = node.getCtx(); + const ancestors = ancestorChain(node); + let failure: unknown; + + try { + for (const ancestor of ancestors) { + for (const hook of ancestor.hooks.beforeEach) { + await runHook(hook, ancestor, ctx); + } + } + } catch (err) { + failure = err; + } - const runDescribe = () => { - const originalContext = ctx; - ctx = context; - const endDescribe = () => { - ctx = originalContext; + if (failure === undefined) { + const { plan: planOption } = node.options; + if (planOption !== undefined && node.plan === null) { + node.plan = new TestPlan(planOption); + } + + const runBody = async () => { + await runWithNode(node, () => invokeTestFn(fn, ctx)); + // Wait for inline subtests created during the body (awaited or not), + // including ones scheduled while earlier subtests were running. + await drainSubtestChain(node); }; try { - return fn(context); - } finally { - endDescribe(); + await awaitWithTimeout(runBody, node.options.timeout); + } catch (err) { + // A body that throws or rejects with a nullish value must still fail. + failure = err ?? makeTestFailure("test failed"); + } + + // A before hook created while the test was running failed (Node fails the + // test with the hook's error). + failure ??= node.hookFailure; + + const { plan } = node; + if (failure === undefined && plan !== null) { + try { + await plan.check(); + } catch (err) { + failure = err; + } + } + + const { failedSubtests, firstSubtestError } = node; + if (failure === undefined && failedSubtests > 0) { + const error = makeTestFailure(`${failedSubtests} subtest${failedSubtests > 1 ? "s" : ""} failed`); + if (firstSubtestError !== undefined) { + (error as { cause?: unknown }).cause = firstSubtestError; + } + failure = error; + } + } + + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + for (const hook of ancestor.hooks.afterEach) { + try { + await runHook(hook, ancestor, ctx); + } catch (err) { + failure ??= err; + } + } + } + + for (const hook of node.hooks.after) { + try { + await runHook(hook, node, ctx); + } catch (err) { + failure ??= err; + } + } + + try { + node.mockTracker?.reset(); + } catch (err) { + failure ??= err; + } + + node.finished = true; + node.passed = failure === undefined; + return failure; +} + +function scheduleSubtest(parent: TestNode, child: TestNode, fn: TestFn): Promise { + const run = async () => { + if (child.options.skip) { + child.finished = true; + child.passed = true; + return; + } + let failure: unknown; + try { + await runOwnBeforeHooks(parent); + failure = await executeTestNode(child, fn); + } catch (err) { + failure = err; + } + if (failure !== undefined && !child.todoFlag && !child.skipped) { + parent.failedSubtests++; + parent.firstSubtestError ??= failure; } }; + const result = (parent.subtestChain = parent.subtestChain.then(run)); + return result.then(() => undefined); +} - return { name, options, fn: runDescribe }; +function recordSuiteFailure(suite: TestNode, err: unknown) { + suite.failedSubtests++; + suite.firstSubtestError ??= err ?? makeTestFailure("suite failed"); } -function parseHookOptions(arg0: unknown, arg1: unknown) { - let fn: HookFn | undefined; - let options: HookOptions; +// Awaits a node's subtest chain, including links appended while waiting. +async function drainSubtestChain(node: TestNode) { + let chain; + do { + chain = node.subtestChain; + try { + await chain; + } catch { + // Failures are tracked through failedSubtests. + } + } while (chain !== node.subtestChain); +} - if (typeof arg0 === "function") { - fn = arg0 as HookFn; +function scheduleSuiteSubtest(parent: TestNode, suite: TestNode, build: unknown): Promise { + // A describe()/suite() created while a test is running becomes a suite + // subtest: its children were collected eagerly when the callback ran and are + // already chained on the suite's own subtestChain; failures roll up here. + const run = async () => { + if (build !== undefined) { + try { + // An async describe() callback that rejects fails the suite (Node + // awaits the suite build). + await build; + } catch (err) { + recordSuiteFailure(suite, err); + } + } + try { + await runOwnBeforeHooks(suite); + } catch (err) { + // A failing suite-level before hook fails the suite, like Node. + recordSuiteFailure(suite, err); + } + // Wait for children created during the callback and any they schedule. + await drainSubtestChain(suite); + for (const hook of suite.hooks.after) { + try { + await runHook(hook, suite, suite.getSuiteCtx()); + } catch (err) { + recordSuiteFailure(suite, err); + } + } + suite.finished = true; + suite.passed = suite.failedSubtests === 0; + // A todo suite's failures do not fail the owning test (Node). + if (suite.failedSubtests > 0 && !suite.todoFlag) { + parent.failedSubtests++; + parent.firstSubtestError ??= suite.firstSubtestError; + } + }; + const result = (parent.subtestChain = parent.subtestChain.then(run)); + return result.then(() => undefined); +} + +// ----------------------------------------------------------------------------- +// Registration with bun:test +// ----------------------------------------------------------------------------- + +function bunTest() { + return jest(Bun.main); +} + +function bunTestOptions(options: TestOptions) { + // The node-style timeout is enforced by executeTestNode itself so that a + // tiny timeout (e.g. 1ms) with a synchronous body still passes like in Node. + // bun:test's own watchdog measures the whole wrapper, so it is only told + // about timeouts that extend past its 5s default. + const { timeout } = options; + if (timeout === Infinity) { + // Node's "no timeout" must override bun:test's default (bun saturates it). + return { timeout }; + } + if (typeof timeout === "number" && Number.isFinite(timeout)) { + // Keep bun:test's watchdog at or above both the node-style timeout and + // bun's default so a lower `--timeout` cannot cut a node timeout short. + return { timeout: Math.max(timeout, kBunTestDefaultTimeoutMs) }; + } + return undefined; +} + +function currentCollectionParent(): TestNode { + const node = currentNode(); + if (node !== undefined && !node.isExecutionPhase && node.isSuite) { + return node; + } + return getRootNode(); +} + +function createTopLevelTestRunner( + node: TestNode, + fn: TestFn, + resolveDeferred: (() => void) | undefined, + declaredTodo = false, +) { + // bun:test invokes this with a `done` callback because the function declares + // one parameter. + return (done: (error?: unknown) => void) => { + executeTestNode(node, fn).then( + failure => { + resolveDeferred?.(); + // A runtime t.skip()/t.todo() suppresses the failure, like Node; a + // declared todo body's failure must reach bun:test's todo accounting. + const runtimeMarked = node.skipped || (node.todoFlag && !declaredTodo); + done(failure === undefined || runtimeMarked ? undefined : failure); + }, + err => { + resolveDeferred?.(); + done(err); + }, + ); + }; +} + +function addTest( + arg0: unknown, + arg1: unknown, + arg2: unknown, + executionParent: TestNode | undefined, + mode?: "skip" | "todo", +): Promise { + const { name, options, fn } = parseTestArgs(arg0, arg1, arg2); + const { ownTags } = validateTestOptions(options); + + const runningNode = executionParent ?? currentNode(); + if (runningNode !== undefined && runningNode.isRunning()) { + // Subtest of a running test (or of an inline suite created inside one). + if (mode === "skip" || options.skip) { + return Promise.resolve(undefined); + } + const child = new TestNode(name, runningNode, options, false, true); + child.ownTags = ownTags; + if (mode === "todo") child.todoFlag = true; + return scheduleSubtest(runningNode, child, fn); + } + + // Collection phase: register with bun:test. + const parent = currentCollectionParent(); + const node = new TestNode(name, parent, options, false, false); + node.ownTags = ownTags; + + const { test } = bunTest(); + const insideSuite = parent.parent !== undefined; + const passOptions = bunTestOptions(options); + + const effectiveMode = mode ?? (options.todo ? "todo" : options.skip ? "skip" : undefined); + + if (effectiveMode === "todo" || effectiveMode === "skip") { + const register = effectiveMode === "todo" ? test.todo : test.skip; + // Node runs todo bodies; bun:test only does so under --todo. + const body = effectiveMode === "todo" ? createTopLevelTestRunner(node, fn, undefined, true) : kDefaultFunction; + if (passOptions !== undefined) { + register(name, body, passOptions); + } else { + register(name, body); + } + return Promise.resolve(undefined); + } + + let resolveDeferred: (() => void) | undefined; + let promise: Promise; + if (insideSuite) { + // Inside a describe() callback Node returns an already-resolved promise. + promise = Promise.resolve(undefined); } else { - fn = kDefaultFunction; + promise = new Promise(resolve => { + resolveDeferred = () => resolve(undefined); + }); } - if (typeof arg1 === "object") { - options = arg1 as HookOptions; + // Node's `only` (the option and the test.only()/describe.only() spellings) + // is a no-op unless --test-only is passed, so it registers an ordinary + // test/suite; bun:test's only() would skip siblings and is rejected in CI. + const runner = createTopLevelTestRunner(node, fn, resolveDeferred); + if (passOptions !== undefined) { + test(name, runner, passOptions); } else { - options = kDefaultOptions; + test(name, runner); } - return { fn, options }; + return promise; } -function createHook(arg0: unknown, arg1: unknown) { - const { fn, options } = parseHookOptions(arg0, arg1); - - const runHook = (done: (error?: unknown) => void) => { - let result: unknown; +function addSuite( + arg0: unknown, + arg1: unknown, + arg2: unknown, + executionParent?: TestNode, + mode?: "skip" | "todo", +): Promise { + const { name, options, fn } = parseTestArgs(arg0, arg1, arg2); + const { ownTags } = validateTestOptions(options); + + const runningNode = executionParent ?? currentNode(); + if (runningNode !== undefined && runningNode.isRunning()) { + const suite = new TestNode(name, runningNode, options, true, true); + suite.ownTags = ownTags; + if (mode === "skip" || options.skip) { + return Promise.resolve(undefined); + } + if (mode === "todo") suite.todoFlag = true; + // The suite's children must run after the parent's previously scheduled + // subtests (Node runs subtests serially), so seed its chain on the parent's. + suite.subtestChain = runningNode.subtestChain; + // Build the suite eagerly (Node also runs describe callbacks immediately), + // collecting children onto the suite's own subtest chain. + let build: unknown; try { - result = fn(); - } catch (error) { - done(error); - return; + build = runWithNode(suite, () => fn(suite.getSuiteCtx())); + } catch (err) { + // The callback threw after possibly registering children: fail the suite + // but still schedule it so those children are awaited and rolled up. + recordSuiteFailure(suite, err); } - if (result instanceof Promise) { - (result as Promise).then(() => done()).catch(error => done(error)); + if (build != null && typeof (build as PromiseLike).then === "function") { + // Attach a handler now: the real await happens when the suite's turn + // comes, which can be many ticks later (no unhandled rejection). + (build as Promise).then(undefined, () => {}); } else { - done(); + build = undefined; } + return scheduleSuiteSubtest(runningNode, suite, build); + } + + const parent = currentCollectionParent(); + const suiteNode = new TestNode(name, parent, options, true, false); + suiteNode.ownTags = ownTags; + + const { describe } = bunTest(); + const wrapped = () => { + return runWithNode(suiteNode, () => fn(suiteNode.getSuiteCtx())); }; - return { options, fn: runHook }; + const effectiveMode = mode ?? (options.todo ? "todo" : options.skip ? "skip" : undefined); + const passOptions = bunTestOptions(options); + + let register: Function = describe; + if (effectiveMode === "skip") register = describe.skip; + else if (effectiveMode === "todo") register = describe.todo; + + if (passOptions !== undefined) { + register(name, wrapped, passOptions); + } else { + register(name, wrapped); + } + return Promise.resolve(undefined); } -type TestFn = (ctx: TestContext) => unknown | Promise; -type HookFn = () => unknown | Promise; +// ----------------------------------------------------------------------------- +// Public API +// ----------------------------------------------------------------------------- -type TestOptions = { - concurrency?: number | boolean | null; - only?: boolean; - signal?: AbortSignal; - skip?: boolean | string; - todo?: boolean | string; - timeout?: number; - plan?: number; +function test(arg0: unknown, arg1: unknown, arg2: unknown) { + return addTest(arg0, arg1, arg2, undefined); +} + +test.skip = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addTest(arg0, arg1, arg2, undefined, "skip"); }; -type HookOptions = { - signal?: AbortSignal; - timeout?: number; +test.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addTest(arg0, arg1, arg2, undefined, "todo"); +}; + +test.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addTest(arg0, arg1, arg2, undefined); +}; + +function describe(arg0: unknown, arg1: unknown, arg2: unknown) { + return addSuite(arg0, arg1, arg2, undefined); +} + +describe.skip = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addSuite(arg0, arg1, arg2, undefined, "skip"); }; +describe.todo = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addSuite(arg0, arg1, arg2, undefined, "todo"); +}; + +describe.only = function (arg0: unknown, arg1: unknown, arg2: unknown) { + return addSuite(arg0, arg1, arg2, undefined); +}; + +function hookOwner(): TestNode { + const node = currentNode(); + if (node !== undefined) { + return node; + } + return getRootNode(); +} + +function hookArgFor(node: TestNode) { + return node.isSuite && node.parent !== undefined ? node.getSuiteCtx() : node.getCtx(); +} + +function before(arg0: unknown, arg1: unknown) { + const hook = createHook(arg0, arg1); + const owner = hookOwner(); + if (owner.isRunning()) { + owner.hooks.before.push(hook); + if (owner.started && !owner.finished) { + scheduleImmediateBeforeHook(owner, hook, hookArgFor(owner)); + } + return; + } + const { beforeAll } = bunTest(); + beforeAll((done: (error?: unknown) => void) => { + Promise.resolve(runHook(hook, owner, hookArgFor(owner))).then( + () => done(), + err => done(err ?? new Error("before hook failed")), + ); + }); +} + +function after(arg0: unknown, arg1: unknown) { + const hook = createHook(arg0, arg1); + const owner = hookOwner(); + if (owner.isRunning()) { + owner.hooks.after.push(hook); + return; + } + const { afterAll } = bunTest(); + afterAll((done: (error?: unknown) => void) => { + Promise.resolve(runHook(hook, owner, hookArgFor(owner))).then( + () => done(), + err => done(err ?? new Error("after hook failed")), + ); + }); +} + +function beforeEach(arg0: unknown, arg1: unknown) { + hookOwner().hooks.beforeEach.push(createHook(arg0, arg1)); +} + +function afterEach(arg0: unknown, arg1: unknown) { + hookOwner().hooks.afterEach.push(createHook(arg0, arg1)); +} + function setDefaultSnapshotSerializer(_serializers: unknown[]) { throwNotImplemented("setDefaultSnapshotSerializer()", 5090, "Use `bun:test` in the interim."); } @@ -797,5 +1838,6 @@ test.snapshot = { }; test.run = run; test.mock = mock; +test.getTestContext = getTestContext; export default test; diff --git a/test/expectations.txt b/test/expectations.txt index 24e09b7de2ad..bcf6e61d2bb3 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -27,6 +27,15 @@ test/js/node/test/parallel/test-inspector-enabled.js [ FAIL ] # alpine 3.23 x64 + x64-baseline only). [ LINUX-X64-MUSL ] test/js/node/test/parallel/test-tls-connect-memleak.js [ FLAKY ] # JSC FinalizationRegistry callback delivery vs setImmediate timing on musl x64 +# The 2GiB structuredClone subprocess suite (split out of structured-clone.test.ts): +# each child transiently needs 3-5GiB (source + serialized copy + clone). Under the +# parallel test runner the linux-x64 shards run out of memory and the kernel OOM +# killer reaps the child (empty stdout, exit by SIGKILL), which the parent cannot +# distinguish from the serializer crash the suite guards against. The crash is +# platform-independent and stays covered on darwin, windows, linux-aarch64, and in +# local runs; only the memory-tight linux-x64 CI shards are quarantined. +[ LINUX-X64 ] test/js/web/workers/structured-clone-2gb.test.ts [ FLAKY ] # children OOM-killed under runner memory pressure on linux-x64 CI shards + # Vendored node v26.3.0 stream tests blocked on missing native subsystems (see PR #31826) test/js/node/test/parallel/test-stream-pipeline.js [ SKIP ] # block at L271 hangs: pipeline(rs, req) writes 11x'hello' raw after a never-ended GET's \r\n\r\n; node's llhttp rejects lowercase 'h' as a method char (HPE_INVALID_METHOD -> clientError -> 400+close -> req 'close' -> pipeline callback fires), but bun's uWS HttpParser buffers any incomplete run of valid tchars waiting for the request-line, so the connection stays open and the callback never fires. Pre-existing server-parser leniency; needs uWS HttpParser to reject non-uppercase method bytes like llhttp. test/js/node/test/parallel/test-stream-wrap.js [ FAIL ] # needs internal/test/binding + js_stream (net.Socket({handle}) libuv compat layer) diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index cec35c12a36d..83f11cfbc57d 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1310,7 +1310,9 @@ function installBunExposeInternalsShim() { })); build.module("internal/timers", () => ({ loader: "object", - exports: { kTimeout: Symbol.for("::buntimeout::") }, + // TIMEOUT_MAX mirrors Node's internal/timers (2 ** 31 - 1) so vendored + // tests exercising the > TIMEOUT_MAX clamp use the real threshold. + exports: { kTimeout: Symbol.for("::buntimeout::"), TIMEOUT_MAX: 2 ** 31 - 1 }, })); }, }); diff --git a/test/js/node/test/parallel/test-runner-aftereach-runtime-skip.js b/test/js/node/test/parallel/test-runner-aftereach-runtime-skip.js new file mode 100644 index 000000000000..7a6ba2ea9cbd --- /dev/null +++ b/test/js/node/test/parallel/test-runner-aftereach-runtime-skip.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +const assert = require('node:assert'); +const { beforeEach, afterEach, test } = require('node:test'); + +let beforeEachTotal = 0; +let afterEachRuntimeSkip = 0; +let afterEachTotal = 0; + +beforeEach(common.mustCall(() => { + beforeEachTotal++; +}, 2)); + +afterEach(common.mustCall((t) => { + afterEachTotal++; + if (t.name === 'runtime skip') { + afterEachRuntimeSkip++; + } +}, 2)); + +test('normal test', (t) => { + t.assert.ok(true); +}); + +test('runtime skip', (t) => { + t.skip('skip after setup'); +}); + +test('static skip', { skip: true }, common.mustNotCall()); + +process.on('exit', () => { + assert.strictEqual(beforeEachTotal, 2); + assert.strictEqual(afterEachRuntimeSkip, 1); + assert.strictEqual(afterEachTotal, 2); +}); diff --git a/test/js/node/test/parallel/test-runner-aliases.js b/test/js/node/test/parallel/test-runner-aliases.js index 1a61da896e9e..00b8d24b61b8 100644 --- a/test/js/node/test/parallel/test-runner-aliases.js +++ b/test/js/node/test/parallel/test-runner-aliases.js @@ -1,8 +1,8 @@ 'use strict'; require('../common'); -const { strictEqual } = require('node:assert'); +const assert = require('node:assert'); const test = require('node:test'); -strictEqual(test.test, test); -strictEqual(test.it, test); -strictEqual(test.describe, test.suite); +assert.strictEqual(test.test, test); +assert.strictEqual(test.it, test); +assert.strictEqual(test.describe, test.suite); diff --git a/test/js/node/test/parallel/test-runner-custom-assertions.js b/test/js/node/test/parallel/test-runner-custom-assertions.js new file mode 100644 index 000000000000..6e398339afe1 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-custom-assertions.js @@ -0,0 +1,63 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { test, assert: testAssertions } = require('node:test'); + +testAssertions.register('isOdd', common.mustCallAtLeast((n) => { + assert.strictEqual(n % 2, 1); +})); + +testAssertions.register('ok', () => { + return 'ok'; +}); + +testAssertions.register('snapshot', () => { + return 'snapshot'; +}); + +testAssertions.register('deepStrictEqual', () => { + return 'deepStrictEqual'; +}); + +testAssertions.register('context', function() { + return this; +}); + +test('throws if name is not a string', () => { + assert.throws(() => { + testAssertions.register(5); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "name" argument must be of type string. Received type number (5)' + }); +}); + +test('throws if fn is not a function', () => { + assert.throws(() => { + testAssertions.register('foo', 5); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "fn" argument must be of type function. Received type number (5)' + }); +}); + +test('invokes a custom assertion as part of the test plan', (t) => { + t.plan(2); + t.assert.isOdd(5); + assert.throws(() => { + t.assert.isOdd(4); + }, { + code: 'ERR_ASSERTION', + message: /Expected values to be strictly equal/ + }); +}); + +test('can override existing assertions', (t) => { + assert.strictEqual(t.assert.ok(), 'ok'); + assert.strictEqual(t.assert.snapshot(), 'snapshot'); + assert.strictEqual(t.assert.deepStrictEqual(), 'deepStrictEqual'); +}); + +test('"this" is set to the TestContext', (t) => { + assert.strictEqual(t.assert.context(), t); +}); diff --git a/test/js/node/test/parallel/test-runner-get-test-context.js b/test/js/node/test/parallel/test-runner-get-test-context.js new file mode 100644 index 000000000000..1e5ef917f9fb --- /dev/null +++ b/test/js/node/test/parallel/test-runner-get-test-context.js @@ -0,0 +1,120 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { + test, + getTestContext, + describe, + it, + before, + after, + beforeEach, + afterEach, +} = require('node:test'); + +// Outside a test — must return undefined +assert.strictEqual(getTestContext(), undefined); + +test('getTestContext returns current context inside test', async () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'getTestContext returns current context inside test'); + assert.strictEqual(typeof ctx.signal, 'object'); + assert.strictEqual(typeof ctx.fullName, 'string'); +}); + +test('getTestContext works in nested test', async (t) => { + await t.test('child', async () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'child'); + }); +}); + +describe('getTestContext works in describe/it', () => { + it('has correct name', () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'has correct name'); + }); +}); + +describe('getTestContext returns SuiteContext in suite', () => { + it('suite context is available', () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + // Suite name appears as parent in nested test context + assert.strictEqual(typeof ctx.signal, 'object'); + assert.strictEqual(typeof ctx.fullName, 'string'); + }); +}); + +describe('getTestContext inside hooks', () => { + const suiteName = 'getTestContext inside hooks'; + + before(common.mustCall((t) => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, suiteName); + assert.strictEqual(ctx.name, t.name); + })); + + beforeEach(common.mustCall(() => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, suiteName); + })); + + afterEach(common.mustCall(() => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, suiteName); + })); + + after(common.mustCall((t) => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, suiteName); + assert.strictEqual(ctx.name, t.name); + })); + + it('runs inside the suite', () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'runs inside the suite'); + }); +}); + +test('getTestContext inside test-level hooks returns the parent test', async (t) => { + const parentName = t.name; + t.beforeEach(common.mustCall(() => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, parentName); + })); + + t.afterEach(common.mustCall(() => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, parentName); + })); + + await t.test('child', () => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'child'); + }); +}); + +test('getTestContext works in test body during async operations', async (t) => { + const ctx = getTestContext(); + assert.ok(ctx !== undefined); + assert.strictEqual(ctx.name, 'getTestContext works in test body during async operations'); + + // Also works in nested async context + const ctxInSetImmediate = await new Promise((resolve) => { + setImmediate(() => resolve(getTestContext())); + }); + assert.ok(ctxInSetImmediate !== undefined); + assert.strictEqual(ctxInSetImmediate.name, 'getTestContext works in test body during async operations'); +}); diff --git a/test/js/node/test/parallel/test-runner-mock-timers-date.js b/test/js/node/test/parallel/test-runner-mock-timers-date.js new file mode 100644 index 000000000000..06ae8817b102 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mock-timers-date.js @@ -0,0 +1,130 @@ +'use strict'; +process.env.NODE_TEST_KNOWN_GLOBALS = 0; +require('../common'); + +const assert = require('node:assert'); +const { it, describe } = require('node:test'); + +describe('Mock Timers Date Test Suite', () => { + it('should return the initial UNIX epoch if not specified', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const date = new Date(); + assert.strictEqual(date.getTime(), 0); + assert.strictEqual(Date.now(), 0); + }); + + it('should throw an error if setTime is called without enabling timers', (t) => { + assert.throws( + () => { + t.mock.timers.setTime(100); + }, + { code: 'ERR_INVALID_STATE' } + ); + }); + + it('should throw an error if epoch passed to enable is not valid', (t) => { + assert.throws( + () => { + t.mock.timers.enable({ now: -1 }); + }, + { code: 'ERR_INVALID_ARG_VALUE' } + ); + + assert.throws( + () => { + t.mock.timers.enable({ now: 'string' }); + }, + { code: 'ERR_INVALID_ARG_TYPE' } + ); + + assert.throws( + () => { + t.mock.timers.enable({ now: NaN }); + }, + { code: 'ERR_INVALID_ARG_VALUE' } + ); + }); + + it('should replace the original Date with the mocked one', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + assert.ok(Date.isMock); + }); + + it('should return the ticked time when calling Date.now after tick', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const time = 100; + t.mock.timers.tick(time); + assert.strictEqual(Date.now(), time); + }); + + it('should return the Date as string when calling it as a function', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const returned = Date(); + // Matches the format: 'Mon Jan 01 1970 00:00:00' + // We don't care about the date, just the format + assert.match( + returned, + /\w{3}\s\w{3}\s\d{1,2}\s\d{2,4}\s\d{1,2}:\d{2}:\d{2}/, + ); + }); + + it('should return the date with different argument calls', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + assert.strictEqual(new Date(0).getTime(), 0); + assert.strictEqual(new Date(100).getTime(), 100); + assert.strictEqual(new Date('1970-01-01T00:00:00.000Z').getTime(), 0); + assert.strictEqual(new Date(1970, 0).getFullYear(), 1970); + assert.strictEqual(new Date(1970, 0).getMonth(), 0); + assert.strictEqual(new Date(1970, 0, 1).getDate(), 1); + assert.strictEqual(new Date(1970, 0, 1, 11).getHours(), 11); + assert.strictEqual(new Date(1970, 0, 1, 11, 10).getMinutes(), 10); + assert.strictEqual(new Date(1970, 0, 1, 11, 10, 45).getSeconds(), 45); + assert.strictEqual(new Date(1970, 0, 1, 11, 10, 45, 898).getMilliseconds(), 898); + assert.strictEqual(new Date(1970, 0, 1, 11, 10, 45, 898).toDateString(), 'Thu Jan 01 1970'); + }); + + it('should return native code when calling Date.toString', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + assert.strictEqual(Date.toString(), 'function Date() { [native code] }'); + }); + + it('should start with a custom epoch if the second argument is specified', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100 }); + const date1 = new Date(); + assert.strictEqual(date1.getTime(), 100); + + t.mock.timers.reset(); + t.mock.timers.enable({ apis: ['Date'], now: new Date(200) }); + const date2 = new Date(); + assert.strictEqual(date2.getTime(), 200); + }); + + it('should replace epoch if setTime is lesser than now and not tick', (t) => { + t.mock.timers.enable(); + const fn = t.mock.fn(); + const id = setTimeout(fn, 1000); + t.mock.timers.setTime(800); + assert.strictEqual(Date.now(), 800); + t.mock.timers.setTime(500); + assert.strictEqual(Date.now(), 500); + assert.strictEqual(fn.mock.callCount(), 0); + clearTimeout(id); + }); + + it('should not tick time when setTime is called', (t) => { + t.mock.timers.enable(); + const fn = t.mock.fn(); + const id = setTimeout(fn, 1000); + t.mock.timers.setTime(1200); + assert.strictEqual(Date.now(), 1200); + assert.strictEqual(fn.mock.callCount(), 0); + clearTimeout(id); + }); + + it((t) => { + t.mock.timers.enable(); + t.test('should throw when a already-mocked Date is mocked', (t2) => { + assert.throws(() => t2.mock.timers.enable(), { code: 'ERR_INVALID_STATE' }); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-runner-mock-timers-scheduler.js b/test/js/node/test/parallel/test-runner-mock-timers-scheduler.js new file mode 100644 index 000000000000..fa019221fc88 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mock-timers-scheduler.js @@ -0,0 +1,122 @@ +'use strict'; +process.env.NODE_TEST_KNOWN_GLOBALS = 0; +const common = require('../common'); + +const assert = require('node:assert'); +const { it, describe } = require('node:test'); +const nodeTimersPromises = require('node:timers/promises'); + +describe('Mock Timers Scheduler Test Suite', () => { + it('should advance in time and trigger timers when calling the .tick function', (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + + const now = Date.now(); + const durationAtMost = 100; + + const p = nodeTimersPromises.scheduler.wait(4000); + t.mock.timers.tick(4000); + + return p.then(common.mustCall((result) => { + assert.strictEqual(result, undefined); + assert.ok( + Date.now() - now < durationAtMost, + `time should be advanced less than the ${durationAtMost}ms` + ); + })); + }); + + it('should advance in time and trigger timers when calling the .tick function multiple times', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + + const fn = t.mock.fn(); + + nodeTimersPromises.scheduler.wait(9999).then(fn).then(common.mustCall()); + + t.mock.timers.tick(8999); + assert.strictEqual(fn.mock.callCount(), 0); + t.mock.timers.tick(500); + + await nodeTimersPromises.setImmediate(); + + assert.strictEqual(fn.mock.callCount(), 0); + t.mock.timers.tick(500); + + await nodeTimersPromises.setImmediate(); + assert.strictEqual(fn.mock.callCount(), 1); + }); + + it('should work with the same params as the original timers/promises/scheduler.wait', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + const controller = new AbortController(); + const p = nodeTimersPromises.scheduler.wait(2000, { + ref: true, + signal: controller.signal, + }); + + t.mock.timers.tick(1000); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + + const result = await p; + assert.strictEqual(result, undefined); + }); + + it('should abort operation if timers/promises/scheduler.wait received an aborted signal', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + const controller = new AbortController(); + const p = nodeTimersPromises.scheduler.wait(2000, { + ref: true, + signal: controller.signal, + }); + + t.mock.timers.tick(1000); + controller.abort(); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + it('should abort operation even if the .tick was not called', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + const controller = new AbortController(); + const p = nodeTimersPromises.scheduler.wait(2000, { + ref: true, + signal: controller.signal, + }); + + controller.abort(); + + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + + it('should abort operation when .abort is called before calling setInterval', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + const p = nodeTimersPromises.scheduler.wait(2000, { + ref: true, + signal: AbortSignal.abort(), + }); + + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + + it('should reject given an an invalid signal instance', async (t) => { + t.mock.timers.enable({ apis: ['scheduler.wait'] }); + const p = nodeTimersPromises.scheduler.wait(2000, { + ref: true, + signal: {}, + }); + + await assert.rejects(() => p, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', + }); + }); +}); diff --git a/test/js/node/test/parallel/test-runner-mock-timers.js b/test/js/node/test/parallel/test-runner-mock-timers.js new file mode 100644 index 000000000000..25510fe023d0 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mock-timers.js @@ -0,0 +1,906 @@ +// Flags: --expose-internals +'use strict'; +process.env.NODE_TEST_KNOWN_GLOBALS = 0; +const common = require('../common'); + +const assert = require('node:assert'); +const { listenerCount } = require('node:events'); +const { it, mock, describe } = require('node:test'); +const nodeTimers = require('node:timers'); +const nodeTimersPromises = require('node:timers/promises'); +const { TIMEOUT_MAX } = require('internal/timers'); + +describe('Mock Timers Test Suite', () => { + describe('MockTimers API', () => { + it('should throw an error if trying to enable a timer that is not supported', (t) => { + assert.throws(() => { + t.mock.timers.enable({ apis: ['DOES_NOT_EXIST'] }); + }, { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('should throw an error if data type of trying to enable a timer is not string', (t) => { + assert.throws(() => { + t.mock.timers.enable({ apis: [1] }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + it('should throw an error if trying to enable a timer twice', (t) => { + t.mock.timers.enable(); + assert.throws(() => { + t.mock.timers.enable(); + }, { + code: 'ERR_INVALID_STATE', + }); + }); + + it('should not throw if calling reset without enabling timers', (t) => { + t.mock.timers.reset(); + }); + + it('should throw an error if calling tick without enabling timers', (t) => { + assert.throws(() => { + t.mock.timers.tick(); + }, { + code: 'ERR_INVALID_STATE', + }); + }); + + it('should throw an error if calling tick with a negative number', (t) => { + t.mock.timers.enable(); + assert.throws(() => { + t.mock.timers.tick(-1); + }, { + code: 'ERR_INVALID_ARG_VALUE', + }); + }); + + it('should check that propertyDescriptor gets back after resetting timers', (t) => { + const getDescriptor = (ctx, fn) => Object.getOwnPropertyDescriptor(ctx, fn); + const getCurrentTimersDescriptors = () => { + const timers = [ + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'setImmediate', + 'clearImmediate', + ]; + + const globalTimersDescriptors = timers.map((fn) => getDescriptor(globalThis, fn)); + const nodeTimersDescriptors = timers.map((fn) => getDescriptor(nodeTimers, fn)); + const nodeTimersPromisesDescriptors = timers + .filter((fn) => !fn.includes('clear')) + .map((fn) => getDescriptor(nodeTimersPromises, fn)); + + return { + global: globalTimersDescriptors, + nodeTimers: nodeTimersDescriptors, + nodeTimersPromises: nodeTimersPromisesDescriptors, + }; + }; + + const originalDescriptors = getCurrentTimersDescriptors(); + + t.mock.timers.enable(); + const during = getCurrentTimersDescriptors(); + t.mock.timers.reset(); + const after = getCurrentTimersDescriptors(); + + for (const env in originalDescriptors) { + for (const prop in originalDescriptors[env]) { + const originalDescriptor = originalDescriptors[env][prop]; + const afterDescriptor = after[env][prop]; + + assert.deepStrictEqual( + originalDescriptor, + afterDescriptor, + ); + + assert.notDeepStrictEqual( + originalDescriptor, + during[env][prop], + ); + + assert.notDeepStrictEqual( + during[env][prop], + after[env][prop], + ); + + } + } + }); + + it('should reset all timers when calling .reset function', (t) => { + t.mock.timers.enable(); + const fn = t.mock.fn(); + globalThis.setTimeout(fn, 1000); + t.mock.timers.reset(); + assert.deepStrictEqual(Date.now, globalThis.Date.now); + assert.throws(() => { + t.mock.timers.tick(1000); + }, { + code: 'ERR_INVALID_STATE', + }); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('should reset all timers when calling Symbol.dispose', (t) => { + t.mock.timers.enable(); + const fn = t.mock.fn(); + globalThis.setTimeout(fn, 1000); + // TODO(benjamingr) refactor to `using` + t.mock.timers[Symbol.dispose](); + assert.throws(() => { + t.mock.timers.tick(1000); + }, { + code: 'ERR_INVALID_STATE', + }); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('should execute in order if timeout is the same', (t) => { + t.mock.timers.enable(); + const order = []; + const fn1 = t.mock.fn(() => order.push('f1')); + const fn2 = t.mock.fn(() => order.push('f2')); + globalThis.setTimeout(fn1, 1000); + globalThis.setTimeout(fn2, 1000); + t.mock.timers.tick(1000); + assert.strictEqual(fn1.mock.callCount(), 1); + assert.strictEqual(fn2.mock.callCount(), 1); + assert.deepStrictEqual(order, ['f1', 'f2']); + }); + + describe('runAll Suite', () => { + it('should throw an error if calling runAll without enabling timers', (t) => { + assert.throws(() => { + t.mock.timers.runAll(); + }, { + code: 'ERR_INVALID_STATE', + }); + }); + + it('should trigger all timers when calling .runAll function', async (t) => { + const timeoutFn = t.mock.fn(); + const intervalFn = t.mock.fn(); + + t.mock.timers.enable(); + globalThis.setTimeout(timeoutFn, 1111); + const id = globalThis.setInterval(intervalFn, 9999); + t.mock.timers.runAll(); + + globalThis.clearInterval(id); + assert.strictEqual(timeoutFn.mock.callCount(), 1); + assert.strictEqual(intervalFn.mock.callCount(), 1); + }); + + it('should increase the epoch as the tick run for runAll', async (t) => { + const timeoutFn = t.mock.fn(); + const intervalFn = t.mock.fn(); + + t.mock.timers.enable(); + globalThis.setTimeout(timeoutFn, 1111); + const id = globalThis.setInterval(intervalFn, 9999); + t.mock.timers.runAll(); + + globalThis.clearInterval(id); + assert.strictEqual(timeoutFn.mock.callCount(), 1); + assert.strictEqual(intervalFn.mock.callCount(), 1); + assert.strictEqual(Date.now(), 9999); + }); + + it('should not error if there are not timers to run', (t) => { + t.mock.timers.enable(); + t.mock.timers.runAll(); + // Should not throw + }); + }); + + it('interval cleared inside callback should only fire once', (t) => { + t.mock.timers.enable(); + const calls = []; + + setInterval(() => { + calls.push('foo'); + }, 10); + const timerId = setInterval(() => { + calls.push('bar'); + clearInterval(timerId); + }, 10); + + t.mock.timers.tick(10); + t.mock.timers.tick(10); + + assert.deepStrictEqual( + calls, + ['foo', 'bar', 'foo'], + ); + }); + }); + + describe('globals/timers', () => { + describe('setTimeout Suite', () => { + it('should advance in time and trigger timers when calling the .tick function', (t) => { + mock.timers.enable({ apis: ['setTimeout'] }); + + const fn = mock.fn(); + + globalThis.setTimeout(fn, 4000); + + mock.timers.tick(4000); + assert.strictEqual(fn.mock.callCount(), 1); + mock.timers.reset(); + }); + + it('should advance in time and trigger timers when calling the .tick function multiple times', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + + globalThis.setTimeout(fn, 2000); + + t.mock.timers.tick(1000); + assert.strictEqual(fn.mock.callCount(), 0); + t.mock.timers.tick(500); + assert.strictEqual(fn.mock.callCount(), 0); + t.mock.timers.tick(500); + assert.strictEqual(fn.mock.callCount(), 1); + }); + + it('should work with the same params as the original setTimeout', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + const args = ['a', 'b', 'c']; + globalThis.setTimeout(fn, 2000, ...args); + + t.mock.timers.tick(1000); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + + assert.strictEqual(fn.mock.callCount(), 1); + assert.deepStrictEqual(fn.mock.calls[0].arguments, args); + }); + + it('should expose Timeout.prototype[Symbol.dispose]', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + const timeout = globalThis.setTimeout(fn, 2000); + + assert.strictEqual(typeof timeout[Symbol.dispose], 'function'); + + timeout[Symbol.dispose](); + t.mock.timers.tick(2000); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('should expose Timeout.prototype.close()', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + const timeout = globalThis.setTimeout(fn, 2000); + + assert.strictEqual(typeof timeout.close, 'function'); + assert.strictEqual(timeout.close(), timeout); + + t.mock.timers.tick(2000); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('should keep setTimeout working if timers are disabled', (t, done) => { + const now = Date.now(); + const timeout = 2; + const expected = () => now - timeout; + globalThis.setTimeout(common.mustCall(() => { + assert.strictEqual(now - timeout, expected()); + done(); + }), timeout); + }); + + it('should change timeout to 1ms when it is > TIMEOUT_MAX', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + globalThis.setTimeout(fn, TIMEOUT_MAX + 1); + t.mock.timers.tick(1); + assert.strictEqual(fn.mock.callCount(), 1); + }); + + it('should change the delay to one if timeout < 0', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const fn = t.mock.fn(); + globalThis.setTimeout(fn, -1); + t.mock.timers.tick(1); + assert.strictEqual(fn.mock.callCount(), 1); + }); + }); + + describe('clearTimeout Suite', () => { + it('should not advance in time if clearTimeout was invoked', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + + const fn = mock.fn(); + + const id = globalThis.setTimeout(fn, 4000); + globalThis.clearTimeout(id); + t.mock.timers.tick(4000); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('clearTimeout does not throw on null and undefined', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + + nodeTimers.clearTimeout(); + nodeTimers.clearTimeout(null); + }); + }); + + describe('setInterval Suite', () => { + it('should tick three times using fake setInterval', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const fn = t.mock.fn(); + + const id = globalThis.setInterval(fn, 200); + + t.mock.timers.tick(200); + t.mock.timers.tick(200); + t.mock.timers.tick(200); + + globalThis.clearInterval(id); + + assert.strictEqual(fn.mock.callCount(), 3); + }); + + it('should work with the same params as the original setInterval', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const fn = t.mock.fn(); + const args = ['a', 'b', 'c']; + const id = globalThis.setInterval(fn, 200, ...args); + + t.mock.timers.tick(200); + t.mock.timers.tick(200); + t.mock.timers.tick(200); + + globalThis.clearInterval(id); + + assert.strictEqual(fn.mock.callCount(), 3); + assert.deepStrictEqual(fn.mock.calls[0].arguments, args); + assert.deepStrictEqual(fn.mock.calls[1].arguments, args); + assert.deepStrictEqual(fn.mock.calls[2].arguments, args); + }); + }); + + describe('clearInterval Suite', () => { + it('should not advance in time if clearInterval was invoked', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const fn = mock.fn(); + const id = globalThis.setInterval(fn, 200); + globalThis.clearInterval(id); + t.mock.timers.tick(200); + + assert.strictEqual(fn.mock.callCount(), 0); + }); + + it('clearInterval does not throw on null and undefined', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + nodeTimers.clearInterval(); + nodeTimers.clearInterval(null); + }); + }); + + describe('setImmediate Suite', () => { + it('should keep setImmediate working if timers are disabled', (t, done) => { + const now = Date.now(); + const timeout = 2; + const expected = () => now - timeout; + globalThis.setImmediate(common.mustCall(() => { + assert.strictEqual(now - timeout, expected()); + done(); + })); + }); + + it('should work with the same params as the original setImmediate', (t) => { + t.mock.timers.enable({ apis: ['setImmediate'] }); + const fn = t.mock.fn(); + const args = ['a', 'b', 'c']; + globalThis.setImmediate(fn, ...args); + t.mock.timers.tick(9999); + + assert.strictEqual(fn.mock.callCount(), 1); + assert.deepStrictEqual(fn.mock.calls[0].arguments, args); + }); + + it('should not advance in time if clearImmediate was invoked', (t) => { + t.mock.timers.enable({ apis: ['setImmediate'] }); + + const id = globalThis.setImmediate(common.mustNotCall()); + globalThis.clearImmediate(id); + t.mock.timers.tick(200); + }); + + it('should advance in time and trigger timers when calling the .tick function', (t) => { + t.mock.timers.enable({ apis: ['setImmediate'] }); + globalThis.setImmediate(common.mustCall(1)); + t.mock.timers.tick(0); + }); + + it('should execute in order if setImmediate is called multiple times', (t) => { + t.mock.timers.enable({ apis: ['setImmediate'] }); + const order = []; + const fn1 = t.mock.fn(common.mustCall(() => order.push('f1'), 1)); + const fn2 = t.mock.fn(common.mustCall(() => order.push('f2'), 1)); + + globalThis.setImmediate(fn1); + globalThis.setImmediate(fn2); + + t.mock.timers.tick(0); + + assert.deepStrictEqual(order, ['f1', 'f2']); + }); + + it('should execute setImmediate first if setTimeout was also called', (t) => { + t.mock.timers.enable({ apis: ['setImmediate', 'setTimeout'] }); + const order = []; + const fn1 = t.mock.fn(common.mustCall(() => order.push('f1'), 1)); + const fn2 = t.mock.fn(common.mustCall(() => order.push('f2'), 1)); + + globalThis.setTimeout(fn2, 0); + globalThis.setImmediate(fn1); + + t.mock.timers.tick(100); + + assert.deepStrictEqual(order, ['f1', 'f2']); + }); + }); + + describe('clearImmediate Suite', () => { + it('clearImmediate does not throw on null and undefined', (t) => { + t.mock.timers.enable({ apis: ['setImmediate'] }); + + nodeTimers.clearImmediate(); + nodeTimers.clearImmediate(null); + }); + }); + + describe('timers/promises', () => { + describe('setTimeout Suite', () => { + it('should advance in time and trigger timers when calling the .tick function multiple times', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + + const p = nodeTimersPromises.setTimeout(2000); + + t.mock.timers.tick(1000); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + + p.then(common.mustCall((result) => { + assert.strictEqual(result, undefined); + })); + }); + + it('should work with the same params as the original timers/promises/setTimeout', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const controller = new AbortController(); + const p = nodeTimersPromises.setTimeout(2000, expectedResult, { + ref: true, + signal: controller.signal, + }); + + t.mock.timers.tick(1000); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + + const result = await p; + assert.strictEqual(result, expectedResult); + }); + + it('should always return the same result as the original timers/promises/setTimeout', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + for (const expectedResult of [undefined, null, false, true, 0, 0n, 1, 1n, '', 'result', {}]) { + const p = nodeTimersPromises.setTimeout(2000, expectedResult); + t.mock.timers.tick(2000); + const result = await p; + assert.strictEqual(result, expectedResult); + } + }); + + it('should abort operation if timers/promises/setTimeout received an aborted signal', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const controller = new AbortController(); + const p = nodeTimersPromises.setTimeout(2000, expectedResult, { + ref: true, + signal: controller.signal, + }); + + t.mock.timers.tick(1000); + controller.abort(); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + t.mock.timers.tick(500); + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + it('should abort operation even if the .tick was not called', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const controller = new AbortController(); + const p = nodeTimersPromises.setTimeout(2000, expectedResult, { + ref: true, + signal: controller.signal, + }); + + controller.abort(); + + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + + it('should abort operation when .abort is called before calling setInterval', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const p = nodeTimersPromises.setTimeout(2000, expectedResult, { + ref: true, + signal: AbortSignal.abort(), + }); + + await assert.rejects(() => p, { + name: 'AbortError', + }); + }); + + it('should clear the abort listener when the timer resolves', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const controller = new AbortController(); + const p = nodeTimersPromises.setTimeout(500, expectedResult, { + ref: true, + signal: controller.signal, + }); + + assert.strictEqual(listenerCount(controller.signal, 'abort'), 1); + + t.mock.timers.tick(500); + await p; + assert.strictEqual(listenerCount(controller.signal, 'abort'), 0); + }); + + it('should reject given an an invalid signal instance', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const expectedResult = 'result'; + const p = nodeTimersPromises.setTimeout(2000, expectedResult, { + ref: true, + signal: {}, + }); + + await assert.rejects(() => p, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', + }); + }); + + // Test for https://github.com/nodejs/node/issues/50365 + it('should not affect other timers when aborting', async (t) => { + const f1 = t.mock.fn(); + const f2 = t.mock.fn(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const ac = new AbortController(); + + // id 1 & pos 1 in priority queue + nodeTimersPromises.setTimeout(100, undefined, { signal: ac.signal }).then(f1, f1).then(common.mustCall()); + // id 2 & pos 1 in priority queue (id 1 is moved to pos 2) + nodeTimersPromises.setTimeout(50).then(f2, f2).then(common.mustCall()); + + ac.abort(); // BUG: will remove timer at pos 1 not timer with id 1! + + t.mock.timers.runAll(); + await nodeTimersPromises.setImmediate(); // let promises settle + + // First setTimeout is aborted + assert.strictEqual(f1.mock.callCount(), 1); + assert.strictEqual(f1.mock.calls[0].arguments[0].code, 'ABORT_ERR'); + + // Second setTimeout should resolve, but never settles, because it was eronously removed by ac.abort() + assert.strictEqual(f2.mock.callCount(), 1); + }); + + // Test for https://github.com/nodejs/node/issues/50365 + it('should not affect other timers when aborted after triggering', async (t) => { + const f1 = t.mock.fn(); + const f2 = t.mock.fn(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const ac = new AbortController(); + + // id 1 & pos 1 in priority queue + nodeTimersPromises.setTimeout(50, true, { signal: ac.signal }).then(f1, f1).then(common.mustCall()); + // id 2 & pos 2 in priority queue + nodeTimersPromises.setTimeout(100).then(f2, f2).then(common.mustCall()); + + // First setTimeout resolves + t.mock.timers.tick(50); + await nodeTimersPromises.setImmediate(); // let promises settle + assert.strictEqual(f1.mock.callCount(), 1); + assert.strictEqual(f1.mock.calls[0].arguments.length, 1); + assert.strictEqual(f1.mock.calls[0].arguments[0], true); + + // Now timer with id 2 will be at pos 1 in priority queue + ac.abort(); // BUG: will remove timer at pos 1 not timer with id 1! + + // Second setTimeout should resolve, but never settles, because it was eronously removed by ac.abort() + t.mock.timers.runAll(); + await nodeTimersPromises.setImmediate(); // let promises settle + assert.strictEqual(f2.mock.callCount(), 1); + }); + + it('should not affect other timers when clearing timeout inside own callback', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = t.mock.fn(); + + const timer = nodeTimers.setTimeout(() => { + f(); + // Clearing the already-expired timeout should do nothing + nodeTimers.clearTimeout(timer); + }, 50); + nodeTimers.setTimeout(f, 50); + nodeTimers.setTimeout(f, 50); + + t.mock.timers.runAll(); + assert.strictEqual(f.mock.callCount(), 3); + }); + + it('should allow clearing timeout inside own callback', (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const f = t.mock.fn(); + + const timer = nodeTimers.setTimeout(() => { + f(); + nodeTimers.clearTimeout(timer); + }, 50); + + t.mock.timers.runAll(); + assert.strictEqual(f.mock.callCount(), 1); + }); + }); + + describe('setInterval Suite', () => { + it('should tick three times using fake setInterval', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const interval = 100; + const intervalIterator = nodeTimersPromises.setInterval(interval, Date.now()); + + const first = intervalIterator.next(); + const second = intervalIterator.next(); + const third = intervalIterator.next(); + + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + + const results = await Promise.all([ + first, + second, + third, + ]); + + const finished = await intervalIterator.return(); + assert.deepStrictEqual(finished, { done: true, value: undefined }); + for (const result of results) { + assert.strictEqual(typeof result.value, 'number'); + assert.strictEqual(result.done, false); + } + }); + it('should tick five times testing a real use case', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const expectedIterations = 5; + const interval = 1000; + let time = 0; + async function run() { + const times = []; + for await (const _ of nodeTimersPromises.setInterval(interval)) { // eslint-disable-line no-unused-vars + time += interval; + times.push(time); + if (times.length === expectedIterations) break; + } + return times; + } + + const r = run(); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + + const timeResults = await r; + assert.strictEqual(timeResults.length, expectedIterations); + for (let it = 1; it < expectedIterations; it++) { + assert.strictEqual(timeResults[it - 1], interval * it); + } + }); + + it('should always return the same result as the original timers/promises/setInterval', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + for (const expectedResult of [undefined, null, false, true, 0, 0n, 1, 1n, '', 'result', {}]) { + const intervalIterator = nodeTimersPromises.setInterval(2000, expectedResult); + const p = intervalIterator.next(); + t.mock.timers.tick(2000); + const result = await p; + await intervalIterator.return(); + assert.strictEqual(result.done, false); + assert.strictEqual(result.value, expectedResult); + } + }); + + it('should abort operation given an abort controller signal', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const interval = 100; + const abortController = new AbortController(); + const intervalIterator = nodeTimersPromises.setInterval(interval, Date.now(), { + signal: abortController.signal, + }); + + const first = intervalIterator.next(); + const second = intervalIterator.next(); + + t.mock.timers.tick(interval); + abortController.abort(); + t.mock.timers.tick(interval); + + const firstResult = await first; + // Interval * 2 because value can be a little bit greater than interval + assert.ok(firstResult.value < Date.now() + interval * 2); + assert.strictEqual(firstResult.done, false); + + await assert.rejects(() => second, { + name: 'AbortError', + }); + }); + + it('should abort operation when .abort is called before calling setInterval', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const interval = 100; + const intervalIterator = nodeTimersPromises.setInterval(interval, Date.now(), { + signal: AbortSignal.abort(), + }); + + const first = intervalIterator.next(); + t.mock.timers.tick(interval); + + await assert.rejects(() => first, { + name: 'AbortError', + }); + }); + + it('should clear the abort listener when the interval returns', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + + const abortController = new AbortController(); + const intervalIterator = nodeTimersPromises.setInterval(1, Date.now(), { + signal: abortController.signal, + }); + + const first = intervalIterator.next(); + t.mock.timers.tick(); + + await first; + assert.strictEqual(listenerCount(abortController.signal, 'abort'), 1); + await intervalIterator.return(); + assert.strictEqual(listenerCount(abortController.signal, 'abort'), 0); + }); + + it('should abort operation given an abort controller signal on a real use case', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const controller = new AbortController(); + const signal = controller.signal; + const interval = 200; + const expectedIterations = 2; + let numIterations = 0; + async function run() { + const it = nodeTimersPromises.setInterval(interval, undefined, { signal }); + for await (const _ of it) { // eslint-disable-line no-unused-vars + numIterations += 1; + if (numIterations === 5) break; + } + } + + const r = run(); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + controller.abort(); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + t.mock.timers.tick(interval); + + await assert.rejects(() => r, { + name: 'AbortError', + }); + assert.strictEqual(numIterations, expectedIterations); + }); + + // Test for https://github.com/nodejs/node/issues/50381 + it('should use the mocked interval', (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + const fn = t.mock.fn(); + setInterval(fn, 1000); + assert.strictEqual(fn.mock.callCount(), 0); + t.mock.timers.tick(1000); + assert.strictEqual(fn.mock.callCount(), 1); + t.mock.timers.tick(1); + t.mock.timers.tick(1); + t.mock.timers.tick(1); + assert.strictEqual(fn.mock.callCount(), 1); + }); + + // Test for https://github.com/nodejs/node/issues/50382 + it('should not prevent due timers to be processed', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] }); + const f1 = t.mock.fn(); + const f2 = t.mock.fn(); + + setInterval(f1, 1000); + setTimeout(f2, 1001); + + assert.strictEqual(f1.mock.callCount(), 0); + assert.strictEqual(f2.mock.callCount(), 0); + + t.mock.timers.tick(1001); + + assert.strictEqual(f1.mock.callCount(), 1); + assert.strictEqual(f2.mock.callCount(), 1); + }); + }); + }); + }); + + describe('Api should have same public properties as original', () => { + it('should have hasRef', (t) => { + t.mock.timers.enable(); + const timer = setTimeout(); + assert.strictEqual(typeof timer.hasRef, 'function'); + assert.strictEqual(timer.hasRef(), true); + clearTimeout(timer); + }); + + it('should have ref', (t) => { + t.mock.timers.enable(); + const timer = setTimeout(); + assert.ok(typeof timer.ref === 'function'); + assert.deepStrictEqual(timer.ref(), timer); + clearTimeout(timer); + }); + + it('should have unref', (t) => { + t.mock.timers.enable(); + const timer = setTimeout(); + assert.ok(typeof timer.unref === 'function'); + assert.deepStrictEqual(timer.unref(), timer); + clearTimeout(timer); + }); + + it('should have refresh', (t) => { + t.mock.timers.enable(); + const timer = setTimeout(); + assert.ok(typeof timer.refresh === 'function'); + assert.deepStrictEqual(timer.refresh(), timer); + clearTimeout(timer); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-runner-option-validation.js b/test/js/node/test/parallel/test-runner-option-validation.js new file mode 100644 index 000000000000..9d0129253613 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-option-validation.js @@ -0,0 +1,26 @@ +'use strict'; +require('../common'); +const assert = require('assert'); +const test = require('node:test'); + +[Symbol(), {}, [], () => {}, 1n, true, '1'].forEach((timeout) => { + assert.throws(() => test({ timeout }), { code: 'ERR_INVALID_ARG_TYPE' }); +}); +[-1, -Infinity, NaN, 2 ** 33, Number.MAX_SAFE_INTEGER].forEach((timeout) => { + assert.throws(() => test({ timeout }), { code: 'ERR_OUT_OF_RANGE' }); +}); +[null, undefined, Infinity, 0, 1, 1.1].forEach((timeout) => { + // Valid values should not throw. + test({ timeout }); +}); + +[Symbol(), {}, [], () => {}, 1n, '1'].forEach((concurrency) => { + assert.throws(() => test({ concurrency }), { code: 'ERR_INVALID_ARG_TYPE' }); +}); +[-1, 0, 1.1, -Infinity, NaN, 2 ** 33, Number.MAX_SAFE_INTEGER].forEach((concurrency) => { + assert.throws(() => test({ concurrency }), { code: 'ERR_OUT_OF_RANGE' }); +}); +[null, undefined, 1, 2 ** 31, true, false].forEach((concurrency) => { + // Valid values should not throw. + test({ concurrency }); +}); diff --git a/test/js/node/test/parallel/test-runner-tags-inheritance.mjs b/test/js/node/test/parallel/test-runner-tags-inheritance.mjs new file mode 100644 index 000000000000..0285ad97b678 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-tags-inheritance.mjs @@ -0,0 +1,118 @@ +import { expectWarning } from '../common/index.mjs'; +import assert from 'node:assert'; +import { afterEach, beforeEach, describe, it, test } from 'node:test'; + +expectWarning('ExperimentalWarning', 'Test tags is an experimental feature and might change at any time'); + +test('child inherits parent suite tags', () => { + describe('outer', { tags: ['db'] }, () => { + it('child', (t) => { + assert.deepStrictEqual(t.tags, ['db']); + }); + }); +}); + +test('child unions own tags with parent', () => { + describe('outer', { tags: ['db'] }, () => { + it('child', { tags: ['integration'] }, (t) => { + assert.deepStrictEqual(t.tags, ['db', 'integration']); + }); + }); +}); + +test('parent tags appear before child tags (parent-first order)', () => { + describe('outer', { tags: ['z'] }, () => { + it('child', { tags: ['a'] }, (t) => { + assert.deepStrictEqual(t.tags, ['z', 'a']); + }); + }); +}); + +test('union dedupes across parent and child', () => { + describe('outer', { tags: ['db'] }, () => { + it('child', { tags: ['DB', 'integration'] }, (t) => { + assert.deepStrictEqual(t.tags, ['db', 'integration']); + }); + }); +}); + +test('multi-level nesting flattens by union', () => { + describe('outer', { tags: ['a'] }, () => { + describe('mid', { tags: ['b'] }, () => { + it('leaf', { tags: ['c'] }, (t) => { + assert.deepStrictEqual(t.tags, ['a', 'b', 'c']); + }); + }); + }); +}); + +test('child without own tags inherits parent set unchanged', () => { + describe('outer', { tags: ['db', 'fast'] }, () => { + it('child', (t) => { + assert.deepStrictEqual(t.tags, ['db', 'fast']); + }); + }); +}); + +test('tagged child under untagged parent shows only own tags', () => { + describe('outer', () => { + it('child', { tags: ['x'] }, (t) => { + assert.deepStrictEqual(t.tags, ['x']); + }); + }); +}); + +test('untagged child under untagged parent has empty tags', () => { + describe('outer', () => { + it('child', (t) => { + assert.deepStrictEqual(t.tags, []); + }); + }); +}); + +test('tags: [] yields an empty array, not undefined', () => { + it('empty', { tags: [] }, (t) => { + assert.deepStrictEqual(t.tags, []); + }); +}); + +test('tags: [] on a child still inherits parent tags (not an opt-out)', () => { + describe('outer', { tags: ['db'] }, () => { + it('child', { tags: [] }, (t) => { + assert.deepStrictEqual(t.tags, ['db']); + }); + }); +}); + +test('t.tags returns a frozen view', () => { + it('frozen', { tags: ['db'] }, (t) => { + const tags = t.tags; + assert.strictEqual(Object.isFrozen(tags), true); + }); +}); + +test('successive t.tags reads return equivalent arrays', () => { + it('idempotent', { tags: ['db', 'fast'] }, (t) => { + assert.deepStrictEqual(t.tags, t.tags); + assert.deepStrictEqual(t.tags, ['db', 'fast']); + }); +}); + +test('beforeEach and afterEach see the flattened tags of the current test', () => { + describe('outer', { tags: ['db'] }, () => { + beforeEach((t) => t.assert.deepStrictEqual(t.tags, ['db', 'integration'])); + afterEach((t) => t.assert.deepStrictEqual(t.tags, ['db', 'integration'])); + it('child', { tags: ['integration'] }, (t) => { + t.assert.deepStrictEqual(t.tags, ['db', 'integration']); + }); + }); +}); + +test('context.test() accepts and inherits tags', async (t) => { + await t.test('parent', { tags: ['db'] }, async (parent) => { + parent.assert.deepStrictEqual(parent.tags, ['db']); + await parent.test('child', { tags: ['integration'] }, (child) => { + child.assert.deepStrictEqual(child.tags, ['db', 'integration']); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-runner-test-filepath.js b/test/js/node/test/parallel/test-runner-test-filepath.js new file mode 100644 index 000000000000..7b86d851a4d0 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-test-filepath.js @@ -0,0 +1,52 @@ +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('node:assert'); +const { writeFileSync } = require('node:fs'); +const { suite, test } = require('node:test'); + +tmpdir.refresh(); + +suite('suite', common.mustCall((t) => { + assert.strictEqual(t.filePath, __filename); + + test('test', (t) => { + assert.strictEqual(t.filePath, __filename); + + t.test('subtest', (t) => { + assert.strictEqual(t.filePath, __filename); + + t.test('subsubtest', (t) => { + assert.strictEqual(t.filePath, __filename); + }); + }); + }); +})); + +test((t) => { + assert.strictEqual(t.filePath, __filename); +}); + +const importedTestFile = tmpdir.resolve('temp.js'); +writeFileSync(importedTestFile, ` + 'use strict'; + const { strictEqual } = require('node:assert'); + const { suite, test } = require('node:test'); + + suite('imported suite', (t) => { + strictEqual(t.filePath, ${JSON.stringify(__filename)}); + + test('imported test', (t) => { + strictEqual(t.filePath, ${JSON.stringify(__filename)}); + + t.test('imported subtest', (t) => { + strictEqual(t.filePath, ${JSON.stringify(__filename)}); + + t.test('imported subsubtest', (t) => { + strictEqual(t.filePath, ${JSON.stringify(__filename)}); + }); + }); + }); + }); +`); +require(importedTestFile); diff --git a/test/js/node/test/parallel/test-runner-test-fullname.js b/test/js/node/test/parallel/test-runner-test-fullname.js new file mode 100644 index 000000000000..cab35a7a9355 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-test-fullname.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { before, suite, test } = require('node:test'); + +before(common.mustCall((t) => { + assert.strictEqual(t.fullName, ''); +})); + +suite('suite', common.mustCall((t) => { + assert.strictEqual(t.fullName, 'suite'); + // Test new SuiteContext properties + assert.strictEqual(typeof t.passed, 'boolean'); + assert.strictEqual(t.attempt, 0); + // diagnostic() can be called to add diagnostic output + t.diagnostic('Suite diagnostic message'); + + test('test', (t) => { + assert.strictEqual(t.fullName, 'suite > test'); + + t.test('subtest', (t) => { + assert.strictEqual(t.fullName, 'suite > test > subtest'); + + t.test('subsubtest', (t) => { + assert.strictEqual(t.fullName, 'suite > test > subtest > subsubtest'); + }); + }); + }); +})); + +test((t) => { + assert.strictEqual(t.fullName, ''); +}); + +// Test SuiteContext passed, attempt, and diagnostic properties +suite('suite with context checks', common.mustCall((ctx) => { + assert.strictEqual(ctx.fullName, 'suite with context checks'); + assert.strictEqual(typeof ctx.passed, 'boolean'); + assert.strictEqual(ctx.attempt, 0); + // Verify diagnostic method is callable + ctx.diagnostic('Test diagnostic message in suite'); + + test('child test', () => { + // Verify properties are accessible in nested test + assert.strictEqual(typeof ctx.passed, 'boolean'); + assert.strictEqual(ctx.attempt, 0); + }); +})); diff --git a/test/js/node/test/parallel/test-runner-typechecking.js b/test/js/node/test/parallel/test-runner-typechecking.js index f270fb062d8b..e96761b1a054 100644 --- a/test/js/node/test/parallel/test-runner-typechecking.js +++ b/test/js/node/test/parallel/test-runner-typechecking.js @@ -6,21 +6,31 @@ require('../common'); const assert = require('assert'); const { test, describe, it } = require('node:test'); +const { isPromise } = require('util/types'); -const testOnly = typeof Bun === 'undefined' ? test('only test', { only: true }) : undefined; // disabled in bun because test.only is disabled in CI environments and it will skip the describe/it +const testOnly = test('only test', { only: true }); const testTodo = test('todo test', { todo: true }); const testSkip = test('skip test', { skip: true }); -const testOnlyShorthand = typeof Bun === 'undefined' ? test.only('only test shorthand') : undefined; // disabled in bun because test.only is disabled in CI environments and it will skip the describe/it +const testOnlyShorthand = test.only('only test shorthand'); const testTodoShorthand = test.todo('todo test shorthand'); const testSkipShorthand = test.skip('skip test shorthand'); describe('\'node:test\' and its shorthands should return the same', () => { - it('should return undefined', () => { - assert.strictEqual(testOnly, undefined); - assert.strictEqual(testTodo, undefined); - assert.strictEqual(testSkip, undefined); - assert.strictEqual(testOnlyShorthand, undefined); - assert.strictEqual(testTodoShorthand, undefined); - assert.strictEqual(testSkipShorthand, undefined); + it('should return a Promise', () => { + assert(isPromise(testOnly)); + assert(isPromise(testTodo)); + assert(isPromise(testSkip)); + assert(isPromise(testOnlyShorthand)); + assert(isPromise(testTodoShorthand)); + assert(isPromise(testSkipShorthand)); + }); + + it('should resolve undefined', async () => { + assert.strictEqual(await testOnly, undefined); + assert.strictEqual(await testTodo, undefined); + assert.strictEqual(await testSkip, undefined); + assert.strictEqual(await testOnlyShorthand, undefined); + assert.strictEqual(await testTodoShorthand, undefined); + assert.strictEqual(await testSkipShorthand, undefined); }); }); diff --git a/test/js/node/test/parallel/test-runner-wait-for.js b/test/js/node/test/parallel/test-runner-wait-for.js new file mode 100644 index 000000000000..8f1e28f12868 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-wait-for.js @@ -0,0 +1,124 @@ +'use strict'; +require('../common'); +const { suite, test } = require('node:test'); + +suite('input validation', () => { + test('throws if condition is not a function', (t) => { + t.assert.throws(() => { + t.waitFor(5); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "condition" argument must be of type function/, + }); + }); + + test('throws if options is not an object', (t) => { + t.assert.throws(() => { + t.waitFor(() => {}, null); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options" argument must be of type object/, + }); + }); + + test('throws if options.interval is not a number', (t) => { + t.assert.throws(() => { + t.waitFor(() => {}, { interval: 'foo' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.interval" property must be of type number/, + }); + }); + + test('throws if options.timeout is not a number', (t) => { + t.assert.throws(() => { + t.waitFor(() => {}, { timeout: 'foo' }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "options\.timeout" property must be of type number/, + }); + }); +}); + +test('returns the result of the condition function', async (t) => { + const result = await t.waitFor(() => { + return 42; + }); + + t.assert.strictEqual(result, 42); +}); + +test('returns the result of an async condition function', async (t) => { + const result = await t.waitFor(async () => { + return 84; + }); + + t.assert.strictEqual(result, 84); +}); + +test('errors if the condition times out', async (t) => { + await t.assert.rejects(async () => { + await t.waitFor(() => { + return new Promise(() => {}); + }, { + interval: 60_000, + timeout: 1, + }); + }, { + message: /waitFor\(\) timed out/, + }); +}); + +test('polls until the condition returns successfully', async (t) => { + let count = 0; + const result = await t.waitFor(() => { + ++count; + if (count < 4) { + throw new Error('resource is not ready yet'); + } + + return 'success'; + }, { + interval: 1, + timeout: 60_000, + }); + + t.assert.strictEqual(result, 'success'); + t.assert.strictEqual(count, 4); +}); + +test('sets last failure as error cause on timeouts', async (t) => { + const error = new Error('boom'); + await t.assert.rejects(async () => { + await t.waitFor(() => { + return new Promise((_, reject) => { + reject(error); + }); + }); + }, (err) => { + t.assert.match(err.message, /timed out/); + t.assert.strictEqual(err.cause, error); + return true; + }); +}); + +test('limits polling if condition takes longer than interval', async (t) => { + let count = 0; + + function condition() { + count++; + return new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 200); + }); + } + + const result = await t.waitFor(condition, { + interval: 1, + timeout: 60_000, + }); + + t.assert.strictEqual(result, 'success'); + t.assert.strictEqual(count, 1); +}); diff --git a/test/js/node/test/parallel/test-set-http-max-http-headers.js b/test/js/node/test/parallel/test-set-http-max-http-headers.js deleted file mode 100644 index 01061a091659..000000000000 --- a/test/js/node/test/parallel/test-set-http-max-http-headers.js +++ /dev/null @@ -1,81 +0,0 @@ -'use strict'; - -const common = require('../common'); -const assert = require('assert'); -const { spawn } = require('child_process'); -const path = require('path'); -const { suite, test } = require('node:test'); -const testName = path.join(__dirname, 'test-http-max-http-headers.js'); - -test(function(_, cb) { - console.log('running subtest expecting failure'); - - // Validate that the test fails if the max header size is too small. - const args = ['--expose-internals', - '--max-http-header-size=1024', - testName]; - const cp = spawn(process.execPath, args, { stdio: 'inherit' }); - - cp.on('close', common.mustCall((code, signal) => { - assert.strictEqual(code, 1); - assert.strictEqual(signal, null); - cb(); - })); -}); - -test(function(_, cb) { - console.log('running subtest expecting success'); - - const env = Object.assign({}, process.env, { - NODE_DEBUG: 'http' - }); - - // Validate that the test now passes if the same limit is large enough. - const args = ['--expose-internals', - '--max-http-header-size=1024', - testName, - '1024']; - const cp = spawn(process.execPath, args, { - env, - stdio: 'inherit' - }); - - cp.on('close', common.mustCall((code, signal) => { - assert.strictEqual(code, 0); - assert.strictEqual(signal, null); - cb(); - })); -}); - -const skip = process.config.variables.node_without_node_options; -suite('same checks using NODE_OPTIONS if it is supported', { skip }, () => { - const env = Object.assign({}, process.env, { - NODE_OPTIONS: '--max-http-header-size=1024' - }); - - test(function(_, cb) { - console.log('running subtest expecting failure'); - - // Validate that the test fails if the max header size is too small. - const args = ['--expose-internals', testName]; - const cp = spawn(process.execPath, args, { env, stdio: 'inherit' }); - - cp.on('close', common.mustCall((code, signal) => { - assert.strictEqual(code, 1); - assert.strictEqual(signal, null); - cb(); - })); - }); - - test(function(_, cb) { - // Validate that the test now passes if the same limit is large enough. - const args = ['--expose-internals', testName, '1024']; - const cp = spawn(process.execPath, args, { env, stdio: 'inherit' }); - - cp.on('close', common.mustCall((code, signal) => { - assert.strictEqual(code, 0); - assert.strictEqual(signal, null); - cb(); - })); - }); -}); diff --git a/test/js/node/test_runner/fixtures/05-test-in-test.js b/test/js/node/test_runner/fixtures/05-test-in-test.js index 68b2fc08f718..4551722cff38 100644 --- a/test/js/node/test_runner/fixtures/05-test-in-test.js +++ b/test/js/node/test_runner/fixtures/05-test-in-test.js @@ -1,26 +1,57 @@ const { describe, test } = require("node:test"); const assert = require("node:assert"); -// for passing to assert.throws -function expectedError(fn) { - return { - name: "NotImplementedError", - message: `${fn}() inside another test() is not yet implemented in Bun. Track the status & thumbs up the issue: https://github.com/oven-sh/bun/issues/5090. Use \`bun:test\` in the interim.`, - }; -} - -test("test() inside test() (global context) throws", () => { - assert.throws(() => test("should throw and not run the test callback", assert.fail), expectedError("test")); -}); +test("t.test() runs subtests inline and returns a promise", async t => { + const order = []; + const result = await t.test("awaited subtest", subtest => { + order.push("awaited"); + assert.strictEqual(subtest.name, "awaited subtest"); + assert.strictEqual(subtest.fullName, "t.test() runs subtests inline and returns a promise > awaited subtest"); + }); + assert.strictEqual(result, undefined); -test("test() inside test() (passed context) throws", t => { - assert.throws(() => t.test("should throw and not run the test callback", assert.fail), expectedError("test")); -}); + const unawaited = t.test("unawaited subtest", () => { + order.push("unawaited"); + }); + assert.ok(unawaited instanceof Promise); + + await t.test("nested subtests", async subtest => { + await subtest.test("inner", inner => { + order.push("inner"); + assert.strictEqual( + inner.fullName, + "t.test() runs subtests inline and returns a promise > nested subtests > inner", + ); + }); + }); -test("describe() inside test() (global context) throws", () => { - assert.throws(() => describe("should throw and not run the test callback", assert.fail), expectedError("describe")); + t.after(() => { + // The unawaited subtest must have completed before the parent finished. + assert.deepStrictEqual(order, ["awaited", "unawaited", "inner"]); + }); }); -test("describe() inside test() (passed context) throws", t => { - assert.throws(() => t.describe("should throw and not run the test callback", assert.fail), expectedError("describe")); +test("test() and describe() called inside a running test become subtests", async t => { + let describeRan = false; + let itRan = false; + + test("global test() inside a running test", subtest => { + assert.strictEqual(subtest.name, "global test() inside a running test"); + itRan = true; + }); + + describe("global describe() inside a running test", () => { + test("nested test", subtest => { + assert.strictEqual( + subtest.fullName, + "test() and describe() called inside a running test become subtests > global describe() inside a running test > nested test", + ); + describeRan = true; + }); + }); + + t.after(() => { + assert.ok(itRan, "global test() subtest ran"); + assert.ok(describeRan, "describe() subtest ran"); + }); }); diff --git a/test/js/node/test_runner/fixtures/06-hook-semantics.js b/test/js/node/test_runner/fixtures/06-hook-semantics.js new file mode 100644 index 000000000000..10162f57bad0 --- /dev/null +++ b/test/js/node/test_runner/fixtures/06-hook-semantics.js @@ -0,0 +1,66 @@ +// Hook and mock-registry semantics that must match Node v26.3.0. +const assert = require("node:assert"); +const { test, before, mock } = require("node:test"); + +test("t.before() registered on a running test runs exactly once", async t => { + let runs = 0; + t.before(() => { + runs++; + }); + await t.test("first subtest", () => {}); + await t.test("second subtest", () => {}); + assert.strictEqual(runs, 1); +}); + +test("before() registered inside a running test runs exactly once", async t => { + let runs = 0; + before(() => { + runs++; + }); + await t.test("subtest", () => {}); + assert.strictEqual(runs, 1); +}); + +test("hook options are validated", t => { + assert.throws(() => t.before(() => {}, { timeout: "x" }), { code: "ERR_INVALID_ARG_TYPE" }); + assert.throws(() => t.beforeEach(() => {}, { timeout: -1 }), { code: "ERR_OUT_OF_RANGE" }); + assert.throws(() => t.after(() => {}, { signal: {} }), { code: "ERR_INVALID_ARG_TYPE" }); + assert.throws(() => before(() => {}, { timeout: Symbol() }), { code: "ERR_INVALID_ARG_TYPE" }); +}); + +test("mock once registries never call user-patched Map.prototype methods", () => { + const keys = ["get", "set", "has", "delete"]; + const original = {}; + const calls = []; + for (const key of keys) { + original[key] = Map.prototype[key]; + Map.prototype[key] = function (...args) { + calls.push(key); + return original[key].apply(this, args); + }; + } + let fnResults; + let propertyReads; + let observedCalls; + try { + const f = mock.fn( + () => "orig", + () => "orig", + ); + f.mock.mockImplementationOnce(() => "once"); + fnResults = [f(), f()]; + const target = { p: 1 }; + const p = mock.property(target, "p", 2); + p.mock.mockImplementationOnce(3); + propertyReads = [target.p, target.p]; + observedCalls = calls.length; + mock.reset(); + } finally { + for (const key of keys) { + Map.prototype[key] = original[key]; + } + } + assert.deepStrictEqual(fnResults, ["once", "orig"]); + assert.deepStrictEqual(propertyReads, [3, 2]); + assert.strictEqual(observedCalls, 0); +}); diff --git a/test/js/node/test_runner/fixtures/07-failing-hooks.js b/test/js/node/test_runner/fixtures/07-failing-hooks.js new file mode 100644 index 000000000000..213f0e360008 --- /dev/null +++ b/test/js/node/test_runner/fixtures/07-failing-hooks.js @@ -0,0 +1,67 @@ +// Every test here must FAIL; node-test.test.ts asserts the exact counts. +// Node v26.3.0 fails all of them. +const { test, describe, before, after } = require("node:test"); + +test("a test body that rejects with undefined fails", () => Promise.reject()); + +test("an async function with a done callback fails", async (t, done) => { + done(); +}); + +test("a done callback invoked twice fails", (t, done) => { + done(); + done(); +}); + +test("a done callback called from a returned promise still fails", (t, done) => { + return Promise.resolve().then(() => done()); +}); + +test("an inline suite whose after hook fails fails the test", async () => { + describe("after-failing suite", () => { + after(() => { + throw new Error("after hook boom"); + }); + test("child", () => {}); + }); +}); + +test("an async before hook with a done callback fails the test", async t => { + t.before(async (ctx, done) => { + done(); + }); + await t.test("subtest", () => {}); +}); + +test("an async inline describe callback rejection fails the test", async () => { + describe("rejecting inline suite", async () => { + await null; + throw new Error("async describe boom"); + }); +}); + +test("an inline suite whose before hook fails fails the test", async () => { + describe("inline suite", () => { + before(() => { + throw new Error("inline suite before hook failed"); + }); + }); +}); + +test("a before hook that exceeds its timeout fails the test", async t => { + // The hook must outlive its 1ms timeout yet still settle, so that a build + // without hook timeouts passes (and this fixture's expected counts differ). + t.before(() => new Promise(resolve => setTimeout(resolve, 200)), { timeout: 1 }); + await t.test("subtest", () => {}); +}); + +test("a subtest created after its parent before hook failed does not run", async t => { + t.before(() => { + throw new Error("boom"); + }); + let bodyRan = false; + await t.test("subtest", () => { + bodyRan = true; + }); + console.log("SUB_BODY_RAN=" + bodyRan); +}); diff --git a/test/js/node/test_runner/fixtures/08-only-no-op.js b/test/js/node/test_runner/fixtures/08-only-no-op.js new file mode 100644 index 000000000000..33659d090b04 --- /dev/null +++ b/test/js/node/test_runner/fixtures/08-only-no-op.js @@ -0,0 +1,21 @@ +// Under `bun test` the shim follows Node's runner semantics: without +// --test-only, `only` registers an ordinary test/suite (and must never trip +// bun:test's CI-only guard; this file runs with CI=1 in node-test.test.ts). +const assert = require("node:assert"); +const { test, describe } = require("node:test"); + +const ran = []; +test.only("only-marked test runs", () => { + ran.push("only"); +}); +test("sibling of an only-marked test also runs", () => { + ran.push("sibling"); +}); +describe.only("only-marked suite runs", () => { + test("test inside an only-marked suite", () => { + ran.push("suite-child"); + }); +}); +test("only is a no-op without --test-only", () => { + assert.deepStrictEqual(ran.sort(), ["only", "sibling", "suite-child"]); +}); diff --git a/test/js/node/test_runner/fixtures/09-inline-suites.js b/test/js/node/test_runner/fixtures/09-inline-suites.js new file mode 100644 index 000000000000..5abd19fb3303 --- /dev/null +++ b/test/js/node/test_runner/fixtures/09-inline-suites.js @@ -0,0 +1,38 @@ +// Inline suites (describe() inside a running test), matched against Node v26.3.0. +const assert = require("node:assert"); +const { test, describe } = require("node:test"); + +test("inline suite children run after previously scheduled subtests", async t => { + const order = []; + t.test("first", async () => { + order.push("first:start"); + await new Promise(resolve => setImmediate(resolve)); + order.push("first:end"); + }); + describe("inline suite", () => { + test("child", () => { + order.push("child"); + }); + }); + await t.test("last", () => { + order.push("last"); + }); + assert.deepStrictEqual(order, ["first:start", "first:end", "child", "last"]); +}); + +test("an async inline describe callback is awaited before the suite finishes", async t => { + const order = []; + describe("async inline suite", async () => { + test("early child", () => { + order.push("early"); + }); + await null; + test("late child", () => { + order.push("late"); + }); + }); + await t.test("after the suite", () => { + order.push("after"); + }); + assert.deepStrictEqual(order, ["early", "late", "after"]); +}); diff --git a/test/js/node/test_runner/fixtures/10-done-callbacks.js b/test/js/node/test_runner/fixtures/10-done-callbacks.js new file mode 100644 index 000000000000..12b2bb4cf526 --- /dev/null +++ b/test/js/node/test_runner/fixtures/10-done-callbacks.js @@ -0,0 +1,31 @@ +// Done-callback signatures for tests and hooks (Node passes `done` when the +// function declares a second parameter), matched against Node v26.3.0. +const assert = require("node:assert"); +const { test, before, beforeEach } = require("node:test"); + +const order = []; +before((t, done) => { + setImmediate(() => { + order.push("before"); + done(); + }); +}); +beforeEach((t, done) => { + order.push("beforeEach"); + done(); +}); + +test("file-level hooks with done callbacks ran first", (t, done) => { + assert.deepStrictEqual(order, ["before", "beforeEach"]); + setImmediate(done); +}); + +test("t.beforeEach with a done callback applies to subtests", async t => { + const ran = []; + t.beforeEach((ctx, done) => { + ran.push("subtest-hook"); + done(); + }); + await t.test("subtest", () => {}); + assert.deepStrictEqual(ran, ["subtest-hook"]); +}); diff --git a/test/js/node/test_runner/fixtures/11-timeout-overrides.js b/test/js/node/test_runner/fixtures/11-timeout-overrides.js new file mode 100644 index 000000000000..63a867ea060f --- /dev/null +++ b/test/js/node/test_runner/fixtures/11-timeout-overrides.js @@ -0,0 +1,11 @@ +// node-test.test.ts runs this with `--timeout 100`: an explicit timeout +// (Infinity or finite) must override the runner's per-test default. +const { test } = require("node:test"); + +test("an Infinity timeout overrides the runner default", { timeout: Infinity }, async () => { + await new Promise(resolve => setTimeout(resolve, 300)); +}); + +test("a finite timeout larger than the runner default is honored", { timeout: 3000 }, async () => { + await new Promise(resolve => setTimeout(resolve, 300)); +}); diff --git a/test/js/node/test_runner/fixtures/12-runtime-todo-and-mock-timers.js b/test/js/node/test_runner/fixtures/12-runtime-todo-and-mock-timers.js new file mode 100644 index 000000000000..63d1da219dfb --- /dev/null +++ b/test/js/node/test_runner/fixtures/12-runtime-todo-and-mock-timers.js @@ -0,0 +1,46 @@ +// Runtime t.todo()/t.skip() suppression and the runner's own timers staying +// real while mock timers are enabled, matched against Node v26.3.0. +const assert = require("node:assert"); +const { test, describe } = require("node:test"); + +test("a runtime t.todo() suppresses a later failure", t => { + t.todo("not implemented yet"); + throw new Error("expected failure under todo"); +}); + +test("a runtime t.skip() suppresses a later failure", t => { + t.skip("skipped at runtime"); + throw new Error("expected failure under skip"); +}); + +test("an inline describe.todo() with a failing child does not fail the test", async () => { + describe.todo("todo suite", () => { + test("child", () => { + throw new Error("child boom"); + }); + }); +}); + +test("an inline describe with todo: true and a failing child does not fail the test", async () => { + describe("todo option suite", { todo: true }, () => { + test("child", () => { + throw new Error("child boom"); + }); + }); +}); + +test("t.waitFor uses real timers while mock timers are enabled", async t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let ready = false; + setImmediate(() => { + ready = true; + }); + await t.waitFor( + () => { + assert.ok(ready); + return true; + }, + { interval: 1, timeout: 1000 }, + ); + t.mock.timers.reset(); +}); diff --git a/test/js/node/test_runner/fixtures/13-todo-bodies.js b/test/js/node/test_runner/fixtures/13-todo-bodies.js new file mode 100644 index 000000000000..bb889c73e91a --- /dev/null +++ b/test/js/node/test_runner/fixtures/13-todo-bodies.js @@ -0,0 +1,13 @@ +// node-test.test.ts runs this with `--todo`: the todo body must actually run +// (bun expects a todo body to fail; an empty registration would "pass"). +const { test } = require("node:test"); + +test.todo("a todo body runs and may fail", () => { + throw new Error("expected todo failure"); +}); + +test("a todo option body runs and may fail", { todo: true }, () => { + throw new Error("expected todo failure too"); +}); + +test("sibling test still passes", () => {}); diff --git a/test/js/node/test_runner/fixtures/14-root-hooks-a.js b/test/js/node/test_runner/fixtures/14-root-hooks-a.js new file mode 100644 index 000000000000..0b9dfb9ca8e0 --- /dev/null +++ b/test/js/node/test_runner/fixtures/14-root-hooks-a.js @@ -0,0 +1,22 @@ +// node-test.test.ts runs this file and then 14-root-hooks-b.js in one +// `bun test` process (in that order): file-level hooks, module-level mocks, +// and assert.register() additions must stay scoped to their own file +// (Node isolates per process). +const assert = require("node:assert"); +const { test, beforeEach, mock, assert: testAssert } = require("node:test"); + +beforeEach(() => { + globalThis.__fileARootHookRuns = (globalThis.__fileARootHookRuns || 0) + 1; +}); + +testAssert.register("fileAOnly", () => {}); + +// Shared with file B, which re-mocks the same method at its module scope. +globalThis.__sharedTarget = { v: () => "original" }; +mock.method(globalThis.__sharedTarget, "v", () => "A"); + +test("file A runs its own file-level beforeEach and sees its own registrations", t => { + assert.strictEqual(globalThis.__fileARootHookRuns, 1); + assert.strictEqual(typeof t.assert.fileAOnly, "function"); + assert.strictEqual(globalThis.__sharedTarget.v(), "A"); +}); diff --git a/test/js/node/test_runner/fixtures/14-root-hooks-b.js b/test/js/node/test_runner/fixtures/14-root-hooks-b.js new file mode 100644 index 000000000000..7944c4af9959 --- /dev/null +++ b/test/js/node/test_runner/fixtures/14-root-hooks-b.js @@ -0,0 +1,24 @@ +// Runs after 14-root-hooks-a.js in the same `bun test` process: this file's +// module-scope registrations (made before its first test) must survive the +// per-file reset, and file A's hooks/mocks/assertions must not apply here. +const assert = require("node:assert"); +const { test, mock, assert: testAssert } = require("node:test"); + +// File A's mock on the shared object must be restored before this one is +// captured, so restoring this one yields the true original. +mock.method(globalThis.__sharedTarget, "v", () => "B"); +testAssert.register("fileBOnly", () => {}); + +test("first test in file B", () => {}); + +test("file A's file-level beforeEach and custom assertion did not leak into file B", t => { + assert.strictEqual(globalThis.__fileARootHookRuns, 1); + assert.strictEqual(t.assert.fileAOnly, undefined); +}); + +test("module-scope registrations from this file survive and capture the true original", t => { + assert.strictEqual(typeof t.assert.fileBOnly, "function"); + assert.strictEqual(globalThis.__sharedTarget.v(), "B"); + globalThis.__sharedTarget.v.mock.restore(); + assert.strictEqual(globalThis.__sharedTarget.v(), "original"); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 3e422ae5ae0d..aa5102f30b9d 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -45,24 +45,109 @@ describe("node:test", () => { }); }); - test("should throw NotImplementedError if you call test() or describe() inside another test()", async () => { + test("should run test() and describe() called inside another test() as subtests", async () => { const { exitCode, stderr } = await runTests(["05-test-in-test.js"]); expect({ exitCode, stderr }).toMatchObject({ exitCode: 0, stderr: expect.stringContaining("0 fail"), }); }); + + test("should run before hooks created on a running test once and validate hook options", async () => { + const { exitCode, stderr } = await runTests(["06-hook-semantics.js"]); + expect(stderr).toContain("4 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should fail tests whose hooks, bodies, or inline suite callbacks fail", async () => { + const { exitCode, stdout, stderr } = await runTests(["07-failing-hooks.js"]); + // The subtest after the failing before hook must not run its body (Node). + expect(stdout).toContain("SUB_BODY_RAN=false"); + expect(stderr).toContain("0 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining("10 fail"), + }); + }); + + test("should support done callbacks in tests and hooks", async () => { + const { exitCode, stderr } = await runTests(["10-done-callbacks.js"]); + expect(stderr).toContain("2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should suppress runtime t.todo()/t.skip() failures and keep runner timers real under mock timers", async () => { + const { exitCode, stderr } = await runTests(["12-runtime-todo-and-mock-timers.js"]); + expect(stderr).toContain("5 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should run todo bodies under --todo instead of registering an empty function", async () => { + const { exitCode, stderr } = await runTests(["13-todo-bodies.js"], {}, ["--todo"]); + expect(stderr).toContain("2 todo"); + expect(stderr).toContain("1 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should forward Infinity and finite timeouts so they override the runner default", async () => { + const { exitCode, stderr } = await runTests(["11-timeout-overrides.js"], {}, ["--timeout", "100"]); + expect(stderr).toContain("2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should not leak file-level beforeEach hooks across files in one process", async () => { + const { exitCode, stderr } = await runTests(["14-root-hooks-a.js", "14-root-hooks-b.js"]); + expect(stderr).toContain("4 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should treat only as a no-op instead of using bun:test's CI-banned only()", async () => { + // bun:test's only() only throws when CI is set; pin the precondition. + const { exitCode, stderr } = await runTests(["08-only-no-op.js"], { CI: "1" }); + expect(stderr).toContain("4 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should serialize inline suites and await async describe callbacks like node", async () => { + const { exitCode, stderr } = await runTests(["09-inline-suites.js"]); + expect(stderr).toContain("2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); }); -async function runTests(filenames: string[]) { +async function runTests(filenames: string[], env: Record = {}, args: string[] = []) { const testPaths = filenames.map(filename => join(import.meta.dirname, "fixtures", filename)); const { exited, stdout: stdoutStream, stderr: stderrStream, } = spawn({ - cmd: [bunExe(), "test", ...testPaths], - env: bunEnv, + cmd: [bunExe(), "test", ...args, ...testPaths], + env: { ...bunEnv, ...env }, stderr: "pipe", }); const [exitCode, stdout, stderr] = await Promise.all([ diff --git a/test/js/web/workers/structured-clone-2gb.test.ts b/test/js/web/workers/structured-clone-2gb.test.ts new file mode 100644 index 000000000000..f5c6ec1c98f7 --- /dev/null +++ b/test/js/web/workers/structured-clone-2gb.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// Split out of structured-clone.test.ts: each child transiently needs 3-5GiB, +// so the suite is quarantined on the memory-tight linux-x64 CI shards +// (test/expectations.txt) while running everywhere else. +describe("structuredClone with ArrayBuffer larger than serialization buffer capacity", () => { + // The serialization buffer is a WTF::Vector capped at 2GiB. Cloning an + // ArrayBuffer at or above that size must throw DataCloneError instead of aborting. + // Run in a subprocess so the ~2GiB allocation does not bloat the test runner. + for (const [label, expr] of [ + ["ArrayBuffer", "new ArrayBuffer(2 ** 31)"], + ["resizable ArrayBuffer", "new ArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], + ["SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31)"], + ["growable SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], + ["Uint8Array", "new Uint8Array(2 ** 31)"], + ] as const) { + test(label, async () => { + const script = ` + let buf; + try { + buf = ${expr}; + } catch { + console.log("SKIP"); + process.exit(0); + } + try { + structuredClone(buf); + console.log("UNEXPECTED_SUCCESS"); + } catch (e) { + console.log(e.name); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(["DataCloneError", "SKIP"]).toContain(stdout.trim()); + expect(exitCode).toBe(0); + }); + } + + // A large-but-under-2GiB ArrayBuffer nested inside an object/array fills the serialization + // buffer to its reserved capacity; the subsequent terminator write then triggers vector + // growth. The default 1.5x growth exceeds the 2GiB cap and would crash. These cases must + // succeed and round-trip correctly since the total serialized size still fits under 2GiB. + for (const [label, expr, check] of [ + ["ArrayBuffer in object", "{ h: new ArrayBuffer(size) }", "r.h.byteLength === size"], + ["ArrayBuffer in array", "[new ArrayBuffer(size)]", "r[0].byteLength === size"], + ["Uint8Array in object", "{ h: new Uint8Array(size) }", "r.h.byteLength === size"], + ["nested ArrayBuffer", "{ a: { b: new ArrayBuffer(size) } }", "r.a.b.byteLength === size"], + [ + "resizable ArrayBuffer in object", + "{ h: new ArrayBuffer(size, { maxByteLength: size }) }", + "r.h.byteLength === size", + ], + ] as const) { + test(`${label} under 2GiB clones without crashing`, async () => { + const script = ` + const size = 1600000000; + let v; + try { + v = ${expr}; + } catch { + console.log("SKIP"); + process.exit(0); + } + const r = structuredClone(v); + console.log((${check}) ? "OK" : "BAD_ROUNDTRIP"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(["OK", "SKIP"]).toContain(stdout.trim()); + expect(proc.signalCode).toBe(null); + expect(exitCode).toBe(0); + }); + } +}); diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index 8bc47a1c46b4..5dde2507db8e 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -453,87 +453,6 @@ function createBlob(arr: number[]): Blob { return new Blob([view]); } -describe("structuredClone with ArrayBuffer larger than serialization buffer capacity", () => { - // The serialization buffer is a WTF::Vector capped at 2GiB. Cloning an - // ArrayBuffer at or above that size must throw DataCloneError instead of aborting. - // Run in a subprocess so the ~2GiB allocation does not bloat the test runner. - for (const [label, expr] of [ - ["ArrayBuffer", "new ArrayBuffer(2 ** 31)"], - ["resizable ArrayBuffer", "new ArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], - ["SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31)"], - ["growable SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], - ["Uint8Array", "new Uint8Array(2 ** 31)"], - ] as const) { - test(label, async () => { - const script = ` - let buf; - try { - buf = ${expr}; - } catch { - console.log("SKIP"); - process.exit(0); - } - try { - structuredClone(buf); - console.log("UNEXPECTED_SUCCESS"); - } catch (e) { - console.log(e.name); - } - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(["DataCloneError", "SKIP"]).toContain(stdout.trim()); - expect(exitCode).toBe(0); - }); - } - - // A large-but-under-2GiB ArrayBuffer nested inside an object/array fills the serialization - // buffer to its reserved capacity; the subsequent terminator write then triggers vector - // growth. The default 1.5x growth exceeds the 2GiB cap and would crash. These cases must - // succeed and round-trip correctly since the total serialized size still fits under 2GiB. - for (const [label, expr, check] of [ - ["ArrayBuffer in object", "{ h: new ArrayBuffer(size) }", "r.h.byteLength === size"], - ["ArrayBuffer in array", "[new ArrayBuffer(size)]", "r[0].byteLength === size"], - ["Uint8Array in object", "{ h: new Uint8Array(size) }", "r.h.byteLength === size"], - ["nested ArrayBuffer", "{ a: { b: new ArrayBuffer(size) } }", "r.a.b.byteLength === size"], - [ - "resizable ArrayBuffer in object", - "{ h: new ArrayBuffer(size, { maxByteLength: size }) }", - "r.h.byteLength === size", - ], - ] as const) { - test(`${label} under 2GiB clones without crashing`, async () => { - const script = ` - const size = 1600000000; - let v; - try { - v = ${expr}; - } catch { - console.log("SKIP"); - process.exit(0); - } - const r = structuredClone(v); - console.log((${check}) ? "OK" : "BAD_ROUNDTRIP"); - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(["OK", "SKIP"]).toContain(stdout.trim()); - expect(proc.signalCode).toBe(null); - expect(exitCode).toBe(0); - }); - } -}); - // A repeated object is serialized as an ObjectReferenceTag holding an index into the // serializer's object pool. The deserializer must rebuild that pool entry-for-entry: // any value it appends that the serializer did not record (BigInt primitives, From 9298be41c67bce59841d4c1c43461c29bb6bb810 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 15:36:24 -0700 Subject: [PATCH 02/18] node:test: address review feedback - bound plan({wait:true}) by the test's own timeout: arm one stop timer per test and race it against both the body and plan.check(), matching Node's stopPromise - reset per-file state on --rerun-each: expose a monotonic enter_file generation counter via Bun.jest().fileGeneration() and compare that in getRootNode() instead of Bun.main (which is unchanged across reruns) - reject t.test()/t.describe() called after the parent finished with ERR_TEST_FAILURE / failureType 'parentAlreadyFinished' instead of falling through to bun:test's internal-phase error - add __proto__:null to every Object.defineProperty descriptor literal so a polluted Object.prototype.value/.get does not turn accessor descriptors into TypeErrors (matches Node's mock.js and this file's own mock_timers.ts) - write node.passed/node.error before the afterEach/after loops so hooks can introspect the outcome; TestContext#error now returns the stored error - capture plan once at first t.assert access, matching Node (t.assert; t.plan(2); t.assert.ok(1) counts 0) - add TestContext#workerId - reproduce Node's swapped-arg tick(time) message verbatim - document only/concurrency/test-level-signal as validated-but-no-op in the compat table and code comments --- docs/runtime/nodejs-compat.mdx | 2 +- src/js/internal/test_runner/mock_timers.ts | 6 +- src/js/node/test.ts | 164 ++++++++++++++---- src/runtime/test_runner/bun_test.rs | 6 + src/runtime/test_runner/jest.rs | 18 +- .../fixtures/15-outcome-in-hooks.js | 37 ++++ .../fixtures/16-plan-and-late-subtest.js | 38 ++++ .../fixtures/16b-plan-wait-timeout.js | 8 + .../fixtures/17-rerun-mock-reset.mjs | 23 +++ test/js/node/test_runner/node-test.test.ts | 61 +++++++ 10 files changed, 320 insertions(+), 43 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/15-outcome-in-hooks.js create mode 100644 test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js create mode 100644 test/js/node/test_runner/fixtures/16b-plan-wait-timeout.js create mode 100644 test/js/node/test_runner/fixtures/17-rerun-mock-reset.mjs diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 744d6afb65ff..e385969ad2f0 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -177,7 +177,7 @@ This page is updated regularly and reflects the latest version of Bun's compatib ### [`node:test`](https://nodejs.org/api/test.html) -🟡 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). Missing `run()`, `node:test/reporters`, snapshot testing, `mock.module()`, code coverage, and Node's `--test` CLI runner mode. Use [`bun:test`](/test) instead. +🟡 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). Missing `run()`, `node:test/reporters`, snapshot testing, `mock.module()`, code coverage, `--test-only`, test-level `signal`/`t.signal` abort, and Node's `--test` CLI runner mode. `test.only()` / `{only: true}` are accepted but do not filter. `concurrency` is validated but subtests always run serially. Use [`bun:test`](/test) instead. ### [`node:trace_events`](https://nodejs.org/api/tracing.html) diff --git a/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts index 6aa4b929bbad..6d2756cc35b6 100644 --- a/src/js/internal/test_runner/mock_timers.ts +++ b/src/js/internal/test_runner/mock_timers.ts @@ -514,9 +514,9 @@ class MockTimers { #assertTimeArg(time) { if (time < 0) { - // Node passes the reason and value swapped here; bun's ERR_INVALID_ARG_VALUE - // requires a string reason, so use the conventional argument order. - throw $ERR_INVALID_ARG_VALUE("time", time, "must be a positive integer"); + // Node's swapped-arg bug reproduced verbatim (nodejs/node v26.3.0 + // lib/internal/test_runner/mock/mock_timers.js:558). + throw $ERR_INVALID_ARG_VALUE("time", "positive integer", `${time}`); } } diff --git a/src/js/node/test.ts b/src/js/node/test.ts index b1472ae89f9b..6e1930bb8bc7 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -184,6 +184,8 @@ class MockPropertyContext { const { configurable, enumerable } = descriptor; Object.defineProperty(object, propertyName, { + // @ts-ignore + __proto__: null, configurable, enumerable, get: () => { @@ -243,6 +245,8 @@ class MockPropertyContext { restore() { Object.defineProperty(this.#object, this.#propertyName, { + // @ts-ignore + __proto__: null, ...this.#descriptor, value: this.#originalValue, }); @@ -294,15 +298,21 @@ class MockTracker { return trackMockCall(context, this, args, new.target); } Object.defineProperty(mockFunction, "mock", { + // @ts-ignore + __proto__: null, value: context, writable: false, enumerable: false, }); Object.defineProperty(mockFunction, "length", { + // @ts-ignore + __proto__: null, value: original.length, configurable: true, }); Object.defineProperty(mockFunction, "name", { + // @ts-ignore + __proto__: null, value: original.name, configurable: true, }); @@ -419,11 +429,14 @@ class MockTracker { } const restore = function restore() { - Object.defineProperty(objectOrFunction, methodName, descriptor!); + // @ts-ignore + Object.defineProperty(objectOrFunction, methodName, { __proto__: null, ...descriptor! }); }; const mocked = this.#createMockFunction(original, implementation as Function | undefined, restore, times); const mockDescriptor: PropertyDescriptor = { + // @ts-ignore + __proto__: null, configurable: descriptor.configurable, enumerable: descriptor.enumerable, }; @@ -593,12 +606,17 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { // methods (minus the uncopied ones), snapshot/fileSnapshot, and custom // assertions; each call counts the plan and binds the TestContext. const result: Record = { __proto__: null } as unknown as Record; + // Node captures `plan` once at first `t.assert` access and closes over it, + // so `t.assert; t.plan(2); t.assert.ok(1)` counts 0 (nodejs/node + // lib/internal/test_runner/test.js:331). Match that. + const { plan } = node; const add = (name: string, method: Function) => { const wrapper = function (...args: unknown[]) { - planCount(node); + plan?.count(); return method.$apply(ctx, args); }; - Object.defineProperty(wrapper, "name", { value: name, configurable: true }); + // @ts-ignore + Object.defineProperty(wrapper, "name", { __proto__: null, value: name, configurable: true }); result[name] = wrapper; }; for (const key of Object.keys(nodeAssert)) { @@ -621,9 +639,12 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { // Test plan // ----------------------------------------------------------------------------- -function makeTestFailure(message: string) { +function makeTestFailure(message: string, failureType?: string) { const error = new Error(message); (error as { code?: string }).code = "ERR_TEST_FAILURE"; + if (failureType !== undefined) { + (error as { failureType?: string }).failureType = failureType; + } return error; } @@ -683,6 +704,8 @@ class TestPlan { } } +// t.test() counts against the parent's plan; only t.assert.* uses the +// captured-at-first-access snapshot (Node reads this.#test.plan fresh here). function planCount(node: TestNode) { node.plan?.count(); } @@ -766,7 +789,9 @@ class TestNode { started = false; finished = false; passed = false; - // Inline subtests are serialized through this chain (Node's default subtest concurrency is 1). + error: unknown = null; + // Inline subtests are serialized through this chain. `concurrency` is + // validated for Node-compat error codes but subtests always run serially. subtestChain: Promise = Promise.resolve(); failedSubtests = 0; firstSubtestError: unknown = undefined; @@ -842,13 +867,17 @@ class TestNode { } let rootNode: TestNode | undefined; +let rootGeneration = -1; +let fileGeneration: (() => number) | undefined; function getRootNode(): TestNode { - // bun test loads every file into one process: a new entry file gets a fresh - // root so file-level hooks and state never leak into later files (Node runs - // each test file in its own process). - if (rootNode === undefined || rootNode.filePath !== Bun.main) { + // Fresh root on each runner enter_file (per file AND per --rerun-each + // iteration) so file-level hooks/state never leak between them; Bun.main + // alone can't detect a rerun of the same file. + const generation = (fileGeneration ??= bunTest().fileGeneration)(); + if (rootNode === undefined || rootGeneration !== generation) { const isNewFile = rootNode !== undefined; + rootGeneration = generation; // Publish the new root before resetting so re-entrant calls (user code run // by a mock's restore) see an up-to-date root and don't reset again. rootNode = new TestNode(kRootName, undefined, kDefaultOptions, true, false); @@ -903,7 +932,7 @@ class TestContext { } get error(): unknown { - return undefined; + return this.#node.error; } get passed(): boolean { @@ -914,6 +943,10 @@ class TestContext { return 0; } + get workerId(): number | undefined { + return Number(process.env.NODE_TEST_WORKER_ID) || undefined; + } + get tags(): string[] { return this.#node.tags; } @@ -1153,6 +1186,8 @@ function validateTimeoutAndSignal(options: TestOptions | HookOptions) { function validateTestOptions(options: TestOptions): { ownTags: string[] | undefined } { const { concurrency, tags, plan } = options; + // signal and concurrency are validated for Node's error contract but not yet + // enforced (t.signal never aborts; subtests always run serially). validateTimeoutAndSignal(options); if (concurrency != null && typeof concurrency !== "boolean") { if (typeof concurrency === "number") { @@ -1266,6 +1301,23 @@ function invokeTestFn(fn: Function, arg: unknown) { return fn(arg); } +// A single timeout armed once per test and raced against both the body and +// plan.check(), matching Node's stopTest()/stopPromise. `promise` never +// resolves; it only rejects with the timeout error. Callers must dispose(). +function createStopController(timeout: number | undefined) { + if (typeof timeout !== "number" || !Number.isFinite(timeout)) { + return undefined; + } + let timer: ReturnType; + const promise = new Promise((_, reject) => { + timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); + timer.unref?.(); + }); + // Swallow the rejection when nothing is racing it anymore. + promise.catch(() => {}); + return { promise, dispose: () => realClearTimeout(timer) }; +} + // Runs `run` racing Node's test timeout; the timer starts before the body so a // long synchronous prefix counts against the timeout, like Node. function awaitWithTimeout(run: () => unknown, timeout: number | undefined) { @@ -1382,31 +1434,45 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { node.plan = new TestPlan(planOption); } - const runBody = async () => { - await runWithNode(node, () => invokeTestFn(fn, ctx)); - // Wait for inline subtests created during the body (awaited or not), - // including ones scheduled while earlier subtests were running. - await drainSubtestChain(node); - }; - + // 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); try { - await awaitWithTimeout(runBody, node.options.timeout); - } catch (err) { - // A body that throws or rejects with a nullish value must still fail. - failure = err ?? makeTestFailure("test failed"); - } - - // A before hook created while the test was running failed (Node fails the - // test with the hook's error). - failure ??= node.hookFailure; + const runBody = async () => { + await runWithNode(node, () => invokeTestFn(fn, ctx)); + // Wait for inline subtests created during the body (awaited or not), + // including ones scheduled while earlier subtests were running. + await drainSubtestChain(node); + }; - const { plan } = node; - if (failure === undefined && plan !== null) { try { - await plan.check(); + await (stop === undefined ? runBody() : Promise.race([stop.promise, runBody()])); } catch (err) { - failure = err; + // A body that throws or rejects with a nullish value must still fail. + failure = err ?? makeTestFailure("test failed"); } + + // A before hook created while the test was running failed (Node fails the + // test with the hook's error). + failure ??= node.hookFailure; + + const { plan } = node; + if (failure === undefined && plan !== null) { + try { + const pending = plan.check(); + if (pending !== undefined) { + // 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])); + } + } catch (err) { + failure = err; + } + } + } finally { + stop?.dispose(); } const { failedSubtests, firstSubtestError } = node; @@ -1419,6 +1485,12 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { } } + // Node sets passed/error before running afterEach/after so hooks can + // introspect the outcome (nodejs/node lib/internal/test_runner/test.js + // pass()/fail() precede afterEach). + node.passed = failure === undefined; + node.error = failure ?? null; + for (let i = ancestors.length - 1; i >= 0; i--) { const ancestor = ancestors[i]; for (const hook of ancestor.hooks.afterEach) { @@ -1446,6 +1518,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { node.finished = true; node.passed = failure === undefined; + node.error = failure ?? null; return failure; } @@ -1601,15 +1674,25 @@ function addTest( const { ownTags } = validateTestOptions(options); const runningNode = executionParent ?? currentNode(); - if (runningNode !== undefined && runningNode.isRunning()) { - // Subtest of a running test (or of an inline suite created inside one). - if (mode === "skip" || options.skip) { - return Promise.resolve(undefined); + if (runningNode !== undefined) { + if (runningNode.finished) { + // t.test() escaped its parent (e.g. via setImmediate). Node marks the + // late subtest failed with parentAlreadyFinished; do not fall through to + // bun:test, which would throw an internal-phase error. + return Promise.reject( + makeTestFailure("test could not be started because its parent finished", "parentAlreadyFinished"), + ); + } + if (runningNode.isRunning()) { + // Subtest of a running test (or of an inline suite created inside one). + if (mode === "skip" || options.skip) { + return Promise.resolve(undefined); + } + const child = new TestNode(name, runningNode, options, false, true); + child.ownTags = ownTags; + if (mode === "todo") child.todoFlag = true; + return scheduleSubtest(runningNode, child, fn); } - const child = new TestNode(name, runningNode, options, false, true); - child.ownTags = ownTags; - if (mode === "todo") child.todoFlag = true; - return scheduleSubtest(runningNode, child, fn); } // Collection phase: register with bun:test. @@ -1670,6 +1753,11 @@ function addSuite( const { ownTags } = validateTestOptions(options); const runningNode = executionParent ?? currentNode(); + if (runningNode !== undefined && runningNode.finished) { + return Promise.reject( + makeTestFailure("test could not be started because its parent finished", "parentAlreadyFinished"), + ); + } if (runningNode !== undefined && runningNode.isRunning()) { const suite = new TestNode(name, runningNode, options, true, true); suite.ownTags = ownTags; diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index a59d41f484d2..4de1e440240c 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -429,6 +429,10 @@ pub struct BunTestRoot { /// so a never-settled promise's `+1` stays reachable; do not free orphans — /// a queued reaction may still consume them. pub pending_then_refs: std::cell::RefCell>, + /// Monotonic per-`enter_file` counter. Exposed to JS so per-file module + /// state (node:test root) resets on `--rerun-each` where `Bun.main` is + /// unchanged across iterations. + pub file_generation: u32, } impl BunTestRoot { @@ -447,6 +451,7 @@ impl BunTestRoot { active_file: None, hook_scope, pending_then_refs: std::cell::RefCell::new(Vec::new()), + file_generation: 0, } } @@ -505,6 +510,7 @@ impl BunTestRoot { let _g = group_begin!(); debug_assert!(self.active_file.is_none()); + self.file_generation = self.file_generation.wrapping_add(1); // Derive the stored backref from the TestRunner's *stable* storage // (the global `Jest::RUNNER` NonNull) rather than `self as *mut _`. diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index a2a3f8ffdeb8..7ebac0adc929 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -327,7 +327,7 @@ pub mod Jest { } pub(crate) fn create_test_module(global_object: &JSGlobalObject) -> JsResult { - let module = JSValue::create_empty_object(global_object, 23); + let module = JSValue::create_empty_object(global_object, 24); let test_scope_functions = create_bound( global_object, @@ -399,6 +399,11 @@ pub mod Jest { b"setDefaultTimeout", jsc::JSFunction::create(global_object, "setDefaultTimeout", __jsc_host_js_set_default_timeout, 1, Default::default()), ); + module.put( + global_object, + b"fileGeneration", + jsc::JSFunction::create(global_object, "fileGeneration", __jsc_host_js_file_generation, 0, Default::default()), + ); module.put(global_object, b"expect", jsc::codegen::js::get_constructor::(global_object)); module.put(global_object, b"expectTypeOf", jsc::codegen::js::get_constructor::(global_object)); @@ -509,6 +514,17 @@ pub mod Jest { Ok(JSValue::UNDEFINED) } + + #[bun_jsc::host_fn] + fn js_file_generation( + _global_object: &JSGlobalObject, + _callframe: &CallFrame, + ) -> JsResult { + // node:test compares this to detect a fresh enter_file (across both + // multiple files and --rerun-each iterations); 0 outside `bun test`. + let generation = runner().map(|r| r.bun_test_root.file_generation).unwrap_or(0); + Ok(JSValue::from(generation)) + } } pub mod on_unhandled_rejection { diff --git a/test/js/node/test_runner/fixtures/15-outcome-in-hooks.js b/test/js/node/test_runner/fixtures/15-outcome-in-hooks.js new file mode 100644 index 000000000000..c0b24fb19763 --- /dev/null +++ b/test/js/node/test_runner/fixtures/15-outcome-in-hooks.js @@ -0,0 +1,37 @@ +const test = require("node:test"); +const assert = require("node:assert"); + +// afterEach must observe the body's outcome via ctx.passed / ctx.error, like +// Node (nodejs/node test/fixtures/test-runner/output/hooks.js:217-233). +test("afterEach sees passed=true, error=null for a passing subtest", async t => { + let seen; + t.afterEach(ctx => { + seen = { passed: ctx.passed, error: ctx.error }; + }); + await t.test("child", () => {}); + assert.deepStrictEqual(seen, { passed: true, error: null }); +}); + +test("afterEach sees passed=false and the thrown error for a failing subtest", async t => { + const boom = new Error("boom"); + let seen; + t.afterEach(ctx => { + seen = { passed: ctx.passed, error: ctx.error }; + }); + // todo on the child so its failure does not roll up into this outer test. + await t.test("child", { todo: true }, () => { + throw boom; + }); + assert.strictEqual(seen.passed, false); + assert.strictEqual(seen.error, boom); +}); + +test("workerId reads NODE_TEST_WORKER_ID", t => { + assert.strictEqual(t.workerId, undefined); + process.env.NODE_TEST_WORKER_ID = "3"; + try { + assert.strictEqual(t.workerId, 3); + } finally { + delete process.env.NODE_TEST_WORKER_ID; + } +}); diff --git a/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js b/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js new file mode 100644 index 000000000000..4a154634f307 --- /dev/null +++ b/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js @@ -0,0 +1,38 @@ +const test = require("node:test"); +const assert = require("node:assert"); + +// t.assert accessed before t.plan(): Node captures plan at first access, so +// later assertions do NOT count (nodejs/node lib/internal/test_runner/test.js:331). +test.describe("plan capture at first t.assert access", () => { + let planFailure; + test.afterEach(ctx => { + if (ctx.name === "assert-before-plan") planFailure = ctx.error; + }); + test("assert-before-plan", t => { + t.assert; + t.plan(2); + t.assert.ok(1); + t.assert.ok(1); + t.todo(); // the plan mismatch is expected + }); + test("verify assert-before-plan failed with 0/2", () => { + assert.match(String(planFailure), /plan expected 2 assertions but received 0/); + }); +}); + +// t.test() after the parent finished must reject with Node's +// parentAlreadyFinished error, not bun:test's internal-phase throw. +test("late subtest after parent finished", async t => { + let saved; + await t.test("parent", pt => { + saved = pt; + }); + let caught; + await saved + .test("late", () => {}) + .catch(e => { + caught = e; + }); + assert.strictEqual(caught?.code, "ERR_TEST_FAILURE"); + assert.strictEqual(caught?.failureType, "parentAlreadyFinished"); +}); diff --git a/test/js/node/test_runner/fixtures/16b-plan-wait-timeout.js b/test/js/node/test_runner/fixtures/16b-plan-wait-timeout.js new file mode 100644 index 000000000000..846e0a71824b --- /dev/null +++ b/test/js/node/test_runner/fixtures/16b-plan-wait-timeout.js @@ -0,0 +1,8 @@ +const test = require("node:test"); + +// plan({wait:true}) with a missing assertion must be bounded by the test's +// own timeout, not hang. Node races plan.check() against stopPromise. +test("wait:true bounded by test timeout", { timeout: 100 }, async t => { + t.plan(2, { wait: true }); + t.assert.ok(1); +}); diff --git a/test/js/node/test_runner/fixtures/17-rerun-mock-reset.mjs b/test/js/node/test_runner/fixtures/17-rerun-mock-reset.mjs new file mode 100644 index 000000000000..d4321c250d61 --- /dev/null +++ b/test/js/node/test_runner/fixtures/17-rerun-mock-reset.mjs @@ -0,0 +1,23 @@ +import { test, mock } from "node:test"; +import assert from "node:assert"; + +// Persist a target across --rerun-each iterations (built-in module state, +// including this global, survives; only the entry file is re-evaluated). +globalThis.__rerunTarget ??= { greet: () => "real" }; +const target = globalThis.__rerunTarget; + +// Module-scope mock.method() must run the file-boundary reset before +// snapshotting the original. Under --rerun-each Bun.main is unchanged, so a +// generation-counter comparison is what makes the reset fire on iterations 2+. +mock.method(target, "greet", () => "mocked"); + +test("module-scope mock.method captured the real original across reruns", () => { + assert.strictEqual(target.greet(), "mocked"); + mock.restoreAll(); + // Without the reset, iteration 2 would have snapshotted iteration 1's mock + // as the "original" and restoreAll() would restore to "mocked". + assert.strictEqual(target.greet(), "real"); + // Re-install so the tracker still holds a mock for the next iteration's + // reset to restore. + mock.method(target, "greet", () => "mocked"); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index aa5102f30b9d..f71484a3d342 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -137,6 +137,43 @@ describe("node:test", () => { stderr: expect.stringContaining("0 fail"), }); }); + + test("should expose the body outcome to afterEach and workerId to the context", async () => { + const { exitCode, stderr } = await runTests(["15-outcome-in-hooks.js"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should capture plan at first t.assert access and reject subtests started after their parent finished", async () => { + const { exitCode, stderr } = await runTests(["16-plan-and-late-subtest.js"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should bound plan({wait:true}) by the test's own timeout instead of hanging", async () => { + const { exitCode, stderr } = await runTests(["16b-plan-wait-timeout.js"]); + expect(stderr).toContain("test timed out after 100ms"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining("1 fail"), + }); + }); + + test("should reset the module-level mock tracker between --rerun-each iterations", async () => { + // ESM entry: --rerun-each currently only re-evaluates ESM entry files. + const { exitCode, stderr } = await runTests(["17-rerun-mock-reset.mjs"], {}, ["--rerun-each=3"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); }); async function runTests(filenames: string[], env: Record = {}, args: string[] = []) { @@ -311,3 +348,27 @@ test("the call record is pushed after the implementation runs, like node", () => expect(f.mock.callCount()).toBe(1); mock.reset(); }); + +test("mock.property/mock.method survive a polluted Object.prototype", async () => { + // The defineProperty descriptors must carry __proto__:null so an inherited + // `value` on Object.prototype does not turn the accessor descriptor into a + // TypeError (nodejs/node lib/internal/test_runner/mock/mock.js does this). + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + Object.prototype.value = 1; + const { mock } = require("node:test"); + const obj = { x: 1, get p() { return 5; } }; + mock.property(obj, "x"); + mock.getter(obj, "p"); + console.log("ok"); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toMatchObject({ stdout: "ok", exitCode: 0 }); +}); From 40c76383c20a041ffcd5ffb9343706103982e9e7 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 7 Jul 2026 15:59:53 -0700 Subject: [PATCH 03/18] node:test: plan option truthy-gate, registered `ok`, private fileGeneration Follow-ups to the previous review round. - The `{plan: N}` option is created before the beforeEach hooks and only for a truthy N, matching Node's `if (this.plan === null && this.expectedAssertions)`. `t.assert` snapshots the plan at first access, so a hook that touches it must already see the option's plan, and `{plan: 0}` must install no plan at all. - `t.assert.ok` is installed outside the generic wrapper and passes itself to innerOk as the stackStartFn, like Node since nodejs/node@028c5864, so the trace starts at the caller instead of a node:test frame. Node only does this when no custom assertion claimed the name (test.js:345), so `assert.register("ok", fn)` still wins. The message itself is unchanged until internal/assert/utils implements getErrMessage, which is why no test asserts on it. - `fileGeneration` moves off the `bun:test` module object (where `import { fileGeneration } from "bun:test"` resolved, widening the public API) onto a private `$newRustFunction` binding. The host_fn reads the runner through `runner_ptr()` rather than `runner()`: node:test calls it on every registration, between `enter_file` and the deferred `exit_file`, and materializing an exclusive `&mut TestRunner` there would invalidate the `bun_test_root` pointer the test command keeps live across the file run. - The `contextNode` WeakMap becomes a plain `#node` private field on TestContext/SuiteContext, mirroring Node's `#test`/`#suite`. - `setInterval(fn, 0)` under `mock.timers` keeps Node's semantics: it re-fires within one tick() until the callback clears it. Node fires it 4x on tick(1); the real-timer 1ms clamp would fire it twice, so a fixture pins the port instead of "fixing" it. Tests: fixtures 18 (interval-zero), 19 (plan-option ordering, plan: 0, t.plan inside beforeEach), 20 (hook-level signal enforced, separate ok install), 21 (registered ok wins and counts toward the plan). All four pass verbatim on the node v26.3.0 binary. --- src/codegen/generate-js2native.ts | 1 + src/js/internal/test_runner/mock_timers.ts | 3 ++ src/js/node/test.ts | 48 +++++++++++-------- src/runtime/test_runner/jest.rs | 31 ++++++------ .../fixtures/18-mock-timers-interval-zero.js | 35 ++++++++++++++ .../fixtures/19-plan-option-order.js | 37 ++++++++++++++ .../fixtures/20-hook-signal-and-assert-ok.js | 32 +++++++++++++ .../test_runner/fixtures/21-register-ok.js | 19 ++++++++ test/js/node/test_runner/node-test.test.ts | 36 ++++++++++++++ 9 files changed, 206 insertions(+), 36 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/18-mock-timers-interval-zero.js create mode 100644 test/js/node/test_runner/fixtures/19-plan-option-order.js create mode 100644 test/js/node/test_runner/fixtures/20-hook-signal-and-assert-ok.js create mode 100644 test/js/node/test_runner/fixtures/21-register-ok.js diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index e7662b3d474c..d7d2b15b164d 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -65,6 +65,7 @@ const rustIdentifierPaths: Record = { "http/H3Client.rs": "http/H3Client.rs", "ini.rs": "ini/ini.rs", "install_binding.rs": "install_jsc/install_binding.rs", + "jest.rs": "runtime/test_runner/jest.rs", "ipc.rs": "jsc/ipc.rs", "mysql.rs": "sql_jsc/mysql.rs", "node_assert_binding.rs": "runtime/node/node_assert_binding.rs", diff --git a/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts index 6d2756cc35b6..df1abe229ee4 100644 --- a/src/js/internal/test_runner/mock_timers.ts +++ b/src/js/internal/test_runner/mock_timers.ts @@ -303,6 +303,9 @@ class MockTimers { } #createTimer(isInterval, callback, delay, ...args) { + // Only the upper bound is clamped, like Node: `setInterval(fn, 0)` re-fires + // within one tick() until cleared. Real timers clamp to 1ms; clamping here + // would diverge from the port. if (delay > TIMEOUT_MAX) { delay = 1; } diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 6e1930bb8bc7..d1de67b609bd 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -574,6 +574,7 @@ function snapshot(_value: unknown, _options: { serializers?: Function[] } = kEmp } const nodeAssert = require("node:assert"); +const { innerOk } = require("internal/assert/utils"); // Custom assertions registered through `require("node:test").assert.register()`. // They become part of every TestContext's `t.assert` built afterwards. @@ -622,7 +623,8 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { for (const key of Object.keys(nodeAssert)) { // CallTracker is also excluded: bun's node:assert still ships it (Node 26 // does not), and copying it would trigger its deprecation accessor. - if (key === "AssertionError" || key === "strict" || key === "CallTracker") continue; + // `ok` is installed below, outside the generic wrapper. + if (key === "AssertionError" || key === "strict" || key === "CallTracker" || key === "ok") continue; const value = nodeAssert[key]; if (!$isCallable(value)) continue; add(key, value); @@ -632,6 +634,14 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { for (const name of Object.keys(customAssertions)) { add(name, customAssertions[name]); } + // `ok` is its own stackStartFn so the trace starts at the caller instead of a + // node:test wrapper frame; a registered `ok` still wins (nodejs/node@028c5864). + if (customAssertions.ok === undefined) { + result.ok = function ok(...args: unknown[]) { + plan?.count(); + innerOk(ok, args.length, ...args); + }; + } return result; } @@ -866,15 +876,18 @@ class TestNode { } } +// Bumped by the runner's enter_file. Bound privately rather than read off the +// bun:test module object, which is public API. +const fileGeneration = $newRustFunction("jest.rs", "jsFileGeneration", 0); + let rootNode: TestNode | undefined; let rootGeneration = -1; -let fileGeneration: (() => number) | undefined; function getRootNode(): TestNode { // Fresh root on each runner enter_file (per file AND per --rerun-each // iteration) so file-level hooks/state never leak between them; Bun.main // alone can't detect a rerun of the same file. - const generation = (fileGeneration ??= bunTest().fileGeneration)(); + const generation = fileGeneration(); if (rootNode === undefined || rootGeneration !== generation) { const isNewFile = rootNode !== undefined; rootGeneration = generation; @@ -895,21 +908,16 @@ function getRootNode(): TestNode { // Contexts // ----------------------------------------------------------------------------- -const contextNode: WeakMap = new WeakMap(); - /** * @link https://nodejs.org/api/test.html#class-testcontext */ class TestContext { + #node: TestNode; #abortController?: AbortController; #assert: Record | undefined; constructor(node: TestNode) { - contextNode.set(this, node); - } - - get #node(): TestNode { - return contextNode.get(this)!; + this.#node = node; } get signal(): AbortSignal { @@ -1060,14 +1068,11 @@ class TestContext { * @link https://nodejs.org/api/test.html#class-suitecontext */ class SuiteContext { + #node: TestNode; #abortController?: AbortController; constructor(node: TestNode) { - contextNode.set(this, node); - } - - get #node(): TestNode { - return contextNode.get(this)!; + this.#node = node; } get signal(): AbortSignal { @@ -1418,6 +1423,14 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { const ancestors = ancestorChain(node); let failure: unknown; + // Node applies the plan option before the beforeEach hooks run, and only for a + // truthy count, so `{ plan: 0 }` installs no plan at all (test.js:1313-1315). + // `t.assert` snapshots the plan at first access, so hooks must see it already. + const { plan: planOption } = node.options; + if (planOption && node.plan === null) { + node.plan = new TestPlan(planOption); + } + try { for (const ancestor of ancestors) { for (const hook of ancestor.hooks.beforeEach) { @@ -1429,11 +1442,6 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { } if (failure === undefined) { - const { plan: planOption } = node.options; - if (planOption !== undefined && node.plan === null) { - node.plan = new TestPlan(planOption); - } - // 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. diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 7ebac0adc929..3157dee6dc31 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -327,7 +327,7 @@ pub mod Jest { } pub(crate) fn create_test_module(global_object: &JSGlobalObject) -> JsResult { - let module = JSValue::create_empty_object(global_object, 24); + let module = JSValue::create_empty_object(global_object, 23); let test_scope_functions = create_bound( global_object, @@ -399,11 +399,6 @@ pub mod Jest { b"setDefaultTimeout", jsc::JSFunction::create(global_object, "setDefaultTimeout", __jsc_host_js_set_default_timeout, 1, Default::default()), ); - module.put( - global_object, - b"fileGeneration", - jsc::JSFunction::create(global_object, "fileGeneration", __jsc_host_js_file_generation, 0, Default::default()), - ); module.put(global_object, b"expect", jsc::codegen::js::get_constructor::(global_object)); module.put(global_object, b"expectTypeOf", jsc::codegen::js::get_constructor::(global_object)); @@ -514,17 +509,21 @@ pub mod Jest { Ok(JSValue::UNDEFINED) } +} - #[bun_jsc::host_fn] - fn js_file_generation( - _global_object: &JSGlobalObject, - _callframe: &CallFrame, - ) -> JsResult { - // node:test compares this to detect a fresh enter_file (across both - // multiple files and --rerun-each iterations); 0 outside `bun test`. - let generation = runner().map(|r| r.bun_test_root.file_generation).unwrap_or(0); - Ok(JSValue::from(generation)) - } +/// Reached only from `node:test`, through `$newRustFunction` rather than the +/// public `bun:test` module object. Returns 0 outside `bun test`. +pub(crate) fn js_file_generation( + _global: &JSGlobalObject, + _callframe: &CallFrame, +) -> JsResult { + // `runner_ptr()` rather than `runner()`: node:test calls this on every test + // registration, and an exclusive `&mut TestRunner` would invalidate the + // `bun_test_root` pointer `test_command.rs` keeps live across the file run. + // SAFETY: same invariant as `runner()` — RUNNER is only read on the JS thread. + let generation = + Jest::runner_ptr().map_or(0, |p| unsafe { (*p.as_ptr()).bun_test_root.file_generation }); + Ok(JSValue::from(generation)) } pub mod on_unhandled_rejection { diff --git a/test/js/node/test_runner/fixtures/18-mock-timers-interval-zero.js b/test/js/node/test_runner/fixtures/18-mock-timers-interval-zero.js new file mode 100644 index 000000000000..15543c0202f1 --- /dev/null +++ b/test/js/node/test_runner/fixtures/18-mock-timers-interval-zero.js @@ -0,0 +1,35 @@ +// Node's mock timers clamp only the upper bound, so a zero-delay interval +// re-fires inside a single tick()/runAll() until its callback clears it. Real +// timers clamp to 1ms. Clamping here would diverge: node fires the interval +// below 4 times on tick(1), a `delay >= 1` clamp fires it twice. Both numbers +// were taken from the node v26.3.0 binary. +const assert = require("node:assert"); +const { test } = require("node:test"); + +test("setInterval(fn, 0) re-fires within one tick until cleared", t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let calls = 0; + const interval = setInterval(() => { + if (++calls > 3) clearInterval(interval); + }, 0); + t.mock.timers.tick(1); + assert.strictEqual(calls, 4); +}); + +test("runAll() drains a zero-delay interval that clears itself", t => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let calls = 0; + const interval = setInterval(() => { + if (++calls > 1) clearInterval(interval); + }, 0); + t.mock.timers.runAll(); + assert.strictEqual(calls, 2); +}); + +test("setTimeout(fn, 0) still fires once on tick(0)", t => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let calls = 0; + setTimeout(() => calls++, 0); + t.mock.timers.tick(0); + assert.strictEqual(calls, 1); +}); diff --git a/test/js/node/test_runner/fixtures/19-plan-option-order.js b/test/js/node/test_runner/fixtures/19-plan-option-order.js new file mode 100644 index 000000000000..ac79329d54d3 --- /dev/null +++ b/test/js/node/test_runner/fixtures/19-plan-option-order.js @@ -0,0 +1,37 @@ +// Node applies the `plan` option before the beforeEach hooks run +// (nodejs/node lib/internal/test_runner/test.js:1313-1315). Because `t.assert` +// snapshots the plan at first access, a hook that merely touches `t.assert` +// would otherwise capture a null plan and silently swallow every count. +// This whole file passes on the node v26.3.0 binary. +const assert = require("node:assert"); +const { test, beforeEach } = require("node:test"); + +let planThrew; + +beforeEach(t => { + t.assert; + // The option's plan already exists here, so a second t.plan() is rejected. + planThrew = undefined; + if (t.name === "the plan option is already set inside beforeEach") { + try { + t.plan(2); + } catch (err) { + planThrew = err.message; + } + } +}); + +test("the plan option survives a beforeEach that touches t.assert", { plan: 1 }, t => { + t.assert.ok(1); +}); + +test("the plan option is already set inside beforeEach", { plan: 1 }, t => { + assert.strictEqual(planThrew, "cannot set plan more than once"); + t.assert.ok(1); +}); + +// Node only installs the option's plan for a truthy count, so `{ plan: 0 }` is +// not a plan of zero assertions. +test("a zero plan option installs no plan", { plan: 0 }, t => { + t.assert.ok(1); +}); diff --git a/test/js/node/test_runner/fixtures/20-hook-signal-and-assert-ok.js b/test/js/node/test_runner/fixtures/20-hook-signal-and-assert-ok.js new file mode 100644 index 000000000000..5a68b59709f4 --- /dev/null +++ b/test/js/node/test_runner/fixtures/20-hook-signal-and-assert-ok.js @@ -0,0 +1,32 @@ +const assert = require("node:assert"); +const { test } = require("node:test"); + +// Hook-level `signal` is enforced (test-level `signal` is validated only). +// Node reports the child's error as its own 'failed running beforeEach hook' +// wrapper while bun surfaces the thrown error, so assert on the outcome. +test("a hook-level signal aborts the hook and fails the owning subtest", async t => { + const controller = new AbortController(); + let hookRan = false; + t.beforeEach( + async () => { + hookRan = true; + controller.abort(new Error("stop the hook")); + // The abort listener rejects before this resolves. If the signal is ever + // ignored the hook resolves instead of hanging, and `seen` fails below. + await new Promise(resolve => setImmediate(resolve)); + }, + { signal: controller.signal }, + ); + const seen = []; + t.afterEach(child => seen.push(child.passed)); + // `todo` keeps the deliberate hook failure from failing this test. + await t.test("child", { todo: true }, () => {}); + assert.ok(hookRan); + assert.deepStrictEqual(seen, [false]); +}); + +test("t.assert.ok is installed separately and still counts toward the plan", t => { + t.plan(1); + assert.strictEqual(t.assert.ok.name, "ok"); + t.assert.ok(true); +}); diff --git a/test/js/node/test_runner/fixtures/21-register-ok.js b/test/js/node/test_runner/fixtures/21-register-ok.js new file mode 100644 index 000000000000..5c41580276d7 --- /dev/null +++ b/test/js/node/test_runner/fixtures/21-register-ok.js @@ -0,0 +1,19 @@ +// Node installs its own `ok` only when no custom assertion claimed the name +// (nodejs/node lib/internal/test_runner/test.js:345 `if (!map.has('ok'))`), so a +// registered `ok` wins. Registration is file-scoped, hence its own fixture. +// Passes on the node v26.3.0 binary. +const assert = require("node:assert"); +const { test, assert: testAssert } = require("node:test"); + +testAssert.register("ok", function () { + return "custom"; +}); + +test("a registered ok overrides the built-in one", t => { + assert.strictEqual(t.assert.ok(false), "custom"); +}); + +test("a registered ok still counts toward the plan", t => { + t.plan(1); + t.assert.ok(false); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index f71484a3d342..6c95b614d65a 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -174,6 +174,42 @@ describe("node:test", () => { stderr: expect.stringContaining("0 fail"), }); }); + + test("should keep node's zero-delay mock interval semantics", async () => { + const { exitCode, stderr } = await runTests(["18-mock-timers-interval-zero.js"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should apply the plan option before beforeEach so a hook cannot snapshot a null plan", async () => { + const { exitCode, stderr } = await runTests(["19-plan-option-order.js"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should enforce a hook-level signal and install t.assert.ok separately", async () => { + const { exitCode, stderr } = await runTests(["20-hook-signal-and-assert-ok.js"]); + expect(stderr).toContain("2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should let a registered ok assertion override the built-in one", async () => { + const { exitCode, stderr } = await runTests(["21-register-ok.js"]); + expect(stderr).toContain("2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); }); async function runTests(filenames: string[], env: Record = {}, args: string[] = []) { From dbfe02754701e5fc550dc89898de293b2d4c7a34 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 7 Jul 2026 16:23:30 -0700 Subject: [PATCH 04/18] node:test: gate nested subtests on ancestor before hooks, resolve filtered promises Two node-compat gaps in the inline-subtest engine, both reproduced against the node v26.3.0 binary before fixing. - `runOwnBeforeHooks` now walks every enclosing inline suite and the owning test, outermost first, instead of only the direct parent. A subtest, its suite, and every outer suite are sibling reactions on the same chain link, so gating only the suite could never stop the test body from running before (or despite) an outer `before` hook: an async hook lost the race and a throwing one still let the body run. `runBeforeHookOnce` memoizes each hook, so the racing siblings all await one result and ancestors shared between callers never re-run. - A top-level `test()` returns an already-resolved promise instead of a deferred settled by the runner. bun:test never invokes the runner for a test that `--test-name-pattern` filters out, so the deferred hung an awaiting caller until the watchdog; Node resolves those too. The deferral's timing is unobservable under bun:test's collect-then-execute model (a later body only runs after the earlier test already finished), which is also what let the deferred machinery be deleted outright. Tests: fixture 22 (an async and a throwing outer `before`, plus one on the owning test) and 23 (run with `-t`, asserting no timeout). Both pass verbatim on node; the async-hook, throwing-hook, and filtered-promise cases all fail without the fix. --- src/js/node/test.ts | 57 +++++++++---------- .../fixtures/22-nested-suite-before.js | 52 +++++++++++++++++ .../fixtures/23-filtered-test-promise.js | 15 +++++ test/js/node/test_runner/node-test.test.ts | 19 +++++++ 4 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/22-nested-suite-before.js create mode 100644 test/js/node/test_runner/fixtures/23-filtered-test-promise.js diff --git a/src/js/node/test.ts b/src/js/node/test.ts index d1de67b609bd..bb7c5c114684 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1406,11 +1406,23 @@ function scheduleImmediateBeforeHook(node: TestNode, hook: Hook, arg: unknown) { } async function runOwnBeforeHooks(node: TestNode) { - const { before } = node.hooks; - if (before.length === 0) return; - const arg = node.isSuite ? node.getSuiteCtx() : node.getCtx(); - for (const hook of before) { - await runBeforeHookOnce(hook, node, arg); + // Node runs suites strictly sequentially, so a subtest is gated on the before + // hooks of every enclosing inline suite and the owning test, outermost first; + // runBeforeHookOnce memoizes each, so the racing siblings share one result. + const owners: TestNode[] = []; + for (let owner: TestNode | undefined = node; owner !== undefined; owner = owner.parent) { + owners.unshift(owner); + // Stop at the owning collection-phase test/suite: hooks above it were + // registered through bun:test's own beforeAll and are not run by the shim. + if (!owner.isExecutionPhase) break; + } + for (const owner of owners) { + const { before } = owner.hooks; + if (before.length === 0) continue; + const arg = owner.isSuite ? owner.getSuiteCtx() : owner.getCtx(); + for (const hook of before) { + await runBeforeHookOnce(hook, owner, arg); + } } } @@ -1646,27 +1658,18 @@ function currentCollectionParent(): TestNode { return getRootNode(); } -function createTopLevelTestRunner( - node: TestNode, - fn: TestFn, - resolveDeferred: (() => void) | undefined, - declaredTodo = false, -) { +function createTopLevelTestRunner(node: TestNode, fn: TestFn, declaredTodo = false) { // bun:test invokes this with a `done` callback because the function declares // one parameter. return (done: (error?: unknown) => void) => { executeTestNode(node, fn).then( failure => { - resolveDeferred?.(); // A runtime t.skip()/t.todo() suppresses the failure, like Node; a // declared todo body's failure must reach bun:test's todo accounting. const runtimeMarked = node.skipped || (node.todoFlag && !declaredTodo); done(failure === undefined || runtimeMarked ? undefined : failure); }, - err => { - resolveDeferred?.(); - done(err); - }, + err => done(err), ); }; } @@ -1709,7 +1712,6 @@ function addTest( node.ownTags = ownTags; const { test } = bunTest(); - const insideSuite = parent.parent !== undefined; const passOptions = bunTestOptions(options); const effectiveMode = mode ?? (options.todo ? "todo" : options.skip ? "skip" : undefined); @@ -1717,7 +1719,7 @@ function addTest( if (effectiveMode === "todo" || effectiveMode === "skip") { const register = effectiveMode === "todo" ? test.todo : test.skip; // Node runs todo bodies; bun:test only does so under --todo. - const body = effectiveMode === "todo" ? createTopLevelTestRunner(node, fn, undefined, true) : kDefaultFunction; + const body = effectiveMode === "todo" ? createTopLevelTestRunner(node, fn, true) : kDefaultFunction; if (passOptions !== undefined) { register(name, body, passOptions); } else { @@ -1726,28 +1728,21 @@ function addTest( return Promise.resolve(undefined); } - let resolveDeferred: (() => void) | undefined; - let promise: Promise; - if (insideSuite) { - // Inside a describe() callback Node returns an already-resolved promise. - promise = Promise.resolve(undefined); - } else { - promise = new Promise(resolve => { - resolveDeferred = () => resolve(undefined); - }); - } - // Node's `only` (the option and the test.only()/describe.only() spellings) // is a no-op unless --test-only is passed, so it registers an ordinary // test/suite; bun:test's only() would skip siblings and is rejected in CI. - const runner = createTopLevelTestRunner(node, fn, resolveDeferred); + const runner = createTopLevelTestRunner(node, fn); if (passOptions !== undefined) { test(name, runner, passOptions); } else { test(name, runner); } - return promise; + // Resolved eagerly rather than when the runner settles: bun:test never invokes + // the runner for a test `--test-name-pattern` filters out, so a deferred tied + // to it would hang an awaiting caller forever. Node resolves those too, and + // the timing is unobservable under bun:test's collect-then-execute model. + return Promise.resolve(undefined); } function addSuite( diff --git a/test/js/node/test_runner/fixtures/22-nested-suite-before.js b/test/js/node/test_runner/fixtures/22-nested-suite-before.js new file mode 100644 index 000000000000..f13a3427945a --- /dev/null +++ b/test/js/node/test_runner/fixtures/22-nested-suite-before.js @@ -0,0 +1,52 @@ +// Node runs suites strictly sequentially, so an outer inline suite's `before` +// hook must finish before any descendant's body — even one nested another +// describe() deeper. All three tests pass verbatim on the node v26.3.0 binary. +const assert = require("node:assert"); +const { test, describe, it, before } = require("node:test"); + +test("a nested test is gated on the outer suite's async before hook", () => { + let setup = false; + describe("outer", () => { + before(async () => { + await new Promise(resolve => setImmediate(resolve)); + setup = true; + }); + describe("inner", () => { + it("x", () => { + assert.ok(setup, "outer before must run before x"); + }); + }); + }); +}); + +test("a nested test is gated on the owning test's before hook too", t => { + let setup = false; + t.before(async () => { + await new Promise(resolve => setImmediate(resolve)); + setup = true; + }); + describe("outer", () => { + describe("inner", () => { + it("x", () => { + assert.ok(setup, "the test's before must run before x"); + }); + }); + }); +}); + +test("an outer suite's throwing before hook fails the test without running x", async t => { + let ran = false; + await t.test("child", { todo: true }, () => { + describe("outer", () => { + before(() => { + throw new Error("boom"); + }); + describe("inner", () => { + it("x", () => { + ran = true; + }); + }); + }); + }); + assert.strictEqual(ran, false); +}); diff --git a/test/js/node/test_runner/fixtures/23-filtered-test-promise.js b/test/js/node/test_runner/fixtures/23-filtered-test-promise.js new file mode 100644 index 000000000000..ac56fa108330 --- /dev/null +++ b/test/js/node/test_runner/fixtures/23-filtered-test-promise.js @@ -0,0 +1,15 @@ +// node-test.test.ts runs this with `-t "should resolve"`, which filters out +// `filtered-out`. Node still resolves that test's returned promise; a deferred +// tied to a runner bun:test never invokes would hang the awaiting test forever. +const assert = require("node:assert"); +const { test, it } = require("node:test"); + +let bodyRan = false; +const p = test("filtered-out", () => { + bodyRan = true; +}); + +it("should resolve the promise of a name-pattern-filtered test", async () => { + assert.strictEqual(await p, undefined); + assert.strictEqual(bodyRan, false); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 6c95b614d65a..bd69eb496542 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -210,6 +210,25 @@ describe("node:test", () => { stderr: expect.stringContaining("0 fail"), }); }); + + test("should gate a nested inline subtest on every ancestor suite's before hooks", async () => { + const { exitCode, stderr } = await runTests(["22-nested-suite-before.js"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + + test("should resolve the promise of a test that a name pattern filters out", async () => { + const { exitCode, stderr } = await runTests(["23-filtered-test-promise.js"], {}, ["-t", "should resolve"]); + expect(stderr).not.toContain("timed out"); + expect(stderr).toContain("1 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); }); async function runTests(filenames: string[], env: Record = {}, args: string[] = []) { From 9f0db7f52298e1fe29d3b0f4673522380b049681 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 20:17:15 -0700 Subject: [PATCH 05/18] node:test: resolve late-subtest promises and count runtime skip/todo t.test() called after its parent finished now returns a promise that resolves undefined (matching Node) instead of rejecting with ERR_TEST_FAILURE. Runtime t.skip()/t.todo() now report as skip/todo in bun:test's accounting via a private jsNodeTestMarkResult hook, instead of pass. --- src/js/node/test.ts | 36 ++++++++++--------- src/runtime/test_runner/jest.rs | 27 ++++++++++++++ .../fixtures/16-plan-and-late-subtest.js | 17 ++++----- test/js/node/test_runner/node-test.test.ts | 11 +++--- 4 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index bb7c5c114684..c948b41c02b5 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -649,12 +649,9 @@ function buildContextAssert(node: TestNode, ctx: TestContext) { // Test plan // ----------------------------------------------------------------------------- -function makeTestFailure(message: string, failureType?: string) { +function makeTestFailure(message: string) { const error = new Error(message); (error as { code?: string }).code = "ERR_TEST_FAILURE"; - if (failureType !== undefined) { - (error as { failureType?: string }).failureType = failureType; - } return error; } @@ -879,6 +876,8 @@ class TestNode { // Bumped by the runner's enter_file. Bound privately rather than read off the // bun:test module object, which is public API. const fileGeneration = $newRustFunction("jest.rs", "jsFileGeneration", 0); +// Overrides the running bun:test sequence result: `false` → skip, `true` → todo. +const markCurrentResult = $newRustFunction("jest.rs", "jsNodeTestMarkResult", 1); let rootNode: TestNode | undefined; let rootGeneration = -1; @@ -1664,10 +1663,18 @@ function createTopLevelTestRunner(node: TestNode, fn: TestFn, declaredTodo = fal return (done: (error?: unknown) => void) => { executeTestNode(node, fn).then( failure => { - // A runtime t.skip()/t.todo() suppresses the failure, like Node; a - // declared todo body's failure must reach bun:test's todo accounting. - const runtimeMarked = node.skipped || (node.todoFlag && !declaredTodo); - done(failure === undefined || runtimeMarked ? undefined : failure); + // 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. + if (node.skipped) { + markCurrentResult(false); + } else if (node.todoFlag && !declaredTodo) { + markCurrentResult(true); + } else { + done(failure); + return; + } + done(undefined); }, err => done(err), ); @@ -1687,12 +1694,9 @@ function addTest( const runningNode = executionParent ?? currentNode(); if (runningNode !== undefined) { if (runningNode.finished) { - // t.test() escaped its parent (e.g. via setImmediate). Node marks the - // late subtest failed with parentAlreadyFinished; do not fall through to - // bun:test, which would throw an internal-phase error. - return Promise.reject( - makeTestFailure("test could not be started because its parent finished", "parentAlreadyFinished"), - ); + // t.test() escaped its parent: Node fails the late subtest but resolves + // the promise; don't fall through to bun:test's internal-phase throw. + return Promise.resolve(undefined); } if (runningNode.isRunning()) { // Subtest of a running test (or of an inline suite created inside one). @@ -1757,9 +1761,7 @@ function addSuite( const runningNode = executionParent ?? currentNode(); if (runningNode !== undefined && runningNode.finished) { - return Promise.reject( - makeTestFailure("test could not be started because its parent finished", "parentAlreadyFinished"), - ); + return Promise.resolve(undefined); } if (runningNode !== undefined && runningNode.isRunning()) { const suite = new TestNode(name, runningNode, options, true, true); diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 3157dee6dc31..7d1b1ed93d27 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -526,6 +526,33 @@ pub(crate) fn js_file_generation( Ok(JSValue::from(generation)) } +/// Reached only from `node:test` (`t.skip()` / `t.todo()` at runtime): overrides +/// the running sequence's result so bun:test reports skip/todo instead of pass. +pub(crate) fn js_node_test_mark_result( + _global: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + use super::execution::Result as ExecResult; + let [mode] = callframe.arguments_as_array::<1>(); + let Some(buntest_strong) = bun_test::clone_active_strong() else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: single-threaded JS VM; the strong is dropped before any re-borrow. + let buntest = unsafe { bun_test::buntest_as_mut(&buntest_strong) }; + let active = buntest.get_current_state_data(); + let Some((sequence_ptr, _)) = + buntest.execution.get_current_and_valid_execution_sequence(&active) + else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: NonNull into `execution.sequences`; deref at point-of-use only. + let sequence = unsafe { &mut *sequence_ptr.as_ptr() }; + if sequence.result == ExecResult::Pending { + sequence.result = if mode.to_boolean() { ExecResult::Todo } else { ExecResult::Skip }; + } + Ok(JSValue::UNDEFINED) +} + pub mod on_unhandled_rejection { use super::*; diff --git a/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js b/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js index 4a154634f307..7548f769db44 100644 --- a/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js +++ b/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js @@ -20,19 +20,20 @@ test.describe("plan capture at first t.assert access", () => { }); }); -// t.test() after the parent finished must reject with Node's -// parentAlreadyFinished error, not bun:test's internal-phase throw. +// t.test() after the parent finished: Node fails the late subtest with +// parentAlreadyFinished but resolves the returned promise (undefined); it must +// not reject or fall through to bun:test's internal-phase throw. test("late subtest after parent finished", async t => { let saved; await t.test("parent", pt => { saved = pt; }); - let caught; + let outcome; await saved .test("late", () => {}) - .catch(e => { - caught = e; - }); - assert.strictEqual(caught?.code, "ERR_TEST_FAILURE"); - assert.strictEqual(caught?.failureType, "parentAlreadyFinished"); + .then( + v => (outcome = { resolved: true, value: v }), + e => (outcome = { rejected: true, code: e?.code }), + ); + assert.deepStrictEqual(outcome, { resolved: true, value: undefined }); }); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index bd69eb496542..f35ff397e76c 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -82,9 +82,11 @@ describe("node:test", () => { }); }); - test("should suppress runtime t.todo()/t.skip() failures and keep runner timers real under mock timers", async () => { + test("should count runtime t.todo()/t.skip() as todo/skip and keep runner timers real under mock timers", async () => { const { exitCode, stderr } = await runTests(["12-runtime-todo-and-mock-timers.js"]); - expect(stderr).toContain("5 pass"); + expect(stderr).toContain("3 pass"); + expect(stderr).toContain("1 skip"); + expect(stderr).toContain("1 todo"); expect({ exitCode, stderr }).toMatchObject({ exitCode: 0, stderr: expect.stringContaining("0 fail"), @@ -147,9 +149,10 @@ describe("node:test", () => { }); }); - test("should capture plan at first t.assert access and reject subtests started after their parent finished", async () => { + test("should capture plan at first t.assert access and resolve subtests started after their parent finished", async () => { const { exitCode, stderr } = await runTests(["16-plan-and-late-subtest.js"]); - expect(stderr).toContain("3 pass"); + expect(stderr).toContain("2 pass"); + expect(stderr).toContain("1 todo"); expect({ exitCode, stderr }).toMatchObject({ exitCode: 0, stderr: expect.stringContaining("0 fail"), From 8664279dce698cb4ebfbf546a5934e972a680436 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Tue, 7 Jul 2026 21:42:03 -0700 Subject: [PATCH 06/18] node:test: keep stop/hook-timeout timers ref'd; cancel waitFor retry on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createStopController and raceWithTimeoutAndSignal now leave their setTimeout handles ref'd — both are always cleared in a finally block, so unref() bought nothing, and on Windows an unref'd timer alone under bun test drops the uws loop's active count to zero: auto_tick then takes the tick_without_idle branch (jsc_hooks.rs), which never fires JS timers, and there is no drain_timers fallback outside Unix. 16b-plan-wait-timeout busy-spun until the parent's 90 s timeout on every Windows job. waitFor now stores the retry timer handle and clears it when the outer timeout rejects, matching Node's pollerId cleanup, so condition() is not invoked one extra time after the promise has already settled. --- src/js/node/test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index c948b41c02b5..a23f9171c5cf 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1026,8 +1026,12 @@ class TestContext { let cause: unknown; let hasCause = false; let timedOut = false; + let retry: ReturnType | undefined; const timer = realSetTimeout(() => { timedOut = true; + // Cancel a pending retry so condition() is not invoked again after + // reject (Node clears its pollerId in done()). + if (retry !== undefined) realClearTimeout(retry); const error = new Error("waitFor() timed out"); if (hasCause) { (error as { cause?: unknown }).cause = cause; @@ -1045,7 +1049,7 @@ class TestContext { if (timedOut) return; cause = err; hasCause = true; - realSetTimeout(poll, interval); + retry = realSetTimeout(poll, interval); } }; poll(); @@ -1314,8 +1318,9 @@ function createStopController(timeout: number | undefined) { } let timer: ReturnType; const promise = new Promise((_, reject) => { + // Not unref'd: dispose() always clears it, and on Windows an unref'd timer + // alone under bun:test leaves the uws loop inactive so auto_tick busy-spins. timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); - timer.unref?.(); }); // Swallow the rejection when nothing is racing it anymore. promise.catch(() => {}); @@ -1346,7 +1351,6 @@ async function raceWithTimeoutAndSignal( racers.push( new Promise((_, reject) => { timer = realSetTimeout(() => reject(makeTestFailure(`test timed out after ${timeout}ms`)), timeout); - timer.unref?.(); }), ); } From 32fc880b0ebae375d25191910e35bf0ff1e70bf5 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 9 Jul 2026 12:20:12 -0700 Subject: [PATCH 07/18] node:test: drain plan-wait subtests, bind markCurrentResult to done()'s sequence, keep plan-wait timer ref'd executeTestNode now re-drains the subtest chain after plan.check()'s {wait} promise resolves, raced against the same stop controller. A t.test() that fulfilled the plan from an async callback was scheduled onto subtestChain during the wait but never awaited, so a failing late subtest was a silent parent PASS; Node fails the parent with '1 subtest failed'. Fixture 24 covers the exact repro (verified against Node v22.20.0 and against system bun, which passes it). js_node_test_mark_result now takes the bun:test done() bound function and derives the target sequence from its DoneCallback wrapper's r#ref.phase (via new Bun__JSBoundFunction__boundThis) instead of get_current_state_data(), so a late t.skip()/t.todo() from a body that outlived the watchdog is rejected by the same staleness guard done() uses rather than writing Skip/Todo onto whatever test is currently running. When r#ref is unset (.then() fired inside run_test_callback's sync-drain, before the DoneCallback is stamped) the runner has not moved on, so the live index is used. TestPlan.check()'s numeric {wait} timer is no longer unref'd, matching 8664279d's fix to createStopController/raceWithTimeoutAndSignal: on Windows an unref'd timer alone under bun:test drops the uws loop's active count to zero and auto_tick busy-spins. count() and the timer's own callback always clear it, so the unref bought nothing. --- src/js/node/test.ts | 16 +++++++++--- src/jsc/bindings/bindings.cpp | 7 ++++++ src/runtime/test_runner/jest.rs | 25 ++++++++++++++++--- .../fixtures/24-plan-wait-late-subtest.js | 14 +++++++++++ test/js/node/test_runner/node-test.test.ts | 12 +++++++++ 5 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/24-plan-wait-late-subtest.js diff --git a/src/js/node/test.ts b/src/js/node/test.ts index a23f9171c5cf..7a1d14c38897 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -704,7 +704,8 @@ class TestPlan { makeTestFailure(`plan timed out after ${wait}ms with ${this.actual} assertions when expecting ${expected}`), ); }, wait); - timer.unref?.(); + // Not unref'd: count()/the timer callback always clear it, and on + // Windows an unref'd timer alone under bun:test busy-spins (8664279d). } this.#pending = { resolve, reject, timer }; }); @@ -877,7 +878,9 @@ class TestNode { // bun:test module object, which is public API. const fileGeneration = $newRustFunction("jest.rs", "jsFileGeneration", 0); // Overrides the running bun:test sequence result: `false` → skip, `true` → todo. -const markCurrentResult = $newRustFunction("jest.rs", "jsNodeTestMarkResult", 1); +// `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); let rootNode: TestNode | undefined; let rootGeneration = -1; @@ -1489,6 +1492,11 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { // reject `pending` afterward with no one listening. pending.catch(() => {}); await (stop === undefined ? pending : Promise.race([stop.promise, 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])); } } catch (err) { failure = err; @@ -1671,9 +1679,9 @@ function createTopLevelTestRunner(node: TestNode, fn: TestFn, declaredTodo = fal // (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. if (node.skipped) { - markCurrentResult(false); + markCurrentResult(false, done); } else if (node.todoFlag && !declaredTodo) { - markCurrentResult(true); + markCurrentResult(true, done); } else { done(failure); return; diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 7ea92a6e4e69..f585b31e26e9 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -6225,6 +6225,13 @@ CPP_DECL [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__JSValue__bind(JS RELEASE_AND_RETURN(scope, JSC::JSValue::encode(boundFunction)); } +CPP_DECL [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue Bun__JSBoundFunction__boundThis(JSC::EncodedJSValue value) +{ + auto* boundFunction = dynamicDowncast(JSC::JSValue::decode(value)); + if (!boundFunction) return JSC::JSValue::encode(JSC::jsUndefined()); + return JSC::JSValue::encode(boundFunction->boundThis()); +} + CPP_DECL [[ZIG_EXPORT(check_slow)]] void Bun__JSValue__setPrototypeDirect(JSC::EncodedJSValue valueEncoded, JSC::EncodedJSValue prototypeEncoded, JSC::JSGlobalObject* globalObject) { auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 07ee5648fca6..f674526f69ce 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -7,7 +7,7 @@ use bun_collections::{ArrayHashMap, MultiArrayList}; use bun_core::Output; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{ - self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult, RegularExpression, + self as jsc, CallFrame, JSGlobalObject, JSValue, JsClass as _, JsResult, RegularExpression, }; use bun_jsc::StringJsc as _; use crate::timer::ElTimespec; @@ -530,20 +530,37 @@ pub(crate) fn js_file_generation( /// Reached only from `node:test` (`t.skip()` / `t.todo()` at runtime): overrides /// the running sequence's result so bun:test reports skip/todo instead of pass. +/// `done`'s bound `DoneCallback.r#ref.phase` names the intended sequence so a +/// late call after the watchdog moved on cannot mark the currently-running one. pub(crate) fn js_node_test_mark_result( _global: &JSGlobalObject, callframe: &CallFrame, ) -> JsResult { use super::execution::Result as ExecResult; - let [mode] = callframe.arguments_as_array::<1>(); + let [mode, done] = callframe.arguments_as_array::<2>(); let Some(buntest_strong) = bun_test::clone_active_strong() else { return Ok(JSValue::UNDEFINED); }; // SAFETY: single-threaded JS VM; the strong is dropped before any re-borrow. let buntest = unsafe { bun_test::buntest_as_mut(&buntest_strong) }; - let active = buntest.get_current_state_data(); + // `done` is a JSBoundFunction whose bound-this is the DoneCallback wrapper. + let wrapper = bun_jsc::cpp::Bun__JSBoundFunction__boundThis(done); + let Some(dcb) = bun_test::DoneCallback::from_js(wrapper) else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: `dcb` is the live `*mut DoneCallback` from `from_js`; single- + // threaded JS VM, GC roots `done` (and its bound-this) for this frame. + let bound = match unsafe { (*dcb).r#ref.as_deref() } { + Some(refdata) => refdata.phase.clone(), + // `r#ref` unset: `.then()` fired inside run_test_callback's microtask + // drain before it stamps the DoneCallback — the runner has not moved + // on, so the live index IS the intended sequence. + None if unsafe { !(*dcb).called } => buntest.get_current_state_data(), + // done() already ran and reported — nothing left to mark. + None => return Ok(JSValue::UNDEFINED), + }; let Some((sequence_ptr, _)) = - buntest.execution.get_current_and_valid_execution_sequence(&active) + buntest.execution.get_current_and_valid_execution_sequence(&bound) else { return Ok(JSValue::UNDEFINED); }; diff --git a/test/js/node/test_runner/fixtures/24-plan-wait-late-subtest.js b/test/js/node/test_runner/fixtures/24-plan-wait-late-subtest.js new file mode 100644 index 000000000000..767defc26fdc --- /dev/null +++ b/test/js/node/test_runner/fixtures/24-plan-wait-late-subtest.js @@ -0,0 +1,14 @@ +const { test } = require("node:test"); + +// https://github.com/oven-sh/bun/pull/32631#discussion_r3541126497 +// A t.test() that fulfills t.plan(N, {wait}) from an async callback is +// scheduled onto the parent's subtest chain during the plan wait. The parent +// must await it so a failing subtest fails the parent instead of passing. +test("p", t => { + t.plan(1, { wait: true }); + setImmediate(() => { + t.test("c", () => { + throw new Error("boom"); + }); + }); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index f35ff397e76c..cba2eafb2dc0 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -168,6 +168,18 @@ describe("node:test", () => { }); }); + test("should fail the parent when a t.test() that fulfills plan({wait}) throws", async () => { + const { exitCode, stderr } = await runTests(["24-plan-wait-late-subtest.js"]); + // The error message from makeTestFailure — must not be satisfied by the + // fixture's own source lines echoed in the failure context. + expect(stderr).toContain("error: 1 subtest failed"); + expect(stderr).toContain("boom"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining("1 fail"), + }); + }); + test("should reset the module-level mock tracker between --rerun-each iterations", async () => { // ESM entry: --rerun-each currently only re-evaluates ESM entry files. const { exitCode, stderr } = await runTests(["17-rerun-mock-reset.mjs"], {}, ["--rerun-each=3"]); From f511e05d6efd7f4fffd2ce07f4cb8926dd9be2ba Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 10 Jul 2026 13:01:18 -0700 Subject: [PATCH 08/18] node:test: mark node finished before after-hooks; hoist dcb reads under one SAFETY block - Set node.finished = true before afterEach/after loops so t.test() called from a teardown hook hits the parentAlreadyFinished early-return instead of scheduling onto subtestChain after the failedSubtests snapshot. - Hoist both (*dcb) field reads under the existing SAFETY comment to satisfy clippy::undocumented_unsafe_blocks. --- src/js/node/test.ts | 4 +++- src/runtime/test_runner/jest.rs | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 7a1d14c38897..0244cf16ed6e 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1521,6 +1521,9 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { // pass()/fail() precede afterEach). node.passed = failure === undefined; node.error = failure ?? null; + // Mark finished before hooks so a late t.test() from an after/afterEach + // hook hits addTest()'s parentAlreadyFinished path (Node cancels these). + node.finished = true; for (let i = ancestors.length - 1; i >= 0; i--) { const ancestor = ancestors[i]; @@ -1547,7 +1550,6 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { failure ??= err; } - node.finished = true; node.passed = failure === undefined; node.error = failure ?? null; return failure; diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index f674526f69ce..af4e27b68aa2 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -550,12 +550,13 @@ pub(crate) fn js_node_test_mark_result( }; // SAFETY: `dcb` is the live `*mut DoneCallback` from `from_js`; single- // threaded JS VM, GC roots `done` (and its bound-this) for this frame. - let bound = match unsafe { (*dcb).r#ref.as_deref() } { + let (dcb_ref, dcb_called) = unsafe { ((*dcb).r#ref.as_deref(), (*dcb).called) }; + let bound = match dcb_ref { Some(refdata) => refdata.phase.clone(), // `r#ref` unset: `.then()` fired inside run_test_callback's microtask // drain before it stamps the DoneCallback — the runner has not moved // on, so the live index IS the intended sequence. - None if unsafe { !(*dcb).called } => buntest.get_current_state_data(), + None if !dcb_called => buntest.get_current_state_data(), // done() already ran and reported — nothing left to mark. None => return Ok(JSValue::UNDEFINED), }; From 334b61b1789b4616afc495e0a5ea7465cdb4bac8 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 14 Jul 2026 00:35:02 +0000 Subject: [PATCH 09/18] Revert unrelated structured-clone 2GiB test split The 2GiB structuredClone subprocess cases are a web/workers concern with no relation to node:test. Moving them into their own file required quarantining them on linux-x64, which traded coverage for an expectations entry. Restore structured-clone.test.ts to its original contents and drop the quarantine. --- test/expectations.txt | 9 -- .../web/workers/structured-clone-2gb.test.ts | 86 ------------------- test/js/web/workers/structured-clone.test.ts | 81 +++++++++++++++++ 3 files changed, 81 insertions(+), 95 deletions(-) delete mode 100644 test/js/web/workers/structured-clone-2gb.test.ts diff --git a/test/expectations.txt b/test/expectations.txt index 4ca01a3dbad3..c96d17500276 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -41,15 +41,6 @@ # alpine 3.23 x64 + x64-baseline only). [ LINUX-X64-MUSL ] test/js/node/test/parallel/test-tls-connect-memleak.js [ FLAKY ] # JSC FinalizationRegistry callback delivery vs setImmediate timing on musl x64 -# The 2GiB structuredClone subprocess suite (split out of structured-clone.test.ts): -# each child transiently needs 3-5GiB (source + serialized copy + clone). Under the -# parallel test runner the linux-x64 shards run out of memory and the kernel OOM -# killer reaps the child (empty stdout, exit by SIGKILL), which the parent cannot -# distinguish from the serializer crash the suite guards against. The crash is -# platform-independent and stays covered on darwin, windows, linux-aarch64, and in -# local runs; only the memory-tight linux-x64 CI shards are quarantined. -[ LINUX-X64 ] test/js/web/workers/structured-clone-2gb.test.ts [ FLAKY ] # children OOM-killed under runner memory pressure on linux-x64 CI shards - # Vendored node v26.3.0 stream tests blocked on missing native subsystems (see PR #31826) test/js/node/test/parallel/test-stream-pipeline.js [ SKIP ] # block at L271 hangs: pipeline(rs, req) writes 11x'hello' raw after a never-ended GET's \r\n\r\n; node's llhttp rejects lowercase 'h' as a method char (HPE_INVALID_METHOD -> clientError -> 400+close -> req 'close' -> pipeline callback fires), but bun's uWS HttpParser buffers any incomplete run of valid tchars waiting for the request-line, so the connection stays open and the callback never fires. Pre-existing server-parser leniency; needs uWS HttpParser to reject non-uppercase method bytes like llhttp. test/js/node/test/parallel/test-stream-wrap.js [ FAIL ] # needs internal/test/binding + js_stream (net.Socket({handle}) libuv compat layer) diff --git a/test/js/web/workers/structured-clone-2gb.test.ts b/test/js/web/workers/structured-clone-2gb.test.ts deleted file mode 100644 index f5c6ec1c98f7..000000000000 --- a/test/js/web/workers/structured-clone-2gb.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; - -// Split out of structured-clone.test.ts: each child transiently needs 3-5GiB, -// so the suite is quarantined on the memory-tight linux-x64 CI shards -// (test/expectations.txt) while running everywhere else. -describe("structuredClone with ArrayBuffer larger than serialization buffer capacity", () => { - // The serialization buffer is a WTF::Vector capped at 2GiB. Cloning an - // ArrayBuffer at or above that size must throw DataCloneError instead of aborting. - // Run in a subprocess so the ~2GiB allocation does not bloat the test runner. - for (const [label, expr] of [ - ["ArrayBuffer", "new ArrayBuffer(2 ** 31)"], - ["resizable ArrayBuffer", "new ArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], - ["SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31)"], - ["growable SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], - ["Uint8Array", "new Uint8Array(2 ** 31)"], - ] as const) { - test(label, async () => { - const script = ` - let buf; - try { - buf = ${expr}; - } catch { - console.log("SKIP"); - process.exit(0); - } - try { - structuredClone(buf); - console.log("UNEXPECTED_SUCCESS"); - } catch (e) { - console.log(e.name); - } - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(["DataCloneError", "SKIP"]).toContain(stdout.trim()); - expect(exitCode).toBe(0); - }); - } - - // A large-but-under-2GiB ArrayBuffer nested inside an object/array fills the serialization - // buffer to its reserved capacity; the subsequent terminator write then triggers vector - // growth. The default 1.5x growth exceeds the 2GiB cap and would crash. These cases must - // succeed and round-trip correctly since the total serialized size still fits under 2GiB. - for (const [label, expr, check] of [ - ["ArrayBuffer in object", "{ h: new ArrayBuffer(size) }", "r.h.byteLength === size"], - ["ArrayBuffer in array", "[new ArrayBuffer(size)]", "r[0].byteLength === size"], - ["Uint8Array in object", "{ h: new Uint8Array(size) }", "r.h.byteLength === size"], - ["nested ArrayBuffer", "{ a: { b: new ArrayBuffer(size) } }", "r.a.b.byteLength === size"], - [ - "resizable ArrayBuffer in object", - "{ h: new ArrayBuffer(size, { maxByteLength: size }) }", - "r.h.byteLength === size", - ], - ] as const) { - test(`${label} under 2GiB clones without crashing`, async () => { - const script = ` - const size = 1600000000; - let v; - try { - v = ${expr}; - } catch { - console.log("SKIP"); - process.exit(0); - } - const r = structuredClone(v); - console.log((${check}) ? "OK" : "BAD_ROUNDTRIP"); - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "inherit", - }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(["OK", "SKIP"]).toContain(stdout.trim()); - expect(proc.signalCode).toBe(null); - expect(exitCode).toBe(0); - }); - } -}); diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index d55ba7fe3f85..47be8ef5b140 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -548,6 +548,87 @@ function createBlob(arr: number[]): Blob { return new Blob([view]); } +describe("structuredClone with ArrayBuffer larger than serialization buffer capacity", () => { + // The serialization buffer is a WTF::Vector capped at 2GiB. Cloning an + // ArrayBuffer at or above that size must throw DataCloneError instead of aborting. + // Run in a subprocess so the ~2GiB allocation does not bloat the test runner. + for (const [label, expr] of [ + ["ArrayBuffer", "new ArrayBuffer(2 ** 31)"], + ["resizable ArrayBuffer", "new ArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], + ["SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31)"], + ["growable SharedArrayBuffer", "new SharedArrayBuffer(2 ** 31, { maxByteLength: 2 ** 31 + 1 })"], + ["Uint8Array", "new Uint8Array(2 ** 31)"], + ] as const) { + test(label, async () => { + const script = ` + let buf; + try { + buf = ${expr}; + } catch { + console.log("SKIP"); + process.exit(0); + } + try { + structuredClone(buf); + console.log("UNEXPECTED_SUCCESS"); + } catch (e) { + console.log(e.name); + } + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(["DataCloneError", "SKIP"]).toContain(stdout.trim()); + expect(exitCode).toBe(0); + }); + } + + // A large-but-under-2GiB ArrayBuffer nested inside an object/array fills the serialization + // buffer to its reserved capacity; the subsequent terminator write then triggers vector + // growth. The default 1.5x growth exceeds the 2GiB cap and would crash. These cases must + // succeed and round-trip correctly since the total serialized size still fits under 2GiB. + for (const [label, expr, check] of [ + ["ArrayBuffer in object", "{ h: new ArrayBuffer(size) }", "r.h.byteLength === size"], + ["ArrayBuffer in array", "[new ArrayBuffer(size)]", "r[0].byteLength === size"], + ["Uint8Array in object", "{ h: new Uint8Array(size) }", "r.h.byteLength === size"], + ["nested ArrayBuffer", "{ a: { b: new ArrayBuffer(size) } }", "r.a.b.byteLength === size"], + [ + "resizable ArrayBuffer in object", + "{ h: new ArrayBuffer(size, { maxByteLength: size }) }", + "r.h.byteLength === size", + ], + ] as const) { + test(`${label} under 2GiB clones without crashing`, async () => { + const script = ` + const size = 1600000000; + let v; + try { + v = ${expr}; + } catch { + console.log("SKIP"); + process.exit(0); + } + const r = structuredClone(v); + console.log((${check}) ? "OK" : "BAD_ROUNDTRIP"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(["OK", "SKIP"]).toContain(stdout.trim()); + expect(proc.signalCode).toBe(null); + expect(exitCode).toBe(0); + }); + } +}); + // A repeated object is serialized as an ObjectReferenceTag holding an index into the // serializer's object pool. The deserializer must rebuild that pool entry-for-entry: // any value it appends that the serializer did not record (BigInt primitives, From c283c78fd5f3e506885a41e23bb6113e7b63e36f Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Tue, 14 Jul 2026 00:48:58 +0000 Subject: [PATCH 10/18] Drop dead leaksan entry for removed test; sort js2native table test-set-http-max-http-headers.js is removed by this branch, so its no-validate-leaksan.txt entry no longer refers to anything. Also restore the alphabetical ordering of the js2native identifier table. --- src/codegen/generate-js2native.ts | 2 +- test/no-validate-leaksan.txt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index ba73b915a60c..f135dc4cdcfd 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -65,8 +65,8 @@ const rustIdentifierPaths: Record = { "http/H3Client.rs": "http/H3Client.rs", "ini.rs": "ini/ini.rs", "install_binding.rs": "install_jsc/install_binding.rs", - "jest.rs": "runtime/test_runner/jest.rs", "ipc.rs": "jsc/ipc.rs", + "jest.rs": "runtime/test_runner/jest.rs", "mysql.rs": "sql_jsc/mysql.rs", "napi_body.rs": "runtime/napi/napi_body.rs", "node_assert_binding.rs": "runtime/node/node_assert_binding.rs", diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 334b94e568ce..d45fc76d93b1 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -226,7 +226,6 @@ test/cli/test/parallel.test.ts # bun.assert(!this.hasPendingActivity() or jsc.VirtualMachine.get().isShuttingDown()); # @call(bun.callmod_inline, Subprocess.finalize, .{thisValue}); -test/js/node/test/parallel/test-set-http-max-http-headers.js test/js/node/test/parallel/test-child-process-windows-hide.js test/cli/inspect/BunFrontendDevServer.test.ts test/cli/inspect/HTTPServerAgent.test.ts From 868c9e4a4f6f96002737895ad3b47a1b75602518 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:55:14 +0000 Subject: [PATCH 11/18] test: drop upstream TODO comment from vendored test-runner-mock-timers.js The comment is a Node.js maintainer note with no runtime effect; removing it keeps the test behaviorally identical to upstream. --- test/js/node/test/parallel/test-runner-mock-timers.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/node/test/parallel/test-runner-mock-timers.js b/test/js/node/test/parallel/test-runner-mock-timers.js index 25510fe023d0..ad63344f7d02 100644 --- a/test/js/node/test/parallel/test-runner-mock-timers.js +++ b/test/js/node/test/parallel/test-runner-mock-timers.js @@ -133,7 +133,6 @@ describe('Mock Timers Test Suite', () => { t.mock.timers.enable(); const fn = t.mock.fn(); globalThis.setTimeout(fn, 1000); - // TODO(benjamingr) refactor to `using` t.mock.timers[Symbol.dispose](); assert.throws(() => { t.mock.timers.tick(1000); From 960184fdd0459bed150ea80da6970d0c68baae41 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:19:23 +0000 Subject: [PATCH 12/18] node:test: gate inline-suite children on the describe callback's build promise An async describe() callback's early-registered children were chained as siblings of scheduleSuiteSubtest.run on the same fulfilled base promise, so the child could run before a before() hook registered after the callback's first await. Node's Suite.run awaits buildPromise before iterating subtests, so the late hook always runs first. Seed suite.subtestChain through a withResolvers gate that the callback's returned promise opens once it settles, so early children wait for both the parent's prior subtests and the callback's completion. Verified against node v26.3.0 (09-inline-suites.js). --- src/js/node/test.ts | 11 +++++++--- .../test_runner/fixtures/09-inline-suites.js | 21 ++++++++++++++++++- test/js/node/test_runner/node-test.test.ts | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 0244cf16ed6e..ec0c963470c2 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -1785,8 +1785,12 @@ function addSuite( } if (mode === "todo") suite.todoFlag = true; // The suite's children must run after the parent's previously scheduled - // subtests (Node runs subtests serially), so seed its chain on the parent's. - suite.subtestChain = runningNode.subtestChain; + // subtests AND after the describe callback's own returned promise settles + // (Node's Suite.run awaits buildPromise before iterating subtests). The + // callback has not returned yet so its promise does not exist; seed the + // chain through a gate the callback's settlement opens. + const gate = Promise.withResolvers(); + suite.subtestChain = runningNode.subtestChain.then(() => gate.promise); // Build the suite eagerly (Node also runs describe callbacks immediately), // collecting children onto the suite's own subtest chain. let build: unknown; @@ -1800,8 +1804,9 @@ function addSuite( if (build != null && typeof (build as PromiseLike).then === "function") { // Attach a handler now: the real await happens when the suite's turn // comes, which can be many ticks later (no unhandled rejection). - (build as Promise).then(undefined, () => {}); + (build as Promise).then(gate.resolve, gate.resolve); } else { + gate.resolve(); build = undefined; } return scheduleSuiteSubtest(runningNode, suite, build); diff --git a/test/js/node/test_runner/fixtures/09-inline-suites.js b/test/js/node/test_runner/fixtures/09-inline-suites.js index 5abd19fb3303..a1b762a58695 100644 --- a/test/js/node/test_runner/fixtures/09-inline-suites.js +++ b/test/js/node/test_runner/fixtures/09-inline-suites.js @@ -1,6 +1,6 @@ // Inline suites (describe() inside a running test), matched against Node v26.3.0. const assert = require("node:assert"); -const { test, describe } = require("node:test"); +const { test, describe, before, it } = require("node:test"); test("inline suite children run after previously scheduled subtests", async t => { const order = []; @@ -36,3 +36,22 @@ test("an async inline describe callback is awaited before the suite finishes", a }); assert.deepStrictEqual(order, ["early", "late", "after"]); }); + +// A child registered before an async describe callback's first await must +// still observe a before() hook registered after that await (Node's +// Suite.run awaits buildPromise before running any subtest). +test("early inline-suite child waits for the async describe callback to settle", async t => { + const order = []; + await describe("async inline suite", async () => { + it("early child", () => { + order.push("child:" + globalThis.__node_test_09_before); + }); + await null; + before(() => { + order.push("before"); + globalThis.__node_test_09_before = true; + }); + }); + delete globalThis.__node_test_09_before; + assert.deepStrictEqual(order, ["before", "child:true"]); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index cba2eafb2dc0..04ece02a33c7 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -133,7 +133,7 @@ describe("node:test", () => { test("should serialize inline suites and await async describe callbacks like node", async () => { const { exitCode, stderr } = await runTests(["09-inline-suites.js"]); - expect(stderr).toContain("2 pass"); + expect(stderr).toContain("3 pass"); expect({ exitCode, stderr }).toMatchObject({ exitCode: 0, stderr: expect.stringContaining("0 fail"), From eb655a295b1f33284c21d5e0a80153288a9eaf54 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Thu, 16 Jul 2026 19:56:32 -0700 Subject: [PATCH 13/18] node:test: match node's mock constructor semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mock.fn(Original, Replacement)` is a Proxy over the original in node, so `new mock()` constructs the replacement while keeping the original's identity: the instance is `instanceof Original`, and the call records the original as `target` and the new instance as `this`. Bun uses a plain wrapper function instead, which lost `new.target` — the instance failed `instanceof Original`, and `target`/`this` were wrong. Mirror the original's `prototype` descriptor onto the wrapper (value and writability, so a class's stays read-only and a method/arrow original keeps having none) and record `target`/`this` the way the construct trap does. Functions declared inside builtins get no `prototype`, so give `mock.fn()`'s default original one explicitly. Vendor test-runner-mocking.js (54/54), test-runner-xfail.js, and test-mock-timers-abortsignal-timeout.js. Two assertions in the mocking test pin V8's exact wording for engine-level errors ("Cannot redefine property", "Cannot read private member"); JSC's text differs while the behavior matches, so those keep the assertion and drop the message regex. --- .claude/skills/verify/SKILL.md | 6 + src/js/node/test.ts | 41 +- .../test-mock-timers-abortsignal-timeout.js | 21 + .../node/test/parallel/test-runner-mocking.js | 1284 +++++++++++++++++ .../node/test/parallel/test-runner-xfail.js | 259 ++++ 5 files changed, 1604 insertions(+), 7 deletions(-) create mode 100644 test/js/node/test/parallel/test-mock-timers-abortsignal-timeout.js create mode 100644 test/js/node/test/parallel/test-runner-mocking.js create mode 100644 test/js/node/test/parallel/test-runner-xfail.js diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 7ae2a948bae1..77d0bc971e07 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -25,6 +25,12 @@ For worker/subprocess-shaped changes, spawn a subprocess (still `-e`) so worker ## Gotchas +- **Prefix every `bun bd` with `PATH="$HOME/.cargo/bin:$PATH"`** — Homebrew's `rust` + formula shadows the pinned nightly, and `bun bd` dies with `the option 'Z' is only + accepted on the nightly compiler`. `bun bd` re-runs cargo on every invocation, so + this is needed for follow-up runs too, not just the first build. - `BUN_DEBUG_QUIET_LOGS=1` suppresses debug-build log spam. +- Debug builds print `[cachefs]`/`[sys]` lines to stdout; filter them before diffing + output against `node`. - MessagePort's `.on/.off` are added by requiring `worker_threads` — plain `new MessageChannel()` ports only have `addEventListener` until then. - The debug+asan build is 10-100× slower than release; large-allocation stress tests can time out locally while passing in CI. diff --git a/src/js/node/test.ts b/src/js/node/test.ts index ec0c963470c2..2e20b3e1c4c6 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -137,23 +137,25 @@ class MockFunctionContext { // record in completion order, and the stack is captured post-invoke. let result: unknown; let error: unknown; + const isConstruct = target !== undefined; try { - result = - target === undefined - ? (implementation as Function).$apply(thisArg, args) - : Reflect.construct(implementation as Function, args, target as Function); + result = !isConstruct + ? (implementation as Function).$apply(thisArg, args) + : Reflect.construct(implementation as Function, args, target as Function); return result; } catch (e) { error = e; throw e; } finally { + // node's mock is a Proxy over the original, so its construct trap + // records the proxy's target (the original) and the new instance. ctx.#calls.push({ arguments: args, error, result, stack: new Error(), - target, - this: thisArg, + target: isConstruct ? ctx.#original : undefined, + this: isConstruct ? result : thisArg, }); } }; @@ -266,6 +268,21 @@ function validateStringOrSymbol(value: unknown, name: string) { } } +// Functions declared inside bun's builtins get no `prototype`, but node's +// default original is a plain `function () {}`, so give it one explicitly. +function createDefaultOriginal(): Function { + const original = function () {}; + Object.defineProperty(original, "prototype", { + // @ts-ignore + __proto__: null, + value: {}, + writable: true, + enumerable: false, + configurable: false, + }); + return original; +} + class MockTracker { #mocks: { ctx: { restore: () => void } }[] = []; #timers: unknown; @@ -316,6 +333,16 @@ class MockTracker { value: original.name, configurable: true, }); + // node's mock proxies the original, so `.prototype` reads through to it: + // mirror the value and its writability (a class's prototype is read-only, + // and a method/arrow original has no prototype at all). + const prototypeDescriptor = Object.getOwnPropertyDescriptor(original, "prototype"); + Object.defineProperty(mockFunction, "prototype", { + // @ts-ignore + __proto__: null, + value: prototypeDescriptor?.value, + writable: prototypeDescriptor?.writable ?? true, + }); return mockFunction; } @@ -347,7 +374,7 @@ class MockTracker { const { times = Infinity } = (options ?? kEmptyObject) as { times?: number }; validateTimes(times, "options.times"); return this.#createMockFunction( - (original as Function) ?? function () {}, + (original as Function) ?? createDefaultOriginal(), implementation as Function | undefined, undefined, times, diff --git a/test/js/node/test/parallel/test-mock-timers-abortsignal-timeout.js b/test/js/node/test/parallel/test-mock-timers-abortsignal-timeout.js new file mode 100644 index 000000000000..242cb3093756 --- /dev/null +++ b/test/js/node/test/parallel/test-mock-timers-abortsignal-timeout.js @@ -0,0 +1,21 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { mock } = require('node:test'); + +const originalAbortSignalTimeout = AbortSignal.timeout; + +mock.timers.enable({ apis: ['AbortSignal.timeout'] }); + +const signal = AbortSignal.timeout(50); +assert.strictEqual(signal.aborted, false); + +mock.timers.tick(49); +assert.strictEqual(signal.aborted, false); + +mock.timers.tick(1); +assert.strictEqual(signal.aborted, true); + +mock.timers.reset(); +assert.strictEqual(AbortSignal.timeout, originalAbortSignalTimeout); diff --git a/test/js/node/test/parallel/test-runner-mocking.js b/test/js/node/test/parallel/test-runner-mocking.js new file mode 100644 index 000000000000..6a50cbe5a40d --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mocking.js @@ -0,0 +1,1284 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { mock, test } = require('node:test'); +test('spies on a function', (t) => { + const sum = t.mock.fn((arg1, arg2) => { + return arg1 + arg2; + }); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 7); + assert.strictEqual(sum.call(1000, 9, 1), 10); + assert.strictEqual(sum.mock.calls.length, 2); + + let call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, 7); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = sum.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, 10); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, 1000); +}); + +test('spies on a bound function', (t) => { + const bound = function(arg1, arg2) { + return this + arg1 + arg2; + }.bind(50); + const sum = t.mock.fn(bound); + + assert.strictEqual(sum.mock.calls.length, 0); + assert.strictEqual(sum(3, 4), 57); + assert.strictEqual(sum(9, 1), 60); + assert.strictEqual(sum.mock.calls.length, 2); + + let call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, 57); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = sum.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 60); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('spies on a constructor', (t) => { + class ParentClazz { + constructor(c) { + this.c = c; + } + } + + class Clazz extends ParentClazz { + #privateValue; + + constructor(a, b) { + super(a + b); + this.a = a; + this.#privateValue = b; + } + + getPrivateValue() { + return this.#privateValue; + } + } + + const ctor = t.mock.fn(Clazz); + const instance = new ctor(42, 85); + + assert(instance instanceof Clazz); + assert(instance instanceof ParentClazz); + assert.strictEqual(instance.a, 42); + assert.strictEqual(instance.getPrivateValue(), 85); + assert.strictEqual(instance.c, 127); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [42, 85]); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.result, instance); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, instance); +}); + +test('a no-op spy function is created by default', (t) => { + const fn = t.mock.fn(); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), undefined); + assert.strictEqual(fn.mock.calls.length, 1); + + const call = fn.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('internal no-op function can be reused', (t) => { + const fn1 = t.mock.fn(); + fn1.prop = true; + const fn2 = t.mock.fn(); + + fn1(1); + fn2(2); + fn1(3); + + assert.notStrictEqual(fn1.mock, fn2.mock); + assert.strictEqual(fn1.mock.calls.length, 2); + assert.strictEqual(fn2.mock.calls.length, 1); + assert.strictEqual(fn1.prop, true); + assert.strictEqual(fn2.prop, undefined); +}); + +test('functions can be mocked multiple times at once', (t) => { + function sum(a, b) { + return a + b; + } + + function difference(a, b) { + return a - b; + } + + function product(a, b) { + return a * b; + } + + const fn1 = t.mock.fn(sum, difference); + const fn2 = t.mock.fn(sum, product); + + assert.strictEqual(fn1(5, 3), 2); + assert.strictEqual(fn2(5, 3), 15); + assert.strictEqual(fn2(4, 2), 8); + assert(!('mock' in sum)); + assert(!('mock' in difference)); + assert(!('mock' in product)); + assert.notStrictEqual(fn1.mock, fn2.mock); + assert.strictEqual(fn1.mock.calls.length, 1); + assert.strictEqual(fn2.mock.calls.length, 2); +}); + +test('internal no-op function can be reused as methods', (t) => { + const obj = { + _foo: 5, + _bar: 9, + foo() { + return this._foo; + }, + bar() { + return this._bar; + }, + }; + + t.mock.method(obj, 'foo'); + obj.foo.prop = true; + t.mock.method(obj, 'bar'); + assert.strictEqual(obj.foo(), 5); + assert.strictEqual(obj.bar(), 9); + assert.strictEqual(obj.bar(), 9); + assert.notStrictEqual(obj.foo.mock, obj.bar.mock); + assert.strictEqual(obj.foo.mock.calls.length, 1); + assert.strictEqual(obj.bar.mock.calls.length, 2); + assert.strictEqual(obj.foo.prop, true); + assert.strictEqual(obj.bar.prop, undefined); +}); + +test('methods can be mocked multiple times but not at the same time', (t) => { + const obj = { + offset: 3, + sum(a, b) { + return this.offset + a + b; + }, + }; + + function difference(a, b) { + return this.offset + (a - b); + } + + function product(a, b) { + return this.offset + a * b; + } + + const originalSum = obj.sum; + const fn1 = t.mock.method(obj, 'sum', difference); + + assert.strictEqual(obj.sum(5, 3), 5); + assert.strictEqual(obj.sum(5, 1), 7); + assert.strictEqual(obj.sum, fn1); + assert.notStrictEqual(fn1.mock, undefined); + assert.strictEqual(originalSum.mock, undefined); + assert.strictEqual(difference.mock, undefined); + assert.strictEqual(product.mock, undefined); + assert.strictEqual(fn1.mock.calls.length, 2); + + const fn2 = t.mock.method(obj, 'sum', product); + + assert.strictEqual(obj.sum(5, 3), 18); + assert.strictEqual(obj.sum, fn2); + assert.notStrictEqual(fn1, fn2); + assert.strictEqual(fn2.mock.calls.length, 1); + + obj.sum.mock.restore(); + assert.strictEqual(obj.sum, fn1); + obj.sum.mock.restore(); + assert.strictEqual(obj.sum, originalSum); + assert.strictEqual(obj.sum.mock, undefined); +}); + +test('spies on an object method', (t) => { + const obj = { + prop: 5, + method(a, b) { + return a + b + this.prop; + }, + }; + + assert.strictEqual(obj.method(1, 3), 9); + t.mock.method(obj, 'method'); + assert.strictEqual(obj.method.mock.calls.length, 0); + assert.strictEqual(obj.method(1, 3), 9); + + const call = obj.method.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [1, 3]); + assert.strictEqual(call.result, 9); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(obj.method.mock.restore(), undefined); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('spies on a getter', (t) => { + const obj = { + prop: 5, + get method() { + return this.prop; + }, + }; + + assert.strictEqual(obj.method, 5); + + const getter = t.mock.method(obj, 'method', { getter: true }); + + assert.strictEqual(getter.mock.calls.length, 0); + assert.strictEqual(obj.method, 5); + + const call = getter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 5); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(getter.mock.restore(), undefined); + assert.strictEqual(obj.method, 5); +}); + +test('spies on a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method(val) { + this.prop = val; + }, + }; + + assert.strictEqual(obj.prop, 100); + obj.method = 88; + assert.strictEqual(obj.prop, 88); + + const setter = t.mock.method(obj, 'method', { setter: true }); + + assert.strictEqual(setter.mock.calls.length, 0); + obj.method = 77; + assert.strictEqual(obj.prop, 77); + assert.strictEqual(setter.mock.calls.length, 1); + + const call = setter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [77]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(setter.mock.restore(), undefined); + assert.strictEqual(obj.prop, 77); + obj.method = 65; + assert.strictEqual(obj.prop, 65); +}); + +test('spy functions can be bound', (t) => { + const sum = t.mock.fn(function(arg1, arg2) { + return this + arg1 + arg2; + }); + const bound = sum.bind(1000); + + assert.strictEqual(bound(9, 1), 1010); + assert.strictEqual(sum.mock.calls.length, 1); + + const call = sum.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 1010); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, 1000); + + assert.strictEqual(sum.mock.restore(), undefined); + assert.strictEqual(sum.bind(0)(2, 11), 13); +}); + +test('mocks prototype methods on an instance', async (t) => { + class Runner { + async someTask(msg) { + return Promise.resolve(msg); + } + + async method(msg) { + await this.someTask(msg); + return msg; + } + } + const msg = 'ok'; + const obj = new Runner(); + assert.strictEqual(await obj.method(msg), msg); + + t.mock.method(obj, obj.someTask.name); + assert.strictEqual(obj.someTask.mock.calls.length, 0); + + assert.strictEqual(await obj.method(msg), msg); + + const call = obj.someTask.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [msg]); + assert.strictEqual(await call.result, msg); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + const obj2 = new Runner(); + // Ensure that a brand new instance is not mocked + assert.strictEqual( + obj2.someTask.mock, + undefined + ); + + assert.strictEqual(obj.someTask.mock.restore(), undefined); + assert.strictEqual(await obj.method(msg), msg); + assert.strictEqual(obj.someTask.mock, undefined); + assert.strictEqual(Runner.prototype.someTask.mock, undefined); +}); + +test('spies on async static class methods', async (t) => { + class Runner { + static async someTask(msg) { + return Promise.resolve(msg); + } + + static async method(msg) { + await this.someTask(msg); + return msg; + } + } + const msg = 'ok'; + assert.strictEqual(await Runner.method(msg), msg); + + t.mock.method(Runner, Runner.someTask.name); + assert.strictEqual(Runner.someTask.mock.calls.length, 0); + + assert.strictEqual(await Runner.method(msg), msg); + + const call = Runner.someTask.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [msg]); + assert.strictEqual(await call.result, msg); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, Runner); + + assert.strictEqual(Runner.someTask.mock.restore(), undefined); + assert.strictEqual(await Runner.method(msg), msg); + assert.strictEqual(Runner.someTask.mock, undefined); + assert.strictEqual(Runner.prototype.someTask, undefined); + +}); + +test('given null to a mock.method it throws an invalid argument error', (t) => { + assert.throws(() => t.mock.method(null, {}), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('it should throw given an inexistent property on a object instance', (t) => { + assert.throws(() => t.mock.method({ abc: 0 }, 'non-existent'), { + code: 'ERR_INVALID_ARG_VALUE' + }); +}); + +test('spy functions can be used on classes inheritance', (t) => { + // Makes sure that having a null-prototype doesn't throw our system off + class A extends null { + static someTask(msg) { + return msg; + } + static method(msg) { + return this.someTask(msg); + } + } + class B extends A {} + class C extends B {} + + const msg = 'ok'; + assert.strictEqual(C.method(msg), msg); + + t.mock.method(C, C.someTask.name); + assert.strictEqual(C.someTask.mock.calls.length, 0); + + assert.strictEqual(C.method(msg), msg); + + const call = C.someTask.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [msg]); + assert.strictEqual(call.result, msg); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, C); + + assert.strictEqual(C.someTask.mock.restore(), undefined); + assert.strictEqual(C.method(msg), msg); + assert.strictEqual(C.someTask.mock, undefined); +}); + +test('spy functions don\'t affect the prototype chain', (t) => { + + class A { + static someTask(msg) { + return msg; + } + } + class B extends A {} + class C extends B {} + + const msg = 'ok'; + + const ABeforeMockIsUnchanged = Object.getOwnPropertyDescriptor(A, A.someTask.name); + const BBeforeMockIsUnchanged = Object.getOwnPropertyDescriptor(B, B.someTask.name); + const CBeforeMockShouldNotHaveDesc = Object.getOwnPropertyDescriptor(C, C.someTask.name); + + t.mock.method(C, C.someTask.name); + C.someTask(msg); + const BAfterMockIsUnchanged = Object.getOwnPropertyDescriptor(B, B.someTask.name); + + const AAfterMockIsUnchanged = Object.getOwnPropertyDescriptor(A, A.someTask.name); + const CAfterMockHasDescriptor = Object.getOwnPropertyDescriptor(C, C.someTask.name); + + assert.strictEqual(CBeforeMockShouldNotHaveDesc, undefined); + assert.ok(CAfterMockHasDescriptor); + + assert.deepStrictEqual(ABeforeMockIsUnchanged, AAfterMockIsUnchanged); + assert.strictEqual(BBeforeMockIsUnchanged, BAfterMockIsUnchanged); + assert.strictEqual(BBeforeMockIsUnchanged, undefined); + + assert.strictEqual(C.someTask.mock.restore(), undefined); + const CAfterRestoreKeepsDescriptor = Object.getOwnPropertyDescriptor(C, C.someTask.name); + assert.ok(CAfterRestoreKeepsDescriptor); +}); + +test('mocked functions report thrown errors', (t) => { + const testError = new Error('test error'); + const fn = t.mock.fn(() => { + throw testError; + }); + + assert.throws(fn, /test error/); + assert.strictEqual(fn.mock.calls.length, 1); + + const call = fn.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.error, testError); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); +}); + +test('mocked constructors report thrown errors', (t) => { + const testError = new Error('test error'); + class Clazz { + constructor() { + throw testError; + } + } + + const ctor = t.mock.fn(Clazz); + + assert.throws(() => { + new ctor(); + }, /test error/); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.error, testError); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, undefined); +}); + +test('mocks a function', (t) => { + const sum = (arg1, arg2) => arg1 + arg2; + const difference = (arg1, arg2) => arg1 - arg2; + const fn = t.mock.fn(sum, difference); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), -1); + assert.strictEqual(fn(9, 1), 8); + assert.strictEqual(fn.mock.calls.length, 2); + + let call = fn.mock.calls[0]; + assert.deepStrictEqual(call.arguments, [3, 4]); + assert.strictEqual(call.result, -1); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + call = fn.mock.calls[1]; + assert.deepStrictEqual(call.arguments, [9, 1]); + assert.strictEqual(call.result, 8); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, undefined); + + assert.strictEqual(fn.mock.restore(), undefined); + assert.strictEqual(fn(2, 11), 13); +}); + +test('mocks a constructor', (t) => { + class ParentClazz { + constructor(c) { + this.c = c; + } + } + + class Clazz extends ParentClazz { + #privateValue; + + constructor(a, b) { + super(a + b); + this.a = a; + this.#privateValue = b; + } + + getPrivateValue() { + return this.#privateValue; + } + } + + class MockClazz { + // eslint-disable-next-line no-unused-private-class-members + #privateValue; + + constructor(z) { + this.z = z; + } + } + + const ctor = t.mock.fn(Clazz, MockClazz); + const instance = new ctor(42, 85); + + assert(!(instance instanceof MockClazz)); + assert(instance instanceof Clazz); + assert(instance instanceof ParentClazz); + assert.strictEqual(instance.a, undefined); + assert.strictEqual(instance.c, undefined); + assert.strictEqual(instance.z, 42); + assert.strictEqual(ctor.mock.calls.length, 1); + + const call = ctor.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [42, 85]); + assert.strictEqual(call.result, instance); + assert.strictEqual(call.target, Clazz); + assert.strictEqual(call.this, instance); + // FIXME: JSC's private-field message differs from V8's ("Cannot access + // invalid private field (evaluating 'this.#privateValue')"). + // assert.throws(() => { + // instance.getPrivateValue(); + // }, /TypeError: Cannot read private member #privateValue /); + assert.throws(() => { + instance.getPrivateValue(); + }, TypeError); +}); + +test('mocks an object method', (t) => { + const obj = { + prop: 5, + method(a, b) { + return a + b + this.prop; + }, + }; + + function mockMethod(a) { + return a + this.prop; + } + + assert.strictEqual(obj.method(1, 3), 9); + t.mock.method(obj, 'method', mockMethod); + assert.strictEqual(obj.method.mock.calls.length, 0); + assert.strictEqual(obj.method(1, 3), 6); + + const call = obj.method.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [1, 3]); + assert.strictEqual(call.result, 6); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(obj.method.mock.restore(), undefined); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('mocks a getter', (t) => { + const obj = { + prop: 5, + get method() { + return this.prop; + }, + }; + + function mockMethod() { + return this.prop - 1; + } + + assert.strictEqual(obj.method, 5); + + const getter = t.mock.method(obj, 'method', mockMethod, { getter: true }); + + assert.strictEqual(getter.mock.calls.length, 0); + assert.strictEqual(obj.method, 4); + + const call = getter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 4); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(getter.mock.restore(), undefined); + assert.strictEqual(obj.method, 5); +}); + +test('mocks a setter', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method(val) { + this.prop = val; + }, + }; + + function mockMethod(val) { + this.prop = -val; + } + + assert.strictEqual(obj.prop, 100); + obj.method = 88; + assert.strictEqual(obj.prop, 88); + + const setter = t.mock.method(obj, 'method', mockMethod, { setter: true }); + + assert.strictEqual(setter.mock.calls.length, 0); + obj.method = 77; + assert.strictEqual(obj.prop, -77); + assert.strictEqual(setter.mock.calls.length, 1); + + const call = setter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [77]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(setter.mock.restore(), undefined); + assert.strictEqual(obj.prop, -77); + obj.method = 65; + assert.strictEqual(obj.prop, 65); +}); + +test('mocks a getter with syntax sugar', (t) => { + const obj = { + prop: 5, + get method() { + return this.prop; + }, + }; + + function mockMethod() { + return this.prop - 1; + } + const getter = t.mock.getter(obj, 'method', mockMethod); + assert.strictEqual(getter.mock.calls.length, 0); + assert.strictEqual(obj.method, 4); + + const call = getter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 4); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(getter.mock.restore(), undefined); + assert.strictEqual(obj.method, 5); +}); + +test('mocks a setter with syntax sugar', (t) => { + const obj = { + prop: 100, + // eslint-disable-next-line accessor-pairs + set method(val) { + this.prop = val; + }, + }; + + function mockMethod(val) { + this.prop = -val; + } + + assert.strictEqual(obj.prop, 100); + obj.method = 88; + assert.strictEqual(obj.prop, 88); + + const setter = t.mock.setter(obj, 'method', mockMethod); + + assert.strictEqual(setter.mock.calls.length, 0); + obj.method = 77; + assert.strictEqual(obj.prop, -77); + assert.strictEqual(setter.mock.calls.length, 1); + + const call = setter.mock.calls[0]; + + assert.deepStrictEqual(call.arguments, [77]); + assert.strictEqual(call.result, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, obj); + + assert.strictEqual(setter.mock.restore(), undefined); + assert.strictEqual(obj.prop, -77); + obj.method = 65; + assert.strictEqual(obj.prop, 65); +}); + +test('mocked functions match name and length', (t) => { + function getNameAndLength(fn) { + return { + name: Object.getOwnPropertyDescriptor(fn, 'name'), + length: Object.getOwnPropertyDescriptor(fn, 'length'), + }; + } + + function func1() {} + const func2 = function(a) {}; // eslint-disable-line func-style + const arrow = (a, b, c) => {}; + const obj = { method(a, b) {} }; + + assert.deepStrictEqual( + getNameAndLength(func1), + getNameAndLength(t.mock.fn(func1)) + ); + assert.deepStrictEqual( + getNameAndLength(func2), + getNameAndLength(t.mock.fn(func2)) + ); + assert.deepStrictEqual( + getNameAndLength(arrow), + getNameAndLength(t.mock.fn(arrow)) + ); + assert.deepStrictEqual( + getNameAndLength(obj.method), + getNameAndLength(t.mock.method(obj, 'method', func1)) + ); +}); + +test('method() fails if method cannot be redefined', (t) => { + const obj = { + prop: 5, + }; + + Object.defineProperty(obj, 'method', { + configurable: false, + value(a, b) { + return a + b + this.prop; + } + }); + + function mockMethod(a) { + return a + this.prop; + } + + // FIXME: JSC's defineProperty message differs from V8's ("Attempting to + // change value of a readonly property."). + // assert.throws(() => { + // t.mock.method(obj, 'method', mockMethod); + // }, /Cannot redefine property: method/); + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod); + }, TypeError); + assert.strictEqual(obj.method(1, 3), 9); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('method() fails if field is a property instead of a method', (t) => { + const obj = { + prop: 5, + method: 100, + }; + + function mockMethod(a) { + return a + this.prop; + } + + assert.throws(() => { + t.mock.method(obj, 'method', mockMethod); + }, /The argument 'methodName' must be a method/); + assert.strictEqual(obj.method, 100); + assert.strictEqual(obj.method.mock, undefined); +}); + +test('mocks can be auto-restored', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + const fn = t.mock.fn(addOne, addTwo, { times: 2 }); + + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 4); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 6); +}); + +test('mock implementation can be changed dynamically', (t) => { + let cnt = 0; + + function addOne() { + cnt++; + return cnt; + } + + function addTwo() { + cnt += 2; + return cnt; + } + + function addThree() { + cnt += 3; + return cnt; + } + + const fn = t.mock.fn(addOne); + + assert.strictEqual(fn.mock.callCount(), 0); + assert.strictEqual(fn(), 1); + assert.strictEqual(fn(), 2); + assert.strictEqual(fn(), 3); + assert.strictEqual(fn.mock.callCount(), 3); + + fn.mock.mockImplementation(addTwo); + assert.strictEqual(fn(), 5); + assert.strictEqual(fn(), 7); + assert.strictEqual(fn.mock.callCount(), 5); + + fn.mock.restore(); + assert.strictEqual(fn(), 8); + assert.strictEqual(fn(), 9); + assert.strictEqual(fn.mock.callCount(), 7); + + assert.throws(() => { + fn.mock.mockImplementationOnce(common.mustNotCall(), 6); + }, /The value of "onCall" is out of range\. It must be >= 7/); + + fn.mock.mockImplementationOnce(addThree, 7); + fn.mock.mockImplementationOnce(addTwo, 8); + assert.strictEqual(fn(), 12); + assert.strictEqual(fn(), 14); + assert.strictEqual(fn(), 15); + assert.strictEqual(fn.mock.callCount(), 10); + fn.mock.mockImplementationOnce(addThree); + assert.strictEqual(fn(), 18); + assert.strictEqual(fn(), 19); + assert.strictEqual(fn.mock.callCount(), 12); +}); + +test('local mocks are auto restored after the test finishes', async (t) => { + const obj = { + foo() {}, + bar() {}, + }; + const originalFoo = obj.foo; + const originalBar = obj.bar; + + assert.strictEqual(originalFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + + const mockFoo = t.mock.method(obj, 'foo'); + + assert.strictEqual(mockFoo, obj.foo); + assert.notStrictEqual(originalFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + + t.beforeEach(common.mustCallAtLeast(() => { + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); + })); + + t.afterEach(common.mustCallAtLeast(() => { + assert.strictEqual(mockFoo, obj.foo); + assert.notStrictEqual(originalBar, obj.bar); + })); + + await t.test('creates mocks that are auto restored', (t) => { + const mockBar = t.mock.method(obj, 'bar'); + + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(mockBar, obj.bar); + assert.notStrictEqual(originalBar, obj.bar); + }); + + assert.strictEqual(mockFoo, obj.foo); + assert.strictEqual(originalBar, obj.bar); +}); + +test('reset mock calls', (t) => { + const sum = (arg1, arg2) => arg1 + arg2; + const difference = (arg1, arg2) => arg1 - arg2; + const fn = t.mock.fn(sum, difference); + + assert.strictEqual(fn(1, 2), -1); + assert.strictEqual(fn(2, 1), 1); + assert.strictEqual(fn.mock.calls.length, 2); + assert.strictEqual(fn.mock.callCount(), 2); + + fn.mock.resetCalls(); + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn.mock.callCount(), 0); + + assert.strictEqual(fn(3, 2), 1); +}); + +test('uses top level mock', () => { + function sum(a, b) { + return a + b; + } + + function difference(a, b) { + return a - b; + } + + const fn = mock.fn(sum, difference); + + assert.strictEqual(fn.mock.calls.length, 0); + assert.strictEqual(fn(3, 4), -1); + assert.strictEqual(fn.mock.calls.length, 1); + mock.reset(); + assert.strictEqual(fn(3, 4), 7); + assert.strictEqual(fn.mock.calls.length, 2); +}); + +test('the getter and setter options cannot be used together', (t) => { + assert.throws(() => { + t.mock.method({}, 'method', { getter: true, setter: true }); + }, /The property 'options\.setter' cannot be used with 'options\.getter'/); +}); + +test('method names must be strings or symbols', (t) => { + const symbol = Symbol(); + const obj = { + method() {}, + [symbol]() {}, + }; + + t.mock.method(obj, 'method'); + t.mock.method(obj, symbol); + + assert.throws(() => { + t.mock.method(obj, {}); + }, /The "methodName" argument must be one of type string or symbol/); +}); + +test('the times option must be an integer >= 1', (t) => { + assert.throws(() => { + t.mock.fn({ times: null }); + }, /The "options\.times" property must be of type number/); + + assert.throws(() => { + t.mock.fn({ times: 0 }); + }, /The value of "options\.times" is out of range/); + + assert.throws(() => { + t.mock.fn(() => {}, { times: 3.14159 }); + }, /The value of "options\.times" is out of range/); +}); + +test('spies on a class prototype method', (t) => { + class Clazz { + constructor(c) { + this.c = c; + } + + getC() { + return this.c; + } + } + + const instance = new Clazz(85); + + assert.strictEqual(instance.getC(), 85); + t.mock.method(Clazz.prototype, 'getC'); + + assert.strictEqual(instance.getC.mock.calls.length, 0); + assert.strictEqual(instance.getC(), 85); + assert.strictEqual(instance.getC.mock.calls.length, 1); + assert.strictEqual(Clazz.prototype.getC.mock.calls.length, 1); + + const call = instance.getC.mock.calls[0]; + assert.deepStrictEqual(call.arguments, []); + assert.strictEqual(call.result, 85); + assert.strictEqual(call.error, undefined); + assert.strictEqual(call.target, undefined); + assert.strictEqual(call.this, instance); +}); + +test('getter() fails if getter options set to false', (t) => { + assert.throws(() => { + t.mock.getter({}, 'method', { getter: false }); + }, /The property 'options\.getter' cannot be false/); +}); + +test('setter() fails if setter options set to false', (t) => { + assert.throws(() => { + t.mock.setter({}, 'method', { setter: false }); + }, /The property 'options\.setter' cannot be false/); +}); + +test('getter() fails if setter options is true', (t) => { + assert.throws(() => { + t.mock.getter({}, 'method', { setter: true }); + }, /The property 'options\.setter' cannot be used with 'options\.getter'/); +}); + +test('setter() fails if getter options is true', (t) => { + assert.throws(() => { + t.mock.setter({}, 'method', { getter: true }); + }, /The property 'options\.setter' cannot be used with 'options\.getter'/); +}); + +test('spies on a property', (t) => { + const obj = { foo: 42 }; + const prop = t.mock.property(obj, 'foo', 100); + + assert.strictEqual(obj.foo, 100); + assert.strictEqual(prop.mock.accessCount(), 1); + assert.strictEqual(prop.mock.accesses[0].type, 'get'); + assert.strictEqual(prop.mock.accesses[0].value, 100); + + obj.foo = 200; + assert.strictEqual(obj.foo, 200); + assert.strictEqual(prop.mock.accesses.length, 3); + assert.strictEqual(prop.mock.accesses[1].type, 'set'); + assert.strictEqual(prop.mock.accesses[1].value, 200); + assert.strictEqual(prop.mock.accesses[2].type, 'get'); + assert.strictEqual(prop.mock.accesses[2].value, 200); + + obj.foo = 300; + assert.strictEqual(obj.foo, 300); + assert.strictEqual(prop.mock.accessCount(), 5); + assert.strictEqual(prop.mock.accesses[3].type, 'set'); + assert.strictEqual(prop.mock.accesses[3].value, 300); + assert.strictEqual(prop.mock.accesses[4].type, 'get'); + assert.strictEqual(prop.mock.accesses[4].value, 300); + + prop.mock.resetAccesses(); + assert.strictEqual(prop.mock.accessCount(), 0); + + obj.foo = 500; + assert.strictEqual(obj.foo, 500); + assert.strictEqual(prop.mock.accessCount(), 2); + assert.strictEqual(prop.mock.accesses[0].type, 'set'); + assert.strictEqual(prop.mock.accesses[1].type, 'get'); + + prop.mock.resetAccesses(); + assert.strictEqual(prop.mock.accessCount(), 0); + + assert.strictEqual(obj.foo, 500); + assert.strictEqual(prop.mock.accessCount(), 1); + assert.strictEqual(prop.mock.accesses[0].type, 'get'); + + prop.mock.restore(); + assert.strictEqual(obj.foo, 42); +}); + +test('spies on a property without providing a value', (t) => { + const obj = { foo: 123 }; + const prop = t.mock.property(obj, 'foo'); + + assert.strictEqual(obj.foo, 123); + assert.strictEqual(prop.mock.accessCount(), 1); + assert.strictEqual(prop.mock.accesses[0].type, 'get'); + assert.strictEqual(prop.mock.accesses[0].value, 123); + + obj.foo = 456; + assert.strictEqual(obj.foo, 456); + assert.strictEqual(prop.mock.accessCount(), 3); + assert.strictEqual(prop.mock.accesses[1].type, 'set'); + assert.strictEqual(prop.mock.accesses[1].value, 456); + assert.strictEqual(prop.mock.accesses[2].type, 'get'); + assert.strictEqual(prop.mock.accesses[2].value, 456); + + prop.mock.restore(); + assert.strictEqual(obj.foo, 123); +}); + +test('spies on a symbol property', (t) => { + const symbol = Symbol('foo'); + const obj = { [symbol]: 123 }; + const prop = t.mock.property(obj, symbol, 456); + + assert.strictEqual(obj[symbol], 456); + assert.strictEqual(prop.mock.accessCount(), 1); + + obj[symbol] = 789; + assert.strictEqual(obj[symbol], 789); + assert.strictEqual(prop.mock.accessCount(), 3); + assert.strictEqual(prop.mock.accesses[1].type, 'set'); + assert.strictEqual(prop.mock.accesses[2].type, 'get'); + + prop.mock.restore(); + assert.strictEqual(obj[symbol], 123); +}); + +test('changes mocked property value dynamically', (t) => { + const obj = { foo: 1 }; + + const prop = t.mock.property(obj, 'foo', 2); + assert.strictEqual(obj.foo, 2); + assert.strictEqual(prop.mock.accessCount(), 1); + + prop.mock.mockImplementation(99); + assert.strictEqual(obj.foo, 99); + assert.strictEqual(prop.mock.accessCount(), 3); + + prop.mock.mockImplementationOnce(42); + assert.strictEqual(obj.foo, 42); + assert.strictEqual(obj.foo, 99); + assert.strictEqual(prop.mock.accessCount(), 5); + + assert.throws(() => { + prop.mock.mockImplementationOnce(55, 4); + }, /The value of "onAccess" is out of range\. It must be >= 5/); + + prop.mock.mockImplementationOnce(100, 5); + prop.mock.mockImplementationOnce(200, 6); + assert.strictEqual(obj.foo, 100); + assert.strictEqual(obj.foo, 200); + assert.strictEqual(obj.foo, 99); + assert.strictEqual(prop.mock.accessCount(), 8); + + prop.mock.mockImplementationOnce(555, 10); + assert.strictEqual(obj.foo, 99); + assert.strictEqual(obj.foo, 99); + assert.strictEqual(obj.foo, 555); + + prop.mock.mockImplementation(undefined); + assert.strictEqual(obj.foo, undefined); +}); + +test('mocks property value to undefined', (t) => { + const obj = { foo: 123 }; + const prop = t.mock.property(obj, 'foo', undefined); + + assert.strictEqual(obj.foo, undefined); + assert.strictEqual(prop.mock.accessCount(), 1); + assert.strictEqual(prop.mock.accesses[0].type, 'get'); + assert.strictEqual(prop.mock.accesses[0].value, undefined); + + prop.mock.restore(); + assert.strictEqual(obj.foo, 123); +}); + +test('resetAccesses does not affect property value', (t) => { + const obj = { foo: 1 }; + const prop = t.mock.property(obj, 'foo', 2); + + obj.foo = 5; + assert.strictEqual(obj.foo, 5); + assert.strictEqual(prop.mock.accessCount(), 2); + + prop.mock.resetAccesses(); + assert.strictEqual(obj.foo, 5); + assert.strictEqual(prop.mock.accessCount(), 1); + assert.strictEqual(prop.mock.accesses[0].type, 'get'); +}); + +test('restores original property value', (t) => { + const obj = { + foo: 10, + }; + + const prop = t.mock.property(obj, 'foo', 20); + assert.strictEqual(obj.foo, 20); + + prop.mock.restore(); + assert.strictEqual(obj.foo, 10); +}); + +test('throws if setting a non-writable property', (t) => { + const obj = {}; + Object.defineProperty(obj, 'bar', { + value: 1, + writable: false, + configurable: true, + enumerable: true, + }); + + t.mock.property(obj, 'bar', 2); + assert.strictEqual(obj.bar, 2); + + assert.throws(() => { obj.bar = 3; }, { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('throws if property does not exist', (t) => { + assert.throws(() => { + t.mock.property({}, 'doesNotExist', 1); + }, { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('throws if object is null', (t) => { + assert.throws(() => { + t.mock.property(null, 'foo', 1); + }, { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('local property mocks are auto restored after the test finishes', async (t) => { + const obj = { foo: 111, bar: 222 }; + + assert.strictEqual(obj.foo, 111); + assert.strictEqual(obj.bar, 222); + + t.mock.property(obj, 'foo', 888); + + assert.strictEqual(obj.foo, 888); + assert.strictEqual(obj.bar, 222); + + t.beforeEach(common.mustCallAtLeast(() => { + assert.strictEqual(obj.foo, 888); + assert.strictEqual(obj.bar, 222); + })); + + t.afterEach(common.mustCallAtLeast(() => { + assert.strictEqual(obj.foo, 888); + assert.strictEqual(obj.bar, 999); + })); + + await t.test('creates property mocks that are auto restored', (t) => { + t.mock.property(obj, 'bar', 999); + + assert.strictEqual(obj.foo, 888); + assert.strictEqual(obj.bar, 999); + }); + + assert.strictEqual(obj.foo, 888); + assert.strictEqual(obj.bar, 222); +}); diff --git a/test/js/node/test/parallel/test-runner-xfail.js b/test/js/node/test/parallel/test-runner-xfail.js new file mode 100644 index 000000000000..b144156209a8 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-xfail.js @@ -0,0 +1,259 @@ +'use strict'; +const common = require('../common'); +const { test, suite } = require('node:test'); +const { spawn } = require('child_process'); +const assert = require('node:assert'); + +if (process.env.CHILD_PROCESS === 'true') { + test('fail with message string', { expectFailure: 'reason string' }, () => { + assert.fail('boom'); + }); + + test('fail with label object', { expectFailure: { label: 'reason object' } }, () => { + assert.fail('boom'); + }); + + test('fail with match regex', { expectFailure: { match: /boom/ } }, () => { + assert.fail('boom'); + }); + + test('fail with match object', { expectFailure: { match: { message: 'boom' } } }, () => { + assert.fail('boom'); + }); + + test('fail with match class', { expectFailure: { match: assert.AssertionError } }, () => { + assert.fail('boom'); + }); + + test('fail with match error (wrong error)', { expectFailure: { match: /bang/ } }, () => { + assert.fail('boom'); // Should result in real failure because error doesn't match + }); + + test('unexpected pass', { expectFailure: true }, () => { + // Should result in real failure because it didn't fail + }); + + test('fail with empty string', { expectFailure: '' }, () => { + assert.fail('boom'); + }); + + // 1. Matcher: RegExp + test('fails with regex matcher', { expectFailure: /expected error/ }, () => { + throw new Error('this is the expected error'); + }); + + test('fails with regex matcher (mismatch)', { expectFailure: /expected error/ }, () => { + throw new Error('wrong error'); // Should fail the test + }); + + // 2. Matcher: Class + test('fails with class matcher', { expectFailure: RangeError }, () => { + throw new RangeError('out of bounds'); + }); + + test('fails with class matcher (mismatch)', { expectFailure: RangeError }, () => { + throw new TypeError('wrong type'); // Should fail the test + }); + + // 3. Matcher: Function + test('fails with function matcher', { + expectFailure: (err) => err.code === 'ERR_FUNCTION', + }, () => { + const err = new Error('boom'); + err.code = 'ERR_FUNCTION'; + throw err; + }); + + test('fails with function matcher (mismatch)', { + expectFailure: (err) => err.code === 'ERR_FUNCTION', + }, () => { + const err = new Error('boom'); + err.code = 'ERR_WRONG'; + throw err; // Should fail + }); + + // 4. Matcher: Object (Properties) + test('fails with object matcher', { expectFailure: { code: 'ERR_TEST' } }, () => { + const err = new Error('boom'); + err.code = 'ERR_TEST'; + throw err; + }); + + test('fails with object matcher (mismatch)', { expectFailure: { code: 'ERR_TEST' } }, () => { + const err = new Error('boom'); + err.code = 'ERR_WRONG'; + throw err; // Should fail + }); + + // 5. Configuration Object: Reason + Validation + test('fails with config object (label + match)', { + expectFailure: { + label: 'Bug #124', + match: /boom/ + } + }, () => { + throw new Error('boom'); + }); + + test('fails with config object (label only)', { + expectFailure: { label: 'Bug #125' } + }, () => { + throw new Error('boom'); + }); + + test('fails with config object (match only)', { + expectFailure: { match: /boom/ } + }, () => { + throw new Error('boom'); + }); + + // 6. Edge Case: Empty Object (Should throw ERR_INVALID_ARG_VALUE during creation) + test('invalid empty object throws', () => { + // eslint-disable-next-line no-restricted-syntax + assert.throws(() => test('invalid empty object', { expectFailure: {} }, () => {})); + }); + + // 7. Primitives and Truthiness + test('fails with boolean true', { expectFailure: true }, () => { + throw new Error('any error'); + }); + + // 8. Unexpected Pass (Enhanced) + test('unexpected pass (reason string)', { expectFailure: 'should fail' }, () => { + // Pass + }); + + test('unexpected pass (matcher)', { expectFailure: /boom/ }, () => { + // Pass + }); + + // 9. Test level expectFailure with subtests + test('test level expectFailure with subtests', { expectFailure: true }, async (t) => { + await t.test('subtest should pass if it throws', () => { + throw new Error('fail'); + }); + + await t.test('subtest should fail if it does not throw', () => { + // This should fail the test because the suite expects failure, + // and this subtest inherited that expectation but didn't throw. + }); + }); + + test('test level expectFailure with subtests and matcher', { expectFailure: /suite error/ }, async (t) => { + await t.test('subtest match suite expectation', () => { + throw new Error('suite error'); + }); + }); + + // 10. Suite level expectFailure + suite('suite level expectFailure', { expectFailure: true }, () => { + test('suite subtest should pass if it throws', () => { + throw new Error('fail'); + }); + + test('suite subtest should fail if it does not throw', () => { + // This should fail the test because the suite expects failure, + // and this subtest inherited that expectation but didn't throw. + }); + }); + + suite('suite level expectFailure with matcher', { expectFailure: /suite error/ }, () => { + test('suite subtest match suite expectation', () => { + throw new Error('suite error'); + }); + }); + + test('correct config with label and match object', { + expectFailure: { + label: 'Bug #124', + match: { code: 'ERR_TEST' } + } + }, () => { + const err = new Error('boom'); + err.code = 'ERR_TEST'; + throw err; + }); + + test('fails with ambiguous config (mixed props)', { + expectFailure: { + code: 'ERR_TEST', + label: 'Bug #124', + } + }, () => { + const err = new Error('boom'); + err.code = 'ERR_TEST'; + throw err; + }); + + test('fails with ambiguous config (mixed props with match)', { + expectFailure: { + code: 'ERR_TEST', + match: /message/ + } + }, () => { + const err = new Error('message'); + err.code = 'ERR_TEST'; + throw err; + }); + +} else { + const child = spawn(process.execPath, ['--test-reporter', 'tap', __filename], { + env: { ...process.env, CHILD_PROCESS: 'true' }, + stdio: 'pipe', + }); + + let stdout = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + + child.on('close', common.mustCall((code) => { + // We expect exit code 1 because 'unexpected pass' and 'wrong error' should fail the test run + assert.strictEqual(code, 1); + + assert.match(stdout, /ok \d+ - fail with message string # EXPECTED FAILURE reason string/); + assert.match(stdout, /ok \d+ - fail with label object # EXPECTED FAILURE reason object/); + assert.match(stdout, /ok \d+ - fail with match regex # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fail with match object # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fail with match class # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fail with match error \(wrong error\) # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - unexpected pass # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fail with empty string # EXPECTED FAILURE/); + + // New tests verification + assert.match(stdout, /ok \d+ - fails with regex matcher # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fails with regex matcher \(mismatch\) # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fails with class matcher # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fails with class matcher \(mismatch\) # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fails with function matcher # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fails with function matcher \(mismatch\) # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fails with object matcher # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fails with object matcher \(mismatch\) # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fails with config object \(label \+ match\) # EXPECTED FAILURE Bug \\#124/); + assert.match(stdout, /ok \d+ - fails with config object \(label only\) # EXPECTED FAILURE Bug \\#125/); + assert.match(stdout, /ok \d+ - fails with config object \(match only\) # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - fails with boolean true # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - unexpected pass \(reason string\) # EXPECTED FAILURE should fail/); + assert.match(stdout, /not ok \d+ - unexpected pass \(matcher\) # EXPECTED FAILURE/); + + // Suite inheritance verification + assert.match(stdout, /ok \d+ - test level expectFailure with subtests # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - subtest should pass if it throws # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - subtest should fail if it does not throw # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - test level expectFailure with subtests and matcher # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - subtest match suite expectation # EXPECTED FAILURE/); + + // Suite level expectFailure verification + assert.match(stdout, /ok \d+ - suite level expectFailure # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - suite subtest should pass if it throws # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - suite subtest should fail if it does not throw # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - suite level expectFailure with matcher # EXPECTED FAILURE/); + assert.match(stdout, /ok \d+ - suite subtest match suite expectation # EXPECTED FAILURE/); + + // Empty object error + assert.match(stdout, /ok \d+ - invalid empty object throws/); + assert.match(stdout, /ok \d+ - correct config with label and match object # EXPECTED FAILURE Bug \\#124/); + assert.match(stdout, /not ok \d+ - fails with ambiguous config \(mixed props\) # EXPECTED FAILURE/); + assert.match(stdout, /not ok \d+ - fails with ambiguous config \(mixed props with match\) # EXPECTED FAILURE/); + + })); +} From 2ce60ce35b4df6f497f3fc12753bdaae0692964e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:01:34 +0000 Subject: [PATCH 14/18] test: drop test-runner-xfail.js and reword engine-diff notes in test-runner-mocking.js test-runner-xfail.js spawns itself with --test-reporter tap and asserts on TAP output for the expectFailure option, neither of which Bun has (listed under 'Not covered' in the PR body). It cannot pass without node:test/reporters and the --test CLI runner mode. The two engine-difference notes in test-runner-mocking.js are kept as 'Bun:' comments (JSC's wording for private-field and defineProperty errors differs from V8's; behavior matches). --- .../node/test/parallel/test-runner-mocking.js | 10 +- .../node/test/parallel/test-runner-xfail.js | 259 ------------------ 2 files changed, 6 insertions(+), 263 deletions(-) delete mode 100644 test/js/node/test/parallel/test-runner-xfail.js diff --git a/test/js/node/test/parallel/test-runner-mocking.js b/test/js/node/test/parallel/test-runner-mocking.js index 6a50cbe5a40d..5a1bd73fbd09 100644 --- a/test/js/node/test/parallel/test-runner-mocking.js +++ b/test/js/node/test/parallel/test-runner-mocking.js @@ -584,8 +584,9 @@ test('mocks a constructor', (t) => { assert.strictEqual(call.result, instance); assert.strictEqual(call.target, Clazz); assert.strictEqual(call.this, instance); - // FIXME: JSC's private-field message differs from V8's ("Cannot access - // invalid private field (evaluating 'this.#privateValue')"). + // Bun: JSC's private-field message differs from V8's ("Cannot access + // invalid private field (evaluating 'this.#privateValue')"), so match on + // the error constructor instead of V8's message text. // assert.throws(() => { // instance.getPrivateValue(); // }, /TypeError: Cannot read private member #privateValue /); @@ -800,8 +801,9 @@ test('method() fails if method cannot be redefined', (t) => { return a + this.prop; } - // FIXME: JSC's defineProperty message differs from V8's ("Attempting to - // change value of a readonly property."). + // Bun: JSC's defineProperty message differs from V8's ("Attempting to + // change value of a readonly property."), so match on the error + // constructor instead of V8's message text. // assert.throws(() => { // t.mock.method(obj, 'method', mockMethod); // }, /Cannot redefine property: method/); diff --git a/test/js/node/test/parallel/test-runner-xfail.js b/test/js/node/test/parallel/test-runner-xfail.js deleted file mode 100644 index b144156209a8..000000000000 --- a/test/js/node/test/parallel/test-runner-xfail.js +++ /dev/null @@ -1,259 +0,0 @@ -'use strict'; -const common = require('../common'); -const { test, suite } = require('node:test'); -const { spawn } = require('child_process'); -const assert = require('node:assert'); - -if (process.env.CHILD_PROCESS === 'true') { - test('fail with message string', { expectFailure: 'reason string' }, () => { - assert.fail('boom'); - }); - - test('fail with label object', { expectFailure: { label: 'reason object' } }, () => { - assert.fail('boom'); - }); - - test('fail with match regex', { expectFailure: { match: /boom/ } }, () => { - assert.fail('boom'); - }); - - test('fail with match object', { expectFailure: { match: { message: 'boom' } } }, () => { - assert.fail('boom'); - }); - - test('fail with match class', { expectFailure: { match: assert.AssertionError } }, () => { - assert.fail('boom'); - }); - - test('fail with match error (wrong error)', { expectFailure: { match: /bang/ } }, () => { - assert.fail('boom'); // Should result in real failure because error doesn't match - }); - - test('unexpected pass', { expectFailure: true }, () => { - // Should result in real failure because it didn't fail - }); - - test('fail with empty string', { expectFailure: '' }, () => { - assert.fail('boom'); - }); - - // 1. Matcher: RegExp - test('fails with regex matcher', { expectFailure: /expected error/ }, () => { - throw new Error('this is the expected error'); - }); - - test('fails with regex matcher (mismatch)', { expectFailure: /expected error/ }, () => { - throw new Error('wrong error'); // Should fail the test - }); - - // 2. Matcher: Class - test('fails with class matcher', { expectFailure: RangeError }, () => { - throw new RangeError('out of bounds'); - }); - - test('fails with class matcher (mismatch)', { expectFailure: RangeError }, () => { - throw new TypeError('wrong type'); // Should fail the test - }); - - // 3. Matcher: Function - test('fails with function matcher', { - expectFailure: (err) => err.code === 'ERR_FUNCTION', - }, () => { - const err = new Error('boom'); - err.code = 'ERR_FUNCTION'; - throw err; - }); - - test('fails with function matcher (mismatch)', { - expectFailure: (err) => err.code === 'ERR_FUNCTION', - }, () => { - const err = new Error('boom'); - err.code = 'ERR_WRONG'; - throw err; // Should fail - }); - - // 4. Matcher: Object (Properties) - test('fails with object matcher', { expectFailure: { code: 'ERR_TEST' } }, () => { - const err = new Error('boom'); - err.code = 'ERR_TEST'; - throw err; - }); - - test('fails with object matcher (mismatch)', { expectFailure: { code: 'ERR_TEST' } }, () => { - const err = new Error('boom'); - err.code = 'ERR_WRONG'; - throw err; // Should fail - }); - - // 5. Configuration Object: Reason + Validation - test('fails with config object (label + match)', { - expectFailure: { - label: 'Bug #124', - match: /boom/ - } - }, () => { - throw new Error('boom'); - }); - - test('fails with config object (label only)', { - expectFailure: { label: 'Bug #125' } - }, () => { - throw new Error('boom'); - }); - - test('fails with config object (match only)', { - expectFailure: { match: /boom/ } - }, () => { - throw new Error('boom'); - }); - - // 6. Edge Case: Empty Object (Should throw ERR_INVALID_ARG_VALUE during creation) - test('invalid empty object throws', () => { - // eslint-disable-next-line no-restricted-syntax - assert.throws(() => test('invalid empty object', { expectFailure: {} }, () => {})); - }); - - // 7. Primitives and Truthiness - test('fails with boolean true', { expectFailure: true }, () => { - throw new Error('any error'); - }); - - // 8. Unexpected Pass (Enhanced) - test('unexpected pass (reason string)', { expectFailure: 'should fail' }, () => { - // Pass - }); - - test('unexpected pass (matcher)', { expectFailure: /boom/ }, () => { - // Pass - }); - - // 9. Test level expectFailure with subtests - test('test level expectFailure with subtests', { expectFailure: true }, async (t) => { - await t.test('subtest should pass if it throws', () => { - throw new Error('fail'); - }); - - await t.test('subtest should fail if it does not throw', () => { - // This should fail the test because the suite expects failure, - // and this subtest inherited that expectation but didn't throw. - }); - }); - - test('test level expectFailure with subtests and matcher', { expectFailure: /suite error/ }, async (t) => { - await t.test('subtest match suite expectation', () => { - throw new Error('suite error'); - }); - }); - - // 10. Suite level expectFailure - suite('suite level expectFailure', { expectFailure: true }, () => { - test('suite subtest should pass if it throws', () => { - throw new Error('fail'); - }); - - test('suite subtest should fail if it does not throw', () => { - // This should fail the test because the suite expects failure, - // and this subtest inherited that expectation but didn't throw. - }); - }); - - suite('suite level expectFailure with matcher', { expectFailure: /suite error/ }, () => { - test('suite subtest match suite expectation', () => { - throw new Error('suite error'); - }); - }); - - test('correct config with label and match object', { - expectFailure: { - label: 'Bug #124', - match: { code: 'ERR_TEST' } - } - }, () => { - const err = new Error('boom'); - err.code = 'ERR_TEST'; - throw err; - }); - - test('fails with ambiguous config (mixed props)', { - expectFailure: { - code: 'ERR_TEST', - label: 'Bug #124', - } - }, () => { - const err = new Error('boom'); - err.code = 'ERR_TEST'; - throw err; - }); - - test('fails with ambiguous config (mixed props with match)', { - expectFailure: { - code: 'ERR_TEST', - match: /message/ - } - }, () => { - const err = new Error('message'); - err.code = 'ERR_TEST'; - throw err; - }); - -} else { - const child = spawn(process.execPath, ['--test-reporter', 'tap', __filename], { - env: { ...process.env, CHILD_PROCESS: 'true' }, - stdio: 'pipe', - }); - - let stdout = ''; - child.stdout.setEncoding('utf8'); - child.stdout.on('data', (chunk) => { stdout += chunk; }); - - child.on('close', common.mustCall((code) => { - // We expect exit code 1 because 'unexpected pass' and 'wrong error' should fail the test run - assert.strictEqual(code, 1); - - assert.match(stdout, /ok \d+ - fail with message string # EXPECTED FAILURE reason string/); - assert.match(stdout, /ok \d+ - fail with label object # EXPECTED FAILURE reason object/); - assert.match(stdout, /ok \d+ - fail with match regex # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fail with match object # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fail with match class # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fail with match error \(wrong error\) # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - unexpected pass # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fail with empty string # EXPECTED FAILURE/); - - // New tests verification - assert.match(stdout, /ok \d+ - fails with regex matcher # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fails with regex matcher \(mismatch\) # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fails with class matcher # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fails with class matcher \(mismatch\) # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fails with function matcher # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fails with function matcher \(mismatch\) # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fails with object matcher # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fails with object matcher \(mismatch\) # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fails with config object \(label \+ match\) # EXPECTED FAILURE Bug \\#124/); - assert.match(stdout, /ok \d+ - fails with config object \(label only\) # EXPECTED FAILURE Bug \\#125/); - assert.match(stdout, /ok \d+ - fails with config object \(match only\) # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - fails with boolean true # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - unexpected pass \(reason string\) # EXPECTED FAILURE should fail/); - assert.match(stdout, /not ok \d+ - unexpected pass \(matcher\) # EXPECTED FAILURE/); - - // Suite inheritance verification - assert.match(stdout, /ok \d+ - test level expectFailure with subtests # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - subtest should pass if it throws # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - subtest should fail if it does not throw # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - test level expectFailure with subtests and matcher # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - subtest match suite expectation # EXPECTED FAILURE/); - - // Suite level expectFailure verification - assert.match(stdout, /ok \d+ - suite level expectFailure # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - suite subtest should pass if it throws # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - suite subtest should fail if it does not throw # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - suite level expectFailure with matcher # EXPECTED FAILURE/); - assert.match(stdout, /ok \d+ - suite subtest match suite expectation # EXPECTED FAILURE/); - - // Empty object error - assert.match(stdout, /ok \d+ - invalid empty object throws/); - assert.match(stdout, /ok \d+ - correct config with label and match object # EXPECTED FAILURE Bug \\#124/); - assert.match(stdout, /not ok \d+ - fails with ambiguous config \(mixed props\) # EXPECTED FAILURE/); - assert.match(stdout, /not ok \d+ - fails with ambiguous config \(mixed props with match\) # EXPECTED FAILURE/); - - })); -} From 6a53c425e3fd15865d7fb675f1957eb8477827c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:08:33 +0000 Subject: [PATCH 15/18] Restore test-set-http-max-http-headers.js and its leaksan entry; quarantine in expectations.txt instead The test spawns test-http-max-http-headers.js (not vendored) and asserts its exit code. Before this PR it only passed because the old node:test shim never awaited (t, done)-style callbacks, so the cp.on('close') assertions never ran. With the real done-callback support, the second and fourth tests fail ('Module not found' where exit 0 is expected). Keep the file as-is and quarantine it in test/expectations.txt so the deletion can go in a separate PR once the spawned target is vendored or the test is removed upstream-side. --- test/expectations.txt | 8 ++ .../test-set-http-max-http-headers.js | 81 +++++++++++++++++++ test/no-validate-leaksan.txt | 1 + 3 files changed, 90 insertions(+) create mode 100644 test/js/node/test/parallel/test-set-http-max-http-headers.js diff --git a/test/expectations.txt b/test/expectations.txt index b62e77445493..59b2ff97ecea 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -54,6 +54,14 @@ test/js/node/test/parallel/test-stream-wrap.js [ FAIL ] # needs internal/test/bi test/js/node/test/parallel/test-stream-wrap-drain.js [ FAIL ] # needs internal/js_stream_socket (net.Socket({handle}) libuv compat layer) test/js/node/test/parallel/test-stream-wrap-encoding.js [ FAIL ] # needs internal/js_stream_socket (net.Socket({handle}) libuv compat layer) +# Spawns test-http-max-http-headers.js, which is not vendored, and asserts its +# exit code. Only "passed" before #32631 because the old node:test shim never +# awaited done-style callbacks, so the cp.on('close') assertions never ran. +# With the real (t, done) support, the second/fourth tests fail (child exits 1 +# with "Module not found" where exit 0 is expected). Remove this entry once +# test-http-max-http-headers.js is vendored. +test/js/node/test/parallel/test-set-http-max-http-headers.js [ FAIL ] # spawns test-http-max-http-headers.js which is not vendored + # Pre-existing fs.watch leak unmasked by this PR's eval-entry exception fix: # the child's thrown leak error ("fs.watch(dir) leaked N MB") used to be # swallowed by the silent-exit-0 eval bug (uncaught throw in a CJS -e script diff --git a/test/js/node/test/parallel/test-set-http-max-http-headers.js b/test/js/node/test/parallel/test-set-http-max-http-headers.js new file mode 100644 index 000000000000..01061a091659 --- /dev/null +++ b/test/js/node/test/parallel/test-set-http-max-http-headers.js @@ -0,0 +1,81 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { spawn } = require('child_process'); +const path = require('path'); +const { suite, test } = require('node:test'); +const testName = path.join(__dirname, 'test-http-max-http-headers.js'); + +test(function(_, cb) { + console.log('running subtest expecting failure'); + + // Validate that the test fails if the max header size is too small. + const args = ['--expose-internals', + '--max-http-header-size=1024', + testName]; + const cp = spawn(process.execPath, args, { stdio: 'inherit' }); + + cp.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + cb(); + })); +}); + +test(function(_, cb) { + console.log('running subtest expecting success'); + + const env = Object.assign({}, process.env, { + NODE_DEBUG: 'http' + }); + + // Validate that the test now passes if the same limit is large enough. + const args = ['--expose-internals', + '--max-http-header-size=1024', + testName, + '1024']; + const cp = spawn(process.execPath, args, { + env, + stdio: 'inherit' + }); + + cp.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + cb(); + })); +}); + +const skip = process.config.variables.node_without_node_options; +suite('same checks using NODE_OPTIONS if it is supported', { skip }, () => { + const env = Object.assign({}, process.env, { + NODE_OPTIONS: '--max-http-header-size=1024' + }); + + test(function(_, cb) { + console.log('running subtest expecting failure'); + + // Validate that the test fails if the max header size is too small. + const args = ['--expose-internals', testName]; + const cp = spawn(process.execPath, args, { env, stdio: 'inherit' }); + + cp.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 1); + assert.strictEqual(signal, null); + cb(); + })); + }); + + test(function(_, cb) { + // Validate that the test now passes if the same limit is large enough. + const args = ['--expose-internals', testName, '1024']; + const cp = spawn(process.execPath, args, { env, stdio: 'inherit' }); + + cp.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + cb(); + })); + }); +}); diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index d45fc76d93b1..334b94e568ce 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -226,6 +226,7 @@ test/cli/test/parallel.test.ts # bun.assert(!this.hasPendingActivity() or jsc.VirtualMachine.get().isShuttingDown()); # @call(bun.callmod_inline, Subprocess.finalize, .{thisValue}); +test/js/node/test/parallel/test-set-http-max-http-headers.js test/js/node/test/parallel/test-child-process-windows-hide.js test/cli/inspect/BunFrontendDevServer.test.ts test/cli/inspect/HTTPServerAgent.test.ts From fa8b9698e6681a484c1f42e4bf86d2942f3bcfb8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:15:56 +0000 Subject: [PATCH 16/18] node:test: clear plan-wait timer on stop race; return SuiteContext from getTestContext; reset tags warning per file TestPlan.cancel() mirrors count()'s cleanup so a ref'd numeric {wait: K} timer does not stay armed for K-N ms after the test-level timeout wins the race and the test reports. getTestContext() now branches on node.isSuite like hookArgFor() already does, so a suite-level hook sees a SuiteContext (no .mock/.plan/.assert) where Node returns one. tagsExperimentalWarningEmitted joins the per-file reset list in getRootNode()'s isNewFile branch (same class as mock/customAssertions). --- src/js/node/test.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 2e20b3e1c4c6..a05981ba0a41 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -731,12 +731,22 @@ class TestPlan { makeTestFailure(`plan timed out after ${wait}ms with ${this.actual} assertions when expecting ${expected}`), ); }, wait); - // Not unref'd: count()/the timer callback always clear it, and on - // Windows an unref'd timer alone under bun:test busy-spins (8664279d). + // Not unref'd: count()/cancel()/the timer callback always clear it, and + // on Windows an unref'd timer alone under bun:test busy-spins (8664279d). } this.#pending = { resolve, reject, timer }; }); } + + // Mirrors count()'s cleanup for the stop-wins-race path: if the test-level + // timeout fires before a numeric {wait: K} is fulfilled, the ref'd plan + // timer must not stay armed for K - N more ms after the test reported. + cancel() { + const pending = this.#pending; + if (pending === undefined) return; + this.#pending = undefined; + if (pending.timer !== undefined) realClearTimeout(pending.timer); + } } // t.test() counts against the parent's plan; only t.assert.* uses the @@ -792,9 +802,10 @@ function runWithNode(node: TestNode, fn: () => T): T { return getAsyncLocalStorage().run(node, fn); } -function getTestContext(): TestContext | undefined { +function getTestContext(): TestContext | SuiteContext | undefined { const node = currentNode(); - return node?.getCtx(); + if (node === undefined) return undefined; + return node.isSuite ? node.getSuiteCtx() : node.getCtx(); } // ----------------------------------------------------------------------------- @@ -928,6 +939,7 @@ function getRootNode(): TestNode { // module-level mocks and assert.register() additions with its root. mock.reset(); customAssertions = { __proto__: null } as unknown as Record; + tagsExperimentalWarningEmitted = false; } } return rootNode; @@ -1531,6 +1543,7 @@ async function executeTestNode(node: TestNode, fn: TestFn): Promise { } } finally { stop?.dispose(); + node.plan?.cancel(); } const { failedSubtests, firstSubtestError } = node; From 884abfb5dc5b3f01b62be6b050c480a1600f0d79 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:36:21 +0000 Subject: [PATCH 17/18] lint: destructure pending.timer once in TestPlan.cancel() --- src/js/node/test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index a05981ba0a41..58e0b1a05479 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -745,7 +745,8 @@ class TestPlan { const pending = this.#pending; if (pending === undefined) return; this.#pending = undefined; - if (pending.timer !== undefined) realClearTimeout(pending.timer); + const { timer } = pending; + if (timer !== undefined) realClearTimeout(timer); } } From cd40cb3753ff6db21b4effe597bb4cd577dd85bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:51:16 +0000 Subject: [PATCH 18/18] node:test: mark runtime skip/todo under --concurrent; getTestContext root, oldRoot.mockTracker reset markCurrentResult's fallback for the 'not yet stamped' DoneCallback window (when .then() fires inside run_test_callback's microtask drain) used get_current_state_data(), which can't name a sequence inside a concurrent group, so the skip/todo mark was dropped and the test was reported pass. Execution now stashes the callback's own EntryData alongside on_stack_entry (set/restored around run_test_callback), and js_node_test_mark_result reads that instead. New --concurrent case in node-test.test.ts pins it. getTestContext() now checks node.parent !== undefined like hookArgFor() does, so a file-level before/after hook (root has isSuite=true, parent=undefined; Node's root is a Test) gets a TestContext. getRootNode()'s isNewFile branch now also resets the outgoing root's own mockTracker (reachable via a file-level before hook's t.mock, distinct from the module-level mock export). --- src/js/node/test.ts | 11 +++++++--- src/runtime/test_runner/Execution.rs | 24 ++++++++++++++++------ src/runtime/test_runner/jest.rs | 15 +++++++++++--- test/js/node/test_runner/node-test.test.ts | 14 +++++++++++++ 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 58e0b1a05479..79c0eba91e9b 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -806,7 +806,9 @@ function runWithNode(node: TestNode, fn: () => T): T { function getTestContext(): TestContext | SuiteContext | undefined { const node = currentNode(); if (node === undefined) return undefined; - return node.isSuite ? node.getSuiteCtx() : node.getCtx(); + // The root has isSuite=true but parent=undefined; Node's root is a Test, + // so match hookArgFor() and give it a TestContext. + return node.isSuite && node.parent !== undefined ? node.getSuiteCtx() : node.getCtx(); } // ----------------------------------------------------------------------------- @@ -930,14 +932,17 @@ function getRootNode(): TestNode { // alone can't detect a rerun of the same file. const generation = fileGeneration(); if (rootNode === undefined || rootGeneration !== generation) { - const isNewFile = rootNode !== undefined; + const oldRoot = rootNode; rootGeneration = generation; // Publish the new root before resetting so re-entrant calls (user code run // by a mock's restore) see an up-to-date root and don't reset again. rootNode = new TestNode(kRootName, undefined, kDefaultOptions, true, false); - if (isNewFile) { + if (oldRoot !== undefined) { // Node also scopes these per process: drop the previous file's // module-level mocks and assert.register() additions with its root. + // The root's own mockTracker (reachable via a file-level before hook's + // `t.mock`) is distinct from the module-level `mock` export. + oldRoot.mockTracker?.reset(); mock.reset(); customAssertions = { __proto__: null } as unknown as Record; tagsExperimentalWarningEmitted = false; diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index 6d2d50950df7..89d5c9d76258 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -93,6 +93,13 @@ pub struct Execution { /// around `run_test_callback` so code re-entered from a test body (e.g. /// spawnSync's wait loop) can read the calling entry's own deadline. pub on_stack_entry: core::cell::Cell>>, + /// The (group_index, sequence_index, entry, repeat) for `on_stack_entry`, + /// set/restored alongside it. `get_current_state_data()` can't name a + /// sequence inside a concurrent group; this can, for code re-entered from + /// the microtask drain inside `run_test_callback` (node:test's runtime + /// `t.skip()`/`t.todo()` mark lands there before the DoneCallback is + /// stamped). + pub on_stack_entry_data: core::cell::Cell>, } pub struct ConcurrentGroup { @@ -290,6 +297,7 @@ impl Execution { sequences: Box::default(), group_index: 0, on_stack_entry: core::cell::Cell::new(None), + on_stack_entry_data: core::cell::Cell::new(None), } } @@ -1010,22 +1018,26 @@ fn step_sequence_one( if let Some(cb) = next_item.callback.as_ref() { group_log::log(format_args!("runSequence queued callback")); + let entry_data = EntryData { + sequence_index, + entry: next_item_ptr.as_ptr() as *const (), + remaining_repeat_count: sequence.remaining_repeat_count as i64, + }; let callback_data = RefDataValue::Execution { group_index: this.group_index, - entry_data: Some(EntryData { - sequence_index, - entry: next_item_ptr.as_ptr() as *const (), - remaining_repeat_count: sequence.remaining_repeat_count as i64, - }), + entry_data: Some(entry_data), }; group_log::log(format_args!("runSequence queued callback: {}", callback_data)); let prev_on_stack = this.on_stack_entry.replace(Some(next_item_ptr)); + let prev_on_stack_data = this.on_stack_entry_data.replace(Some(entry_data)); let on_stack_cell = &raw const this.on_stack_entry; - // SAFETY: `on_stack_cell` points into `buntest.execution`, which is + let on_stack_data_cell = &raw const this.on_stack_entry_data; + // SAFETY: both cells point into `buntest.execution`, which is // never moved during execution (arena-owned BunTest behind an Rc). let _restore = scopeguard::guard((), move |()| unsafe { (*on_stack_cell).set(prev_on_stack); + (*on_stack_data_cell).set(prev_on_stack_data); }); if BunTest::run_test_callback( diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index b695dc8b2328..bc605d9ab39b 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -577,9 +577,18 @@ pub(crate) fn js_node_test_mark_result( let bound = match dcb_ref { Some(refdata) => refdata.phase.clone(), // `r#ref` unset: `.then()` fired inside run_test_callback's microtask - // drain before it stamps the DoneCallback — the runner has not moved - // on, so the live index IS the intended sequence. - None if !dcb_called => buntest.get_current_state_data(), + // drain before it stamps the DoneCallback. `get_current_state_data()` + // can't name a sequence inside a concurrent group, but + // `on_stack_entry_data` holds exactly the `cfg_data` that + // `run_test_callback` was invoked with (set/restored around it), so + // the mark lands on the right sequence under --concurrent too. + None if !dcb_called => match buntest.execution.on_stack_entry_data.get() { + Some(entry_data) => bun_test::RefDataValue::Execution { + group_index: buntest.execution.group_index, + entry_data: Some(entry_data), + }, + None => buntest.get_current_state_data(), + }, // done() already ran and reported — nothing left to mark. None => return Ok(JSValue::UNDEFINED), }; diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 04ece02a33c7..2b480dddb050 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -93,6 +93,20 @@ describe("node:test", () => { }); }); + test("should count runtime t.todo()/t.skip() as todo/skip under --concurrent too", async () => { + // markCurrentResult's microtask-drain fallback could not name a sequence + // inside a concurrent group, so the skip/todo mark was dropped and both + // tests were reported as pass. + const { exitCode, stderr } = await runTests(["12-runtime-todo-and-mock-timers.js"], {}, ["--concurrent"]); + expect(stderr).toContain("3 pass"); + expect(stderr).toContain("1 skip"); + expect(stderr).toContain("1 todo"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }); + test("should run todo bodies under --todo instead of registering an empty function", async () => { const { exitCode, stderr } = await runTests(["13-todo-bodies.js"], {}, ["--todo"]); expect(stderr).toContain("2 todo");