Navigation and waiting
Load pages with goto, reload and history navigation, read the NavigationResult, and wait for URLs, load states, functions, text, selectors and page events without races.
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: navigation for navigations, waitForURL, waitForLoadState and waitForNavigation; default for most other waits. All defaults are 30 000 ms.
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:
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:
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
suspend fun goto(url: String, waitUntil: LoadState = LoadState.Load, timeoutMs: Long? = null): NavigationResultNavigates the main frame and suspends until the backend reports that the new document finished loading, then waits for waitUntil.
Prop
Type
page.goto("https://example.com", waitUntil = LoadState.NetworkIdle, timeoutMs = 15_000)NavigationResult
Prop
Type
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 | 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
| 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
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.
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 aheadpushState
suspend fun pushState(url: String, timeoutMs: Long? = null): NavigationResultClient-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
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.
page.setContent("""<button onclick="this.textContent='Clicked'">Click</button>""")
page.getByRole("button").click()
expect(page.getByRole("button")).toHaveText("Clicked")Use page.content() to read the current HTML.
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 and waitForDownload/waitForFileChooser follow the same pattern; waitForDialog is covered with dialogs.
waitForURL
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)Prop
Type
- 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.
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
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 (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
suspend fun waitForNavigation(url: String? = null, waitUntil: LoadState? = null, timeoutMs: Long? = null): NavigationResult?Prop
Type
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.
waitForFunction
suspend fun waitForFunction(expression: String, timeoutMs: Long? = null): JsResultPolls expression (evaluated once per poll) until its value is truthy and returns that value as a JsResult. 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.
page.waitForFunction("window.__APP_READY__ === true")
val heading = page.waitForFunction("document.querySelector('h1') && document.querySelector('h1').textContent")
println(heading.value) // the heading textwaitForText
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
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 for the element afterwards.
waitForEvent
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.
waitForTimeout
waitForTimeout(ms) is delay(ms). Use it only for debugging; every other wait is event- or condition-based.
Action-scoped waits
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
val url: StateFlow<String?>
val title: StateFlow<String?>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:
val liveUrl = page.evaluate("location.href").value
expect(page).toHaveURL(Regex("/settings$")) // reads location.href on every attemptCollect the flows to react to navigations:
scope.launch {
page.url.filterNotNull().collect { println("Now at $it") }
}Page events
page.events exposes hot SharedFlows, the Kotlin form of Playwright's page.on(...).
| Flow | Type | Emits |
|---|---|---|
console | SharedFlow<ConsoleMessage> | Console messages (type, text, location). |
navigation | SharedFlow<NavigationEvent> | Every finished navigation, including failed ones (status 0). |
loadState | SharedFlow<LoadState> | DOMContentLoaded and Load after each successful navigation; NetworkIdle whenever tracked requests drop to zero. |
dialog | SharedFlow<DialogEvent> | JavaScript dialogs; see Dialogs and frames. |
request | SharedFlow<Request> | Requests issued by the page. |
response | SharedFlow<Response> | Responses; a failed request is a Response with status 0 and the error in statusText. |
download | SharedFlow<Download> | Downloads (JVM only; see the downloads capability). |
fileChooser | SharedFlow<FileChooser> | 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. Emission never suspends; if a collector falls behind, the oldest buffered events are dropped.
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
- Locators and actions: actions wait for actionability themselves.
- Assertions:
expect(page).toHaveURLandtoHaveTitle. - Network:
waitForRequest,waitForResponse, routing,setAllowedDomains. - Capabilities
- Errors
Assertions
Web-first expect(locator) and expect(page) assertions that retry until they pass or time out with a KmpwrightTimeoutException.
Screenshots and PDFs
Capture the viewport, the full page, a clip or one element with page.screenshot, render PDFs with page.pdf, and compare images pixel by pixel with VisualDiff.