Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
de0f07c
feat(protocol): add WebViewInfo, WebViewBridge, and WebViewSession types
gmegidish May 10, 2026
a2c68f3
feat(query-engine): add webview strategy kind
gmegidish May 10, 2026
4ba901a
feat(core): add WebViewLocator, Page stub, and screen.getByWebView()
gmegidish May 10, 2026
8ed5a83
feat(core): implement Page class and WebLocator stub
gmegidish May 10, 2026
afe7eaa
feat(core): Playwright-compatible DOM selector engine and WebLocator …
gmegidish May 10, 2026
6029b75
feat(core): add PageAssertions and WebLocatorAssertions to expect()
gmegidish May 10, 2026
09684e4
refactor(expect): WebLocatorAssertions extends LocatorAssertions
gmegidish May 10, 2026
6ef547d
style(expect): restore multi-line inline functions for readability
gmegidish May 10, 2026
1928a59
feat(tests): add unit tests for Page and WebLocator (step 7)
gmegidish May 10, 2026
571a2e5
Merge origin/main into feat-adding-webview; thread stepFn through web…
gmegidish Jun 3, 2026
35ebda1
feat(webview): mobilecli webView bridge, page goBack/goForward, iOS p…
gmegidish Jun 3, 2026
71b4f4c
refactor(core): share step/eval helpers and drop dead WebViewSession …
gmegidish Jun 3, 2026
0e08b8b
refactor(core): dedupe assertion helpers, share webview test fakes, t…
gmegidish Jun 3, 2026
a3983e3
fix test so it uses local mobilewright without npx
gmegidish Jun 4, 2026
02bde8b
Merge remote-tracking branch 'origin/main' into feat-adding-webview
gmegidish Jun 4, 2026
71c1c14
fix(core): resolve webview locators uniquely, reset regex state, hono…
gmegidish Jun 4, 2026
f3b1882
fix(core): scope chained web locators and reset regex state in waitFo…
gmegidish Jun 4, 2026
7798f42
fix(core): assert element exists before web locator actions instead o…
gmegidish Jun 4, 2026
19b32fb
test(core): add regression tests for webview resolution, regex state,…
gmegidish Jun 4, 2026
d876764
upgrade mobilecli
gmegidish Jun 4, 2026
3b522c1
test(core): cover webview locator nth/first, chaining, and page() err…
gmegidish Jun 5, 2026
c95aa9f
fixed tests
gmegidish Jun 5, 2026
bfbf455
chore(deps): pin playwright, playwright-core and @playwright/test to …
gmegidish Jun 6, 2026
182d5ec
feat(core): route webview locators and web-first assertions through p…
gmegidish Jun 6, 2026
becc706
docs: add webview playwright parity design specs and plans
gmegidish Jun 6, 2026
7b29e69
feat(webview): run the injected engine on real devices (engine detect…
gmegidish Jun 7, 2026
40fcf40
test(e2e): add on-device webview Playwright-parity conformance suite
gmegidish Jun 7, 2026
5eaf547
docs: add webview conformance suite spec and plan
gmegidish Jun 7, 2026
9bed6d4
feat(core): export Page and WebLocator from the umbrella package
gmegidish Jun 7, 2026
0b416cd
chore: drop superpowers design docs from the branch
gmegidish Jun 7, 2026
49f84ea
refactor(core): tidy webview locator and query-engine per clean-code …
gmegidish Jun 7, 2026
79966c7
refactor(core): drop unsupported Page.screenshot() stub until a captu…
gmegidish Jun 7, 2026
9ff58d1
fix(core): inject the webview engine after navigation settles so it s…
gmegidish Jun 7, 2026
65ecb44
test(e2e): add a live-navigation webview conformance test against a r…
gmegidish Jun 7, 2026
93a6dc6
chore: deleted old test
gmegidish Jun 7, 2026
0eb57f6
test(e2e): run shared conformance specs under both mobilewright and p…
gmegidish Jun 7, 2026
cb0bfc3
fix(core): re-inject the webview engine when a page navigation drops it
gmegidish Jun 7, 2026
029ef5d
test(e2e): split platform-specific tests into per-platform projects
gmegidish Jun 7, 2026
1d57ef3
feat(core): make webview Page and Locator drop-in Playwright Page/Loc…
gmegidish Jun 8, 2026
bcb4a6d
test(e2e): drive conformance specs with Playwright's expect; skip foc…
gmegidish Jun 8, 2026
aa74609
feat(core): select webviews by testId and harden webview resolution
gmegidish Jun 8, 2026
bb2ce67
docs: add Web Views guide
gmegidish Jun 8, 2026
a695974
fix(core): recognize WebKit's engine-missing error so the webview eng…
gmegidish Jun 8, 2026
c831050
test(e2e): run e2e tests serially by dropping fullyParallel
gmegidish Jun 8, 2026
685e33b
Merge remote-tracking branch 'origin/main' into fix-improving-webview
gmegidish Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions docs/src/guides/webviews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
---
sidebar_position: 7
title: Web Views
---

# Web Views

Many mobile apps embed web content in a native **web view** (`WKWebView` on iOS, Android System WebView on Android, or a React Native web view). Mobilewright lets you drive that web content with the **same web API as [Playwright](https://playwright.dev)** — the same locators, the same actions, and the same web‑first assertions.

This is not a look‑alike API. Under the hood Mobilewright runs Playwright's own injected engine inside the web view, and the objects you get back (`Page`, `Locator`) **implement Playwright's interfaces**. That means a test written against `@playwright/test` can run, unchanged, against a web view on a real device.

## Requirements

This guide is about web views **embedded inside a native app** — it is *not* a way to automate a standalone browser over the Chrome DevTools Protocol (CDP). Mobilewright attaches to the web view through the app process, so:

- **The app must be debuggable.** Mobilewright can only inspect and inject into a web view that the OS allows it to attach to:
- **Android** — the app must be built with `android:debuggable="true"` (a debug build). Release builds disable web view debugging.
- **iOS** — the app must carry the `get-task-allow` entitlement (a development/debug build, including Simulator builds). App Store / distribution builds do not.
- It must be a real, in-app web view (`WKWebView`, Android System WebView, or a React Native web view) — not native UI that merely looks web-like.

If the app isn't debuggable, `getByWebView()` won't find a web view to attach to.

## Getting a page

From a `screen`, locate the web view and call `.page()` to attach to it:

```typescript
import { test, expect } from '@mobilewright/test';

test('open the in-app browser', async ({ device, screen }) => {
await device.launchApp('com.example.app');

// Navigate to the screen that hosts the web view (app-specific):
await screen.getByText('Web View').tap();

// Attach to the web view and get a Playwright-style Page:
const page = await screen.getByWebView().page();

await page.goto('https://example.com');
await expect(page.getByRole('heading')).toHaveText('Example Domain');
});
```

`screen.getByWebView()` resolves the web view in the current screen. If an app shows **more than one** web view, pick one by position with `.first()`, `.last()`, or `.nth(i)`:

```typescript
const page = await screen.getByWebView().nth(1).page();
```

Or select a specific web view by its **native testId** — the accessibility identifier on the web view element (`resource-id` on Android, e.g. a React Native `<WebView testID="checkout">`; `accessibilityIdentifier` on iOS):

```typescript
const page = await screen.getByWebView({ testId: 'checkout' }).page();
```

## Driving the page

A web `Page` exposes the Playwright locator factories and navigation methods you already know:

```typescript
// Locators — same builders as Playwright
page.locator('#submit');
page.getByRole('button', { name: 'Sign in' });
page.getByText('Welcome back');
page.getByLabel('Email');
page.getByPlaceholder('you@example.com');
page.getByTestId('cart');
page.getByAltText('Company logo');
page.getByTitle('Close');

// Navigation
await page.goto('https://example.com/login');
await page.reload();
await page.goBack();
await page.goForward();
await page.waitForLoadState('domcontentloaded');
await page.waitForURL(/\/dashboard/);

const title = await page.title();
const html = await page.content();
const ua = await page.evaluate(() => navigator.userAgent);
```

Locators support the usual actions and queries, and they **auto-wait** just like native locators (see [Auto-waiting](./auto-waiting)):

```typescript
await page.getByPlaceholder('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.locator('#search').press('Enter');
await page.getByText('Terms').hover();
await page.locator('#footer-link').scrollIntoViewIfNeeded();

const count = await page.getByRole('listitem').count();
const value = await page.locator('#email').inputValue();
```

## Assertions

Web views use Playwright's **web-first assertions**, which retry until the condition holds or the timeout elapses:

```typescript
await expect(page.locator('#status')).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.locator('input[name="email"]')).toHaveValue(/@example\.com$/);
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.locator('#btn')).toHaveClass(/primary/);
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle(/Dashboard/);
```

Both `expect` from `@mobilewright/test` and `expect` from `@playwright/test` work on web pages and locators — they route through the same injected matcher, so the results are identical.

## Sharing code with Playwright

Because Mobilewright's web `Page` and `Locator` **implement Playwright's `Page` and `Locator`**, you can write a test body **once** and run it both on a device (Mobilewright) and in a desktop browser (Playwright) — no copy‑paste, no adapter layer.

The pattern is: extract the test body into a function that receives `page` and `expect` as parameters (typed against `@playwright/test`), then call it from a thin wrapper in each runner.

**1. The shared spec** — `specs/login.spec.ts`:

```typescript
import { type Page, type Expect } from '@playwright/test';

// Pure test logic. Imports nothing from a specific runner — it receives the
// page and expect, so the exact same code runs under either runtime.
export async function loginSpec(page: Page, expect: Expect): Promise<void> {
await page.getByPlaceholder('Email').fill('user@example.com');
await page.getByPlaceholder('Password').fill('correct horse');
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('heading')).toHaveText('Welcome');
}
```

**2. The Mobilewright runner** — `login.test.ts` (runs on a real device's web view):

```typescript
import { test } from '@mobilewright/test';
import { expect } from '@playwright/test';
import { loginSpec } from './specs/login.spec';

test('login works in the app web view', async ({ device, screen }) => {
await device.launchApp('com.example.app');
await screen.getByText('Web View').tap();

const page = await screen.getByWebView().page();
await page.goto('https://example.com/login');

await loginSpec(page, expect);
});
```

**3. The Playwright runner** — `login.pw.ts` (runs in a desktop browser):

```typescript
import { test, expect } from '@playwright/test';
import { loginSpec } from './specs/login.spec';

test('login works in the browser', async ({ page }) => {
await page.goto('https://example.com/login');
await loginSpec(page, expect);
});
```

The body in `loginSpec` is identical for both. The only per-runtime code is *how the `page` is obtained* — from a launched app's web view on device, or from Playwright's `page` fixture in the browser. This makes Playwright a useful **parity oracle**: if a spec passes in the browser but fails on device, your app behaves differently there.

:::tip
Keep the runners apart with file naming. Point Mobilewright at `*.test.ts` and Playwright at `*.pw.ts` (via `testMatch` in each config) so neither runner picks up the other's wrapper, and the shared `*.spec.ts` files are imported by both but run by neither.
:::

## How it works

When you attach to a web view, Mobilewright injects Playwright's own selector-and-assertion engine into the page. Locators are resolved in-page by that engine, and every web-first assertion runs Playwright's matcher inside the web view — so selector semantics, whitespace normalization, and matcher behavior match Playwright exactly.

The engine is re-injected automatically after navigations (a fresh document drops it), including page-initiated redirects, so your locators keep working across `goto`, `reload`, and in-page navigation.

## Supported API and limitations

The web-first surface for driving content is supported: navigation, the `getBy*` locators, actions (`click`, `fill`, `type`, `press`, `hover`, `focus`, `scrollIntoViewIfNeeded`), value/state queries, and the web-first `expect` matchers (`toBeVisible`, `toHaveText`, `toHaveValue`, `toHaveCount`, `toHaveAttribute`, `toHaveClass`, `toHaveCSS`, `toHaveId`, `toHaveJSProperty`, `toBeChecked`, `toBeEnabled`, `toBeEditable`, `toHaveURL`, `toHaveTitle`, …).

Some Playwright capabilities have no equivalent inside an embedded web view and will throw if called:

- Network interception (`page.route`, `routeFromHAR`)
- Screenshots / visual snapshots (`page.screenshot`, `toHaveScreenshot`)
- Dialogs, downloads, file choosers, and multiple tabs / popups
- `page.pdf`, `page.addInitScript`, browser contexts

### Platform notes

- **`page.url()` is synchronous** (matching Playwright) and returns the last URL from a navigation Mobilewright drove. After a link click or in-app redirect it can lag — use `expect(page).toHaveURL(...)` or `page.waitForURL(...)` for live, auto-waiting checks.
- **`toBeFocused()` is not reliable on Android.** The embedded Android System WebView ignores programmatic focus without renderer focus emulation (a capability that isn't available there), so `document.activeElement` doesn't update. Focus assertions work on iOS and in desktop Chromium.
12 changes: 8 additions & 4 deletions e2e/mobilewright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,18 @@ const config: MobilewrightConfig = defineConfig({
testMatch: '**/*.test.ts',
retries: 0,
timeout: 60_000,
platform: 'ios',

// parallel by test() instead of parallel by file
fullyParallel: true,

// supports mobilecli and mobilenext drivers
driver: resolveDriver(),

// one project per platform. Tests under src/conformance run on both; tests
// under src/ios or src/android are platform-specific and only run on that
// project (each project ignores the other platform's directory).
projects: [
{ name: 'ios', use: { platform: 'ios' }, testIgnore: '**/android/**' },
{ name: 'android', use: { platform: 'android' }, testIgnore: '**/ios/**' },
],

// filter used devices with regexp
// deviceName: /Max/,
});
Expand Down
6 changes: 4 additions & 2 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
"description": "End-to-end tests for mobilewright drivers",
"type": "module",
"scripts": {
"test:mobilecli": "MOBILEWRIGHT_DRIVER=mobilecli npx mobilewright test",
"test:mobilenext": "MOBILEWRIGHT_DRIVER=mobilenext npx mobilewright test"
"test:mobilecli": "MOBILEWRIGHT_DRIVER=mobilecli node ../packages/mobilewright/dist/cli.js test",
"test:mobilenext": "MOBILEWRIGHT_DRIVER=mobilenext node ../packages/mobilewright/dist/cli.js test",
"test:playwright": "playwright test"
},
"dependencies": {
"mobilewright": "^0.0.1",
"@mobilewright/test": "^0.0.1"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/node": "^22.0.0"
}
}
17 changes: 17 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig, devices } from '@playwright/test';

// Runs the shared conformance specs (src/conformance/specs) against a real
// browser, as the parity oracle for the mobilewright on-device runner. Only
// picks up *.pw.ts so it never collides with the mobilewright *.test.ts files.
export default defineConfig({
testDir: './src',
testMatch: '**/*.pw.ts',
timeout: 60_000,
fullyParallel: true,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
11 changes: 11 additions & 0 deletions e2e/src/conformance/conformance.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test';
import { conformanceSpecs } from './specs/index.js';

// Playwright runner: drive the exact same conformance specs against a real
// browser. This is the parity oracle — if a spec passes here but fails under
// mobilewright (conformance.test.ts), mobilewright diverges from Playwright.
for (const spec of conformanceSpecs) {
test(spec.name, async ({ page }) => {
await spec.run(page, expect);
});
}
15 changes: 15 additions & 0 deletions e2e/src/conformance/conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { test } from '@mobilewright/test';
import { expect } from '@playwright/test';
import { openWebviewPage } from './harness.js';
import { conformanceSpecs } from './specs/index.js';

// mobilewright runner: drive the shared conformance specs against a real
// on-device webview, using Playwright's own expect (which the MobileWebViewPage /
// MobileWebViewLocator satisfy). Each spec body lives in ./specs and also runs
// under Playwright via conformance.pw.ts — same files, two runtimes.
for (const spec of conformanceSpecs) {
test(spec.name, async ({ device, screen }) => {
const page = await openWebviewPage({ device, screen });
await spec.run(page, expect);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
21 changes: 21 additions & 0 deletions e2e/src/conformance/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Device, Screen, Page } from 'mobilewright';

const PLAYGROUND_APP = 'com.mobilenext.playground';

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Launch the Playground app, open its WebView screen, and return the web Page.
// All conformance tests start from the Page this returns.
export async function openWebviewPage(ctx: { device: Device; screen: Screen }): Promise<Page> {
await ctx.device.terminateApp(PLAYGROUND_APP).catch(() => {});
await ctx.device.launchApp(PLAYGROUND_APP);
// Android's foreground detection races right after launch (a known mobilecli
// flake), which makes the subsequent webview list fail; let it settle.
await sleep(2000);
const webviewButton = ctx.screen.getByText('Web View');
await webviewButton.tap();
const page = await ctx.screen.getByWebView().page();
return page;
}
49 changes: 49 additions & 0 deletions e2e/src/conformance/specs/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Page, Expect } from '@playwright/test';
import { pageWithBody, isAndroidWebView } from './fixtures.js';

export const actionsSpec = async (page: Page, expect: Expect): Promise<void> => {
await page.goto(pageWithBody(`
<button id="b" onclick="this.textContent='clicked'">press me</button>
<input id="fill" type="text">
<input id="type" type="text">
<input id="key" type="text" onkeydown="this.value='key:'+event.key">
<input id="focusable" type="text">
<div id="hovered">idle</div>
<button id="hover" onmouseover="document.getElementById('hovered').textContent='hovered'">hover me</button>
<div style="height:2000px"></div>
<button id="bottom">bottom</button>
`));

// click
await page.locator('#b').click();
await expect(page.locator('#b')).toHaveText('clicked');

// fill
await page.locator('#fill').fill('hello@example.com');
await expect(page.locator('#fill')).toHaveValue('hello@example.com');

// type (appends)
await page.locator('#type').type('abc');
await expect(page.locator('#type')).toHaveValue('abc');

// press
await page.locator('#key').press('Enter');
await expect(page.locator('#key')).toHaveValue('key:Enter');

// focus — Android System WebView ignores programmatic el.focus() without
// renderer focus emulation (a CDP-only capability we don't have on Android),
// so activeElement never updates. Known gap: skip the focus assertion there.
const onAndroid = await isAndroidWebView(page);
if (!onAndroid) {
await page.locator('#focusable').focus();
await expect(page.locator('#focusable')).toBeFocused();
}

// hover
await page.locator('#hover').hover();
await expect(page.locator('#hovered')).toHaveText('hovered');

// scrollIntoViewIfNeeded — no throw, element becomes in viewport
await page.locator('#bottom').scrollIntoViewIfNeeded();
await expect(page.locator('#bottom')).toBeInViewport();
};
37 changes: 37 additions & 0 deletions e2e/src/conformance/specs/assertions-state.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Page, Expect } from '@playwright/test';
import { pageWithBody } from './fixtures.js';

export const stateAssertionsSpec = async (page: Page, expect: Expect): Promise<void> => {
await page.goto(pageWithBody(`
<div id="visible">shown</div>
<div id="hidden" style="display:none">gone</div>
<button id="enabled">ok</button>
<button id="disabled" disabled>no</button>
<input id="editable" type="text">
<input id="readonly" type="text" readonly>
<input id="checkbox" type="checkbox" checked>
<input id="empty" type="text" value="">
`));

await expect(page.locator('#visible')).toBeVisible();
await expect(page.locator('#hidden')).not.toBeVisible();
await expect(page.locator('#hidden')).toBeHidden();
await expect(page.locator('#visible')).not.toBeHidden();

await expect(page.locator('#enabled')).toBeEnabled();
await expect(page.locator('#disabled')).toBeDisabled();
await expect(page.locator('#disabled')).not.toBeEnabled();

await expect(page.locator('#editable')).toBeEditable();
await expect(page.locator('#readonly')).not.toBeEditable();

await expect(page.locator('#checkbox')).toBeChecked();

await expect(page.locator('#visible')).toBeAttached();
await expect(page.locator('#missing')).not.toBeAttached();

await expect(page.locator('#empty')).toBeEmpty();
await expect(page.locator('#visible')).not.toBeEmpty();

await expect(page.locator('#visible')).toBeInViewport();
};
Loading