Coming from Playwright
A Playwright (JavaScript) to KMPwright mapping for lifecycle, navigation, locators, assertions, network, storage, emulation, input, dialogs, frames, downloads, screenshots and tracing, plus the differences that matter.
KMPwright uses Playwright's JavaScript names and shapes wherever Kotlin allows. If you know Playwright, most code translates line by line:
// await page.goto('https://example.com');
page.goto("https://example.com")
// await page.getByRole('button', { name: 'Save' }).click();
page.getByRole("button", name = "Save").click()
// await expect(page.getByText('Saved')).toBeVisible();
expect(page.getByText("Saved")).toBeVisible()
// await page.route('**/api/**', r => r.fulfill({ json: { ok: true } }));
page.route("**/api/**") { route -> route.fulfill(json = """{"ok":true}""") }The naming rules, in order:
- Playwright JS names and shapes.
- Playwright Java names where the JS name is a Kotlin keyword:
route.resume()forroute.continue(). - agent-browser names for agent features:
snapshot(),@e3refs,setAllowedDomains,runCommand. - Kotlin idioms only where needed:
suspendinstead of Promises, named parameters instead of option objects, flows instead ofpage.on(...),timeoutMs: Long?,use {}for cleanup.
Playwright option objects become Kotlin named parameters with the same names: page.screenshot({ fullPage: true }) is page.screenshot(fullPage = true), and locator.click({ button: 'right', force: true }) is locator.click(button = MouseButton.Right, force = true). Every timeout becomes timeoutMs: Long? (milliseconds; null uses the configured default), and every delay becomes delayMs.
The tables below list every Playwright API with a KMPwright counterpart. "—" means there is none. Snippets assume page: Page.
Browser, context and page lifecycle
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
chromium.launch({ executablePath, headless, args, proxy }) | launchBrowser(executablePath, headless = true, arguments = …, proxy = ProxyConfig(…)) | JVM only. Returns LaunchedBrowser; use launched.browser. executablePath is required. |
chromium.connectOverCDP(wsEndpoint) | Browser(JvmPlatformContext(), KmpwrightConfig(cdp = CdpConfig(websocketUrl = …))) | JVM only. Also CdpConfig(provider = CloudProvider.…). |
| — | Browser(AndroidPlatformContext(context, webView)), Browser(IOSPlatformContext(webView = …)) | Attach to an app's WebView. |
browser.newContext() | browser.newContext() | Android: throws KmpwrightUnsupportedException. iOS: WKWebsiteDataStore isolation (Partial). |
browser.newPage() / context.newPage() | browser.newPage() / context.newPage() | suspend. |
context.pages() | browser.pages(), context.pages() | |
| — | browser.page() | The attached or default page; synchronous. |
browser.version() | browser.version() | Returns BrowserVersion(product, version, userAgent). |
browser.isConnected() | browser.isConnected.value | StateFlow<Boolean>; also connectionError. |
browser.close() | browser.close() / browser.dispose() | close() suspends until teardown; dispose() is non-suspending, best effort. |
try { … } finally { await browser.close() } | browser.use { browser -> … } | Browser, BrowserContext, Page and LaunchedBrowser close themselves after use {}, even on exceptions or cancellation, like Kotlin's Closeable.use and Ktor's HttpClient.use. For example browser.newPage().use { page -> … }. |
context.close() | context.close() / context.dispose() | |
page.close() | page.close() / page.dispose() | |
context.clearCookies() | context.clearCookies() or page.clearCookies() | |
| — | context.clearData() | Cookies, storage and caches of the context. |
context.on('page'), page.waitForEvent('popup') | — | Use page.openNewTab(url) and browser.pages(). |
| — | browser.targets(), attachTarget(id), activateTarget(id), closeTarget(id), newWindow() | CDP only: tabs not created by KMPwright, and windows. |
page.bringToFront() | browser.activateTarget(id) | CDP only. |
page.setDefaultTimeout(ms), setDefaultNavigationTimeout(ms) | KmpwrightConfig(timeouts = TimeoutConfig(default, navigation, action, waitForSelector)) | Per browser, not per page. |
Navigation and waits
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }) | page.goto(url, waitUntil = LoadState.NetworkIdle, timeoutMs = 10_000) | Returns NavigationResult(url, status, ok, error), not a Response. A failed or refused navigation returns ok = false instead of throwing; a timeout throws KmpwrightTimeoutException. |
page.reload({ waitUntil, timeout }) | page.reload(waitUntil, timeoutMs) | |
page.goBack() / page.goForward() | page.goBack() / page.goForward() | null when there is no history entry, like Playwright. |
waitUntil: 'load' | 'domcontentloaded' | 'networkidle' | LoadState.Load, DOMContentLoaded, NetworkIdle | No 'commit'. |
page.url() | page.url.value | StateFlow<String?>, updated on every committed navigation. |
page.title() | page.title.value | StateFlow<String?> as of the last navigation. For the live title use expect(page).toHaveTitle(...) or page.evaluate("document.title"). |
page.waitForURL(url) | page.waitForURL(url) | String glob (exact URL without wildcards), Regex, or predicate { url -> … }. Also takes waitUntil and timeoutMs. |
Promise.all([page.waitForURL(u), click()]) | page.waitForURL(u) { click("…") } | Action-scoped overload subscribes before the action runs. String URL only. |
page.waitForLoadState(state) | page.waitForLoadState(state, timeoutMs) | Default Load. |
page.waitForTimeout(ms) | page.waitForTimeout(ms) | |
page.waitForNavigation({ url, waitUntil, timeout }) | page.waitForNavigation(url, waitUntil, timeoutMs) | Returns null on timeout. Prefer page.waitForNavigation { … }, which throws on timeout and cannot miss a fast navigation. |
page.waitForFunction(fn, arg, { polling }) | page.waitForFunction(expression, timeoutMs) | A JavaScript expression string; no arg, no polling option. Returns JsResult. |
page.waitForSelector(sel, { state }) | page.waitForSelector(sel, SelectorState.Visible, timeoutMs) | States: Attached, Detached, Visible, Hidden. |
page.waitForEvent(name, { predicate, timeout }) | page.waitForEvent(name, timeoutMs) { event -> … } | Names: console, navigation, dialog, request, response, download, filechooser, domcontentloaded, load, networkidle. Returns null on timeout. |
| — | page.waitForText(text, timeoutMs) | agent-browser wait --text. Throws KmpwrightTimeoutException. |
| — | page.pushState(url) | Client-side navigation through the Next.js router or history.pushState. |
page.setContent(html, { waitUntil }) / page.content() | page.setContent(html, waitUntil = …) / page.content() | |
page.evaluate(fn, arg) | page.evaluate(expression) | Takes an expression string, not a function, and no arg. Promises are awaited. Returns JsResult(value, type): strings verbatim, other values as JSON. |
page.addInitScript(script) | page.addInitScript(script) → id; page.removeInitScript(id) | Runs in later documents and child frames, not the current one. |
page.addScriptTag({ url, content, type }) / addStyleTag({ url, content }) | page.addScriptTag(url, content, type, id, async, defer) / page.addStyleTag(url, content, id) | Return the element id. |
page.on('console'), page.on('pageerror') | page.events.console (flow), page.consoleMessages(), page.pageErrors() | Lists cover messages since the last navigation. |
Locators and getBy*
locator, frameLocator and the getBy* finders are available on Page, Locator and FrameLocator (the shared ElementFinder interface). Text arguments match case-insensitively as substrings unless exact = true.
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.locator(selector) | page.locator(selector) | CSS (pierces open shadow roots), text=, role=, label=, placeholder=, alt=, title=, testid=, xpath=, >> chaining, and snapshot refs @e3 / e3 / ref=e3. |
page.locator(sel, { hasText, has }) | page.locator(sel).filter(hasText = …, has = …) | locator() takes no options. |
getByRole(role, { name, exact, checked, disabled, expanded, selected, pressed, level, includeHidden }) | getByRole(role, name, exact, checked, disabled, expanded, selected, pressed, level, includeHidden) | name may be a Regex (no exact then). |
getByText(text, { exact }) | getByText(text, exact) / getByText(Regex) | |
getByLabel | getByLabel(text, exact) / getByLabel(Regex) | <label>, aria-label, aria-labelledby. |
getByPlaceholder | getByPlaceholder(text, exact) / getByPlaceholder(Regex) | |
getByAltText | getByAltText(text, exact) / getByAltText(Regex) | |
getByTitle | getByTitle(text, exact) / getByTitle(Regex) | |
getByTestId | getByTestId(String) / getByTestId(Regex) | data-testid; not configurable. |
locator.locator(sel) | locator.locator(sel) | |
locator.filter({ hasText, hasNotText, has, hasNot }) | filter(hasText, hasNotText, has, hasNot) | String or Regex text variants; has/hasNot take a Locator. |
locator.and(other) / locator.or(other) | and(other) / or(other) | |
first() / last() / nth(i) | first() / last() / nth(i) | nth accepts negative indices from the end. |
locator.all() | locator.all() | |
page.frameLocator(sel) / locator.frameLocator(sel) | same | |
locator.contentFrame(), frameLocator.owner() | — |
Locator actions and queries
Actions wait for the element to be attached, visible, enabled, stable and hit-testable (fill: editable) and retry until timeoutMs (default TimeoutConfig.action). force = true skips the checks.
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
click({ button: 'right', clickCount, delay, position, force, timeout }) | click(button = MouseButton.Right, clickCount, delayMs, position, force, timeoutMs) | No modifiers, trial or noWaitAfter. MouseButton.Left, Middle, Right. |
dblclick({ button, delay, position, force, timeout }) | dblclick(button, delayMs, position, force, timeoutMs) | |
tap({ force, timeout }) | tap(force, modifiers, timeoutMs) | modifiers is a CDP bit mask. |
fill(value, { force, timeout }) | fill(value, force, timeoutMs) | input, textarea, select, [contenteditable]. |
clear() | clear() | Like fill(""). |
press(key, { delay }) | press(key, delayMs = …) | "Enter", "Control+A". |
pressSequentially(text, { delay: 50 }) | pressSequentially(text, delayMs = 50) | |
type(text) (deprecated) | page.type(selector, text, delayMs) | Page-level convenience only. |
hover({ force }) | hover(force = true) | |
focus() / blur() | focus(timeoutMs) / blur(timeoutMs) | |
check() / uncheck() / setChecked(b) | same, with force and timeoutMs | |
selectOption(values) | selectOption(value) / selectOption(values: List<String>) | Matches option value, then label. No { index } or { label } objects. Empty list deselects. |
setInputFiles(files) | setInputFiles(paths: List<String>) | File paths only (no in-memory buffers). Empty list clears. Unsupported on mobile WebViews. |
dragTo(target, { sourcePosition, targetPosition }) | dragTo(target, sourcePosition = Position(…), targetPosition = Position(…)) | |
scrollIntoViewIfNeeded() | scrollIntoViewIfNeeded(block, inline, timeoutMs) | ScrollPosition alignment, default Center / Nearest. |
dispatchEvent(type, eventInit) | dispatchEvent(type, timeoutMs) | No eventInit. |
evaluate(fn) | evaluate("el => …") | A JavaScript function string that receives the element. Runs synchronously; a returned Promise is not awaited. |
evaluateAll(fn), evaluateHandle, elementHandle() | — | No handles. |
highlight() | highlight(color, borderColor, durationMs) | |
screenshot({ omitBackground, quality, type, scale }) | screenshot(omitBackground, quality, format, scale, timeoutMs) | |
boundingBox() | boundingBox() | Rectangle? in CSS pixels. |
waitFor({ state }) | waitFor(SelectorState.Visible, timeoutMs) | |
textContent() / innerText() / innerHTML() | same | |
getAttribute(name) / inputValue() | same | |
count() | count() | No waiting. |
allTextContents() / allInnerTexts() | same | |
isVisible() / isHidden() / isEnabled() / isDisabled() / isChecked() / isEditable() | same | No waiting. |
| — | isFocused() | |
selectText(), ariaSnapshot() | — | For an accessibility tree use page.snapshot(). |
page.click(sel), page.fill(sel, v), … | page.click(sel), page.fill(sel, v), … | Selector conveniences: click, dblclick, tap, fill, clear, type, press, hover, focus, highlight, check, uncheck, setChecked, selectOption, setInputFiles, dragAndDrop, dispatchEvent, textContent, innerText, innerHTML, getAttribute, inputValue, isVisible, isHidden, isEnabled, isDisabled, isChecked, isEditable, count. |
Assertions
expect(locator) returns LocatorAssertions; expect(page) returns PageAssertions. Each assertion re-reads until it holds or timeoutMs (default TimeoutConfig.waitForSelector) passes, then throws KmpwrightTimeoutException. Use .not to negate.
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
toBeVisible() / toBeHidden() | same | |
toBeAttached() | toBeAttached() | No { attached: false }; use .not.toBeAttached(). |
toBeEnabled() / toBeDisabled() | same | |
toBeChecked() | toBeChecked() | No { checked: false }; use .not. |
toBeFocused() / toBeEditable() / toBeEmpty() | same | |
toHaveText(text) | toHaveText(String) / toHaveText(Regex) | Whitespace-normalised innerText; full match for strings. No array form. |
toContainText(text) | toContainText(String) / toContainText(Regex) | |
toHaveValue(value) | toHaveValue(String) / toHaveValue(Regex) | |
toHaveAttribute(name, value?) | toHaveAttribute(name), (name, String), (name, Regex) | |
toHaveClass(cls) | toHaveClass(String) / toHaveClass(Regex) | String compares the whole normalised class attribute. |
toHaveCount(n) | toHaveCount(n) | |
expect(page).toHaveURL(url) | expect(page).toHaveURL(String) / (Regex) / { url -> … } | String is a URL glob; without glob characters it must equal the URL. |
expect(page).toHaveTitle(title) | expect(page).toHaveTitle(String) / (Regex) | |
toHaveCSS, toHaveId, toHaveJSProperty, toBeInViewport, toHaveAccessibleName, toHaveRole, toHaveValues, toHaveScreenshot, toMatchAriaSnapshot, expect.poll, expect.soft | — | Use locator.evaluate(...) with your own check, or page.visualDiff(...) for images. |
Network
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.route(url, handler) | page.route(url) { route -> … } | String glob (* within a path segment, ** across segments) or Regex. suspend: returns once interception is active. Registering the same URL again replaces its handler. |
route.fulfill({ status, headers, body, contentType, json }) | route.fulfill(status, headers, body, bodyBytes, contentType, json) | json is a JSON string. bodyBytes for binary. contentType defaults to application/json, so set it for other bodies. No path or response. |
route.abort(errorCode) | route.abort(errorCode = "failed") | e.g. "aborted", "timedout". |
route.continue({ url, method, headers, postData }) | route.resume(url, method, headers, postData) | Playwright-Java name. |
route.fallback() | route.fallback() | Returning from the handler without deciding also falls back. |
route.request() | route.request | Request(url, method, headers, postData, postDataJSON, resourceType, timestamp, id). |
route.fetch(), page.routeFromHAR(), page.routeWebSocket() | — | |
page.unroute(url) / page.unrouteAll() | page.unroute(url) / page.unrouteAll() | |
page.waitForRequest(urlOrPredicate) | page.waitForRequest(url) / (Regex) / { request -> … } | String is a URL glob; without glob characters it is an exact URL, like Playwright. Returns Request. |
page.waitForResponse(urlOrPredicate) | page.waitForResponse(url) / (Regex) / { response -> … } | Returns Response(url, status, statusText, headers, body, fromCache, …); ok is true for 2xx. |
Promise.all([page.waitForResponse(u), click()]) | page.waitForResponse(u) { click("#load") } | Also for waitForRequest. |
page.on('request') / page.on('response') | page.events.request / page.events.response | Hot SharedFlows. A failed request is a response with status 0. |
context.setExtraHTTPHeaders(h) | page.setExtraHTTPHeaders(h) | |
httpCredentials context option | page.setHTTPCredentials(HttpCredentials(user, pass)); null clears | |
recordHar context option | page.network.startHar() / page.network.stopHar() (HAR JSON string) | |
| — | page.network.requests(filter, limit), detail(id), responseBody(id, maxBytes), clear() | Request history (agent-browser network requests). |
Cookies, storage and storage state
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
context.cookies(urls?) | page.cookies(urls) | Empty list returns every cookie. |
context.addCookies(cookies) | page.addCookies(cookies) | Cookie(name, value, domain, path, expires, secure, httpOnly, sameSite, hostOnly); expires in Unix seconds, null for session. |
context.clearCookies({ name, domain }) | page.clearCookies(name, domain) | Both null removes all. |
context.storageState() | page.storageState() | StorageState(url, cookies, localStorage, sessionStorage) for the current origin. |
storageState({ path }) | page.storageState().save(path, password) | password is required; pass null explicitly for plaintext. |
newContext({ storageState }) | page.setStorageState(StorageState.load(path, password)) | Restores cookies, navigates to the state URL when needed, restores storage. |
page.evaluate(() => localStorage…) | page.localStorage.get/set/remove/clear/entries(), page.sessionStorage.… | |
context.grantPermissions(['clipboard-read']) + clipboard | page.clipboard.readText() / writeText(text) | |
| — | page.autosaveStorageState(path, password, intervalMs = 30_000) |
Emulation
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.emulateMedia({ colorScheme }) | page.emulateMedia(colorScheme = ColorScheme.Dark); null resets | Only colorScheme; no media, reducedMotion, forcedColors. Native on CDP and on iOS (light/dark, in a window); JavaScript fallback on Android. |
context.setGeolocation(...) | page.setGeolocation(Geolocation(lat, lon, accuracy)); null resets | CDP grants the permission for you. JavaScript fallback on WebViews. |
context.setOffline(b) | page.setOffline(b) | |
CDP Network.emulateNetworkConditions | page.emulateNetworkConditions(NetworkConditions(...)); presets Slow3G, Fast3G, Offline | WebViews support offline only and throw KmpwrightUnsupportedException for latency or throughput. |
page.setViewportSize({ width, height }) / viewportSize() | page.setViewportSize(w, h) / page.viewportSize() | ViewportSize, never null. |
deviceScaleFactor option | page.setDeviceScaleFactor(scale) | |
userAgent option | page.setUserAgent(ua) or KmpwrightConfig(userAgent = …) | |
devices['iPhone 15'] | page.emulateDevice(Devices.iPhone15) | Devices.all maps names to descriptors. |
locale, timezoneId, permissions options | — |
Input devices
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.keyboard.type(text, { delay: 50 }) | page.keyboard.type(text, delayMs = 50) | |
page.keyboard.press(key, { delay }) | page.keyboard.press(key, delayMs = …) | |
page.keyboard.down(key) / up(key) | same | |
page.keyboard.insertText(text) | same | |
page.mouse.move(x, y, { steps: 5 }) | page.mouse.move(x, y, steps = 5) | |
page.mouse.click(x, y, { button, clickCount, delay }) | page.mouse.click(x, y, button, clickCount, delayMs) | |
page.mouse.dblclick(x, y) | same | |
page.mouse.down({ button, clickCount }) / up({ button }) | down(button, clickCount) / up(button) | |
page.mouse.wheel(dx, dy) | page.mouse.wheel(dx, dy) | |
| — | page.mouse.drag(x1, y1, x2, y2) | |
page.touchscreen.tap(x, y) | page.touchscreen.tap(x, y) | Also longTap, scroll, swipe, pinch. |
Coordinates are viewport CSS pixels. Mouse input is Partial on Android and iOS (PageCapability.MouseInput); CDP uses trusted input events.
Dialogs
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.on('dialog', d => d.accept()) | page.onDialog { DialogResponse.Accept } | Returns a DialogHandler; close() removes it. Newest handler wins. |
dialog.dismiss() / dialog.accept(text) | DialogResponse.Dismiss / DialogResponse.Text(text) | |
dialog.type() / message() / defaultValue() | event.type / event.message / event.defaultPrompt | DialogType.Alert, Confirm, Prompt, BeforeUnload. |
| Auto-dismiss when unhandled | Same | Also dismissed if a handler throws or exceeds TimeoutConfig.handler. |
page.waitForEvent('dialog') | page.waitForDialog { click("…") } | Also acceptDialogs(), dismissDialogs(), acceptDialogsIf { … }. |
Frames
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.frameLocator(sel).getByRole(...) | same | |
page.mainFrame() | page.mainFrame() | Returns a FrameLocator. |
frameLocator.… attributes | frame.name(), frame.url(), frame.boundingBox() | |
page.frames(), page.frame(name), frame.evaluate(...) | — |
CDP resolves same-process and cross-process (out-of-process) frames. Mobile WebViews are limited to same-origin frames.
Downloads and file chooser
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.waitForEvent('download') | page.waitForDownload(url) { click("…") } | Returns Download(url, suggestedFilename). CDP only. |
download.path() / saveAs(path) | download.path() / download.saveAs(path) | path() is null when not saved to disk. |
acceptDownloads option | page.setDownloadBehavior(DownloadBehavior.…), page.setDownloadPath(dir) | SaveToDownloadPath, Allow, Deny. |
page.waitForEvent('filechooser') | page.setFileChooserInterception(true) then page.waitForFileChooser { click("…") } | CDP needs interception first. |
fileChooser.setFiles(files) / isMultiple() | chooser.setFiles(paths) / chooser.isMultiple | Empty list cancels. |
Screenshots and PDF
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
page.screenshot({ fullPage: true }) | page.screenshot(fullPage = true) | Also clip, omitBackground, quality, format (Playwright type), scale, annotate, timeoutMs. Returns ScreenshotResult(bytes, width, height, annotations). No path (write bytes yourself), mask or animations. format: ScreenshotFormat.Png, Jpeg, Webp. |
locator.screenshot() | locator.screenshot() | |
| — | page.screenshot(annotate = true) | Draws snapshot refs on the image (agent-browser screenshot --annotate). |
page.pdf({ format: 'A4', landscape: true }) | page.pdf(format = PdfFormat.A4, landscape = true) | Also displayHeaderFooter, headerTemplate, footerTemplate, printBackground, scale, width, height, margin, pageRanges, timeoutMs. Returns PdfResult(bytes). Partial on every backend; PdfFormat.Content for WKWebView's unpaginated canvas. |
Tracing and video
| Playwright (JS) | KMPwright | Notes |
|---|---|---|
context.tracing.start({ screenshots }) / stop() | page.tracing.start(categories, screenshots, path) / page.tracing.stop() | Chrome trace JSON (ByteArray?), not a Playwright trace zip. CDP only. |
recordVideo context option | page.video.start(path, fps = 30, …) / page.video.stop() | Records the current page on demand. CDP only; needs FFmpeg. |
| — | page.profiler.start() / stop() | V8 CPU profile JSON. CDP only. |
| — | page.screencast(maxFps, quality, maxWidth, maxHeight) | Experimental frame Flow. CDP only. |
Differences to keep in mind
suspend instead of await. Every operation that touches the page is a suspend function. There are no Promises to forget to await, but you need a coroutine scope.
use {} instead of try/finally. Browser, BrowserContext, Page and LaunchedBrowser have use { }, which closes them when the block ends, even when it throws or is cancelled, like Kotlin's Closeable.use and Ktor's HttpClient.use:
browser.newPage().use { page ->
page.goto("https://example.com")
page.screenshot(fullPage = true)
}State as flows. page.url and page.title are StateFlows you can read (.value) or collect. page.events exposes hot SharedFlows (console, navigation, loadState, dialog, request, response, download, fileChooser) instead of page.on(...). Subscribe before you trigger the action, or use the action-scoped helpers (waitForNavigation { }, waitForResponse(url) { }, …), which subscribe for you.
Options are named parameters. page.goto(url, waitUntil = LoadState.DOMContentLoaded). Kotlin's named and default arguments replace option objects; the names match Playwright's, except timeout → timeoutMs, delay → delayMs and screenshot type → format. timeoutMs comes after the other options (only a trailing lambda follows it).
Timeouts are timeoutMs: Long?. null (the default) means "use the configured default" from KmpwrightConfig.timeouts: navigation for navigations, action for locator actions, waitForSelector for assertions, default for everything else. All four default to 30 000 ms. Timeouts throw KmpwrightTimeoutException; other failures throw KmpwrightException.
Strict selectors. By default a locator action that matches more than one element fails, like Playwright. KmpwrightConfig(strictSelectors = false) relaxes this.
No element handles. Everything goes through lazy locators. page.evaluate takes an expression string; locator.evaluate takes a function string such as "el => el.value".
String URL patterns. route, unroute, waitForURL, waitForRequest, waitForResponse, waitForNavigation(url = …), waitForDownload and expect(page).toHaveURL use Playwright's URL glob over the full URL; a string without glob characters is an exact URL. A regex: prefix makes the string a regular expression where there is no Regex overload (such as waitForDownload). Prefer the Regex overloads where they exist.
Capabilities instead of "it just works". Check what the backend can do before you depend on it:
page.requireCapabilities(PageCapability.Downloads) // fails fast if not Supported
page.requireCapabilities(PageCapability.RouteInterception.require(SupportLevel.Partial))
val level = page.capabilities().supportLevel(PageCapability.VideoRecording)
val why = page.capabilities().notes // human-readable gapsNot supported
- Firefox and WebKit desktop browsers. The JVM backend is Chromium over CDP; mobile backends are the system WebViews.
- A test runner. There is no
@playwright/testequivalent (fixtures, projects, retries, reporters); use your own test framework and callexpect. - Codegen, the inspector,
page.pause(), Playwright trace viewer files. - Element handles,
evaluateHandle,exposeFunction,exposeBinding. route.fetch,routeFromHAR, WebSocket routing.- Context options without a runtime setter:
locale,timezoneId,permissions,reducedMotion. - Visual assertions (
toHaveScreenshot).page.visualDiff(expected, actual)compares twoScreenshotResults.
Mobile WebView limitations
On Android WebView and iOS WKWebView, KMPwright runs inside an app's view rather than a browser it controls, so:
- Input is synthesized as DOM events, not trusted OS input. Mouse and (on iOS) keyboard input are Partial.
- Routing covers what the platform exposes: Android
shouldInterceptRequest; iOS fetch/XHR through a bridge plus optional custom-scheme interception. Neither sees every request a desktop browser would. - No downloads, file chooser,
setInputFiles, tracing, profiling, video or screencast. - Network emulation is offline-only.
- Android has no isolated browser contexts; cookies are shared by every
WebViewin the process. - Frames are limited to same-origin documents.
See Capabilities for the full matrix and each backend's notes.
Sample app
The Compose Multiplatform sample puts an agent console, runnable recipes and an inspector next to a live WebView on Android and iOS.
Coming from agent-browser
How agent-browser CLI commands and flags map to page.runCommand (same command grammar) and to the typed Kotlin API, including what is partial, unsupported or intentionally out of scope.