Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 20 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3608,6 +3608,26 @@ test('top level test', async (t) => {
});
```

### `context.waitFor(condition[, options])`

<!-- YAML
added: REPLACEME
-->

* `condition` {Function|AsyncFunction} A function that is invoked periodically
until it completes successfully or the defined polling timeout elapses. This
function does not accept any arguments, and is allowed to return any value.
* `options` {Object} An optional configuration object for the polling operation.
The following properties are supported:
* `interval` {number} The polling period in milliseconds. The `condition`
function is invoked according to this interval. **Default:** `50`.
* `timeout` {number} The poll timeout in milliseconds. If `condition` has not
succeeded by the time this elapses, an error occurs. **Default:** `1000`.
* Returns: {Promise} Fulfilled with the value returned by `condition`.

This method polls a `condition` function until that function either returns
successfully or the operation times out.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to have an example code snippet here


## Class: `SuiteContext`

<!-- YAML
Expand Down
62 changes: 61 additions & 1 deletion lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
ArrayPrototypeSplice,
ArrayPrototypeUnshift,
ArrayPrototypeUnshiftApply,
Error,
FunctionPrototype,
MathMax,
Number,
Expand Down Expand Up @@ -58,11 +59,18 @@ const {
const { isPromise } = require('internal/util/types');
const {
validateAbortSignal,
validateFunction,
validateNumber,
validateObject,
validateOneOf,
validateUint32,
} = require('internal/validators');
const { setTimeout } = require('timers');
const {
clearInterval,
clearTimeout,
setInterval,
setTimeout,
} = require('timers');
const { TIMEOUT_MAX } = require('internal/timers');
const { fileURLToPath } = require('internal/url');
const { availableParallelism } = require('os');
Expand Down Expand Up @@ -340,6 +348,58 @@ class TestContext {
loc: getCallerLocation(),
});
}

waitFor(condition, options = kEmptyObject) {
validateFunction(condition, 'condition');
validateObject(options, 'options');

const {
interval = 50,
timeout = 1000,
} = options;

validateNumber(interval, 'options.interval', 0, TIMEOUT_MAX);
validateNumber(timeout, 'options.timeout', 0, TIMEOUT_MAX);

const { promise, resolve, reject } = PromiseWithResolvers();
const noError = Symbol();
let cause = noError;
let intervalId;
let timeoutId;
const done = (err, result) => {
clearInterval(intervalId);
clearTimeout(timeoutId);

if (err === noError) {
resolve(result);
} else {
reject(err);
}
};

timeoutId = setTimeout(() => {
// eslint-disable-next-line no-restricted-syntax
const err = new Error('waitFor() timed out');

if (cause !== noError) {
err.cause = cause;
}

done(err);
}, timeout);

intervalId = setInterval(async () => {
try {
const result = await condition();

done(noError, result);
} catch (err) {
cause = err;
}
}, interval);

return promise;
}
}

class SuiteContext {
Expand Down
101 changes: 101 additions & 0 deletions test/parallel/test-runner-wait-for.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';
require('../common');
const { test } = require('node:test');

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;
});
});
Loading