KMPwright

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): NavigationResult

Navigates 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)

Prop

Type

Failures

goto reports a failed load in the result instead of throwing:

SituationResult
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 setAllowedDomainsReturns ok = false with an explanatory error.
Requested URL not allowed by KmpwrightConfig.allowedOriginRulesThrows KmpwrightException before navigating.
No load within the timeoutThrows 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

Backendstatus
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 ahead

pushState

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

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

FunctionWaits forDefault timeoutOn timeout
waitForURL(url)location.href to match, then waitUntilnavigationThrows KmpwrightTimeoutException
waitForLoadState(state)The document to reach statenavigationThrows KmpwrightTimeoutException
waitForNavigation(url, waitUntil, timeoutMs)The next committed navigationnavigationReturns null
waitForFunction(expression)expression to be truthydefaultThrows KmpwrightTimeoutException
waitForText(text)text in the page's visible textdefaultThrows KmpwrightTimeoutException
waitForSelector(selector, state)An element to reach statewaitForSelectorThrows KmpwrightTimeoutException
waitForEvent(event, timeoutMs, predicate)A page event by namedefaultReturns null
waitForTimeout(ms)ms millisecondsnone—

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:

LoadStateReached when
DOMContentLoadeddocument.readyState is interactive or complete.
Loaddocument.readyState is complete.
NetworkIdleNo 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): JsResult

Polls 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 text

waitForText

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.

FunctionReturns
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 attempt

Collect 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(...).

FlowTypeEmits
consoleSharedFlow<ConsoleMessage>Console messages (type, text, location).
navigationSharedFlow<NavigationEvent>Every finished navigation, including failed ones (status 0).
loadStateSharedFlow<LoadState>DOMContentLoaded and Load after each successful navigation; NetworkIdle whenever tracked requests drop to zero.
dialogSharedFlow<DialogEvent>JavaScript dialogs; see Dialogs and frames.
requestSharedFlow<Request>Requests issued by the page.
responseSharedFlow<Response>Responses; a failed request is a Response with status 0 and the error in statusText.
downloadSharedFlow<Download>Downloads (JVM only; see the downloads capability).
fileChooserSharedFlow<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.

On this page