Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
122 changes: 106 additions & 16 deletions docs/api/twd-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,16 +444,102 @@ modal.should("be.visible");
#### Best Practices

```ts
// ✅ Good - Wait for specific conditions
const spinner = await twd.get(".loading-spinner");
spinner.should("be.visible");
await twd.wait(1000);
spinner.should("not.be.visible");

// ❌ Avoid - Arbitrary waits without context
await twd.wait(5000); // Why 5 seconds?
// ✅ Use twd.wait for intentional fixed delays
await twd.wait(300); // Wait for CSS exit animation to finish

// ❌ Avoid - Use twd.waitFor() instead for condition-based waiting
await twd.wait(1000); // Hoping the spinner is gone by now
```

::: tip
For condition-based waiting (waiting for an element to change, an event to fire, etc.), use [`twd.waitFor()`](#twd-waitfor-callback-options) instead.
:::

---

### twd.waitFor(callback, options?)

Retries a callback until it stops throwing or the timeout expires. Use this instead of `twd.wait(ms)` to avoid blind delays — `waitFor` resolves as soon as your condition is met, making tests faster and more reliable.

#### Syntax

```ts
twd.waitFor(
callback: () => void | Promise<void>,
options?: {
timeout?: number; // Default: 2000
interval?: number; // Default: 50
message?: string;
}
): Promise<void>
```

#### Parameters

- **callback** (`() => void | Promise<void>`) - Function to retry. Should throw if the condition is not yet met. Can be sync or async.
- **options** (`object`, optional):
- **timeout** (`number`) - Max time to wait in milliseconds. Default: `2000`
- **interval** (`number`) - Poll interval in milliseconds. Default: `50`
- **message** (`string`) - Context message included in timeout errors for easier debugging

#### Returns

`Promise<void>` - Resolves when the callback succeeds. Rejects with a timeout error if the callback keeps throwing past the timeout.

#### Error Format

```
// Without message:
waitFor timed out after 2000ms.
Last error: expected undefined to exist

// With message:
waitFor timed out after 2000ms waiting for: purchase event to fire.
Last error: expected undefined to exist
```

#### Examples

```ts
// DOM assertion - wait for attribute to update
const heading = await screenDom.findByText("Checkout");
await twd.waitFor(() => {
expect(heading).to.have.attribute("data-loaded", "true");
});

// Analytics event - wait for dataLayer event to fire
await twd.waitFor(() => {
const event = findEvent("purchase"); // your analytics helper
expect(event).to.exist;
}, { message: "purchase event to fire" });

// Custom timeout for slow operations
await twd.waitFor(() => {
const dropin = document.querySelector(".adyen-checkout__dropin");
if (!dropin) throw new Error("Adyen dropin not rendered");
}, { timeout: 5000 });

// UI state change after action
const submitButton = screenDom.getByRole("button", { name: /submit/i });
await userEvent.click(submitButton);
await twd.waitFor(() => {
expect(submitButton.disabled).to.be.false;
}, { message: "submit button to re-enable" });
```

#### `waitFor` vs `twd.wait`

| | `twd.waitFor(fn)` | `twd.wait(ms)` |
|---|---|---|
| **Resolves when** | Callback stops throwing | Fixed time elapses |
| **Speed** | As fast as the condition is met | Always waits the full duration |
| **Reliability** | Adapts to timing variations | Fails if operation is slower than the wait |
| **Use for** | Any async condition (DOM, events, state) | Intentional delays (animations, debounce testing) |

::: tip Prefer waitFor over twd.wait
Most uses of `twd.wait(ms)` can be replaced with `twd.waitFor()`. The callback should be a **pure check** — don't perform actions inside it, only assertions or reads.
:::

---

### twd.notExists(selector)
Expand Down Expand Up @@ -1174,14 +1260,18 @@ describe("Component Tests", () => {
### 3. Wait Appropriately

```ts
// ✅ Good - Wait for specific conditions
const spinner = await twd.get(".loading");
spinner.should("be.visible");
await twd.waitForRequest("getData");
spinner.should("not.be.visible");

// ❌ Avoid - Arbitrary waits
await twd.wait(3000); // Why 3 seconds?
// ✅ Best - Use waitFor for condition-based waiting
await userEvent.click(loadButton.el);
await twd.waitFor(() => {
const spinner = screenDom.queryByRole("progressbar");
expect(spinner).not.to.exist;
}, { message: "loading to complete" });

// ✅ OK - Use twd.wait only for intentional fixed delays
await twd.wait(300); // Wait for CSS transition to finish

// ❌ Avoid - Blind waits for async conditions
await twd.wait(2000); // Hoping the API responded by now
```

### 4. Use Realistic Mock Data
Expand Down
104 changes: 104 additions & 0 deletions src/tests/utils/waitFor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi } from 'vitest';
import { waitFor } from '../../utils/waitFor';

describe('waitFor', () => {
it('resolves immediately when callback passes on first call', async () => {
const callback = vi.fn();
await waitFor(callback);
expect(callback).toHaveBeenCalledTimes(1);
});

it('retries and resolves when callback passes after a few ticks', async () => {
let count = 0;
const callback = () => {
count++;
if (count < 3) throw new Error('not yet');
};
await waitFor(callback, { interval: 10 });
expect(count).toBe(3);
});

it('times out with wrapped error after timeout expires', async () => {
const callback = () => {
throw new Error('still failing');
};
await expect(
waitFor(callback, { timeout: 100, interval: 10 })
).rejects.toThrow('waitFor timed out after 100ms.');
await expect(
waitFor(callback, { timeout: 100, interval: 10 })
).rejects.toThrow('Last error: still failing');
});

it('includes custom message in timeout error', async () => {
const callback = () => {
throw new Error('nope');
};
await expect(
waitFor(callback, { timeout: 100, interval: 10, message: 'button to be enabled' })
).rejects.toThrow('waitFor timed out after 100ms waiting for: button to be enabled.');
});

it('works with async callbacks', async () => {
let count = 0;
const callback = async () => {
count++;
if (count < 2) throw new Error('not yet');
};
await waitFor(callback, { interval: 10 });
expect(count).toBe(2);
});

it('handles non-Error throws by wrapping them', async () => {
const callback = () => {
throw 'string error';
};
await expect(
waitFor(callback, { timeout: 100, interval: 10 })
).rejects.toThrow('Last error: string error');
});

it('respects custom timeout and interval options', async () => {
const start = Date.now();
const callback = () => {
throw new Error('fail');
};
await expect(
waitFor(callback, { timeout: 200, interval: 50 })
).rejects.toThrow('waitFor timed out after 200ms.');
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(180);
expect(elapsed).toBeLessThan(400);
});

it('calls callback immediately with no initial delay', async () => {
const timestamps: number[] = [];
const start = Date.now();
let count = 0;
const callback = () => {
timestamps.push(Date.now() - start);
count++;
if (count < 2) throw new Error('not yet');
};
await waitFor(callback, { interval: 50 });
// First call should happen immediately (within a few ms of start)
expect(timestamps[0]).toBeLessThan(20);
});

it('uses default timeout of 2000ms', async () => {
vi.useFakeTimers();
const callback = vi.fn(() => { throw new Error('fail'); });

const promise = waitFor(callback);
// Attach catch immediately so the rejection is never "unhandled"
const caught = promise.catch(() => {});
await vi.advanceTimersByTimeAsync(2100);
await caught;

try {
await expect(promise).rejects.toThrow('waitFor timed out after 2000ms.');
} finally {
vi.useRealTimers();
}
});
});
22 changes: 17 additions & 5 deletions src/twd-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ export type ShouldFn = {
* ```ts
* const btn = await twd.get("button");
* btn.should("have.text", "Clicked").click();
*
*
* ```
*
*
*/
export interface TWDElemAPI {
/** The underlying DOM element. */
Expand All @@ -117,14 +117,26 @@ export interface TWDElemAPI {
* @param name The name of the assertion.
* @param args Arguments for the assertion.
* @returns The same API for chaining.
*
*
* @example
* ```ts
* const btn = await twd.get("button");
* btn.should("have.text", "Click me").should("not.be.disabled");
*
*
* ```
*
*
*/
should: ShouldFn;
}

/**
* Options for `twd.waitFor()`.
*/
export interface WaitForOptions {
/** Max time to wait in ms. Default: 2000 */
timeout?: number;
/** Poll interval in ms. Default: 50 */
interval?: number;
/** Context message included in timeout errors */
message?: string;
}
38 changes: 32 additions & 6 deletions src/twd.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { waitForElement, wait, waitForElements } from "./utils/wait";
import { waitFor } from "./utils/waitFor";
import { runAssertion } from "./asserts";
import { log } from "./utils/log";
import { mockRequest, Options, Rule, waitForRequest, initRequestMocking, clearRequestMockRules, getRequestMockRules, waitForRequests, getRequestCount, getRequestCounts } from "./commands/mockBridge";
import type { AnyAssertion, ArgsFor, TWDElemAPI } from "./twd-types";
import type { AnyAssertion, ArgsFor, TWDElemAPI, WaitForOptions } from "./twd-types";
import urlCommand, { type URLCommandAPI } from "./commands/url";
import { visit } from "./commands/visit";
import { mockComponent, clearComponentMocks } from "./ui/componentMocks";
Expand Down Expand Up @@ -65,7 +66,7 @@ interface TWDAPI {
/**
* Mock a network request.
*
* @param alias Identifier for the mock rule. Useful for `waitFor()`.
* @param alias Identifier for the mock rule. Useful for `waitForRequest()`.
* @param options Options to configure the mock:
* - `method`: HTTP method ("GET", "POST", …)
* - `url`: URL string or RegExp to match
Expand All @@ -92,14 +93,14 @@ interface TWDAPI {
* @param retries The number of retries to make
* @param retryDelay The delay between retries
* @return The matched rule (with body if applicable)
*
*
* @example
* ```ts
* const rule = await twd.waitFor("aliasId");
* const rule = await twd.waitForRequest("aliasId");
* console.log(rule.body);
* const rule = await twd.waitFor("aliasId", 5, 100);
* const rule = await twd.waitForRequest("aliasId", 5, 100);
* console.log(rule.body);
*
*
* ```
*/
waitForRequest: (alias: string, retries?: number, retryDelay?: number) => Promise<Rule>;
Expand Down Expand Up @@ -189,6 +190,30 @@ interface TWDAPI {
* ```
*/
wait: (time: number) => Promise<void>;
/**
* Retries a callback until it stops throwing or the timeout expires.
* Use this instead of `twd.wait(ms)` to wait for conditions rather than fixed delays.
*
* @param callback Function to retry — can be sync or async. Should throw if the condition is not yet met.
* @param options Optional timeout, interval, and message settings
* @returns A promise that resolves when the callback succeeds
*
* @example
* ```ts
* // Wait for an analytics event
* await twd.waitFor(() => {
* const event = findEvent("purchase");
* expect(event).to.exist;
* }, { message: "purchase event to fire" });
*
* // Wait with custom timeout
* await twd.waitFor(() => {
* const el = document.querySelector(".loaded");
* if (!el) throw new Error("not loaded");
* }, { timeout: 5000 });
* ```
*/
waitFor: (callback: () => void | Promise<void>, options?: WaitForOptions) => Promise<void>;
/**
* Asserts something about the element.
* @param el The element to assert on
Expand Down Expand Up @@ -328,6 +353,7 @@ export const twd: TWDAPI = {
log(message);
},
wait,
waitFor,
mockComponent,
clearComponentMocks,
viewport,
Expand Down
Loading