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
198 changes: 198 additions & 0 deletions docs/src/test/timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
---
title: Timeouts
description: Configure timeouts for tests, assertions, actions, and device operations.
sidebar:
order: 5
---

Mobilewright follows Playwright's timeout model. There are several independent timeout layers, each controlling a different part of the test lifecycle. All timeouts are in milliseconds.

## Timeout overview

| Timeout | Default | Config location | Description |
|---|---|---|---|
| Test timeout | 30 000 | `timeout` | Limits a single test (setup + body + teardown) |
| Global timeout | none | `globalTimeout` | Caps the entire test run |
| Action timeout | 5 000 | `use.actionTimeout` | Limits a single locator action (`tap`, `fill`, etc.) |
| Expect timeout | 5 000 | `expect.timeout` | Limits a single assertion (`toBeVisible`, `toHaveText`, etc.) |
| App launch timeout | 20 000 | `use.appLaunchTimeout` | Limits waiting for the app to reach the foreground |
| Install timeout | none | `use.installTimeout` | Limits app installation (`installApps`) |
| Allocation timeout | 5 min | `driver.allocationTimeout` | Limits waiting for a cloud device (mobilenext only) |
| Upload timeout | none | `driver.uploadTimeout` | Limits test result upload (mobilenext only) |

---

## Test timeout

A test fails if it does not complete within the test timeout. This includes fixture setup, the test body, and `beforeEach` / `afterEach` hooks.

```ts
// mobilewright.config.ts
export default defineConfig({
timeout: 60_000, // 60 seconds
});
```

Override for a single test:

```ts
test('slow workflow', async ({ screen }) => {
test.setTimeout(120_000);
// ...
});
```

Extend the current timeout instead of replacing it (useful in `beforeEach`):

```ts
test.beforeEach(async ({}, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
});
```

---

## Expect timeout

Auto-retrying assertions poll until they pass or the expect timeout expires. Applies to `toBeVisible`, `toHaveText`, `toBeEnabled`, and all other `expect()` assertions.

```ts
export default defineConfig({
expect: {
timeout: 10_000,
},
});
```

Override for a single assertion:

```ts
await expect(screen.getByText('Order confirmed')).toBeVisible({ timeout: 15_000 });
```

---

## Action timeout

Limits individual locator actions: `tap`, `fill`, `longPress`, `getText`, `isVisible`, and so on. The action fails if the element is not found and actionable within this time.

```ts
export default defineConfig({
use: {
actionTimeout: 10_000,
},
});
```

Override for a single action:

```ts
await screen.getByRole('button', { name: 'Submit' }).tap({ timeout: 5_000 });
```

---

## Global timeout

Stops the entire test run after the given duration. Useful in CI to prevent a runaway test suite from consuming hours of build time. There is no default — omitting it means the run can take as long as it needs.

```ts
export default defineConfig({
globalTimeout: 30 * 60_000, // 30 minutes
});
```

---

## App launch timeout

Real devices can be significantly slower to launch apps than simulators. This timeout limits how long Mobilewright waits for the app to reach the foreground after `launchApp()` is called.

```ts
export default defineConfig({
use: {
appLaunchTimeout: 60_000, // 60 seconds for slow real devices
},
});
```

---

## Install timeout

When `installApps` is set, Mobilewright installs the app before each test run. Installation can be slow over USB or on cloud devices. By default there is no limit.

```ts
export default defineConfig({
use: {
installTimeout: 3 * 60_000, // 3 minutes
},
});
```

---

## Cloud device timeouts (mobilenext)

These timeouts apply only when using the `mobilenext` driver.

### Allocation timeout

Cloud devices are allocated from a shared pool. Under load, a device may not be immediately available. This timeout limits how long Mobilewright waits before giving up.

```ts
export default defineConfig({
driver: {
type: 'mobilenext',
apiKey: process.env.MOBILENEXT_API_KEY,
allocationTimeout: 15 * 60_000, // 15 minutes
},
});
```

### Upload timeout

When `testResult` is configured, Mobilewright uploads the test report to mobilenext.ai after the run. This timeout limits how long that upload may take.

