> ## Documentation Index
> Fetch the complete documentation index at: https://e2e.tester.army/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Working with the browser

> Mock an API, handle a dialog, download a file, reach into an iframe, and pick a browser.

The `browser` fixture of `@e2e-dev/web` mocks requests, handles dialogs,
downloads files, and runs code in the page. The engine runs Chromium,
Firefox, and WebKit through
[Playwright](https://github.com/microsoft/playwright). For navigation,
interactions, and checks, use `screen`, `app`, and `expect`.

Import `test` from `@e2e-dev/web` to get `browser`:

```ts

test('the dashboard loads', async ({ app, screen, browser }) => {
  await app.open('/dashboard');
  await expect(browser).toHaveURL('/dashboard');
  await expect(browser).toHaveTitle(/Dashboard/);
  await expect(screen.getByRole('heading', 'Overview')).toBeVisible();
});
```

In a suite with device targets, add `{ requires: ['browser'] }` to a test
that uses `browser`, so the runner skips it on devices.

## Mock an API

`browser.route` intercepts requests for the rest of the attempt, also after
`app.restart()` and a session restore. Register it before the page opens.

```ts
await browser.route('**/api/quote', async (route) => {
  await route.fulfill({ json: { cents: 4200 } });
});

await app.open('/checkout');
await expect(screen.getByText('$42.00')).toBeVisible();
```

- Each handler calls exactly one of `fulfill`, `continue`, `fallback`, or
  `abort`.
- The newest route runs first. `continue` sends the request to the network.
  `fallback` passes it to the route registered before.
- An `expect` that fails in a handler fails the test, at the next step or
  at the end of the test.

Start `browser.waitForResponse` before the request. This also works before
the first `browser.goto` or `app.open`:

```ts
const [response] = await Promise.all([
  browser.waitForResponse('**/api/orders'),
  screen.getByRole('button', 'Place order').tap(),
]);
expect(response.status).toBe(201);
```

## Handle a dialog

An unhandled `alert`, `confirm`, or `prompt` fails the next step. Register a
handler first:

```ts
const off = await browser.onDialog('accept');
await screen.getByRole('button', 'Delete').tap();
await off();
```

A function handler gets the dialog and must call `accept(text?)` or
`dismiss()`.

## Download a file

```ts

const file = await browser.waitForDownload(() =>
  screen.getByRole('link', 'Export CSV').tap(),
);
const csv = await readFile(file.absolutePath, 'utf8');
expect(csv).toContain('Invoice');
```

e2e saves the file as a `download` artifact. Read it through
`file.absolutePath`, which works with hosted browsers too. `file.path` is
relative to the attempt's artifact directory, not to the project.

## Reach into an iframe

`browser.frameLocator` scopes queries to a frame:

```ts
const payment = browser.frameLocator('#payment-frame');
await payment.getByLabel('Card number').fill('4242424242424242');
```

In a frame, `locator` takes a CSS or XPath selector, and `frameLocator`
enters a nested frame:

```ts
const editor = browser.frameLocator('#result');
await editor.locator('#upload').setInputFiles('fixtures/photo.png');
await expect(editor.frameLocator('#preview').getByRole('heading', 'Uploaded')).toBeVisible();
```

`browser.locator('css or xpath')` does the same on the page. Use a selector
only when role, label, and test-id queries can't find the element. For a
match made ambiguous by a hidden duplicate, try `{ visible: true }` first.

The agent's semantic tree shows a frame from another site, such as a hosted
card field, as an `iframe` element with none of its content
([site check](https://e2e.tester.army/docs/reference/security#site-check)). The agent reaches that
content only through screenshots. Use `frameLocator` for steps in that
frame. To judge its content with `agent.assert`, pass
[`vision`](https://e2e.tester.army/docs/reference/agent#vision). An `agent.act` whose only change is in
that frame never records a [replay](https://e2e.tester.army/docs/cache#when-a-recording-is-written).

## Read from the page

`browser.evaluate` runs a function in the page. e2e serializes the
function, so it can't use test variables. Pass data as the second argument:

```ts
const count = await browser.evaluate(() => document.querySelectorAll('li').length);
const flag = await browser.evaluate((name: string) => window.localStorage.getItem(name), 'featureFlags');
```

Use `evaluate` only for state that `screen` can't read.

## Run a script before the page

An init script runs in every document before the page's own scripts. Use it
to mock a browser API, seed `Math.random`, or set a flag the app reads at
boot. For every test:

```ts
web({ initScripts: [{ path: 'tests/init.js' }] })
```

For one test, before the page opens:

```ts
await browser.addInitScript((value) => { Math.random = () => value; }, 0.5);
await app.open('/raffle');
```

See [init scripts](https://e2e.tester.army/docs/reference/web#init-scripts).

## Emulate dark mode and reduced motion

`colorScheme` and `reducedMotion` set what the page's `@media` rules and
`matchMedia` read, from the first paint. For every test:

```ts
web({ colorScheme: 'dark', reducedMotion: 'reduce' })
```

For one test, call `browser.emulateMedia`. Call it before the page opens to
load the page with those values:

```ts
await browser.emulateMedia({ reducedMotion: 'reduce' });
await app.open('/');
```

A value holds for the rest of the test, through `app.restart()` and
`app.clearState()`. See [media emulation](https://e2e.tester.army/docs/reference/web#media-emulation).

## Cookies

```ts
await browser.setCookies([{ url: 'http://localhost:3000', name: 'tz', value: 'UTC' }]);
```

A cookie URL that doesn't parse, `about:blank`, or a scheme other than
`http:` or `https:` is `POLICY_DENIED`. A domain cookie is checked as the
origin it would go to. When one entry is refused, e2e sets none.

## Pick a browser

The browser is an option of the engine, `chromium` by default. Two
browsers on one app are two targets, as in
[One size per target](https://e2e.tester.army/docs/web#one-size-per-target):

```ts title="Target fragment"
{ name: 'webkit', engine: web({ browser: 'webkit' }), app },
```

In CI, install the extra browser with the
[install command](https://e2e.tester.army/docs/reference/web#installing-browsers):

<CodeGroup>
```bash npm
npx @e2e-dev/web install webkit --with-deps
```

```bash pnpm
pnpm exec e2e-web install webkit --with-deps
```

```bash bun
bunx @e2e-dev/web install webkit --with-deps
```
</CodeGroup>

`connect` attaches to a remote Chromium over CDP instead of launching one,
or to another browser that speaks CDP, such as a running
[Lightpanda](https://e2e.tester.army/docs/integrations/lightpanda).
To set sizes, see [Viewports](https://e2e.tester.army/docs/web#viewports).

## Hosted browsers

A browser provider leases a browser from a service and gives the engine its
CDP endpoint. Pass it as `browser`. The engine gives each browser back when
its scope ends, on every exit path, so a session billed by the minute stops
when the run no longer uses it.

| Service | Provider |
| --- | --- |
| [Kernel](https://e2e.tester.army/docs/integrations/kernel) | `kernel()` from `@e2e-dev/kernel` |
| [smol machines](https://e2e.tester.army/docs/integrations/smol) microVMs on your own computer | `smol()` from `@e2e-dev/smol` |
| [Lightpanda](https://e2e.tester.army/docs/integrations/lightpanda), the open-source headless browser, on your own machine | `lightpanda()` from `@e2e-dev/lightpanda` |
| [TestMu AI](https://e2e.tester.army/docs/integrations/testmu#run-web-targets-in-browsers) | `testmuBrowsers()` from `@e2e-dev/testmu/web` |
| Any other service | Your own provider, a few dozen lines in your project |

When the service shows the window in a live view, add `viewport: null` so
the page [fills the window](https://e2e.tester.army/docs/web#fill-the-window).

A provider implies a browser the engine drives as Chromium over CDP,
Chromium itself or Lightpanda, and can't be combined with `connect`. A
target with nothing to run leases nothing.

### Write a provider

A provider implements this interface:

```ts
export interface BrowserProvider {
  /** Label in progress lines and error messages. */
  readonly name: string;
  /** `worker` (default): one browser per worker slot for the run. `attempt`: a fresh browser per attempt. */
  readonly scope?: 'worker' | 'attempt';
  acquire(request: BrowserRequest): Promise<BrowserLease>;
  release(lease: BrowserLease, context: BrowserReleaseContext): Promise<void>;
  /** Optional: the service's own recording of the browser, in the worker, for an attempt that records video. */
  record?(lease: BrowserLease, context: ProviderRecordContext): Promise<ProviderRecording>;
  /** Optional: serves `browser.waitForDownload` for a browser on another machine. */
  readonly downloads?: BrowserProviderDownloads;
  /** Optional: releases the run's leases a dead worker left open, once the run ends; resolves to their ids. */
  sweep?(context: BrowserReleaseContext): Promise<readonly string[]>;
}

export interface BrowserProviderDownloads {
  /** Absolute directory on the browser's machine the browser saves downloads to. */
  readonly dir: string;
  /** The bytes of one finished download, `file` a path under `dir`, read off the leased browser. */
  read(lease: BrowserLease, file: string, context: BrowserDownloadContext): Promise<Uint8Array>;
}

export interface BrowserLease {
  /** The provider's handle (a session id), handed back to `release`. */
  readonly id: string;
  /** `ws://`, `wss://`, or `http://` DevTools endpoint to attach to over CDP. */
  readonly cdpEndpoint: string;
  /** Endpoint to reattach to the same browser after a transport drop; per-attempt leases only. */
  readonly reconnectEndpoint?: string;
}
```

`acquire` gets the run id, the target, the worker slot and slot count, the
attempt id for a per-attempt lease, the run's environment, an abort signal,
and a `log` that reaches the reporter. Read tokens from `request.env`, not
`process.env`.

A lease is JSON data. `release` gets back the object `acquire` returned, so
a provider can keep its own fields on it. Only `id`, `cdpEndpoint`, and
`reconnectEndpoint` travel to the worker, so key anything a worker uses,
such as a recording, on `lease.id`.

Against a typical session API, a provider sends three requests: create a
session, poll it until it has a DevTools endpoint, and stop it.

<Accordion title="Example provider and config">

```ts title="browser-provider.ts"

/**
 * Leases browsers from a service that starts a Chromium per session and
 * exposes its DevTools endpoint. Replace the three requests with your
 * service's API.
 */
export function hostedBrowsers(options: { serviceUrl: string; scope?: 'worker' | 'attempt' }): BrowserProvider {
  return {
    name: 'hosted-browsers',
    scope: options.scope ?? 'worker',
    async acquire(request) {
      const token = request.env['BROWSER_SERVICE_TOKEN'];
      if (token === undefined || token === '') throw new Error('BROWSER_SERVICE_TOKEN is not set');
      const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
      // 1. Ask for a browser; name the session after the run so it can be found later.
      const created = await fetch(`${options.serviceUrl}/sessions`, {
        method: 'POST',
        headers,
        body: JSON.stringify({
          name: `e2e ${request.runId} ${request.targetName} ${request.attemptId ?? `slot ${request.slot + 1} of ${request.slots}`}`,
        }),
        signal: request.signal,
      });
      if (!created.ok) throw new Error(`browser service: HTTP ${created.status}`);
      const { id } = (await created.json()) as { id: string };
      // 2. Wait until the session exposes its DevTools endpoint.
      for (;;) {
        const response = await fetch(`${options.serviceUrl}/sessions/${id}`, { headers, signal: request.signal });
        const session = (await response.json()) as { status: string; cdpUrl?: string; viewerUrl?: string };
        if (session.status === 'ready' && session.cdpUrl !== undefined) {
          if (session.viewerUrl !== undefined) request.log(`watch at ${session.viewerUrl}`);
          return { id, cdpEndpoint: session.cdpUrl };
        }
        if (session.status !== 'starting') throw new Error(`browser service: session ${id} is ${session.status}`);
        await new Promise((resolve) => setTimeout(resolve, 2_000));
      }
    },
    async release(lease, context) {
      // 3. Stop the session; the run waits for this within cleanupTimeout.
      const token = context.env['BROWSER_SERVICE_TOKEN'] ?? '';
      await fetch(`${options.serviceUrl}/sessions/${lease.id}/stop`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
        signal: context.signal,
      });
    },
  };
}
```

```ts title="e2e.config.ts"

export default {
  targets: [
    {
      name: 'hosted',
      engine: web({
        browser: hostedBrowsers({ serviceUrl: 'https://browsers.example.com' }),
      }),
      app: { url: 'https://staging.example.com' },
    },
  ],
  workers: 2,
} satisfies E2EConfig;
```

Two workers lease two sessions in parallel and keep them for the run.

</Accordion>

### Pick a lease scope

`scope: 'worker'`, the default, keeps one browser per worker slot for the
whole run. `scope: 'attempt'` gives each attempt, retries included, a fresh
browser that no earlier test touched, for the cost of a session start per
attempt. A hosted session costs money while it runs, so keep `workers` at
what the suite can fill.

A worker that dies (a crash, `process.exit`, a kill) can't release what it
acquired. Implement `sweep` to have the runner release those leases when
the run ends, one time per target: it finds them at the service by the
run's id and the target, and resolves to their ids for the reporter. A
failed sweep shows as a line and fails nothing. Without `sweep`, such a
browser lives until the service ends it.

<Accordion title="Worker scope: when leases start and end">
- The engine leases every slot's browser in parallel when the run starts.
  When one slot fails, the run ends before any test, and the leases that
  succeeded are released.
- The engine releases leases after the last worker exits, within
  `cleanupTimeout`, on every exit path: failure, interrupt, and a
  `prepare` that threw.
- When a worker's browser drops, the worker leases a replacement at the
  next attempt start and releases it when it exits. So `acquire` also runs
  in a worker process: don't depend on state built in the runner process.
  Its `log` still reaches the reporter, prefixed with the target and slot.
- The side that acquired a lease releases it, exactly one time.
</Accordion>

<Accordion title="Attempt scope: limits">
The attempt runs in the browser's persistent default context, on its first
tab, so the remote browser must contain only that context. The limits of
[`connect.reconnectEndpoint`](https://e2e.tester.army/docs/reference/web#recovering-a-cdp-transport)
apply:

- No `headers`, `basicAuth`, `userAgent`, `locale`, or `timezoneId`. Each
  is `INVALID_CONFIG`.
- No `app.clearState()`. It is `UNSUPPORTED_CAPABILITY`, naming the scope.
- No session state capture or restore. A run whose tests use a
  [session](https://e2e.tester.army/docs/authentication) on the target fails with `COLLECTION_ERROR`
  before its setup test signs in.

After a transport drop, the next operation reattaches through the lease's
`reconnectEndpoint`, else its `cdpEndpoint`, and checks that it is the same
browser.
</Accordion>

### Downloads on the provider

Implement `downloads`, and `browser.waitForDownload` works as it does
locally. A hosted browser saves a download to its own disk, where the
runner can't open it. With `downloads`, it saves the file under `dir`, and
the engine passes the finished file's path to `read`. `read` fetches the
bytes through the service's file API, in the worker, bounded by the call's
timeout. [Kernel](https://e2e.tester.army/docs/integrations/kernel#downloads) serves downloads this
way.

Without `downloads`, a download on a browser on another machine fails with
`ENGINE_FAILURE`, naming the provider. A provider whose browsers run on the
runner's machine needs nothing.

### Recording on the provider

Implement `record` to use the service's own recording as the attempt's
video. When an attempt records [video](https://e2e.tester.army/docs/reference/config#video), the worker
calls `record` instead of starting the engine's screencast, once the page
is open. The recording covers the whole browser window, tabs and address
bar included.

`record` returns a `ProviderRecording` from `e2e/engine`. When the attempt
collects its video, its `stop` does one of two things:

- Writes a file into the directory it gets and returns `{ file }`. e2e keeps
  it as `video/<file>` beside the attempt's other artifacts.
- Returns `{ url, mediaType }` for a recording the service keeps. The
  report links to it.

<Accordion title="Example record method">

```ts title="browser-provider.ts"

// In hostedBrowsers(), beside acquire and release.
async record(lease, context): Promise<ProviderRecording> {
  const headers = { Authorization: `Bearer ${context.env['BROWSER_SERVICE_TOKEN'] ?? ''}` };
  const startedAt = new Date().toISOString();
  const started = await fetch(`${options.serviceUrl}/sessions/${lease.id}/recordings`, {
    method: 'POST',
    headers,
    signal: context.signal,
  });
  if (!started.ok) throw new Error(`browser service: HTTP ${started.status}`);
  const { id } = (await started.json()) as { id: string };
  return {
    startedAt,
    async stop({ signal }) {
      const stopped = await fetch(`${options.serviceUrl}/recordings/${id}/stop`, { method: 'POST', headers, signal });
      if (!stopped.ok) throw new Error(`browser service: HTTP ${stopped.status}`);
      // The service keeps the recording; the report links to its player page.
      return { url: `${options.serviceUrl}/recordings/${id}`, mediaType: 'text/html' };
    },
  };
},
```

</Accordion>

A failed `record` fails the attempt's launch, naming the provider and the
lease. A failed `stop` runs one more time when the attempt ends, so no
recording keeps running on a shared browser. When the service can't record
a browser (a headless one, say), leave `record` out and the engine records
the screencast. [Kernel](https://e2e.tester.army/docs/integrations/kernel#recordings) records this way.

## Icon fonts and generated content

Text that a stylesheet adds with `::before` or `::after` is part of a
control's name, as screen readers read it: a button with
`.next::before { content: "→" }` is `button "→ Next"`. Label text, as
`getByLabel` reads it, never includes generated content.

Icon fonts are the exception. Font Awesome and similar fonts draw each icon
from a private-use character (`\f090`), and the engine drops those
characters from every name:

- `<button><i class="fa fa-sign-in"> Login</i></button>` is
  `button "Login"`, and `getByRole('button', 'Login')` finds it.
- A glyph between two words reads as a space.
- A control with only an icon reads as its `title`, when it has one. The
  browser still names it by the glyph, so `getByRole` by that title finds
  nothing. Give it an `aria-label`, or use
  `browser.locator('[title="Sign out"]')`.
- A `RegExp` name matches the browser's name, glyph included, so don't
  anchor it: `/Login/`.

## Inert content

The agent doesn't see an element with the `inert` attribute or anything
under it, so it never aims at a control behind an open drawer. Locators
still find inert content, and `toBeVisible` still passes, as in Playwright.

## Shadow DOM

`screen` queries, scopes, and `has` filters reach into open and closed
shadow roots, and the agent sees their content. `first`, `last`, and `nth`
count the matches outside a closed root first, then each root's matches in
order.

```ts
// The input and the button live in a closed shadow root a widget attached.
await screen.getByPlaceholder('Access code').fill('SHADOW-42');
await screen.getByRole('button', 'Submit').tap();
await expect(screen.getByText('Access granted')).toBeVisible();
```

`browser.locator`, `frameLocator`, and `filter({ hasText })` stop at a
closed root. Query the text inside the root instead, or filter with `has`.
A closed root declared in HTML (`<template shadowrootmode="closed">`) is out
of reach.

## Next

<CardGroup cols={2}>
  <Card title="Web engine reference" icon="masks-theater" href="/reference/web">
    Every `browser` method, its timeout, and what it throws.
  </Card>
  <Card title="Web" icon="play" href="/web">
    Start the dev server, reach protected previews, and test viewports.
  </Card>
</CardGroup>
