Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/js/internal/test_runner/mock_timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -283,7 +289,7 @@ class MockTimers {
}

#storeOriginalSchedulerWait() {
this.#realTimersPromisifiedSchedulerWait = nodeTimersPromises.scheduler.wait.bind(this);
this.#realTimersPromisifiedSchedulerWait = Object.getOwnPropertyDescriptor(nodeTimersPromises.scheduler, "wait");
}

#storeOriginalSetTimeout() {
Expand Down
29 changes: 24 additions & 5 deletions src/js/node/timers.promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

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,
};
15 changes: 8 additions & 7 deletions test/js/node/test/parallel/test-timers-promises-scheduler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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());
Expand All @@ -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',
});
Expand All @@ -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',
});
59 changes: 59 additions & 0 deletions test/js/node/test_runner/node-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
114 changes: 113 additions & 1 deletion test/js/node/timers.promises/timers.promises.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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<unknown>;
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" }),
);
});
});
Loading