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/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index d928a8db3cc0..6b7c928fd51b 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 `mock.module`, `mock.timers`, snapshots. 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/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 2143557ef408..f135dc4cdcfd 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -66,6 +66,7 @@ const rustIdentifierPaths: Record = { "ini.rs": "ini/ini.rs", "install_binding.rs": "install_jsc/install_binding.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/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts new file mode 100644 index 000000000000..df1abe229ee4 --- /dev/null +++ b/src/js/internal/test_runner/mock_timers.ts @@ -0,0 +1,756 @@ +// 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) { + // 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; + } + + 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'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}`); + } + } + + #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 e671cd520799..890dcec10900 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -2667,7 +2667,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 3e02223934c1..79c0eba91e9b 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; } @@ -108,200 +137,352 @@ 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, }); } }; } } -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); +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, { + // @ts-ignore + __proto__: null, + 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), + }); } - 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; + get accesses() { + return this.#accesses.slice(0); } - 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; - } - 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, { + // @ts-ignore + __proto__: null, + ...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'"); +} + +// 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; + // 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", { + // @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, + }); + // 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; } - 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); } - original = descriptor.value; + if (options !== undefined) { + validateObject(options, "options"); + } + const { times = Infinity } = (options ?? kEmptyObject) as { times?: number }; + validateTimes(times, "options.times"); + return this.#createMockFunction( + (original as Function) ?? createDefaultOriginal(), + 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() { + // @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, + }; + 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 +490,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 +504,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 +529,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 +600,28 @@ function snapshot(_value: unknown, _options: { serializers?: Function[] } = kEmp throwNotImplemented("snapshot()", 5090, "Use `bun:test` in the interim."); } +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. +// 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 +629,342 @@ 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; + // 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[]) { + plan?.count(); + return method.$apply(ctx, args); + }; + // @ts-ignore + Object.defineProperty(wrapper, "name", { __proto__: null, 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. + // `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); + } + add("snapshot", snapshot); + add("fileSnapshot", fileSnapshot); + 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; +} + +// ----------------------------------------------------------------------------- +// 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); + // 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; + const { timer } = pending; + if (timer !== undefined) realClearTimeout(timer); + } +} + +// 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(); +} + +// ----------------------------------------------------------------------------- +// 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 | SuiteContext | undefined { + const node = currentNode(); + if (node === undefined) return undefined; + // 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(); +} + +// ----------------------------------------------------------------------------- +// 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; + 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; + // 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; + } +} + +// 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. +// `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; + +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(); + if (rootNode === undefined || rootGeneration !== generation) { + 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 (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; + } + } + return rootNode; +} + +// ----------------------------------------------------------------------------- +// Contexts +// ----------------------------------------------------------------------------- /** * @link https://nodejs.org/api/test.html#class-testcontext */ class TestContext { - #insideTest: boolean; - #name: string | undefined; - #filePath: string | undefined; - #parent?: TestContext; + #node: TestNode; #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) { + this.#node = node; } get signal(): AbortSignal { @@ -420,40 +975,58 @@ 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 this.#node.error; + } + + get passed(): boolean { + return this.#node.passed; + } + + get attempt(): number { + return 0; + } + + get workerId(): number | undefined { + return Number(process.env.NODE_TEST_WORKER_ID) || undefined; + } + + 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() { - // Node gives each TestContext its own tracker so after-test restoration - // is scoped; sharing the module-level tracker is enough for what's - // implemented today (Node's own sqlite tests use t.mock.fn()). - return mock; + get mock(): MockTracker { + const node = this.#node; + node.mockTracker ??= new MockTracker(); + return node.mockTracker; } runOnly(_value?: boolean) { @@ -461,171 +1034,156 @@ 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; + 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; + } + 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; + retry = 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); + } +} - this.#checkNotInsideTest("describe"); +/** + * @link https://nodejs.org/api/test.html#class-suitecontext + */ +class SuiteContext { + #node: TestNode; + #abortController?: AbortController; - const { describe } = bunTest(); - describe(name, fn); + constructor(node: TestNode) { + this.#node = node; } - #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") { @@ -652,130 +1210,769 @@ 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 }; +} + +// 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); + } } -function createTest(arg0: unknown, arg1: unknown, arg2: unknown) { - const { name, options, fn } = parseTestOptions(arg0, arg1, arg2); +function validateTestOptions(options: TestOptions): { ownTags: string[] | undefined } { + const { concurrency, tags, plan } = options; - checkNotInsideTest(ctx, "test"); - const context = new TestContext(true, name, Bun.main, ctx); + // 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") { + validateUint32(concurrency, "options.concurrency", true); + } else { + throw $ERR_INVALID_ARG_TYPE("options.concurrency", ["boolean", "number"], concurrency); + } + } + if (plan !== undefined) { + validateUint32(plan, "options.plan"); + } - const runTest = (done: (error?: unknown) => void) => { - const originalContext = ctx; - ctx = context; - const endTest = (error?: unknown) => { - try { - done(error); - } finally { - ctx = originalContext; + 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); +} + +// 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) => { + // 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); + }); + // 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) { + 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); + }), + ); + } + 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) { + // 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); + } + } +} - 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; + + // 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); + } - const runDescribe = () => { - const originalContext = ctx; - ctx = context; - const endDescribe = () => { - ctx = originalContext; - }; + try { + for (const ancestor of ancestors) { + for (const hook of ancestor.hooks.beforeEach) { + await runHook(hook, ancestor, ctx); + } + } + } catch (err) { + failure = err; + } + if (failure === undefined) { + // Node arms one stopPromise (timeout + signal) and races both the body + // AND the plan wait against it. Arm timeout once here so plan({wait:true}) + // is bounded by the same test timeout, not left unbounded. + const stop = createStopController(node.options.timeout); try { - return fn(context); + 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 { + await (stop === undefined ? runBody() : Promise.race([stop.promise, runBody()])); + } 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 { + 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])); + // 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; + } + } } finally { - endDescribe(); + stop?.dispose(); + node.plan?.cancel(); + } + + 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; + } + } + + // 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; + // 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]; + 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.passed = failure === undefined; + node.error = failure ?? null; + 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; - } else { - fn = kDefaultFunction; +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; +} - if (typeof arg1 === "object") { - options = arg1 as HookOptions; - } else { - options = kDefaultOptions; +function currentCollectionParent(): TestNode { + const node = currentNode(); + if (node !== undefined && !node.isExecutionPhase && node.isSuite) { + return node; } + return getRootNode(); +} - return { fn, options }; +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 => { + // 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, done); + } else if (node.todoFlag && !declaredTodo) { + markCurrentResult(true, done); + } else { + done(failure); + return; + } + done(undefined); + }, + err => done(err), + ); + }; } -function createHook(arg0: unknown, arg1: unknown) { - const { fn, options } = parseHookOptions(arg0, arg1); +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) { + if (runningNode.finished) { + // 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). + 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 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, true) : kDefaultFunction; + if (passOptions !== undefined) { + register(name, body, passOptions); + } else { + register(name, body); + } + return Promise.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); + if (passOptions !== undefined) { + test(name, runner, passOptions); + } else { + test(name, runner); + } + + // 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); +} - 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.finished) { + return Promise.resolve(undefined); + } + 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 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; 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(gate.resolve, gate.resolve); } else { - done(); + gate.resolve(); + 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."); } @@ -799,5 +1996,6 @@ test.snapshot = { }; test.run = run; test.mock = mock; +test.getTestContext = getTestContext; export default test; diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 3a30e116154e..cd05183e3a82 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -6219,6 +6219,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/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/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 7e20e6fe6125..bc605d9ab39b 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; @@ -536,6 +536,75 @@ pub mod Jest { } } +/// 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)) +} + +/// 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, 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) }; + // `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 (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. `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), + }; + let Some((sequence_ptr, _)) = + buntest.execution.get_current_and_valid_execution_sequence(&bound) + 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/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/common/index.js b/test/js/node/test/common/index.js index 168be9dfb6d4..bd895f25b471 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1342,7 +1342,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 }, })); // node's internal/http: serve the very same symbols Bun's _http_outgoing // attaches to OutgoingMessage instances, so tests poke at real state. 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-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..ad63344f7d02 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mock-timers.js @@ -0,0 +1,905 @@ +// 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); + 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-mocking.js b/test/js/node/test/parallel/test-runner-mocking.js new file mode 100644 index 000000000000..5a1bd73fbd09 --- /dev/null +++ b/test/js/node/test/parallel/test-runner-mocking.js @@ -0,0 +1,1286 @@ +'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); + // 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 /); + 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; + } + + // 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/); + 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-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_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..a1b762a58695 --- /dev/null +++ b/test/js/node/test_runner/fixtures/09-inline-suites.js @@ -0,0 +1,57 @@ +// Inline suites (describe() inside a running test), matched against Node v26.3.0. +const assert = require("node:assert"); +const { test, describe, before, it } = 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"]); +}); + +// 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/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/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..7548f769db44 --- /dev/null +++ b/test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js @@ -0,0 +1,39 @@ +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: 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 outcome; + await saved + .test("late", () => {}) + .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/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/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/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/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 3e422ae5ae0d..2b480dddb050 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -45,24 +45,230 @@ 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 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("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 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"); + 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("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + 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 resolve subtests started after their parent finished", async () => { + const { exitCode, stderr } = await runTests(["16-plan-and-late-subtest.js"]); + expect(stderr).toContain("2 pass"); + expect(stderr).toContain("1 todo"); + 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 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"]); + expect(stderr).toContain("3 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + 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"), + }); + }); + + 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[]) { +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([ @@ -226,3 +432,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 }); +});