Skip to content

Commit c42078d

Browse files
committed
test_runner: add TestContext.prototype.waitFor()
This commit adds a waitFor() method to the TestContext class in the test runner. As the name implies, this method allows tests to more easily wait for things to happen.
1 parent e6a988d commit c42078d

File tree

3 files changed

+223
-1
lines changed

3 files changed

+223
-1
lines changed

doc/api/test.md

+24
Original file line numberDiff line numberDiff line change
@@ -3608,6 +3608,30 @@ test('top level test', async (t) => {
36083608
});
36093609
```
36103610

3611+
### `context.waitFor(condition[, options])`
3612+
3613+
<!-- YAML
3614+
added: REPLACEME
3615+
-->
3616+
3617+
* `condition` {Function|AsyncFunction} A function that is invoked periodically
3618+
until it completes successfully or the defined polling timeout elapses. This
3619+
function does not accept any arguments, and is allowed to return any value.
3620+
* `options` {Object} An optional configuration object for the polling operation.
3621+
The following properties are supported:
3622+
* `abortOnError` {boolean} If `true`, the polling operation is cancelled the
3623+
first time an error is encountered. If `false`, polling continues until the
3624+
`condition` function returns successfully or the timeout has elapsed.
3625+
**Default:** `false`.
3626+
* `interval` {number} The polling period in milliseconds. The `condition`
3627+
function is invoked according to this interval. **Default:** `50`.
3628+
* `timeout` {number} The poll timeout in milliseconds. If `condition` has not
3629+
succeeded by the time this elapses, an error occurs. **Default:** `1000`.
3630+
* Returns: {Promise} Fulfilled with the value returned by `condition`.
3631+
3632+
This method polls a `condition` function until that function either returns
3633+
successfully or the operation times out.
3634+
36113635
## Class: `SuiteContext`
36123636

36133637
<!-- YAML

lib/internal/test_runner/test.js

+67-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const {
88
ArrayPrototypeSplice,
99
ArrayPrototypeUnshift,
1010
ArrayPrototypeUnshiftApply,
11+
Error,
1112
FunctionPrototype,
1213
MathMax,
1314
Number,
@@ -58,11 +59,19 @@ const {
5859
const { isPromise } = require('internal/util/types');
5960
const {
6061
validateAbortSignal,
62+
validateBoolean,
63+
validateFunction,
6164
validateNumber,
65+
validateObject,
6266
validateOneOf,
6367
validateUint32,
6468
} = require('internal/validators');
65-
const { setTimeout } = require('timers');
69+
const {
70+
clearInterval,
71+
clearTimeout,
72+
setInterval,
73+
setTimeout,
74+
} = require('timers');
6675
const { TIMEOUT_MAX } = require('internal/timers');
6776
const { fileURLToPath } = require('internal/url');
6877
const { availableParallelism } = require('os');
@@ -340,6 +349,63 @@ class TestContext {
340349
loc: getCallerLocation(),
341350
});
342351
}
352+
353+
waitFor(condition, options = kEmptyObject) {
354+
validateFunction(condition, 'condition');
355+
validateObject(options, 'options');
356+
357+
const {
358+
interval = 50,
359+
timeout = 1000,
360+
abortOnError = false,
361+
} = options;
362+
363+
validateNumber(interval, 'options.interval', 0, TIMEOUT_MAX);
364+
validateNumber(timeout, 'options.timeout', 0, TIMEOUT_MAX);
365+
validateBoolean(abortOnError, 'options.abortOnError');
366+
367+
const { promise, resolve, reject } = PromiseWithResolvers();
368+
const noError = Symbol();
369+
let cause = noError;
370+
let intervalId;
371+
let timeoutId;
372+
const done = (err, result) => {
373+
clearInterval(intervalId);
374+
clearTimeout(timeoutId);
375+
376+
if (err === noError) {
377+
resolve(result);
378+
} else {
379+
reject(err);
380+
}
381+
};
382+
383+
timeoutId = setTimeout(() => {
384+
// eslint-disable-next-line no-restricted-syntax
385+
const err = new Error('waitFor() timed out');
386+
387+
if (cause !== noError) {
388+
err.cause = cause;
389+
}
390+
391+
done(err);
392+
}, timeout);
393+
394+
intervalId = setInterval(async () => {
395+
try {
396+
const result = await condition();
397+
398+
done(noError, result);
399+
} catch (err) {
400+
cause = err;
401+
if (abortOnError) {
402+
done(err);
403+
}
404+
}
405+
}, interval);
406+
407+
return promise;
408+
}
343409
}
344410

345411
class SuiteContext {

test/parallel/test-runner-wait-for.js

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use strict';
2+
require('../common');
3+
const { test } = require('node:test');
4+
5+
test('throws if condition is not a function', (t) => {
6+
t.assert.throws(() => {
7+
t.waitFor(5);
8+
}, {
9+
code: 'ERR_INVALID_ARG_TYPE',
10+
message: /The "condition" argument must be of type function/,
11+
});
12+
});
13+
14+
test('throws if options is not an object', (t) => {
15+
t.assert.throws(() => {
16+
t.waitFor(() => {}, null);
17+
}, {
18+
code: 'ERR_INVALID_ARG_TYPE',
19+
message: /The "options" argument must be of type object/,
20+
});
21+
});
22+
23+
test('throws if options.interval is not a number', (t) => {
24+
t.assert.throws(() => {
25+
t.waitFor(() => {}, { interval: 'foo' });
26+
}, {
27+
code: 'ERR_INVALID_ARG_TYPE',
28+
message: /The "options\.interval" property must be of type number/,
29+
});
30+
});
31+
32+
test('throws if options.timeout is not a number', (t) => {
33+
t.assert.throws(() => {
34+
t.waitFor(() => {}, { timeout: 'foo' });
35+
}, {
36+
code: 'ERR_INVALID_ARG_TYPE',
37+
message: /The "options\.timeout" property must be of type number/,
38+
});
39+
});
40+
41+
test('throws if options.abortOnError is not a boolean', (t) => {
42+
t.assert.throws(() => {
43+
t.waitFor(() => {}, { abortOnError: 'foo' });
44+
}, {
45+
code: 'ERR_INVALID_ARG_TYPE',
46+
message: /The "options\.abortOnError" property must be of type boolean/,
47+
});
48+
});
49+
50+
test('rejects if an error is thrown and abortOnError is set', async (t) => {
51+
const err = new Error('boom');
52+
53+
await t.assert.rejects(async () => {
54+
await t.waitFor(() => {
55+
throw err;
56+
}, { abortOnError: true });
57+
}, err);
58+
});
59+
60+
test('rejects if a promise rejects and abortOnError is set', async (t) => {
61+
const err = new Error('boom');
62+
63+
await t.assert.rejects(async () => {
64+
await t.waitFor(() => {
65+
return new Promise((_, reject) => {
66+
reject(err);
67+
});
68+
}, { abortOnError: true });
69+
}, err);
70+
});
71+
72+
test('returns the result of the condition function', async (t) => {
73+
const result = await t.waitFor(() => {
74+
return 42;
75+
});
76+
77+
t.assert.strictEqual(result, 42);
78+
});
79+
80+
test('returns the result of an async condition function', async (t) => {
81+
const result = await t.waitFor(async () => {
82+
return 84;
83+
});
84+
85+
t.assert.strictEqual(result, 84);
86+
});
87+
88+
test('errors if the condition times out', async (t) => {
89+
await t.assert.rejects(async () => {
90+
await t.waitFor(() => {
91+
return new Promise(() => {});
92+
}, {
93+
interval: 60_000,
94+
timeout: 1,
95+
});
96+
}, {
97+
message: /waitFor\(\) timed out/,
98+
});
99+
});
100+
101+
test('polls until the condition returns successfully', async (t) => {
102+
let count = 0;
103+
const result = await t.waitFor(() => {
104+
++count;
105+
if (count < 4) {
106+
throw new Error('resource is not ready yet');
107+
}
108+
109+
return 'success';
110+
}, {
111+
interval: 1,
112+
timeout: 60_000,
113+
});
114+
115+
t.assert.strictEqual(result, 'success');
116+
t.assert.strictEqual(count, 4);
117+
});
118+
119+
test('sets last failure as error cause on timeouts', async (t) => {
120+
const error = new Error('boom');
121+
await t.assert.rejects(async () => {
122+
await t.waitFor(() => {
123+
return new Promise((_, reject) => {
124+
reject(error);
125+
});
126+
});
127+
}, (err) => {
128+
t.assert.match(err.message, /timed out/);
129+
t.assert.strictEqual(err.cause, error);
130+
return true;
131+
});
132+
});

0 commit comments

Comments
 (0)