Skip to content

Commit

Permalink
test_runner: add TestContext.prototype.waitFor()
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cjihrig committed Jan 14, 2025
1 parent e6a988d commit c42078d
Show file tree
Hide file tree
Showing 3 changed files with 223 additions and 1 deletion.
24 changes: 24 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3608,6 +3608,30 @@ 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:
* `abortOnError` {boolean} If `true`, the polling operation is cancelled the
first time an error is encountered. If `false`, polling continues until the
`condition` function returns successfully or the timeout has elapsed.
**Default:** `false`.
* `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.

## Class: `SuiteContext`

<!-- YAML
Expand Down
68 changes: 67 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,19 @@ const {
const { isPromise } = require('internal/util/types');
const {
validateAbortSignal,
validateBoolean,
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 +349,63 @@ class TestContext {
loc: getCallerLocation(),
});
}

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

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

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

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;
if (abortOnError) {
done(err);
}
}
}, interval);

return promise;
}
}

class SuiteContext {
Expand Down
132 changes: 132 additions & 0 deletions test/parallel/test-runner-wait-for.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
'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('throws if options.abortOnError is not a boolean', (t) => {
t.assert.throws(() => {
t.waitFor(() => {}, { abortOnError: 'foo' });
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: /The "options\.abortOnError" property must be of type boolean/,
});
});

test('rejects if an error is thrown and abortOnError is set', async (t) => {
const err = new Error('boom');

await t.assert.rejects(async () => {
await t.waitFor(() => {
throw err;
}, { abortOnError: true });
}, err);
});

test('rejects if a promise rejects and abortOnError is set', async (t) => {
const err = new Error('boom');

await t.assert.rejects(async () => {
await t.waitFor(() => {
return new Promise((_, reject) => {
reject(err);
});
}, { abortOnError: true });
}, err);
});

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

0 comments on commit c42078d

Please sign in to comment.