Repository: nodejs/node
Pull request: 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.

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/test.md
patch: @@ -3608,6 +3608,27 @@ test('top level test', async (t) => {
 });
 ```
 
+### `context.waitFor(condition[, options])`
+
+<!-- YAML
+added: REPLACEME
+-->
+
+* `condition` {Function|AsyncFunction} An assertion function that is invoked
+  periodically until it completes successfully or the defined polling timeout
+  elapses. Successful completion is defined as not throwing or rejecting. 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 number of milliseconds to wait after an unsuccessful
+    invocation of `condition` before trying again. **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

filename: lib/internal/test_runner/test.js
patch: @@ -8,6 +8,7 @@ const {
   ArrayPrototypeSplice,
   ArrayPrototypeUnshift,
   ArrayPrototypeUnshiftApply,
+  Error,
   FunctionPrototype,
   MathMax,
   Number,
@@ -58,11 +59,16 @@ const {
 const { isPromise } = require('internal/util/types');
 const {
   validateAbortSignal,
+  validateFunction,
   validateNumber,
+  validateObject,
   validateOneOf,
   validateUint32,
 } = require('internal/validators');
-const { setTimeout } = require('timers');
+const {
+  clearTimeout,
+  setTimeout,
+} = require('timers');
 const { TIMEOUT_MAX } = require('internal/timers');
 const { fileURLToPath } = require('internal/url');
 const { availableParallelism } = require('os');
@@ -340,6 +346,60 @@ 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 pollerId;
+    let timeoutId;
+    const done = (err, result) => {
+      clearTimeout(pollerId);
+      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);
+
+    const poller = async () => {
+      try {
+        const result = await condition();
+
+        done(noError, result);
+      } catch (err) {
+        cause = err;
+        pollerId = setTimeout(poller, interval);
+      }
+    };
+
+    poller();
+    return promise;
+  }
 }
 
 class SuiteContext {

filename: test/parallel/test-runner-wait-for.js
patch: @@ -0,0 +1,124 @@
+'use strict';
+require('../common');
+const { suite, test } = require('node:test');
+
+suite('input validation', () => {
+  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;
+  });
+});
+
+test('limits polling if condition takes longer than interval', async (t) => {
+  let count = 0;
+
+  function condition() {
+    count++;
+    return new Promise((resolve) => {
+      setTimeout(() => {
+        resolve('success');
+      }, 200);
+    });
+  }
+
+  const result = await t.waitFor(condition, {
+    interval: 1,
+    timeout: 60_000,
+  });
+
+  t.assert.strictEqual(result, 'success');
+  t.assert.strictEqual(count, 1);
+});