diff --git a/test/test-clear.js b/test/test-clear.js index 7a7e897..385a178 100644 --- a/test/test-clear.js +++ b/test/test-clear.js @@ -1,9 +1,10 @@ -const test = require("node:test"); +const { test, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); -const { setImmediate } = require("node:timers/promises"); const when = require("../"); -test("clear stops future checks and is safe to repeat", async (t) => { +beforeEach((t) => t.mock.timers.enable({ apis: ["setTimeout"] })); + +test("clear stops future checks and is safe to repeat", (t) => { let checks = 0; let calls = 0; const handle = when( @@ -17,13 +18,13 @@ test("clear stops future checks and is safe to repeat", async (t) => { handle.clear(); handle.clear(); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(checks, 1); assert.equal(calls, 0); }); -test("setters do not restart a cancelled controller", async (t) => { +test("setters do not restart a cancelled controller", (t) => { let calls = 0; const handle = when( () => false, @@ -34,7 +35,7 @@ test("setters do not restart a cancelled controller", async (t) => { handle.clear(); handle.setCondition(() => true); handle.setCode(() => calls++); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 0); }); diff --git a/test/test-polling.js b/test/test-polling.js new file mode 100644 index 0000000..8c055d6 --- /dev/null +++ b/test/test-polling.js @@ -0,0 +1,150 @@ +const { test, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const when = require("../"); + +beforeEach((t) => t.mock.timers.enable({ apis: ["setTimeout"] })); + +test("the default poll waits 10 ms after the synchronous initial check", (t) => { + let ready = false; + let checks = 0; + let calls = 0; + const handle = when( + () => { + checks++; + return ready; + }, + () => calls++, + ); + t.after(() => handle.clear()); + + assert.equal(checks, 1); + ready = true; + t.mock.timers.tick(9); + assert.equal(checks, 1); + t.mock.timers.tick(1); + assert.equal(checks, 2); + assert.equal(calls, 1); + t.mock.timers.tick(100); + assert.equal(calls, 1); +}); + +test("each unsuccessful poll waits the configured interval", (t) => { + let checks = 0; + const handle = when( + () => { + checks++; + return false; + }, + () => {}, + { interval: 25 }, + ); + t.after(() => handle.clear()); + + t.mock.timers.tick(24); + assert.equal(checks, 1); + t.mock.timers.tick(1); + assert.equal(checks, 2); + t.mock.timers.tick(24); + assert.equal(checks, 2); + t.mock.timers.tick(1); + assert.equal(checks, 3); +}); + +test("clear cancels a pending timer and reset reuses the chosen interval", (t) => { + let checks = 0; + const handle = when( + () => { + checks++; + return false; + }, + () => {}, + { interval: 50 }, + ); + t.after(() => handle.clear()); + + handle.clear(); + t.mock.timers.tick(100); + assert.equal(checks, 1); + handle.reset(); + assert.equal(checks, 2); + t.mock.timers.tick(49); + assert.equal(checks, 2); + t.mock.timers.tick(1); + assert.equal(checks, 3); +}); + +test("rejects invalid options before running the predicate", () => { + for (const options of [null, false, 1, "options", []]) { + let checks = 0; + assert.throws( + () => + when( + () => { + checks++; + return true; + }, + () => {}, + options, + ), + /options must be an object/, + ); + assert.equal(checks, 0); + } +}); + +test("rejects intervals outside the supported timer range", () => { + for (const interval of [0, -1, 0.5, NaN, Infinity, 2147483648, "10", null]) { + assert.throws( + () => + when( + () => true, + () => {}, + { interval }, + ), + { + name: "RangeError", + message: "interval must be an integer from 1 to 2147483647", + }, + ); + } +}); + +test("a Promise returned after cancellation is discarded without a rejection leak", async (t) => { + const handle = when( + () => false, + () => assert.fail("cancelled callback"), + ); + t.after(() => handle.clear()); + handle.setCondition(() => { + handle.clear(); + return Promise.reject(new Error("discarded result")); + }); + t.mock.timers.tick(10); + await new Promise(setImmediate); +}); + +test("a Promise returned during a later poll throws and ends that poll chain", () => { + const output = execFileSync( + process.execPath, + [ + "-e", + ` + const when = require(process.argv[1]); + process.once("uncaughtException", (error) => { + console.log(JSON.stringify({ name: error.name, message: error.message })); + }); + let checks = 0; + when(() => ++checks === 1 ? false : Promise.resolve(false), () => { + throw new Error("unexpected callback"); + }); + `, + require.resolve("../"), + ], + { encoding: "utf8", timeout: 5000 }, + ); + assert.deepEqual(JSON.parse(output), { + name: "TypeError", + message: "condition must return a synchronous value", + }); +}); diff --git a/test/test-reentrancy.js b/test/test-reentrancy.js index 3682df9..0e2b76e 100644 --- a/test/test-reentrancy.js +++ b/test/test-reentrancy.js @@ -1,9 +1,10 @@ -const test = require("node:test"); +const { test, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); -const { setImmediate } = require("node:timers/promises"); const when = require("../"); -test("clear inside a false predicate stops future checks", async (t) => { +beforeEach((t) => t.mock.timers.enable({ apis: ["setTimeout"] })); + +test("clear inside a false predicate stops future checks", (t) => { let checks = 0; let calls = 0; const handle = when( @@ -16,15 +17,15 @@ test("clear inside a false predicate stops future checks", async (t) => { ); t.after(() => handle.clear()); - await setImmediate(); - await setImmediate(); - await setImmediate(); + t.mock.timers.tick(10); + t.mock.timers.tick(10); + t.mock.timers.tick(10); assert.equal(checks, 2); assert.equal(calls, 0); }); -test("clear inside a truthy predicate suppresses its callback", async (t) => { +test("clear inside a truthy predicate suppresses its callback", (t) => { let checks = 0; let calls = 0; const handle = when( @@ -40,12 +41,12 @@ test("clear inside a truthy predicate suppresses its callback", async (t) => { ); t.after(() => handle.clear()); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 0); }); -test("reset ignores a truthy result from the previous check", async (t) => { +test("reset ignores a truthy result from the previous check", (t) => { let checks = 0; let calls = 0; const handle = when( @@ -61,16 +62,16 @@ test("reset ignores a truthy result from the previous check", async (t) => { ); t.after(() => handle.clear()); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(checks, 3); assert.equal(calls, 0); handle.setCondition(() => true); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 1); }); -test("reset inside a false predicate leaves only one cancellable poll", async (t) => { +test("reset inside a false predicate leaves only one cancellable poll", (t) => { let checks = 0; let calls = 0; const handle = when( @@ -83,9 +84,9 @@ test("reset inside a false predicate leaves only one cancellable poll", async (t ); t.after(() => handle.clear()); - await setImmediate(); + t.mock.timers.tick(10); handle.clear(); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(checks, 3); assert.equal(calls, 0); diff --git a/test/test-reset.js b/test/test-reset.js index ebd78be..0d27f33 100644 --- a/test/test-reset.js +++ b/test/test-reset.js @@ -1,8 +1,9 @@ -const test = require("node:test"); +const { test, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); -const { setImmediate } = require("node:timers/promises"); const when = require("../"); +beforeEach((t) => t.mock.timers.enable({ apis: ["setTimeout"] })); + test("reset checks a cancelled controller synchronously", (t) => { let ready = false; let calls = 0; @@ -32,7 +33,7 @@ test("reset checks a completed controller synchronously", (t) => { assert.equal(calls, 2); }); -test("a callback can reset the controller after asynchronous success", async (t) => { +test("a callback can reset the controller after asynchronous success", (t) => { let ready = false; let calls = 0; const handle = when( @@ -48,11 +49,11 @@ test("a callback can reset the controller after asynchronous success", async (t) t.after(() => handle.clear()); ready = true; - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 1); ready = true; - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 2); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 2); }); diff --git a/test/test.js b/test/test.js index 0a532ed..3631f3b 100644 --- a/test/test.js +++ b/test/test.js @@ -1,8 +1,9 @@ -const test = require("node:test"); +const { test, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); -const { setImmediate } = require("node:timers/promises"); const when = require("../"); +beforeEach((t) => t.mock.timers.enable({ apis: ["setTimeout"] })); + test("calls the callback synchronously when the initial condition is truthy", (t) => { let calls = 0; const handle = when( @@ -14,7 +15,7 @@ test("calls the callback synchronously when the initial condition is truthy", (t assert.equal(calls, 1); }); -test("checks a false condition again and calls the callback once", async (t) => { +test("checks a false condition again and calls the callback once", (t) => { let ready = false; let calls = 0; const handle = when( @@ -25,13 +26,13 @@ test("checks a false condition again and calls the callback once", async (t) => assert.equal(calls, 0); ready = true; - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 1); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 1); }); -test("uses replacement condition and callback while active", async (t) => { +test("uses replacement condition and callback while active", (t) => { let oldCalls = 0; let newCalls = 0; const handle = when( @@ -42,13 +43,13 @@ test("uses replacement condition and callback while active", async (t) => { handle.setCondition(() => true); handle.setCode(() => newCalls++); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(oldCalls, 0); assert.equal(newCalls, 1); }); -test("setters do not restart a completed controller", async (t) => { +test("setters do not restart a completed controller", (t) => { let calls = 0; const handle = when( () => true, @@ -58,7 +59,7 @@ test("setters do not restart a completed controller", async (t) => { handle.setCondition(() => true); handle.setCode(() => calls++); - await setImmediate(); + t.mock.timers.tick(10); assert.equal(calls, 1); }); diff --git a/when.js b/when.js index a54fe42..08ac99e 100644 --- a/when.js +++ b/when.js @@ -1,9 +1,10 @@ require("setimmediate"); -function when(condition, code) { +function when(condition, code, options = {}) { assertFunction(condition, "condition"); assertFunction(code, "code"); - let immediate; + const interval = getInterval(options); + let timer; let generation = 0; function checkCondition() { @@ -20,13 +21,13 @@ function when(condition, code) { clear(); code(); } else { - immediate = setImmediate(checkCondition); + timer = setTimeout(checkCondition, interval); } } function clear() { generation++; - clearImmediate(immediate); + clearTimeout(timer); } function reset() { @@ -54,4 +55,19 @@ function assertFunction(value, name) { } } +function getInterval(options) { + if ( + options === null || + typeof options !== "object" || + Array.isArray(options) + ) { + throw new TypeError("options must be an object"); + } + const { interval = 10 } = options; + if (!Number.isInteger(interval) || interval < 1 || interval > 2147483647) { + throw new RangeError("interval must be an integer from 1 to 2147483647"); + } + return interval; +} + module.exports = when;