Skip to content

[Feature]: Add built-in rs.waitFor() / rs.waitUntil() polling helpers (vitest parity) #973

@omarshibli

Description

@omarshibli

Description

vitest provides built-in vi.waitFor() and vi.waitUntil() polling helpers that retry a callback until it succeeds or a timeout is reached. rstest currently has no equivalent — users migrating from vitest have to build their own polling helpers.


How vi.waitFor() works in vitest

vitest ships vi.waitFor() as part of VitestUtils:

function waitFor<T>(
  callback: WaitForCallback<T>,
  options?: number | WaitForOptions,
): Promise<T>

interface WaitForOptions {
  timeout?: number   // default: 1000
  interval?: number  // default: 50
}

The function repeatedly calls callback at the specified interval. If the callback throws or returns a rejected promise, it retries. If it succeeds, the resolved value is returned. If the timeout is reached, the last error is thrown.

Key behaviors:

  • Supports both sync and async callbacks
  • Integrates with fake timers (vi.useFakeTimers) — automatically advances time by the interval on each retry
  • The thrown error on timeout includes retry count and elapsed time for diagnostics
  • Prevents concurrent callback executions (waits for a pending promise before retrying)

Usage examples

// E2E: poll until a server responds
await vi.waitFor(
  async () => {
    const res = await fetch('http://localhost:3000/health');
    expect(res.ok).toBe(true);
  },
  { timeout: 30_000, interval: 1_000 },
);

// Unit: wait for async state
const element = await vi.waitFor(
  async () => {
    const el = await findElement('#loaded');
    expect(el).toBeTruthy();
    return el;
  },
  { timeout: 500, interval: 20 },
);

vitest also ships vi.waitUntil() which expects the callback to return a truthy value (rather than not-throwing):

const server = await vi.waitUntil(
  () => startServer().catch(() => null),
  { timeout: 5000, interval: 100 },
);

Proposal

Add waitFor() and waitUntil() to the RstestUtilities interface with the same API as vitest:

export interface RstestUtilities {
  // ... existing methods ...

  /**
   * Retry callback until it succeeds (doesn't throw) or timeout is reached.
   * If timeout is reached, throws the last error from the callback.
   */
  waitFor<T>(
    callback: WaitForCallback<T>,
    options?: number | WaitForOptions,
  ): Promise<T>

  /**
   * Retry callback until it returns a truthy value or timeout is reached.
   * If timeout is reached, throws an error.
   */
  waitUntil<T>(
    callback: () => T | Promise<T>,
    options?: number | WaitUntilOptions,
  ): Promise<T>
}

interface WaitForOptions {
  timeout?: number   // default: 1000
  interval?: number  // default: 50
}

Key implementation considerations:

  • Fake timer integration — if rs.isFakeTimers() is true, advance timers by interval on each retry instead of using real setTimeout
  • Concurrency guard — don't start a new retry while a previous async callback is still pending
  • Diagnostics — include retry count and elapsed time in the timeout error message

Result

// ✅ Built-in, zero setup
await rs.waitFor(async () => {
  const res = await fetch(url);
  expect(res.ok).toBe(true);
}, { timeout: 30_000, interval: 1_000 });

This would make the vitest → rstest migration fully mechanical for projects that use vi.waitFor().

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions