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

No one assigned

    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