```ts
export default defineConfig({
driver: {
type: 'mobilenext',
apiKey: process.env.MOBILENEXT_API_KEY,
testResult: { uploadReport: 'on' },
uploadTimeout: 2 * 60_000, // 2 minutes
},
});
```

---

## Full example

```ts
// mobilewright.config.ts
import { defineConfig } from 'mobilewright';

export default defineConfig({
timeout: 60_000,
globalTimeout: 30 * 60_000,

use: {
actionTimeout: 10_000,
appLaunchTimeout: 45_000,
installTimeout: 3 * 60_000,
},

expect: {
timeout: 10_000,
},

driver: {
type: 'mobilenext',
apiKey: process.env.MOBILENEXT_API_KEY,
allocationTimeout: 15 * 60_000,
uploadTimeout: 2 * 60_000,
testResult: { uploadReport: 'on-failure' },
},
});
```
6 changes: 4 additions & 2 deletions packages/driver-mobilenext/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ interface MobileNextDevicesResponse {
export interface MobileNextDriverOptions {
region?: string;
apiKey?: string;
/** Timeout waiting for cloud device allocation in ms. Default: 300000 (5 min). */
allocationTimeout?: number;
}

const VALID_PLATFORMS = new Set<string>(['ios', 'android']);
Expand Down Expand Up @@ -261,7 +263,7 @@ export class MobileNextDriver implements MobilewrightDriver {
let type: DeviceType | undefined;
if (result?.state === 'allocating' && result.sessionId) {
debug('device is provisioning, waiting for allocation (session=%s)', result.sessionId);
const device = await this.waitForAllocation(rpc, result.sessionId, config.timeout);
const device = await this.waitForAllocation(rpc, result.sessionId);
debug('allocated device %s (session=%s, model=%s)', device.id, result.sessionId, device.model);
deviceId = device.id;
model = device.model;
Expand All @@ -284,8 +286,8 @@ export class MobileNextDriver implements MobilewrightDriver {
private async waitForAllocation(
rpc: RpcClient,
sessionId: string,
timeout = 300_000,
): Promise<DevicesListDevice> {
const timeout = this.options.allocationTimeout ?? 300_000;
const pollInterval = 5_000;
const deadline = Date.now() + timeout;

Expand Down
21 changes: 21 additions & 0 deletions packages/driver-mobilenext/src/upload-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,24 @@ test('leaves path-based attachments unchanged', async () => {
// Only 1 asset call: just report.json (no upload for path-based attachments)
expect(assetCallCount).toBe(1);
});

test('uploadTestResult rejects when timeout is exceeded', async () => {
const slowFetch: typeof fetch = (_url, init) => {
const signal = (init as RequestInit | undefined)?.signal as AbortSignal | undefined;
return new Promise((resolve, reject) => {
if (signal?.aborted) { reject(signal.reason); return; }
const timer = setTimeout(() => resolve(new Response('{}', { status: 200 })), 500);
signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal!.reason); });
});
};

await expect(
uploadTestResult({
apiKey: 'key',
report: {},
userAgent: 'test/1.0',
timeout: 50,
_fetchFn: slowFetch,
}),
).rejects.toThrow();
});
10 changes: 8 additions & 2 deletions packages/driver-mobilenext/src/upload-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface UploadTestResultParams {
name?: string;
tags?: string[];
environment?: string;
/** Timeout for the entire upload operation in ms. */
timeout?: number;
_fetchFn?: typeof fetch;
}

Expand Down Expand Up @@ -44,7 +46,7 @@ function extensionForContentType(contentType: string): string {
return CONTENT_TYPE_EXTENSIONS[contentType] ?? 'bin';
}

function makeAttachmentUploader(testResultId: string, apiKey: string, fetchFn: typeof fetch) {
function makeAttachmentUploader(testResultId: string, apiKey: string, fetchFn: typeof fetch, signal?: AbortSignal) {
async function uploadAndReplace(obj: unknown): Promise<void> {
if (!obj || typeof obj !== 'object') { return; }
if (Array.isArray(obj)) {
Expand Down Expand Up @@ -72,6 +74,7 @@ function makeAttachmentUploader(testResultId: string, apiKey: string, fetchFn: t
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` },
body: form,
...(signal && { signal }),
});

if (!res.ok) {
Expand All @@ -94,6 +97,7 @@ function makeAttachmentUploader(testResultId: string, apiKey: string, fetchFn: t

export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> {
const fetchFn = params._fetchFn ?? fetch;
const signal = params.timeout ? AbortSignal.timeout(params.timeout) : undefined;
const hasGitInfo = params.gitInfo !== undefined && Object.values(params.gitInfo).some(v => v !== undefined);

debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', params.userAgent);
Expand All @@ -108,6 +112,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise<
userAgent: params.userAgent,
...(hasGitInfo ? { git: params.gitInfo } : {}),
}),
...(signal && { signal }),
});

if (!createRes.ok) {
Expand All @@ -119,7 +124,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise<

// Deep-clone so attachment body replacement does not mutate the caller's object
const report = JSON.parse(JSON.stringify(params.report)) as Record<string, unknown>;
const uploadAndReplace = makeAttachmentUploader(testResult.id, params.apiKey, fetchFn);
const uploadAndReplace = makeAttachmentUploader(testResult.id, params.apiKey, fetchFn, signal);
await uploadAndReplace(report);

const modifiedJson = JSON.stringify(report);
Expand All @@ -139,6 +144,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise<
method: 'POST',
headers: { 'Authorization': `Bearer ${params.apiKey}` },
body: form,
...(signal && { signal }),
}).finally(() => clearInterval(progressTimer));

if (!uploadRes.ok) {
Expand Down
27 changes: 25 additions & 2 deletions packages/mobilewright-core/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ const LAUNCH_APP_TIMEOUT = 20_000;

export interface DeviceOptions {
locatorDefaults?: LocatorOptions;
/** Timeout waiting for app to reach foreground after launch, in ms. Default: 20000. */
appLaunchTimeout?: number;
/** Timeout for app installation in ms. Default: none (no limit). */
installTimeout?: number;
}

function raceWithTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}

export class Device {
Expand Down Expand Up @@ -87,12 +101,13 @@ export class Device {
if (opts?.noWaitAfter) {
return;
}
const timeout = this.opts.appLaunchTimeout ?? LAUNCH_APP_TIMEOUT;
debug('waiting for %s to reach foreground', bundleId);
try {
await retryUntil(
() => this.getForegroundApp(),
(app) => app.bundleId === bundleId,
LAUNCH_APP_TIMEOUT,
timeout,
`launchApp: timed out waiting for "${bundleId}" to be in foreground`,
);
debug('%s is in foreground', bundleId);
Expand Down Expand Up @@ -122,7 +137,15 @@ export class Device {
}

async installApp(path: string): Promise<void> {
return this.driver.installApp(path);
const { installTimeout } = this.opts;
if (installTimeout === undefined) {
return this.driver.installApp(path);
}
return raceWithTimeout(
this.driver.installApp(path),
installTimeout,
`installApp timed out after ${installTimeout}ms`,
);
}

async uninstallApp(bundleId: string): Promise<void> {
Expand Down
23 changes: 23 additions & 0 deletions packages/mobilewright-core/src/expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,26 @@ test.describe('expect', () => {
});
});
});

test('toBeVisible uses expectTimeout from locator options as default', async () => {
const driver = createMockDriver([node({ type: 'Button', label: 'Submit', isVisible: false })]);
const locator = Locator.root(driver, { expectTimeout: 100 }).getByLabel('Submit');

const start = Date.now();
await expect(mwExpect(locator).toBeVisible()).rejects.toThrow();
const elapsed = Date.now() - start;

expect(elapsed).toBeGreaterThanOrEqual(90);
expect(elapsed).toBeLessThan(1_000);
});

test('per-call timeout overrides expectTimeout from locator options', async () => {
const driver = createMockDriver([node({ type: 'Button', label: 'Submit', isVisible: false })]);
const locator = Locator.root(driver, { expectTimeout: 5_000 }).getByLabel('Submit');

const start = Date.now();
await expect(mwExpect(locator).toBeVisible({ timeout: 100 })).rejects.toThrow();
const elapsed = Date.now() - start;

expect(elapsed).toBeLessThan(1_000);
});
Loading