` both contain "Save", only the button is returned. Text inside `script`, `style`, `noscript`, `head`, `template` and `title` is ignored.
### Role attributes [#role-attributes]
`role=` takes attribute filters in square brackets, the same ones `getByRole` builds:
| Attribute | Example | Notes |
|---|---|---|
| `name` | `[name="Save"]`, `[name=/^Save/i]` | A quoted value is a case-insensitive substring. Add `s` for an exact, case-sensitive match (`[name="Save" s]`) or `i` for an exact, case-insensitive match (`[name="save" i]`). |
| `checked` | `[checked]`, `[checked=false]`, `[checked=mixed]` | A bare attribute means `true`. |
| `disabled` | `[disabled]`, `[disabled=false]` | |
| `expanded` | `[expanded=true]` | |
| `selected` | `[selected]` | |
| `pressed` | `[pressed=true]`, `[pressed=mixed]` | Compared with the `aria-pressed` attribute. |
| `level` | `[level=2]` | |
| `include-hidden` | `[include-hidden]` | Include elements hidden from assistive technology. |
### Shadow DOM [#shadow-dom]
Open shadow roots are pierced by every engine except XPath:
- CSS runs `querySelectorAll` on the document and separately on each open shadow root. A single CSS selector can't cross a shadow boundary: `my-card button` does not match a button inside `my-card`'s shadow root. Chain instead: `my-card >> button`.
- `text=`, `role=`, `label=`, `placeholder=`, `alt=`, `title=` and `testid=` walk open shadow trees.
- Closed shadow roots are never pierced.
### Snapshot refs [#snapshot-refs]
`page.locator("@e3")` targets the element that [`page.snapshot()`](/docs/agents/snapshots) labelled `e3`. `e3`, `ref=e3` and `ref=@e3` are equivalent when they make up the whole selector or a whole `>>` part of it. A ref is not re-resolved by role or name. It points at that exact DOM node, and matches nothing once the node is detached or a new document is loaded. Take a new snapshot after the page changes.
Inside `locator.locator(...)` and filters, write the `ref=e3` form. Refs only match within the scope of the parent locator.
### Invalid selectors [#invalid-selectors]
Syntax errors fail right away with a `KmpwrightException` whose message starts with `Invalid selector`. KMPwright does not retry them until the timeout. This covers malformed CSS, a bad XPath expression, an invalid regular expression and unparseable `role=` attributes.
## Composing locators [#composing-locators]
```kotlin
val row = page.locator("tr")
.filter(hasText = "Ada Lovelace")
.filter(has = page.getByRole("button", name = "Edit"))
row.getByRole("button", name = "Edit").click()
val items = page.getByRole("listitem")
items.first().click()
items.nth(-2).click() // second to last
items.filter(hasNotText = "archived").count()
items.filter(hasText = Regex("^Q[1-4]")).count()
val submit = page.getByRole("button").and(page.locator("[type=submit]"))
val dismiss = page.getByText("Close").or(page.getByLabel("Dismiss"))
for (item in items.all()) println(item.innerText())
```
| Method | Behavior |
|---|---|
| `locator(selector)` and `getBy*` | Search inside every current match (descendants only, never the element itself). |
| `first()` / `last()` / `nth(index)` | Pick one match. A negative `index` counts from the end. An out-of-range index matches nothing. |
| `filter(hasText, hasNotText, has, hasNot)` | Keep matches whose text contains `hasText`, does not contain `hasNotText`, has a descendant matching `has`, and has none matching `hasNot`. Text matching follows the same rules as `getByText` (case-insensitive substring). |
| `filter(hasText: Regex?, hasNotText: Regex?, has, hasNot)` | The same with regular expressions. |
| `and(locator)` | Matches of this locator that also match `locator`. |
| `or(locator)` | Matches of either locator, deduplicated, in document order. |
| `all()` | One locator per element matching right now (`nth(0)`…`nth(count - 1)`). The elements are not awaited. |
`has`, `hasNot`, `and` and `or` take a `Locator` from the same page. A locator from another page throws `KmpwrightException`. The `has`/`hasNot` locator is evaluated relative to each candidate, so `page.getByRole("button")` inside `filter(has = …)` means "a button inside this element".
Steps keep their order. `locator("li").nth(1).locator("a")` searches inside the second `li`, and `locator("li a").nth(1)` takes the second link overall.
## Strictness [#strictness]
KMPwright enforces Playwright-style strict mode by default (`KmpwrightConfig.strictSelectors = true`). An operation that needs one element fails immediately with `Strict mode violation: '
' resolved to N elements (…). Use first(), last(), nth() or a more specific selector.` when the locator matches more than one element. Strict operations:
- every action (`click`, `fill`, `press`, `check`, …),
- single-element queries (`textContent`, `innerText`, `innerHTML`, `getAttribute`, `inputValue`, `boundingBox`, `isVisible`, `isEnabled`, `isChecked`, `isEditable`, `isFocused`, `evaluate`, …).
These operations work on all matches and are never strict: `count()`, `all()`, `allTextContents()`, `allInnerTexts()`. `waitFor(state)` checks the first match.
To act on the first match instead of failing, call `first()`. To turn strictness off globally, set `KmpwrightConfig(strictSelectors = false)`. Every single-element operation then uses the first match.
## Reading element state [#reading-element-state]
Queries run once and return. They don't auto-wait, except where noted. To wait for a state, use [assertions](/docs/guides/assertions) (`expect(locator).toBeVisible()`) or `waitFor`.
| Method | Returns | When nothing matches |
|---|---|---|
| `count()` | Number of matches | `0` |
| `textContent()` | `textContent` | `null` |
| `innerText()` | Rendered `innerText` | `""` |
| `innerHTML()` | `innerHTML` | `""` |
| `getAttribute(name)` | Attribute value, `null` if absent | `null` |
| `inputValue()` | `value` of an input, textarea or select | `null` |
| `allTextContents()` | `textContent` of every match | empty list |
| `allInnerTexts()` | `innerText` of every match | empty list |
| `boundingBox()` | `Rectangle(x, y, width, height)` in CSS pixels, viewport-relative | `null` |
| `isVisible()` | Non-empty box and `visibility` not `hidden`/`collapse` | `false` |
| `isHidden()` | `!isVisible()` | `true` |
| `isEnabled()` / `isDisabled()` | Not `:disabled` and no `aria-disabled="true"` on it or an ancestor | throws |
| `isChecked()` | Checkbox/radio state or `aria-checked` (`mixed` counts as checked); throws for other elements | throws |
| `isEditable()` | Enabled, a fillable element, not `readonly`/`aria-readonly` | throws |
| `isFocused()` | Element is the active element of its document or shadow root | `false` |
| `waitFor(state, timeoutMs)` | Waits until the first match is `Attached`, `Detached`, `Visible` (default) or `Hidden` | waits |
| `evaluate("el => …", timeoutMs)` | `JsResult(value, type)` of a function called with the element. The function runs synchronously and a returned Promise is not awaited. Waits for the element to be attached. | waits, then throws |
| `screenshot(omitBackground, quality, format, scale, timeoutMs)` | Image of the element's box (scrolls it into view first); see [Screenshots](/docs/guides/screenshots) | waits, then throws |
Opacity is ignored by `isVisible()`: an `opacity: 0` element with a size counts as visible. Playwright: same.
`waitFor` uses `TimeoutConfig.waitForSelector` when `timeoutMs` is null. The other queries take no timeout.
The DOM-reading queries (`textContent`, `innerText`, `innerHTML`, `getAttribute`, `inputValue`, `count`, `all*`, `boundingBox`) throw when `KmpwrightConfig.allowDomExtraction = false`. `evaluate` throws when `allowEval = false`.
### Page shortcuts [#page-shortcuts]
The same queries exist on `Page`, taking a selector:
```kotlin
page.textContent("#total") // waits for the element to be attached
page.getAttribute("a.next", "href")
page.inputValue("#email")
page.isVisible("#banner") // no waiting
page.count("li")
page.waitForSelector("#results", SelectorState.Visible)
```
`textContent`, `innerText`, `innerHTML`, `getAttribute` and `inputValue` on `Page` first wait for the element to be attached. They take an optional `timeoutMs`, which defaults to `TimeoutConfig.waitForSelector`. `isVisible`, `isHidden`, `isEnabled`, `isDisabled`, `isChecked`, `isEditable` and `count` don't wait.
## Related [#related]
# Navigation and waiting (/docs/guides/navigation)
```kotlin
val result = page.goto("https://example.com/login")
check(result.ok) { "Navigation failed: ${result.status} ${result.error}" }
page.getByLabel("Email").fill("ada@example.com")
page.getByLabel("Password").fill(password)
// Subscribe to the URL change before clicking, so a fast redirect can't be missed.
page.waitForURL("**/dashboard") {
getByRole("button", name = "Sign in").click()
}
expect(page).toHaveTitle(Regex("Dashboard"))
```
Every navigation and wait is a `suspend` function. A `timeoutMs` of `null` uses the matching default from [`TimeoutConfig`](/docs/reference/configuration): `navigation` for navigations, `waitForURL`, `waitForLoadState` and `waitForNavigation`; `default` for most other waits. All defaults are 30 000 ms.
## Choose a readiness signal [#choose-a-readiness-signal]
Wait for the result your workflow needs. Locators already wait for actionable
elements, and assertions retry until the expected state appears:
```kotlin
page.goto("https://example.com/orders")
page.getByRole("button", name = "Refresh").click()
expect(page.getByRole("status")).toHaveText("Orders updated")
```
Use an action-scoped wait when the event itself matters. It subscribes before the
action, so a fast response cannot be missed:
```kotlin
val response = page.waitForResponse("**/api/orders") {
getByRole("button", name = "Refresh").click()
}
check(response.ok)
expect(page.getByRole("status")).toHaveText("Orders updated")
```
A response can arrive before the UI updates; keep the assertion when later steps
need the rendered result. `NetworkIdle` measures observed traffic, which may omit
requests on WebViews or never become idle on a polling application. Prefer a
specific result over a fixed sleep or an idle wait.
Navigation and input timeouts include policy confirmation and setup. A navigation
queued behind another navigation still uses its own total budget. Load-state and
URL waits include WebView evaluation callbacks in that budget. Closing the page
terminates pending navigation, load-state and event waits with `KmpwrightException`;
cancelling the caller propagates coroutine cancellation. A timeout fails the operation without closing the page.
## goto [#goto]
```kotlin
suspend fun goto(url: String, waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null): NavigationResult
```
Navigates the main frame and suspends until the backend reports that the new document finished loading, then waits for `waitUntil`.
```kotlin
page.goto("https://example.com", waitUntil = LoadState.NetworkIdle, timeoutMs = 15_000)
```
### NavigationResult [#navigationresult]
### Failures [#failures]
`goto` reports a failed load in the result instead of throwing:
| Situation | Result |
|---|---|
| HTTP error page (404, 500, …) | Returns `ok = false`, `status` = the HTTP code. |
| Network error (DNS, TLS, connection refused) | Returns `ok = false`, `status = 0`, `error` = the engine's message. |
| Committed URL not allowed by `KmpwrightConfig.allowedOriginRules`, or a host blocked by [`setAllowedDomains`](/docs/guides/network) | Returns `ok = false` with an explanatory `error`. |
| Requested URL not allowed by `KmpwrightConfig.allowedOriginRules` | Throws `KmpwrightException` before navigating. |
| No load within the timeout | Throws `KmpwrightTimeoutException` (`Timeout 30000ms exceeded while navigating to "…"`). |
Playwright differences: `page.goto` there throws on network errors, and returns `Response?`. In KMPwright `goto` always returns a `NavigationResult`; `ok` means 200–299, like `response.ok()`.
### HTTP status per backend [#http-status-per-backend]
| Backend | `status` |
|---|---|
| JVM (CDP) | The main document's real status, from `Network.responseReceived`. |
| iOS (WKWebView) | The real status from the navigation response (`NSHTTPURLResponse.statusCode`). |
| Android (WebView) | Native HTTP errors are reported through `onReceivedHttpError`; successful loads use Navigation Timing's `responseStatus` when available, with a `200` fallback. |
On Android, use `ok` to detect errors. WebView versions that omit
`responseStatus` cannot distinguish successful status codes such as 200 and 204.
## reload, goBack, goForward [#reload-goback-goforward]
```kotlin
suspend fun reload(waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null): NavigationResult
suspend fun goBack(waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null): NavigationResult?
suspend fun goForward(waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null): NavigationResult?
```
They take the same parameters as `goto`: `waitUntil` and `timeoutMs` (null uses `TimeoutConfig.navigation`).
`goBack` and `goForward` return `null` when there is no history entry in that direction. Each backend's native history (`WebView.canGoBackOrForward`, `WKWebView.canGoBack`/`canGoForward`, CDP `Page.getNavigationHistory`) is checked first, so the `null` is immediate in the common case. When history can't be inspected, KMPwright starts the traversal and returns `null` if nothing changes within 750 ms.
Traversals between entries of the same document (`pushState`, hash changes) return `NavigationResult(url, 200, true)` once `location.href` changes.
```kotlin
page.goto("https://example.com/a")
page.goto("https://example.com/b")
page.goBack() // back on /a
page.goForward() // back on /b
check(page.goForward() == null) // nothing ahead
```
## pushState [#pushstate]
```kotlin
suspend fun pushState(url: String, timeoutMs: Long? = null): NavigationResult
```
Client-side navigation without loading a new document (agent-browser `pushstate`). `url` is resolved against the current URL. If the page has a Next.js router (`window.next.router`), KMPwright calls `router.push(url)`; otherwise it calls `history.pushState` and dispatches `popstate` and `navigate` events so client routers react. It returns once `location.href` equals the target, with `NavigationResult(target, 200, true)`.
`pushState` needs `KmpwrightConfig.allowEval` and throws `KmpwrightException` when the target isn't allowed by `allowedOriginRules`. It throws `KmpwrightTimeoutException` when the URL doesn't change in time (for example when a Next.js router cancels the navigation).
## setContent [#setcontent]
```kotlin
suspend fun setContent(html: String, waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null)
```
Replaces the document with `html` and waits for `waitUntil`; `timeoutMs` null uses `TimeoutConfig.navigation`. Throws `KmpwrightException` when the content can't be loaded.
```kotlin
page.setContent("""Click """)
page.getByRole("button").click()
expect(page.getByRole("button")).toHaveText("Clicked")
```
Use `page.content()` to read the current HTML.
## Waiting [#waiting]
| Function | Waits for | Default timeout | On timeout |
|---|---|---|---|
| `waitForURL(url)` | `location.href` to match, then `waitUntil` | `navigation` | Throws `KmpwrightTimeoutException` |
| `waitForLoadState(state)` | The document to reach `state` | `navigation` | Throws `KmpwrightTimeoutException` |
| `waitForNavigation(url, waitUntil, timeoutMs)` | The next committed navigation | `navigation` | Returns `null` |
| `waitForFunction(expression)` | `expression` to be truthy | `default` | Throws `KmpwrightTimeoutException` |
| `waitForText(text)` | `text` in the page's visible text | `default` | Throws `KmpwrightTimeoutException` |
| `waitForSelector(selector, state)` | An element to reach `state` | `waitForSelector` | Throws `KmpwrightTimeoutException` |
| `waitForEvent(event, timeoutMs, predicate)` | A page event by name | `default` | Returns `null` |
| `waitForTimeout(ms)` | `ms` milliseconds | none | — |
[`waitForRequest` and `waitForResponse`](/docs/guides/network) and `waitForDownload`/`waitForFileChooser` follow the same pattern; [`waitForDialog`](/docs/guides/dialogs-and-frames#waiting-for-a-dialog) is covered with dialogs.
### waitForURL [#waitforurl]
```kotlin
suspend fun waitForURL(url: String, waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null)
suspend fun waitForURL(url: Regex, waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null)
suspend fun waitForURL(waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null, predicate: (String) -> Boolean)
```
- **String** is a Playwright URL glob matched against the whole URL: `*` matches anything except `/`, `**` matches anything, `{a,b}` either alternative, and every other character (including `?`) is literal. Without glob characters it must equal the URL exactly.
- **Regex** is searched in the URL (`containsMatchIn`).
- **Predicate** receives the URL string.
The URL is read from the live `location.href`, so `pushState`, hash changes and client-side routing count. It returns immediately when the current URL already matches.
```kotlin
page.waitForURL("https://example.com/orders/*")
page.waitForURL(Regex("/orders/\\d+$"))
page.waitForURL { it.endsWith("#done") }
```
Playwright: same name and glob rules; Playwright's `waitUntil` also accepts `commit`, which KMPwright doesn't have.
### waitForLoadState [#waitforloadstate]
```kotlin
suspend fun waitForLoadState(state: LoadState = LoadState.Load, timeoutMs: Long? = null)
```
Waits for the requested state; document readiness checks return immediately if already satisfied:
| `LoadState` | Reached when |
|---|---|
| `DOMContentLoaded` | `document.readyState` is `interactive` or `complete`. |
| `Load` | `document.readyState` is `complete`. |
| `NetworkIdle` | No request the backend tracks has been in flight for 500 ms. |
On WebViews, `NetworkIdle` only sees requests KMPwright can observe; see the `networkHistory` [capability](/docs/platforms/capabilities) (`Partial` on Android and iOS).
On JVM, browser favicon requests and long-lived EventSource streams do not block
network idle. A new document also stops waiting for the replaced document's
requests; those requests remain in network history until they complete. Prefer
an element, text or predicate wait when checking that your application is ready.
### waitForNavigation [#waitfornavigation]
```kotlin
suspend fun waitForNavigation(url: String? = null, waitUntil: LoadState? = null, timeoutMs: Long? = null): NavigationResult?
```
Waits for the next navigation that finishes after the call starts; it returns `null` on timeout instead of throwing. New-document loads (including failed ones, returned with `ok = false`) and same-document navigations (`pushState`, `replaceState`, hash changes) both count, as in Playwright. A navigation that has already finished is never seen, so prefer the [action-scoped form](#action-scoped-waits).
### waitForFunction [#waitforfunction]
```kotlin
suspend fun waitForFunction(expression: String, timeoutMs: Long? = null): JsResult
```
Polls `expression` (evaluated once per poll) until its value is truthy and returns that value as a [`JsResult`](/docs/reference/page). A returned Promise is awaited and a returned function is called, as in Playwright. An exception thrown by the expression fails the wait immediately. Requires `KmpwrightConfig.allowEval`.
```kotlin
page.waitForFunction("window.__APP_READY__ === true")
val heading = page.waitForFunction("document.querySelector('h1') && document.querySelector('h1').textContent")
println(heading.value) // the heading text
```
### waitForText [#waitfortext]
```kotlin
suspend fun waitForText(text: String, timeoutMs: Long? = null)
```
Waits until `document.body.innerText` contains `text` (case-sensitive substring, main document only). agent-browser: `wait --text`.
### waitForSelector [#waitforselector]
```kotlin
suspend fun Page.waitForSelector(selector: String, state: SelectorState = SelectorState.Visible, timeoutMs: Long? = null)
```
Same as `page.locator(selector).waitFor(state, timeoutMs)`. `SelectorState` is `Attached`, `Detached`, `Visible` or `Hidden`. It returns `Unit`; Playwright returns an element handle. Use a [locator](/docs/guides/locators) for the element afterwards.
### waitForEvent [#waitforevent]
```kotlin
suspend fun waitForEvent(event: String, timeoutMs: Long? = null, predicate: ((Any?) -> Boolean)? = null): Any?
```
Untyped wait by event name: `console`, `navigation`, `dialog`, `request`, `response`, `download`, `filechooser`, `domcontentloaded`, `load` or `networkidle` (case-insensitive). `predicate` filters events (any event when null) and can be a trailing lambda; `timeoutMs` null uses `TimeoutConfig.default`. Returns `null` on timeout; throws `KmpwrightException` for an unknown name. Prefer the typed waits or [`page.events`](#page-events).
### waitForTimeout [#waitfortimeout]
`waitForTimeout(ms)` is `delay(ms)`. Use it only for debugging; every other wait is event- or condition-based.
## Action-scoped waits [#action-scoped-waits]
```kotlin
val nav = page.waitForNavigation {
getByRole("link", name = "Next").click()
}
val response = page.waitForResponse("**/api/items") {
getByRole("button", name = "Load more").click()
}
```
These overloads take the triggering action as a trailing lambda with `Page` as receiver. KMPwright starts listening before it runs the action, then returns the result. This is the Kotlin form of Playwright's `Promise.all([page.waitForX(), action()])`.
Calling a wait after the action has a race: if the navigation or response completes before the wait subscribes, the wait never sees it and times out. The action-scoped form can't miss it.
| Function | Returns |
|---|---|
| `waitForNavigation(url, waitUntil, timeoutMs) { … }` | `NavigationResult`; throws `KmpwrightTimeoutException` instead of returning `null` |
| `waitForURL(url: String or Regex, waitUntil, timeoutMs) { … }`, `waitForURL(predicate, waitUntil, timeoutMs) { … }` | `Unit` |
| `waitForRequest(url: String or Regex, timeoutMs) { … }` | `Request` |
| `waitForResponse(url: String or Regex, timeoutMs) { … }` | `Response` |
| `waitForDownload(url, timeoutMs) { … }` | `Download` |
| `waitForFileChooser(timeoutMs) { … }` | `FileChooser` |
| `waitForDialog(timeoutMs) { … }` | `DialogEvent` |
If the action throws, the wait is cancelled and the exception propagates.
## URL and title [#url-and-title]
```kotlin
val url: StateFlow
val title: StateFlow
```
`page.url.value` is the URL of the last committed navigation, or `null` before the first one. `page.title.value` is the document title read when that navigation completed. Both update after `goto`, `reload`, history navigation, `setContent` and `pushState`, and after navigations the page starts itself (link clicks, form posts, redirects).
They are not live reads of the document: a page that changes `document.title` later, or routes with its own `history.pushState`, doesn't update them. For those cases read the document:
```kotlin
val liveUrl = page.evaluate("location.href").value
expect(page).toHaveURL(Regex("/settings$")) // reads location.href on every attempt
```
Collect the flows to react to navigations:
```kotlin
scope.launch {
page.url.filterNotNull().collect { println("Now at $it") }
}
```
## Page events [#page-events]
`page.events` exposes hot `SharedFlow`s, the Kotlin form of Playwright's `page.on(...)`.
| Flow | Type | Emits |
|---|---|---|
| `console` | `SharedFlow` | Console messages (`type`, `text`, `location`). |
| `navigation` | `SharedFlow` | Every finished navigation, including failed ones (`status` 0). |
| `loadState` | `SharedFlow` | `DOMContentLoaded` and `Load` after each successful navigation; `NetworkIdle` whenever tracked requests drop to zero. |
| `dialog` | `SharedFlow` | JavaScript dialogs; see [Dialogs and frames](/docs/guides/dialogs-and-frames). |
| `request` | `SharedFlow` | Requests issued by the page. |
| `response` | `SharedFlow` | Responses; a failed request is a `Response` with `status` 0 and the error in `statusText`. |
| `download` | `SharedFlow` | Downloads (JVM only; see the `downloads` capability). |
| `fileChooser` | `SharedFlow` | Native file choosers (JVM only, after `setFileChooserInterception()`). |
`NavigationEvent` has `url: String`, `status: Int` and `navigationType: NavigationType`, which is `NewDocument` for a document load, or `SameDocument` for `pushState` and history traversals within one document.
The flows don't replay: subscribe before triggering the action, or use an [action-scoped wait](#action-scoped-waits). Emission never suspends; if a collector falls behind, the oldest buffered events are dropped.
```kotlin
val job = scope.launch {
page.events.console.collect { println("[${it.type}] ${it.text}") }
}
page.goto("https://example.com")
job.cancel()
```
`DOMContentLoaded` and `Load` are emitted together when the backend reports a finished load, not at the browser's own `DOMContentLoaded` moment. To check the document's real state, use `waitForLoadState`, which reads `document.readyState`.
## Related [#related]
- [Locators](/docs/guides/locators) and [actions](/docs/guides/actions): actions wait for actionability themselves.
- [Assertions](/docs/guides/assertions): `expect(page).toHaveURL` and `toHaveTitle`.
- [Network](/docs/guides/network): `waitForRequest`, `waitForResponse`, routing, `setAllowedDomains`.
- [Capabilities](/docs/platforms/capabilities)
- [Errors](/docs/reference/errors)
# Network (/docs/guides/network)
```kotlin
// Mock an API before the page calls it.
page.route("**/api/user") { route ->
route.fulfill(json = """{"name": "Ada"}""")
}
// Block images.
page.route(Regex("""\.(png|jpe?g|webp)$""")) { route -> route.abort() }
val response = page.waitForResponse("**/api/user") {
page.goto("https://example.com/profile")
}
```
The trailing lambda runs the action after the wait is subscribed, so the response cannot be missed (see [Waiting for requests and responses](#waiting-for-requests-and-responses)).
Routing works on every backend, but each engine exposes a different part of the network to it. Read [Coverage per backend](#coverage-per-backend) before relying on a route for anything other than `fetch`/XHR.
## Routing [#routing]
```kotlin
suspend fun route(url: String, handler: suspend (Route) -> Unit)
suspend fun route(url: Regex, handler: suspend (Route) -> Unit)
suspend fun unroute(url: String)
suspend fun unroute(url: Regex)
suspend fun unrouteAll()
```
`route` returns once interception is active, so requests issued after it returns are routed (Playwright: `await page.route(...)`). On the JVM this waits for `Fetch.enable`.
The handler receives a `Route` and makes one decision:
| Method | Effect |
|---|---|
| `route.fulfill(status = 200, headers = emptyMap(), body = null, bodyBytes = null, contentType = null, json = null)` | Answer without contacting the network. |
| `route.abort(errorCode = "failed")` | Fail the request with a network error. |
| `route.resume(url = null, method = null, headers = null, postData = null)` | Send the request to the network, optionally overriding parts of it. Playwright JS `route.continue()`; `continue` is a Kotlin keyword. |
| `route.fallback()` | Pass the request to the next older matching route, or to the network. |
`route.request` is the intercepted [`Request`](#request-and-response).
A handler that returns without calling any method falls back. Calling a second method on the same `Route` throws `KmpwrightException("Route is already handled")`.
### Fulfill [#fulfill]
- `json` sets the body; it wins over `body` and `bodyBytes`.
- `bodyBytes` wins over `body` for binary payloads.
- `contentType` defaults to `application/json` when you don't pass it. Pass `contentType = "text/html"` (or the type you serve) for anything that isn't JSON.
- `headers` are sent as response headers. If they also contain `Content-Type`, which one wins depends on the backend, so set the type through `contentType` only.
- Android cannot fulfill with a redirect status (3xx) or a status outside 100..599; such a fulfill aborts the request.
```kotlin
page.route("https://example.com/") { route ->
route.fulfill(body = "Offline copy ", contentType = "text/html")
}
```
### Abort [#abort]
`errorCode` is a network error name. On the JVM it is mapped to a CDP `Network.ErrorReason`: `"failed"`, `"aborted"`, `"timedout"`, `"accessdenied"`, `"blockedbyclient"` and `"blockedbyresponse"` (any case), or an exact CDP name such as `"ConnectionRefused"` or `"NameNotResolved"`; anything else becomes `Failed`. On Android and iOS the request fails as a generic network error and `errorCode` is only reported in events and history.
### Resume with overrides [#resume-with-overrides]
```kotlin
page.route("**/api/**") { route ->
route.resume(headers = route.request.headers + ("x-test-run" to "42"))
}
```
`headers` replaces the request headers; it does not merge. Copy `route.request.headers` as above to add one. `postData` replaces the body.
How overrides are applied differs per backend; see [Coverage per backend](#coverage-per-backend).
### URL matching [#url-matching]
A `String` pattern is a glob matched against the whole URL:
| Glob | Matches |
|---|---|
| `*` | Any characters except `/` |
| `**` | Any characters, including `/` |
| `?` | Exactly one character |
| anything else | Itself, literally |
A string without wildcards must equal the full URL (`"https://example.com/api"` does not match `https://example.com/api?x=1`). Use `**` at either end to match a part: `"**/api/user*"`.
A `Regex` pattern matches when it is found anywhere in the URL (like `Regex.containsMatchIn`). Anchor it with `^` and `$` for a full match.
Playwright: the same `*`/`**` rules. Differences: `?` is a single-character wildcard here (Playwright matches it literally), `{a,b}` alternation is not supported (braces are literal), and there is no `baseURL` resolution. Use a `Regex` for anything more complex.
### Order and fallback [#order-and-fallback]
```kotlin
page.route("**/*") { route ->
if (route.request.method == "POST") route.abort() else route.fallback()
}
page.route("**/api/**") { route ->
// Registered last, so it runs first.
if (route.request.url.endsWith("/health")) route.fulfill(body = "ok", contentType = "text/plain")
// Returning without a decision falls back to the "**/*" route above.
}
```
- The most recently registered matching route runs first (Playwright order).
- `fallback()`, or returning without a decision, hands the request to the next older matching route, and finally to the network.
- Registering the same pattern again replaces its handler and makes it the newest route. Patterns are compared as written: `"**/api/**"` and `Regex("""/api/""")` are different routes.
- `unroute(url)` removes the route registered with exactly that pattern; `unrouteAll()` removes every route.
- Routes are copied to tabs opened with `page.openNewTab()`.
### Handler time limits and errors [#handler-time-limits-and-errors]
Handlers run while the request is paused, so they are bounded:
| Backend | Time limit | On timeout | On exception |
|---|---|---|---|
| Android | 10 s | Aborted (`timedout`) | Aborted (`failed`) |
| iOS | 5 s | Aborted (`timedout`) | Aborted (`failed`) |
| JVM (CDP) | 5 s | Aborted (`TimedOut`) | Aborted (`Failed`) |
A failing handler never lets the request through silently. The failure is logged, not thrown to your code.
On iOS the in-page bridge also gives up after 10 seconds without an answer from native code and rejects the `fetch`/XHR.
## Coverage per backend [#coverage-per-backend]
`PageCapability.RouteInterception` is `Supported` on the JVM and `Partial` on Android and iOS.
| | Android WebView | iOS WKWebView | JVM (CDP) |
|---|---|---|---|
| Mechanism | `WebViewClient.shouldInterceptRequest` | A document-start script that wraps `fetch` and `XMLHttpRequest` and asks native code over a script-message bridge; optional custom URL scheme | `Fetch` domain (`Fetch.enable` with pattern `*` on the page and its child frame sessions) |
| Requests that reach routes | What WebView passes to `shouldInterceptRequest`: documents, subresources, `fetch`, XHR | `fetch` and XHR from the main frame (same origin as the page); document and subresource loads only through the custom scheme (below) | Every HTTP(S) request of the page and its frames |
| `route.request.postData` | Always `null` (WebView does not expose request bodies) | The `fetch`/XHR body | The request body as CDP reports it |
| `resume()` without overrides | WebView loads the request itself | The original `fetch`/XHR runs | `Fetch.continueRequest` |
| `resume()` with overrides | KMPwright re-issues the request with `HttpURLConnection` (http/https only, cookies from `CookieManager`, final 3xx is an error, connect 15 s / read 30 s timeouts). Without `postData` a POST is re-sent without its body. | The `fetch`/XHR runs with the new URL, method, headers and body | `Fetch.continueRequest` with the overrides |
| Responses in events/history | Only for fulfilled, resumed-with-overrides and failed requests; other requests finish without a status | Status and headers of bridged `fetch`/XHR and scheme requests | All |
The `notes` of `page.capabilities()` repeat the important limits:
- Android: "Fulfill and abort are supported; continue overrides are applied only for requests WebView exposes to shouldInterceptRequest."
- iOS: "WKWebView routing covers fetch, XHR, and optional custom-scheme interception, but full browser-network parity remains partial."
### iOS custom-scheme interception [#ios-custom-scheme-interception]
WKWebView never shows its own `http`/`https` document requests to apps. To route document and subresource loads on iOS, configure a custom scheme and load your pages through it:
```kotlin
val config = KmpwrightConfig(
iosSchemeInterception = IosSchemeInterceptionConfig(scheme = "kmpwright", fallbackScheme = "https")
)
// page.goto("kmpwright://example.com/") is fetched natively as https://example.com/
```
Requests to `kmpwright://…` are rewritten to `fallbackScheme://…`, offered to your routes (with the rewritten URL), and fetched with `NSURLSession` when they fall through. The scheme handler can only be registered when KMPwright creates the `WKWebView`; it is ignored (with a log message) for a host-provided web view.
## Waiting for requests and responses [#waiting-for-requests-and-responses]
```kotlin
val request = page.waitForRequest("**/api/orders")
val response = page.waitForResponse(Regex("""/api/orders/\d+$"""))
val created = page.waitForResponse { it.url.endsWith("/api/orders") && it.status == 201 }
```
| Overload | Matches |
|---|---|
| `waitForRequest(url: String, timeoutMs: Long? = null): Request` | Glob when `url` contains `*` or `?`, substring otherwise; a `regex:` prefix makes it a regular expression |
| `waitForRequest(url: Regex, timeoutMs: Long? = null): Request` | Regex found anywhere in the URL |
| `waitForRequest(timeoutMs: Long? = null, predicate: (Request) -> Boolean): Request` | Your predicate |
`waitForResponse` has the same three overloads and returns `Response`. `timeoutMs = null` uses `TimeoutConfig.default`; on timeout they throw `KmpwrightTimeoutException`.
These functions only see requests issued after they start waiting: the event streams are hot and do not replay. Prefer the action-scoped extensions, which subscribe first and then run your action (the Kotlin form of Playwright's `Promise.all([page.waitForResponse(...), action()])`):
```kotlin
val response = page.waitForResponse("**/api/items") { click("#load") }
val request = page.waitForRequest(Regex("/api/items")) { click("#load") }
```
They exist for `String` and `Regex` patterns: `waitForRequest(url, timeoutMs = null) { … }` and `waitForResponse(url, timeoutMs = null) { … }`. The lambda's receiver is the `Page`. If the action throws, the wait is cancelled and the exception propagates.
`waitForResponse` does not resolve for failed requests. Failures are emitted on `page.events.response` with `status = 0` and the error in `statusText`.
Playwright: same names, returning `Request`/`Response` objects. Difference: a string without wildcards is a substring match here, not an exact URL.
### Events [#events]
`page.events.request` and `page.events.response` are `SharedFlow` and `SharedFlow`, the Kotlin form of `page.on("request")` and `page.on("response")`:
```kotlin
val job = launch {
page.events.response.collect { if (!it.ok) println("${it.status} ${it.url}") }
}
```
What they report is limited by the same per-backend coverage as routes.
## Request and response [#request-and-response]
```kotlin
data class Request(
val url: String,
val method: String = "GET",
val headers: Map = emptyMap(),
val postData: String? = null,
val resourceType: String = "other",
val timestamp: Long, // epoch milliseconds
val id: String // backend request id
)
data class Response(
val url: String,
val status: Int,
val statusText: String? = null,
val headers: Map = emptyMap(),
val fromCache: Boolean = false,
val timestamp: Long,
val id: String // id of the matching Request
) { val ok: Boolean /* status in 200..299 */ }
```
Both are serializable data classes. A response delivered by a page also carries
an internal body reader: use `response.body()`, `response.text()` or
`response.json()` on the JVM. Copies and deserialized responses have no reader;
use [`page.network.responseBody`](#request-history) while their record is retained.
- `resourceType` is the lowercase Chromium type on CDP. Android reports `document` for the main frame and `other` for subresources; iOS reports `fetch`/`xhr` for shim requests, `document` for intercepted main documents and `other` otherwise.
- `request.postDataJSON()` parses JSON or URL-encoded form data from `postData`, returning `null` when there is no body.
- `fromCache` is reported on the JVM (disk cache or service worker).
- `Response.id` equals the `id` of its `Request`, which is how you correlate them and how you find a request in the history.
## Request history [#request-history]
`page.network` keeps a bounded history of the page's requests from the moment the page is attached, across navigations (agent-browser `network requests`).
```kotlin
page.goto("https://example.com")
val apiCalls = page.network.requests(filter = "/api/", limit = 20)
for (record in apiCalls) {
println("#${record.id} ${record.request.method} ${record.request.url} -> ${record.response?.status} ${record.state}")
}
val record = apiCalls.first { it.state == NetworkRequestState.Finished }
val body = page.network.responseBody(record.id)
println(if (body.base64Encoded) "binary, ${body.content.length} base64 chars" else body.content)
```
| Member | Description |
|---|---|
| `requests(filter: String? = null, limit: Int = 100): List` | Newest first. `filter` keeps records whose URL contains it (case-insensitive) or whose method equals it. `limit` must be 1..500. |
| `detail(id: Long): NetworkRequestRecord?` | One record, or `null` when it is no longer retained. |
| `suspend responseBody(id: Long, maxBytes: Int = 1_048_576): NetworkResponseBody` | Waits for the request to finish, then reads its body. `maxBytes` must be 1..4 MiB. |
| `suspend responseBody(response: Response, maxBytes: Int = 1_048_576): NetworkResponseBody` | Finds the retained request for this response, then reads its body. |
| `clear()` | Forgets every record. |
| `suspend startHar()` / `suspend stopHar(): String` | [HAR capture](#har). |
`NetworkRequestRecord(id: Long, request: Request, response: Response?, state: NetworkRequestState, error: String?, finishedAt: Long?)`. `id` is a per-page counter starting at 1; each redirect hop gets its own record. `state` is `Pending`, `Finished` or `Failed`.
`NetworkResponseBody(content: String, base64Encoded: Boolean, truncated: Boolean)`: text bodies are returned as UTF-8 text, binary bodies as base64; `truncated` is set when the body was longer than `maxBytes`.
Retention: at most 500 records and an estimated 4 MiB; the oldest records are dropped first. URLs are kept up to 8 KiB, post data up to 64 KiB and headers up to 32 KiB per request. The history is cleared when the page closes.
Headers can arrive before the response body finishes downloading. Body reads wait
for completion, then read the bytes within one `TimeoutConfig.default` budget.
Closing the page, a failed request, clearing history or evicting the record ends
a pending read with `KmpwrightException`. Deadline expiry throws
`KmpwrightTimeoutException`.
Reads also fail when a finished record has no response or represents an earlier
redirect hop (CDP reuses request IDs; only the last hop has a body). They require
`KmpwrightConfig.allowDomExtraction` and check the action policy as `ActionType.Evaluate`.
| | Android | iOS | JVM (CDP) |
|---|---|---|---|
| `PageCapability.NetworkHistory` | `Partial` | `Partial` | `Partial` |
| Records | Requests seen by `shouldInterceptRequest`; most have no `response` | Main-frame `fetch`/XHR and custom-scheme requests | Every request of the page and its frames |
| `responseBody` | `KmpwrightUnsupportedException` | `KmpwrightUnsupportedException` | `Network.getResponseBody`; bodies larger than one protocol message are refused |
Read a response directly after an action-scoped wait:
```kotlin
val response = page.waitForResponse("**/api/orders") {
getByRole("button", name = "Refresh").click()
}
val json = response.json()
```
## HAR [#har]
```kotlin
page.network.startHar()
page.goto("https://example.com")
page.getByRole("button", name = "Load more").click()
val har: String = page.network.stopHar()
// JVM: java.io.File("session.har").writeText(har)
```
`stopHar()` returns the archive as a HAR 1.2 JSON string (`{"log": {"version": "1.2", "creator": {"name": "kmpwright", …}, "entries": […]}}`). Nothing is written to disk. This is agent-browser `network har start|stop`; Playwright's `recordHar` context option and `routeFromHAR` replay have no equivalent.
- `startHar()` while a capture is running keeps the running capture. `stopHar()` without a capture returns an empty log.
- Entries are created when a response arrives, including failed requests (status 0, error in `statusText`).
- Timings are coarse: `wait` and `receive` from event times; `blocked`, `dns`, `connect` and `ssl` are `-1`.
- Response bodies are fetched for finished, non-redirect, non-failed responses: up to 1 MiB of text (or the base64 equivalent), at most 32 fetches at a time, 1.5 s each. `stopHar` waits up to 2 s for pending fetches. A truncated body is marked with the custom field `"_kmpwrightTruncated": true`.
- At most 500 entries and 16 MiB; the oldest entries are dropped first.
- `startHar` requires `KmpwrightConfig.allowDomExtraction` and passes the action policy as `ActionType.Evaluate`.
`PageCapability.HarRecording` is `Supported` on the JVM and `Partial` on Android and iOS. On Android and iOS the archive contains metadata only (no bodies, since `responseBody` is unavailable), and only for the requests listed in the history table above. On Android that means pass-through requests, which never get a response status, produce no entries.
## Extra headers and HTTP credentials [#extra-headers-and-http-credentials]
```kotlin
page.setExtraHTTPHeaders(mapOf("x-test-run" to "42"))
page.setHTTPCredentials(HttpCredentials(username = "admin", password = "secret"))
page.goto("https://staging.example.com")
page.setHTTPCredentials(null) // clear
```
`setExtraHTTPHeaders(headers)` sends the headers with every request of the page. It works on the JVM only (`Network.setExtraHTTPHeaders`). On Android and iOS it throws `KmpwrightException`: neither WebView offers a native way to add headers to every subrequest, and KMPwright refuses to inject them through page JavaScript. Passing an empty map is allowed everywhere.
`setHTTPCredentials(credentials)` answers HTTP authentication. The credentials are bound to one HTTP(S) origin: the page's origin when you call it. On Android and iOS, calling it before the first navigation binds them to the origin of the first navigation. On the JVM, calling it while the page has no HTTP(S) URL (for example on `about:blank`) binds nothing, so navigate first. Credentials are never sent to other origins.
| Backend | How credentials are used |
|---|---|
| Android | `Authorization: Basic` on KMPwright-initiated navigations to the bound origin, and `onReceivedHttpAuthRequest` for the bound host (one attempt per host and realm) |
| iOS | `Authorization: Basic` on KMPwright-initiated navigations to the bound origin |
| JVM (CDP) | `Authorization: Basic` added through `Fetch` interception to every request to the bound origin |
`HttpCredentials.toString()` masks the password. Both calls pass the action policy as `ActionType.Network`, and both are copied to tabs opened with `openNewTab()`.
Playwright: `setExtraHTTPHeaders` is the same; `httpCredentials` is a context option there, `setHTTPCredentials(HttpCredentials?)` here.
## Allowed domains [#allowed-domains]
```kotlin
page.setAllowedDomains(listOf("example.com", "*.example-cdn.com"))
page.setAllowedDomains(emptyList()) // remove the restriction
```
`setAllowedDomains` restricts the hosts the page may contact (agent-browser `--allowed-domains`). `"*.example.com"` matches `example.com` and every subdomain. Requests to other hosts fail as network errors. The list also applies to `page.read(url)`.
Enforcement differs per backend (`PageCapability.DomainFiltering`: `Supported` on the JVM and iOS, `Partial` on Android). Android cannot filter server redirects of subresources, WebSockets or service-worker requests; the JVM does not filter WebSocket handshakes. See [Security](/docs/security) for the full model.
## File uploads [#file-uploads]
On JVM Chromium, use a file-input locator or handle a file chooser:
```kotlin
page.locator("input[type=file]").setInputFiles(listOf("/absolute/path/report.csv"))
```
With `launchBrowser`, Chromium and the JVM share the local filesystem. With an explicit CDP endpoint or a cloud provider, uploads are rejected by default: a file on the JVM machine is not automatically available to that browser.
If both processes intentionally share the same absolute paths, set `CdpConfig(remoteBrowserSharesFileSystem = true)`. This option applies to locator uploads and `FileChooser.setFiles`; it does not transfer files. Clearing a selection with an empty list does not require filesystem access.
Non-empty file uploads are unavailable in native WebViews. Check `PageCapability.FileChooser` and the [capability matrix](/docs/platforms/capabilities) before requesting an upload.
## Downloads [#downloads]
```kotlin
page.setDownloadPath("/tmp/downloads")
val download = page.waitForDownload("**/*.csv") { getByText("Export CSV").click() }
println(download.suggestedFilename)
download.saveAs("/tmp/report.csv")
```
Downloads are reported on the JVM only (`PageCapability.Downloads`). On Android and iOS, `waitForDownload`, `setDownloadPath` and `setDownloadBehavior` throw `KmpwrightUnsupportedException`.
- `waitForDownload(url: String? = null, timeoutMs: Long? = null): Download` waits for the next download, optionally one whose URL matches `url` (same string rules as `waitForRequest`). The action-scoped overload `waitForDownload(url, timeoutMs) { … }` runs the click after subscribing.
- `Download.url` and `Download.suggestedFilename` are available immediately. `path()` waits for the download to finish and returns the saved file, or `null` when it was not saved to disk. `saveAs(path)` copies it and throws `KmpwrightException` when nothing was saved.
- Files are only saved when a download path is set with `setDownloadPath`. `setDownloadBehavior(DownloadBehavior.SaveToDownloadPath | Allow | Deny)` controls whether downloads are saved, allowed or denied.
## Related [#related]
- [Capabilities](/docs/platforms/capabilities): support levels and notes per backend.
- [Security](/docs/security): allowed domains, action policies and `allowDomExtraction`.
- [Emulation](/docs/guides/emulation): `setOffline` and `emulateNetworkConditions`.
- [Page reference](/docs/reference/page)
# Reading, vitals, React and WebMCP (/docs/guides/reading)
```kotlin
// Markdown first: Accept negotiation, then .md, then llms.txt, then HTML converted to text.
val docs = page.read("https://kotlinlang.org/docs/coroutines-overview.html", outline = true)
println(docs.source) // e.g. "html-outline" or "markdown-outline"
println(docs.content)
// Without a browser at all:
val reader = HttpReader()
val llms = reader.read("https://example.com/docs", llms = LlmsReadMode.Index)
```
This page covers four page tools that come from agent-browser and have no Playwright equivalent:
| Tool | Access | agent-browser |
|---|---|---|
| HTTP reading | `page.read(url, …)`, `HttpReader(config).read(url, …)` | `read` |
| Web Vitals | `page.webVitals(url, reload, settleMs, timeoutMs)` | `vitals` |
| React inspection | `page.react` (`ReactInspector`) | `react` |
| WebMCP tools | `page.webMcp` (`WebMcp`) | `webmcp` |
## Reading pages over HTTP [#reading-pages-over-http]
`page.read(url)` fetches `url` with its own HTTP client (Ktor: OkHttp on Android and JVM, Darwin on iOS), not the browser. It doesn't render JavaScript and doesn't send the browser's cookies. `page.read()` with no URL converts the page's current HTML instead (see [Reading the current page](#reading-the-current-page)).
`HttpReader` does the same thing without a page:
```kotlin
val reader = HttpReader(KmpwrightConfig()) // config.allowedOriginRules and allowDomExtraction apply
val result: ReadResult = reader.read("https://example.com")
```
A URL without a scheme gets `https://`. Only `http` and `https` URLs are accepted, and URLs with embedded credentials are rejected. The fragment is dropped and dot segments are normalized before any policy check.
### How content is chosen [#how-content-is-chosen]
The reader asks for Markdown first: by default it sends `Accept: text/markdown, text/plain;q=0.9, text/html;q=0.7, */*;q=0.1` and `User-Agent: Kmpwright read`.
1. **Primary response.** If it's a 2xx response with a Markdown media type (`text/markdown`, `text/x-markdown`, `application/markdown`), or `raw = true`, return it.
2. **Markdown URL.** Otherwise, unless `raw` is set or you passed your own `Accept` header, try `.md` (`/index.md` for the root). Use it if it's 2xx Markdown, or `text/plain` when `requireMarkdown` is false.
3. **llms.txt link.** Look for `llms.txt` in the URL's directory and each parent up to the root. If a list item there links to this page (same origin and path ignoring `.md`, `.html` and `/index`, or a unique last-segment or slugified-title match), fetch and return that link.
4. **Fallback.** Return the primary response. If it is HTML, it is converted to Markdown-like text. A non-2xx primary response throws `KmpwrightException("HTTP read failed with status …")`.
HTML conversion parses the document with Ksoup, removes `script`, `style`, `noscript`, `svg` and `template`, and writes headings as `#`, lists as `- `, links as `[text](absolute-url)`, `pre` as fenced code, `code`, `strong` and `em` inline, images as their `alt` text, and table cells separated by `|`.
### Parameters [#parameters]
`page.read` and `HttpReader.read` take the same named parameters after `url` (`url` is optional on `page.read` and required on `HttpReader.read`).
', description: 'Extra request headers. Duplicate names (case-insensitive) are rejected. Setting Accept disables the .md and llms.txt fallbacks.', default: 'emptyMap()' },
allowedDomains: { type: 'Set', description: 'Host allowlist checked on every request and redirect hop. "*.example.com" matches the apex and subdomains. Empty allows all.', default: 'emptySet()' },
maxBytes: { type: 'Int', description: 'Body budget per response, 1 byte to 16 MiB. Longer bodies are cut and truncated = true.', default: '2 MiB' },
caCertificatePath: { type: 'String?', description: 'A PEM file of extra CA certificates to trust, in addition to the system roots (or instead of them with useSystemCa = false). Works on JVM, Android and iOS.', default: 'null' },
useSystemCa: { type: 'Boolean', description: 'Trust the system CA store. false requires caCertificatePath.', default: 'true' },
timeoutMs: { type: 'Long?', description: 'Timeout for the whole read, including redirects and fallbacks. Must be positive. null uses TimeoutConfig.default of the page or reader config.', default: 'null' },
}}
/>
### Result [#result]
| Field | Type | Meaning |
|---|---|---|
| `url` | `String` | Normalized request URL. |
| `finalUrl` | `String` | URL of the response that was used, after redirects and fallbacks. |
| `status` | `Int?` | HTTP status; null for the current-page read. |
| `contentType` | `String` | Response `Content-Type`. |
| `source` | `String` | How the content was produced (see below). |
| `truncated` | `Boolean` | The body exceeded `maxBytes`. |
| `content` | `String` | The text. |
| `headers` | `Map` | Response headers, lower-case names. |
`source` is one of `markdown`, `html`, `text`, `raw`, `markdown-url`, `llms-link`, `llms-index`, `llms-full`, `active-tab-html` or `active-tab-raw`. With `outline` it gets an `-outline` suffix, and with `filter` (without outline) a `-filtered` suffix. llms modes take no suffix.
### llms.txt modes [#llmstxt-modes]
```kotlin
page.read("https://example.com/docs/guide", llms = LlmsReadMode.Index, filter = "auth")
page.read("https://example.com/docs", llms = LlmsReadMode.Full, filter = "Webhooks")
```
The reader starts in the URL's directory and walks up to the root. It uses the first file found that is 2xx and not HTML, and throws if none is found.
- `Index` reads `llms.txt` and returns its list links as `- [title](absolute-url)` under a `Source:` line. With `filter`, only links whose title or URL contains it are kept. If nothing matches, the content is `No matching llms.txt links`. A file with no list links and no `filter` is returned as is.
- `Full` reads `llms-full.txt`. `filter` keeps the matching sections. `outline` is ignored in both modes.
### Redirects, domains and limits [#redirects-domains-and-limits]
- Redirects (301, 302, 303, 307, 308) are followed by KMPwright, up to 10. Each hop is checked before the request is made. A loop throws.
- Your custom headers (API keys, for example) are only sent to the original origin. `Accept` follows redirects.
- Every request URL, including redirects and fallbacks, must pass all of these:
- `KmpwrightConfig.allowedOriginRules`, if set.
- The `allowedDomains` parameter.
- For `page.read`: the page's `setAllowedDomains` list, and the page's [action policy](/docs/security) for `ActionType.Navigate`.
- Responses are decoded with the charset from `Content-Type`, defaulting to UTF-8.
- HTML conversion stops with an error past 1,000,000 nodes or 4 MiB of output.
- Errors: `KmpwrightTimeoutException` on timeout; `KmpwrightException` for disallowed hosts, bad URLs, bad charsets, too many redirects, or when `KmpwrightConfig.allowDomExtraction` is false.
`PageCapability.HttpRead` is `Supported` on every backend.
### Reading the current page [#reading-the-current-page]
```kotlin
val visible = page.read() // current DOM as text, source = "active-tab-html"
val html = page.read(raw = true) // source = "active-tab-raw"
```
With no URL, `read` takes `page.content()` and runs the same HTML conversion, `outline` and `filter`. `maxBytes` still applies. `llms` and `requireMarkdown` need a URL and throw here.
## Web Vitals [#web-vitals]
```kotlin
val vitals = page.webVitals() // reloads the current page, waits 3 s
println("LCP ${vitals.lcp?.startTime} ms, CLS ${vitals.cls.score}, TTFB ${vitals.ttfb} ms")
vitals.metrics["LCP"]?.rating // "good" | "needs-improvement" | "poor"
page.webVitals(url = "https://example.com", settleMs = 5_000)
page.webVitals(reload = false) // measure the already-loaded document
```
KMPwright injects the bundled [web-vitals](https://github.com/GoogleChrome/web-vitals) library (unmodified 6.2.3). By default it reloads the page so the collector runs from document start, waits `settleMs`, and reads the report. The collector is removed when it finishes.
= 0.', default: '3_000' },
timeoutMs: { type: 'Long?', description: 'Timeout for navigation, settle time and read combined. null uses TimeoutConfig.default.', default: 'null' },
}}
/>
`WebVitalsReport`:
| Field | Type | Meaning |
|---|---|---|
| `url` | `String` | Measured document. |
| `metrics` | `Map` | Keys `LCP`, `CLS`, `INP`, `FCP`, `TTFB` when reported; each has `value`, `rating`, `id` and `delta`. |
| `ttfb`, `fcp`, `inp` | `Double?` | Milliseconds. INP needs an interaction during the settle time. |
| `lcp` | `LargestContentfulPaint?` | `startTime`, `size`, `element` (tag name) and `url` (image URL). |
| `cls` | `LayoutShiftReport` | `score` and up to 100 `entries` (`value`, `startTime`). |
| `hydration` | `PerformancePhase?` | Span of React's "Hydrated" scheduler phases, when the page's React reports performance tracks. |
| `phases`, `hydratedComponents` | `List` | React scheduler phases and hydrated component spans (`label`, `startTime`, `endTime`, `duration`). Components are sorted longest first. |
| `supportedEntryTypes` | `Set` | `PerformanceObserver.supportedEntryTypes` of the engine. Use it to tell "not supported" from "not reported". |
| `droppedTimingEntries` | `Int` | Timing entries dropped past the 3,000-entry cap. |
`PageCapability.WebVitals` is `Supported` on JVM and `Partial` on Android and iOS. WebKit doesn't report every entry type, so check `supportedEntryTypes`. Reload and `url` measurements need document-start scripts (`PageCapability.InitScripts`), which are `Partial` on Android. Throws `KmpwrightTimeoutException` on timeout and `KmpwrightException` when `allowEval` is false.
## React inspection [#react-inspection]
`page.react` embeds React DevTools (react-devtools-inline 8.0.0 with a private React 19.3.0 runtime) and reads the component tree of the page's own React app.
```kotlin
page.react.enable(reload = true) // install the DevTools hook, then reload so React finds it
val tree: List = page.react.tree()
val app = tree.first { it.name == "CheckoutForm" }
println(page.react.inspect(app.id).text) // props, state and hooks as text
page.react.suspense().filter { it.isSuspended }.forEach { println("${it.name} waits on ${it.suspendedBy}") }
page.react.startRenderProfile()
page.getByRole("button", name = "Add to cart").click()
val profile = page.react.stopRenderProfile()
profile.components.take(5).forEach { println("${it.name}: ${it.count} renders, ${it.selfTime} ms") }
page.react.disable()
```
| Method | Returns | Notes |
|---|---|---|
| `enable(reload: Boolean = false)` | `Unit` | Registers the hook as a document-start script and installs it in the current document. React only finds a hook that exists before it starts, so use `reload = true` for an app that's already mounted. |
| `disable()` | `Unit` | Removes the hook and stops a running profile. |
| `tree(timeoutMs: Long? = null)` | `List` | `id`, `type` (DevTools element type code), `name`, `key` and `parent` id. |
| `inspect(id: Long, timeoutMs: Long? = null)` | `ReactInspection` | `text` (props, state, hooks) and `source` when available. `id` must be positive. |
| `suspense(timeoutMs: Long? = null)` | `List` | Boundaries with `isSuspended`, `suspendedBy`, `owners`, `environments` and `jsxSource`. |
| `startRenderProfile()` | `Unit` | Throws unless `enable()` ran first, or if a profile is already running. |
| `stopRenderProfile()` | `ReactRenderProfile` | `elapsed`, `fps` (`avg`, `min`, `max`, `drops`), totals, and per-component `count`, `mounts`, `reRenders`, `totalTime`, `selfTime`, `domMutations` and `changes` (why it re-rendered). |
A null `timeoutMs` uses `TimeoutConfig.default`. `PageCapability.ReactInspection` is `Supported` on every backend, but `enable` relies on document-start scripts, which are `Partial` on Android. Reads need `allowEval` and `allowDomExtraction`, and every call is checked against `ActionType.Evaluate`.
## WebMCP [#webmcp]
[WebMCP](https://github.com/webmachinelearning/webmcp) lets a page register tools for agents. `page.webMcp` lists them and calls them through Chromium's experimental `WebMCP` DevTools domain.
```kotlin
val tools = page.webMcp.discover()
tools.forEach { println("${it.name}: ${it.description}") }
val call = page.webMcp.invoke(
name = "searchFlights",
input = buildJsonObject { put("from", "SFO"); put("to", "JFK") }
)
if (call.status == WebMcpInvocationStatus.Completed) println(call.output)
```
| Method | Notes |
|---|---|
| `discover(): List` | Tools registered by the page and its frames: `name`, `description`, `inputSchema`, `annotations`, `origin`, `frameId`, `backendNodeId`. |
| `context(): WebMcpContext` | Not `suspend`. Bounded summary: `observed`, `toolCount`, `tools`, `truncated`, and `experimental = true`, `untrusted = true` flags. |
| `invoke(name, input = JsonObject(emptyMap()), frameId = null, timeoutMs = null, waitForResult = true)` | Returns a `WebMcpInvocation`. With `waitForResult = false` it returns while the call is `Pending`. |
| `invocation(id)` | Not `suspend`. Current state of an invocation. |
| `awaitInvocation(id, timeoutMs = null)` | Waits for a pending invocation. |
| `cancelInvocation(id)` | Cancels it. |
`WebMcpInvocation` has `id`, `toolName`, `frameId`, `origin`, `status` (`Pending`, `Completed`, `Canceled`, `Failed`, `TimedOut`), `output`, `error`, `outputTruncated`, `originalOutputBytes`, `durationMs` and `rawStatus`.
Tool names, descriptions and outputs come from the page. Treat them as untrusted input.
| Backend | `webMcp` capability | Behavior |
|---|---|---|
| JVM (CDP) | Partial | Needs a Chromium that exposes the `WebMCP` domain. `launchBrowser(executablePath, webMcp = true)` (the default) enables the `WebMCPTesting` and `DevToolsWebMCPSupport` features. Iframe sessions are capped at 128. |
| Android, iOS | Unsupported | Every call throws `KmpwrightException`, except `context()`, which returns `observed = false`. |
`discover` and `context` need `allowDomExtraction`, and `invoke` needs `allowEval`.
## Related [#related]
- [Security](/docs/security): allowed domains, origin rules, action policies
- [Capabilities](/docs/platforms/capabilities)
- [JVM platform](/docs/platforms/jvm): `launchBrowser`
- [Page reference](/docs/reference/page)
# Recording, tracing and profiling (/docs/guides/recording)
```kotlin
page.requireCapabilities(PageCapability.VideoRecording, PageCapability.Tracing)
page.tracing.start(path = "build/trace.json")
page.video.start("build/checkout.mp4", contactSheetPath = "build/checkout.png")
page.goto("https://example.com/shop")
page.getByRole("button", name = "Add to cart").click()
val video = page.video.stop()
val trace = page.tracing.stop()
println("${video.path}: ${video.frameCount} frames, ${video.durationMs} ms, ${video.width}x${video.height}")
```
Everything on this page uses Chrome DevTools Protocol domains (`Tracing`, `Profiler`, `Page.startScreencast`), so it works only on the JVM backend.
## Backend support [#backend-support]
| Capability | API | Android | iOS | JVM (CDP) |
|---|---|---|---|---|
| `PageCapability.Tracing` | `page.tracing` | Unsupported | Unsupported | Supported |
| `PageCapability.Profiling` | `page.profiler` | Unsupported | Unsupported | Supported |
| `PageCapability.VideoRecording` | `page.video` | Unsupported | Unsupported | Supported |
| `PageCapability.Screencast` | `page.screencast()` | Unsupported | Unsupported | Supported |
On Android and iOS every call throws `KmpwrightUnsupportedException` (for example "Tracing is unavailable on this backend"). `page.screencast()` throws when you call it, before you collect the flow.
To fail early in shared code, check capabilities first:
```kotlin
if (page.capabilities().supports(PageCapability.VideoRecording)) {
page.video.start("run.mp4")
}
// or throw up front:
page.requireCapabilities(PageCapability.Tracing)
```
See [Capabilities](/docs/platforms/capabilities).
## Video [#video]
```kotlin
page.video.start(
path = "build/login.webm",
fps = 24,
cursor = true,
contactSheetPath = "build/login-sheet.png",
)
// ... drive the page ...
val result = page.video.stop()
result.contactFrames.forEach { println("frame ${it.index} at ${it.timestampMs} ms") }
```
`page.video.start(path, ...)` records the page to a video file until `page.video.stop()` (agent-browser `record`). Recording is video only, without audio.
Encoding needs an `ffmpeg` executable on the JVM host. `start` runs `ffmpeg -version` and a short test encode first, and throws `KmpwrightException("ffmpeg is required before recording: …")` when it is missing. Point `ffmpegExecutable` at a full path if `ffmpeg` is not on `PATH`.
Out-of-range values make `start` throw `IllegalArgumentException`.
`page.video.stop()` returns:
```kotlin
data class RecordingResult(
val path: String, // absolute output path
val fps: Int,
val frameCount: Long,
val durationMs: Long, // frameCount * 1000 / fps
val width: Int, // first frame size, rounded up to even numbers
val height: Int,
val contactSheetPath: String? = null,
val contactFrames: List = emptyList() // (timestampMs, index) of each sheet frame
)
```
How it works:
- Frames come from `Page.startScreencast` on a separate CDP session, so you can stream a [screencast](#screencast) at the same time.
- Chromium only sends frames when the page changes. The encoder writes frames at a fixed `fps`, repeating the latest frame, so the video plays in real time. Frames are letterboxed onto a canvas the size of the first frame.
- The video is encoded into a temporary file next to the output and moved into place on `stop()`, so a failed recording never leaves a partial file at `path`.
- `start` returns once the first frame is encoded (up to 10 s).
- The cursor is drawn from trusted mouse pointer events, whether they come from KMPwright's CDP mouse input or a person using a headed browser. It is drawn into the video only, never into the page or the contact sheet.
- Contact sheet: each frame is reduced to 640 px wide and kept when at least `contactSheetThreshold` of its pixels changed noticeably since the last kept frame; the last frame is always included. Kept frames are laid out four per row and labeled with their time and frame index. When the limit is reached, newer key frames replace the last slot.
Errors:
| Situation | Exception |
|---|---|
| `start` while recording | `IllegalStateException("Video recording is already active")` |
| `stop` without a recording | `KmpwrightException("Video recording is not active")` |
| Output exists and `overwrite = false` | `KmpwrightException("Output already exists: …")` |
| No frame was captured | `KmpwrightException("Recording captured no frames")` |
| The encoder falls more than 500 ms behind, or blocks a frame for 2 s | `KmpwrightException` from `stop()` |
Closing the page stops an active recording. Recording passes the action policy as `ActionType.Screenshot`.
Playwright: video is a context option (`recordVideo`) there and is saved when the page closes; here you start and stop it explicitly on the page.
## Screencast [#screencast]
Experimental. The frame stream may change.
```kotlin
page.screencast(maxFps = 10, quality = 70).collect { frame ->
// frame.bytes is a JPEG
preview.update(frame.bytes)
}
```
`page.screencast(maxFps = 30, quality = 80, maxWidth = 1920, maxHeight = 1080): Flow` streams JPEG frames of the page (agent-browser `screencast`). The flow is cold: each collector attaches its own CDP session and starts `Page.startScreencast`; cancelling the collector stops it.
The flow is conflated: when your collector is slower than the page, intermediate frames are dropped and you always get the latest one. Chromium only emits frames when something changes, so a static page produces few frames; `maxFps` limits delivery and never adds duplicate frames.
`ScreencastFrame` fields: `bytes` (JPEG), `timestampMs`, `viewportWidth`, `viewportHeight` (the device size Chromium reports in the frame metadata), `pageScaleFactor`, `scrollOffsetX`, `scrollOffsetY`, `mimeType` (`"image/jpeg"`).
The flow fails with `KmpwrightException` when the page crashes, its session detaches, the CDP connection closes, or a frame exceeds 16 MiB. Collection passes the action policy as `ActionType.Screenshot`.
## Tracing [#tracing]
```kotlin
page.tracing.start(screenshots = true, path = "build/trace.json")
page.goto("https://example.com")
val json: ByteArray? = page.tracing.stop()
```
`page.tracing` records a Chrome performance trace with the CDP `Tracing` domain. The result is Chrome trace-event JSON, which you can load in the Performance panel of Chrome DevTools or in Perfetto.
Playwright: `context.tracing` produces a Playwright trace (a zip for the Trace Viewer). KMPwright's `tracing` is the Chromium performance trace, like Puppeteer's `page.tracing`.
- `stop()` returns the trace JSON, or `null` when no trace was active.
- Tracing belongs to the browser connection: only one page per `Browser` can trace at a time. A second `start` throws `KmpwrightException("Tracing is already owned by another page on this browser")`.
- With `KmpwrightConfig.enableTraceExport = false`, `start` with a `path` throws `KmpwrightException`; tracing without a path still works.
## CPU profiling [#cpu-profiling]
```kotlin
page.profiler.start()
page.getByRole("button", name = "Run report").click()
val profile: String? = page.profiler.stop()
// JVM: java.io.File("report.cpuprofile").writeText(profile!!)
```
`page.profiler` runs the V8 sampling profiler of the page (agent-browser `profiler`) through the CDP `Profiler` domain. `stop()` returns the `Profiler.Profile` object as JSON, which DevTools can open as a `.cpuprofile` file, or `null` when the profiler was not running.
## Related [#related]
- [Screenshots and PDFs](/docs/guides/screenshots): single images work on every backend.
- [Capabilities](/docs/platforms/capabilities)
- [Page reference](/docs/reference/page)
# Screenshots and PDFs (/docs/guides/screenshots)
```kotlin
page.goto("https://example.com")
val viewport = page.screenshot()
val full = page.screenshot(fullPage = true)
val heading = page.locator("h1").screenshot()
println("${full.width}x${full.height} px, ${full.bytes.size} bytes")
// JVM: java.io.File("full.png").writeBytes(full.bytes)
```
`page.screenshot(...)` and `locator.screenshot(...)` are `suspend` functions that return a `ScreenshotResult`. The library never writes files: you get the encoded image as `bytes` and store it with your platform's file API.
Playwright: same name, and the options are named parameters (`page.screenshot({ fullPage: true })` is `page.screenshot(fullPage = true)`). Differences: there is no `path` parameter, the result is a `ScreenshotResult` instead of a buffer, and `type` is called `format`. `mask`, `animations`, `caret` and `style` do not exist.
## Parameters [#parameters]
Every parameter of `page.screenshot` has a default.
`Rectangle(x, y, width, height)` uses `Double` CSS pixels.
Errors: setting both `fullPage` and `clip` throws `IllegalArgumentException` ("fullPage and clip are mutually exclusive"). A capture that takes longer than the timeout throws `KmpwrightTimeoutException`. Every other failure of `page.screenshot`, including a `scale` that is not finite and positive or a `quality` outside `0..100`, throws `KmpwrightException` (non-KMPwright exceptions are wrapped as `"Screenshot failed: …"`).
Screenshots go through the page's [action policy](/docs/security) as `ActionType.Screenshot`.
## The result [#the-result]
```kotlin
data class ScreenshotResult(
val bytes: ByteArray, // encoded image in the requested format (PNG by default)
val width: Int, // pixel width of the encoded image
val height: Int, // pixel height of the encoded image
val annotations: List = emptyList(),
val truncated: Boolean = false // explicitly bounded document capture only
)
```
`width` and `height` are the real pixel dimensions of the image, which include the device pixel ratio and `scale`. They are not CSS pixels:
| Backend | Pixel size of a viewport capture |
|---|---|
| Android | WebView size in device pixels × `scale` |
| iOS | Web view size in points × screen scale × `scale` |
| JVM (CDP) | Decoded from the image Chromium returns |
`ScreenshotResult.toRgbaImage(): RgbaImage?` decodes the bytes into straight (non-premultiplied) RGBA pixels, or returns `null` when the platform cannot decode the format. Use it for [visual diffs](#visual-diffs) or your own image processing. On the JVM it uses `javax.imageio`, which does not decode WEBP by default.
## Annotated screenshots [#annotated-screenshots]
```kotlin
val shot = page.screenshot(annotate = true)
for (a in shot.annotations) {
println("${a.ref} ${a.role} \"${a.name}\" at ${a.box}")
}
// @e1 link "Learn more" at Rectangle(x=..., y=..., width=..., height=...)
page.locator(shot.annotations.first().ref).click()
```
`annotate = true` is agent-browser's `screenshot --annotate`. KMPwright takes an accessibility [snapshot](/docs/agents/snapshots), draws a red label with each ref (`e1`, `e2`, …) over the top-left corner of its element, captures the image, then removes the labels. Every visible ref is returned as an `Annotation(ref, role, name, box)`; `box` is the element's bounding box in viewport CSS pixels at capture time. Because a new snapshot is taken, the refs from the annotated screenshot replace refs from earlier snapshots.
Annotation works on every backend and combines with `fullPage`. `locator.screenshot` has no `annotate` parameter.
From an agent: `page.runCommand("screenshot --annotate")` returns the image in `result.screenshot` and a legend (`@e1 link: Learn more`) in `result.output`. `screenshot --full` (or `-f`) captures the full page.
## Element screenshots [#element-screenshots]
```kotlin
val card = page.getByTestId("pricing-card").screenshot()
```
`locator.screenshot(omitBackground = false, quality = 90, format = ScreenshotFormat.Png, scale = 1.0, timeoutMs = null)` scrolls the element into view, reads its bounding box and captures that rectangle as a clip through the same path as `page.screenshot`, so `format`, `quality` and `omitBackground` behave identically. It has no `fullPage`, `clip` or `annotate`. It throws `KmpwrightException` when no element matches.
## Clips [#clips]
```kotlin
val banner = page.screenshot(clip = Rectangle(0.0, 0.0, 800.0, 200.0))
```
On every backend `clip` is in CSS pixels relative to the top-left of the viewport, as in Playwright. A clip that extends past the viewport is trimmed to it; a clip entirely outside the viewport throws `KmpwrightException` ("Clipped area is either empty or outside the viewport").
`fullPage` and `clip` cannot be combined: setting both throws `IllegalArgumentException` on every backend, as in Playwright.
## How full-page capture works [#how-full-page-capture-works]
Each backend uses the native mechanism of its engine:
| Backend | Mechanism | Size limit |
|---|---|---|
| Android | Scroll and stitch. KMPwright measures the document (`scrollWidth`/`scrollHeight`), hides the WebView's scrollbars, scrolls the window tile by tile with `window.scrollTo`, captures each tile and draws it into one bitmap, then restores the original scroll position. | 128 MiB of ARGB bitmap (width × height × 4 bytes) per capture; larger captures throw `KmpwrightException` suggesting a smaller `scale` or a clip. |
| iOS | One `WKWebView.takeSnapshot` for the document CSS rectangle converted to native points. The original scroll position is restored. | 32 megapixels (after screen scale); larger captures throw `KmpwrightException`. |
| JVM (CDP) | `Page.captureScreenshot` with `captureBeyondViewport: true`. With `scale` other than 1.0 the clip is the document size from `Page.getLayoutMetrics` (`cssContentSize`). | No KMPwright limit. |
### Android tile capture and stable frames [#android-tile-capture-and-stable-frames]
Each Android tile is read from the composited window with `PixelCopy` (API 26+, WebView attached to an `Activity` window). Chromium can composite a frame before all of its tiles are rasterized, which shows up as blank, background-colored areas. To avoid that, KMPwright waits for a WebView visual-state callback plus two vsync frames, copies again, and accepts the frame once two consecutive copies are identical at an unchanged scroll position. It tries up to 10 copies, then uses the latest one and logs a warning.
When `PixelCopy` is unavailable (API below 26, WebView not attached to a window, or the copy fails or times out after 500 ms), KMPwright falls back to `WebView.draw` into a bitmap.
Because the page really scrolls, scroll listeners run during a full-page capture, and `position: fixed` or `sticky` elements are captured in every tile they appear in.
### Scrollbars on the JVM [#scrollbars-on-the-jvm]
With `CdpConfig.hideScrollbars = true` (the default) KMPwright hides native scrollbars with an injected style for the duration of each capture, so images are stable across platforms.
## Per-backend summary [#per-backend-summary]
| | Android | iOS | JVM (CDP) |
|---|---|---|---|
| Formats | PNG, JPEG, WEBP | PNG, JPEG | PNG, JPEG, WEBP |
| `omitBackground` | throws | throws | `Emulation.setDefaultBackgroundColorOverride`, reset after the capture |
| `quality` | JPEG and WEBP | JPEG | JPEG and WEBP |
| `scale` | clamped to 0.1..5.0 | any positive value | sent as the clip `scale` |
| Full page | scroll and stitch | single snapshot | `captureBeyondViewport` |
| Annotate | yes | yes | yes |
## Capture an app-owned native WebView [#capture-an-app-owned-native-webview]
Android and iOS expose `captureWebViewDocument` for an existing `WebView` or `WKWebView`. It does not navigate, replace delegates, or take ownership of the view. Prepare the page and settle lazy content before calling it; the `Page` action policy is not applied by this low-level API.
```kotlin
val shot = captureWebViewDocument(
webView,
clip = Rectangle(0.0, 6000.0, 100.0, 1800.0),
pixelsPerCssPixel = 2.0,
maxPixels = 7_000_000,
maxHeight = 10_000,
)
// The complete selected region is 200 × 3600 output pixels.
```
The clip uses **document CSS coordinates**, including scroll offsets. This differs from a viewport clip passed to `page.screenshot`. Native element screenshots also capture the entire bounding box, including content taller than the viewport.
Selected regions are complete or fail: an out-of-bounds or oversized selection never becomes a partial successful image. For a bounded whole-page preview, omit `clip` and set `truncateFullPage = true`; `ScreenshotResult.truncated` then reports whether the height or pixel budget shortened it. `viewportWidthOnly = true` limits an unselected document to the visible CSS width.
`pixelsPerCssPixel` controls the output independently of device density. CSS bounds are converted using the actual WebView scale, including overview layouts. Android tiles the region; iOS snapshots the converted native rectangle. Both restore the original scroll position on success, failure, or cancellation. Android also restores its temporary root transform and scrollbar settings. A tile is rejected if the Android scroll position changes while its frame is being acquired.
An optional `afterFirstTile` suspending callback runs after the first viewport row on Android, allowing an app to hide pinned headers before subsequent rows. iOS takes one native snapshot and invokes the callback afterward. This hook does not settle the page or prevent a page's own animations.
## PDFs [#pdfs]
```kotlin
val pdf = page.pdf(format = PdfFormat.A4, margin = PdfMargin(top = "2cm", bottom = "2cm"))
// JVM: java.io.File("page.pdf").writeBytes(pdf.bytes)
```
`page.pdf(...): PdfResult` returns the document as `PdfResult.bytes`. Its `timeoutMs` parameter (null uses `TimeoutConfig.default`) limits the call, and it is checked against the action policy as `ActionType.Screenshot`. The defaults work on every backend. `PageCapability.Pdf` is `Partial` on every backend, because each one supports a different subset of the parameters:
Margin values are a number followed by `in`, `cm`, `mm`, `pt` or `px`; a bare number is pixels (96 per inch). Anything else throws `IllegalArgumentException("Invalid PDF margin: …")`. `width` and `height` must be positive.
Playwright: `width`, `height` and margins are strings there; here `width`/`height` are `Double` inches. `path`, `preferCSSPageSize`, `outline` and `tagged` do not exist.
| Backend | How | Supported parameters | Limits |
|---|---|---|---|
| Android | Scrolls through the document, captures viewport tiles and draws them into an `android.graphics.pdf.PdfDocument`, scaled to the printable width. Pages are images, so text is not selectable. | `format` (not `Content`), `width`, `height`, `landscape`, `margin`. Anything else throws `KmpwrightUnsupportedException`. | Document up to 16 megapixels, at most 128 pages, output at most 32 MiB (each throws `KmpwrightException`). |
| iOS | `WKWebView.createPDF` with the default configuration: one unpaginated page of the whole content. | Only `format` null or `PdfFormat.Content` with zero margins and every other parameter at its default. Anything else throws `KmpwrightUnsupportedException`. | None in KMPwright. |
| JVM (CDP) | `Page.printToPDF`, streamed back in chunks. | All parameters. `scale` outside 0.1..2.0 throws `IllegalArgumentException`. | 512 MiB. |
iOS only renders the content canvas: leave `format` null (or pass `PdfFormat.Content`) and keep margins at zero. An explicit paper size such as `PdfFormat.A4` throws there. `PdfFormat.Content` throws `KmpwrightUnsupportedException` on Android and the JVM.
## Visual diffs [#visual-diffs]
```kotlin
val baseline = page.screenshot()
page.getByRole("button", name = "Dark mode").click()
val current = page.screenshot()
val diff = page.visualDiff(baseline, current, threshold = 0.1)
if (!diff.match) {
println("${diff.differentPixels} of ${diff.totalPixels} pixels differ (${diff.mismatchPercentage}%)")
}
```
`Page.visualDiff(expected, actual, threshold = 0.1, generateDiffImage = true)` decodes both screenshots with `toRgbaImage()` and compares them pixel by pixel. It does not take a new screenshot; the `Page` receiver is only a convenience. `VisualDiff.compareRgba(expected, actual, threshold, generateDiffImage)` does the same for two `RgbaImage`s you already have (for example a baseline loaded from disk).
`DiffResult` fields:
| Field | Meaning |
|---|---|
| `match` | `true` only when no pixel differs. There is no "allowed number of different pixels"; compare `mismatchPercentage` yourself if you need one. |
| `mismatchPercentage` | `differentPixels / totalPixels × 100`. |
| `differentPixels`, `totalPixels` | Pixel counts. |
| `diffImage` | Raw RGBA bytes (not PNG) with the expected image's size: differing pixels are opaque red, the rest is the expected image in dimmed grayscale. `null` when disabled or when the images could not be compared. |
| `dimensionMismatch` | Set when the sizes differ: `DimensionMismatch(expectedWidth, expectedHeight, actualWidth, actualHeight)`. |
When the sizes differ, the result is `match = false`, `mismatchPercentage = 100.0`, `totalPixels = 0` and no diff image. `Page.visualDiff` throws `KmpwrightException` when either image cannot be decoded (for example WEBP on the JVM). Compare screenshots taken with the same backend, viewport and `scale`: pixel sizes include the device pixel ratio.
## Related [#related]
- [Capabilities](/docs/platforms/capabilities): `PageCapability.Pdf` and the per-backend notes.
- [Recording](/docs/guides/recording): video, screencast frames and traces (JVM only).
- [Page reference](/docs/reference/page)
# Storage and sessions (/docs/guides/storage)
```kotlin
// Sign in once, then save the session encrypted.
page.goto("https://app.example.com/login")
page.getByLabel("Email").fill("me@example.com")
page.getByLabel("Password").fill(System.getenv("APP_PASSWORD"))
page.getByRole("button", name = "Sign in").click()
page.waitForURL("**/dashboard")
page.storageState().save("auth/app.json", password = System.getenv("STATE_PASSWORD"))
// Later, in a fresh page: restore it and continue signed in.
val state = StorageState.load("auth/app.json", password = System.getenv("STATE_PASSWORD"))
page.setStorageState(state)
page.goto("https://app.example.com/dashboard")
```
All state APIs live on `Page` and are `suspend`. Playwright keeps cookies and `storageState()` on the `BrowserContext`; KMPwright puts them on the page because Android WebView has no isolated context (see [Isolation per backend](#isolation-per-backend)).
## Cookies [#cookies]
```kotlin
val all = page.cookies() // every cookie the backend can see
val forApp = page.cookies(listOf("https://app.example.com/")) // only cookies sent to these URLs
val session = page.cookies().firstOrNull { it.name == "session" }
page.addCookies(listOf(
Cookie(name = "consent", value = "yes", domain = "example.com", secure = true, sameSite = "Lax")
))
page.clearCookies(name = "consent", domain = "example.com") // matching cookies only
page.clearCookies() // every cookie
```
| Method | Playwright | Behavior |
|---|---|---|
| `cookies(urls: List = emptyList())` | `context.cookies(urls)` | Empty list returns every cookie. With URLs, a cookie is kept when its domain, path and `secure` flag match at least one URL. |
| `addCookies(cookies: List