diff --git a/src/js/internal/test_runner/mock_timers.ts b/src/js/internal/test_runner/mock_timers.ts index df1abe229ee4..52180d439714 100644 --- a/src/js/internal/test_runner/mock_timers.ts +++ b/src/js/internal/test_runner/mock_timers.ts @@ -254,8 +254,14 @@ class MockTimers { Object.defineProperty(nodeTimersPromises, "setInterval", this.#realPromisifiedSetInterval); } + // Unlike node, not restored as a copy bound to this: Scheduler.prototype.wait checks its receiver. #restoreOriginalSchedulerWait() { - nodeTimersPromises.scheduler.wait = this.#realTimersPromisifiedSchedulerWait.bind(this); + const { scheduler } = nodeTimersPromises; + if (this.#realTimersPromisifiedSchedulerWait === undefined) { + delete scheduler.wait; + } else { + Object.defineProperty(scheduler, "wait", this.#realTimersPromisifiedSchedulerWait); + } } #restoreOriginalSetTimeout() { @@ -283,7 +289,7 @@ class MockTimers { } #storeOriginalSchedulerWait() { - this.#realTimersPromisifiedSchedulerWait = nodeTimersPromises.scheduler.wait.bind(this); + this.#realTimersPromisifiedSchedulerWait = Object.getOwnPropertyDescriptor(nodeTimersPromises.scheduler, "wait"); } #storeOriginalSetTimeout() { diff --git a/src/js/node/timers.promises.ts b/src/js/node/timers.promises.ts index 2074fb57ba1a..698f055076b3 100644 --- a/src/js/node/timers.promises.ts +++ b/src/js/node/timers.promises.ts @@ -74,7 +74,7 @@ function setTimeout(after = 1, value, options = {}) { : returnValue; } -function setImmediate(value, options = {}) { +function setImmediate(value?, options = {}) { try { validateObject(options, "options"); } catch (error) { @@ -231,12 +231,31 @@ function setInterval(after = 1, value, options = {}) { } } +const kScheduler = Symbol("kScheduler"); + +// Mirrors node's lib/timers/promises.js: the exported `scheduler` is the only instance. +class Scheduler { + constructor() { + throw $ERR_ILLEGAL_CONSTRUCTOR(); + } + + yield() { + if (!this[kScheduler]) throw $ERR_INVALID_THIS("Scheduler"); + return setImmediate(); + } + + wait(delay, options) { + if (!this[kScheduler]) throw $ERR_INVALID_THIS("Scheduler"); + return setTimeout(delay, undefined, options); + } +} + +const scheduler = Object.create(Scheduler.prototype); +scheduler[kScheduler] = true; + export default { setTimeout, setImmediate, setInterval, - scheduler: { - wait: (delay, options) => setTimeout(delay, undefined, options), - yield: setImmediate, - }, + scheduler, }; diff --git a/test/js/node/test/parallel/test-timers-promises-scheduler.js b/test/js/node/test/parallel/test-timers-promises-scheduler.js index 7caf92fdf6a7..0855db34b1b4 100644 --- a/test/js/node/test/parallel/test-timers-promises-scheduler.js +++ b/test/js/node/test/parallel/test-timers-promises-scheduler.js @@ -4,10 +4,7 @@ const common = require('../common'); const { scheduler } = require('timers/promises'); const { setTimeout } = require('timers'); -const { - strictEqual, - rejects, -} = require('assert'); +const assert = require('assert'); async function testYield() { await scheduler.yield(); @@ -22,7 +19,7 @@ async function testWait() { let value = 0; setTimeout(() => value++, 10); await scheduler.wait(15); - strictEqual(value, 1); + assert.strictEqual(value, 1); } testWait().then(common.mustCall()); @@ -31,7 +28,7 @@ async function testCancelableWait1() { const ac = new AbortController(); const wait = scheduler.wait(1e6, { signal: ac.signal }); ac.abort(); - await rejects(wait, { + await assert.rejects(wait, { code: 'ABORT_ERR', message: 'The operation was aborted', }); @@ -41,10 +38,14 @@ testCancelableWait1().then(common.mustCall()); async function testCancelableWait2() { const wait = scheduler.wait(10000, { signal: AbortSignal.abort() }); - await rejects(wait, { + await assert.rejects(wait, { code: 'ABORT_ERR', message: 'The operation was aborted', }); } testCancelableWait2().then(common.mustCall()); + +assert.throws(() => new scheduler.constructor(), { + code: 'ERR_ILLEGAL_CONSTRUCTOR', +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 2a27963203c0..59c1279041bf 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -521,3 +521,62 @@ test("mock.property/mock.method survive a polluted Object.prototype", async () = 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 }); }); + +describe("node:test mock.timers and timers/promises scheduler.wait", () => { + const { mock } = require("node:test"); + const { scheduler } = require("node:timers/promises"); + + test("reset() puts the inherited Scheduler.prototype.wait back and it still works", async () => { + const realWait = scheduler.wait; + expect(Object.hasOwn(scheduler, "wait")).toBe(false); + + mock.timers.enable({ apis: ["scheduler.wait"] }); + try { + expect(Object.hasOwn(scheduler, "wait")).toBe(true); + const waited = scheduler.wait(1000).then(() => "ticked"); + mock.timers.tick(1000); + expect(await waited).toBe("ticked"); + } finally { + mock.timers.reset(); + } + + // Node restores a copy bound to the MockTimers instance here, so this call throws + // ERR_INVALID_THIS on node itself; the real method must be back on the prototype. + expect(await scheduler.wait(1)).toBeUndefined(); + expect(scheduler.wait).toBe(realWait); + expect(Object.hasOwn(scheduler, "wait")).toBe(false); + }); + + test("reset() works across repeated enable()/reset() cycles", async () => { + const realWait = scheduler.wait; + try { + for (let cycle = 0; cycle < 2; cycle++) { + mock.timers.enable({ apis: ["scheduler.wait"] }); + const waited = scheduler.wait(50).then(() => `ticked ${cycle}`); + mock.timers.tick(50); + expect(await waited).toBe(`ticked ${cycle}`); + mock.timers.reset(); + } + } finally { + mock.timers.reset(); + } + expect(await scheduler.wait(1)).toBeUndefined(); + expect(scheduler.wait).toBe(realWait); + }); + + test("reset() restores a wait() the user had installed on the scheduler itself", async () => { + const customWait = () => Promise.resolve("custom"); + scheduler.wait = customWait; + try { + mock.timers.enable({ apis: ["scheduler.wait"] }); + expect(scheduler.wait).not.toBe(customWait); + mock.timers.reset(); + expect(scheduler.wait).toBe(customWait); + expect(await scheduler.wait()).toBe("custom"); + } finally { + mock.timers.reset(); + delete scheduler.wait; + } + expect(await scheduler.wait(1)).toBeUndefined(); + }); +}); diff --git a/test/js/node/timers.promises/timers.promises.test.ts b/test/js/node/timers.promises/timers.promises.test.ts index c3ecf9588751..1842fa09062f 100644 --- a/test/js/node/timers.promises/timers.promises.test.ts +++ b/test/js/node/timers.promises/timers.promises.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { setImmediate, setInterval, setTimeout } from "node:timers/promises"; +import { scheduler, setImmediate, setInterval, setTimeout } from "node:timers/promises"; describe("setTimeout", () => { it("abort() does not emit global error", async () => { @@ -162,3 +162,115 @@ describe("setInterval", () => { } }); }); + +// In node (lib/timers/promises.js) `scheduler` is the only instance of a Scheduler class: +// the constructor throws, and yield()/wait() are prototype methods that check their receiver. +describe("scheduler", () => { + const Scheduler = scheduler.constructor as new () => object; + const SchedulerPrototype = Object.getPrototypeOf(scheduler); + + const illegalConstructor = { name: "TypeError", code: "ERR_ILLEGAL_CONSTRUCTOR", message: "Illegal constructor" }; + const invalidThis = { + name: "TypeError", + code: "ERR_INVALID_THIS", + message: 'Value of "this" must be of type Scheduler', + }; + + function thrownBy(fn: () => unknown) { + try { + fn(); + } catch (error: any) { + expect(error).toBeInstanceOf(TypeError); + return { name: error.name, code: error.code, message: error.message }; + } + throw new Error("expected a synchronous throw"); + } + + it("is an instance of a Scheduler class whose constructor throws ERR_ILLEGAL_CONSTRUCTOR", () => { + expect(Scheduler.name).toBe("Scheduler"); + expect(scheduler).toBeInstanceOf(Scheduler); + expect(thrownBy(() => new Scheduler())).toEqual(illegalConstructor); + }); + + it("cannot be instantiated through a subclass or Reflect.construct either", () => { + class Sub extends Scheduler {} + expect(thrownBy(() => new Sub())).toEqual(illegalConstructor); + expect(thrownBy(() => Reflect.construct(Scheduler, [], class {}))).toEqual(illegalConstructor); + }); + + it("inherits yield() and wait() from Scheduler.prototype", () => { + expect(Object.keys(scheduler)).toEqual([]); + expect(Object.getOwnPropertyNames(SchedulerPrototype)).toEqual(["constructor", "yield", "wait"]); + expect([scheduler.yield.name, scheduler.yield.length, scheduler.wait.name, scheduler.wait.length]).toEqual([ + "yield", + 0, + "wait", + 2, + ]); + expect(scheduler.yield).not.toBe(setImmediate); + }); + + it("yield() and wait() throw ERR_INVALID_THIS synchronously for a receiver that is not the scheduler", () => { + const { yield: yieldFn, wait } = scheduler; + for (const receiver of [{}, Object.create(SchedulerPrototype), "scheduler", 1]) { + expect(thrownBy(() => yieldFn.call(receiver))).toEqual(invalidThis); + expect(thrownBy(() => wait.call(receiver, 1))).toEqual(invalidThis); + } + }); + + // Node reads this[kScheduler] unguarded, so a nullish receiver (including an unbound call) + // surfaces as the engine's own TypeError with no code rather than ERR_INVALID_THIS. + it("yield() and wait() throw a plain TypeError for a null or undefined receiver, as in node", () => { + const { yield: yieldFn, wait } = scheduler; + for (const receiver of [null, undefined]) { + const [yieldError, waitError] = [thrownBy(() => yieldFn.call(receiver)), thrownBy(() => wait.call(receiver, 1))]; + expect([yieldError.name, yieldError.code, waitError.name, waitError.code]).toEqual([ + "TypeError", + undefined, + "TypeError", + undefined, + ]); + } + }); + + it("yield() and wait() resolve for the scheduler itself and for objects inheriting from it", async () => { + const inheriting = Object.create(scheduler); + expect( + await Promise.all([ + scheduler.yield(), + scheduler.wait(1), + scheduler.yield.call(scheduler), + scheduler.wait.call(scheduler, 1), + inheriting.yield(), + inheriting.wait(1), + ]), + ).toEqual([undefined, undefined, undefined, undefined, undefined, undefined]); + }); + + it("yield() ignores its arguments instead of forwarding them to setImmediate()", async () => { + const yieldWithArgs = scheduler.yield as (...args: unknown[]) => Promise; + expect(await yieldWithArgs.call(scheduler, "value")).toBeUndefined(); + expect(await yieldWithArgs.call(scheduler, undefined, { signal: AbortSignal.abort() })).toBeUndefined(); + }); + + it("wait() forwards delay and options to setTimeout()", async () => { + const controller = new AbortController(); + const pending = scheduler.wait(100_000, { signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toThrow( + expect.objectContaining({ name: "AbortError", code: "ABORT_ERR", cause: controller.signal.reason }), + ); + + const reason = new Error("custom reason"); + await expect(scheduler.wait(1, { signal: AbortSignal.abort(reason) })).rejects.toThrow( + expect.objectContaining({ name: "AbortError", code: "ABORT_ERR", cause: reason }), + ); + + await expect(scheduler.wait("1" as any)).rejects.toThrow( + expect.objectContaining({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }), + ); + await expect(scheduler.wait(1, null as any)).rejects.toThrow( + expect.objectContaining({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +});