Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ node_modules
# outputs
dist/
dist-ssr/
dist-testing/
.tanstack/
.wvb/
.wrangler/
2 changes: 2 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ supportedArchitectures:
- darwin
- linux
- win32

npmMinimalAgeGate: 0
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"name": "webview-bundle-playground",
"private": true,
"workspaces": [
"webviews/*"
"packages/*",
"webviews/*",
"remotes/*"
],
"packageManager": "yarn@4.16.0",
"scripts": {
Expand Down
87 changes: 87 additions & 0 deletions packages/testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# `@wvb-playground/testing`

A tiny, platform-agnostic **`WebviewDriver`** abstraction for end-to-end testing
webviews, plus ready-made implementations for each platform's automation tool.

Test suites are written once against `WebviewDriver`; each platform supplies a
concrete driver. App-specific suites (e.g.
[`@wvb-playground/webview-hacker-news/testing`](../../webviews/hacker-news/testing))
provide the *cases*; this package provides the *driver*.

## Exports

| Import | What |
| --- | --- |
| `@wvb-playground/testing` | The `WebviewDriver` interface + `DriverOptions`/`WaitOptions` types |
| `@wvb-playground/testing/playwright` | `createPlaywrightDriver(page, options)` — Electron |
| `@wvb-playground/testing/selenium` | `createSeleniumDriver(wd, options)` — Tauri (`tauri-driver`) |
| `@wvb-playground/testing/appium` | `createAppiumDriver(browser, options)` — Android / iOS |

Each automation library (`playwright-core`, `selenium-webdriver`, `webdriverio`)
is an **optional peer dependency** — install only the one your platform needs.

## The interface

```ts
interface WebviewDriver {
goto(path: string): Promise<void>; // full load of an in-app path
location(): Promise<string>; // pathname + search
click(selector: string): Promise<void>;
fill(selector: string, value: string): Promise<void>;
text(selector: string): Promise<string>;
getAttribute(selector: string, name: string): Promise<string | null>;
count(selector: string): Promise<number>;
isVisible(selector: string): Promise<boolean>;
waitForVisible(selector: string, options?: { timeoutMs?: number }): Promise<void>;
waitForHidden(selector: string, options?: { timeoutMs?: number }): Promise<void>;
}
```

`selector` is a CSS selector against the live webview DOM. Single-element
operations act on the **first visible match**, so a test id present in both the
desktop and mobile chrome resolves to whichever layout is on screen — suites stay
viewport-agnostic. `DriverOptions` carries `{ baseURL, defaultTimeoutMs? }`.

## Usage

### Electron — Playwright

```ts
import { _electron as electron } from '@playwright/test';
import { createPlaywrightDriver } from '@wvb-playground/testing/playwright';

const app = await electron.launch({ args: [mainJs] });
const page = await app.firstWindow();
const driver = createPlaywrightDriver(page, { baseURL: 'app://news.wvb.dev' });
```

### Tauri — Selenium (`tauri-driver`)

```ts
import { Builder } from 'selenium-webdriver';
import { createSeleniumDriver } from '@wvb-playground/testing/selenium';

const wd = await new Builder().usingServer('http://127.0.0.1:4444').build();
const driver = createSeleniumDriver(wd, { baseURL: 'tauri://localhost' });
```

### Android / iOS — Appium (WebdriverIO)

```ts
import { remote } from 'webdriverio';
import { createAppiumDriver } from '@wvb-playground/testing/appium';

const browser = await remote({ capabilities: { /* … */ } });
await browser.switchContext('WEBVIEW_…'); // CSS selectors need the webview context
const driver = createAppiumDriver(browser, { baseURL: 'app://news.wvb.dev' });
```

### Running an app-specific suite

```ts
import { test } from 'vitest';
import { cases } from '@wvb-playground/webview-hacker-news/testing';

const driver = createPlaywrightDriver(page, { baseURL });
for (const c of cases) test(c.name, () => c.run(driver));
```
62 changes: 62 additions & 0 deletions packages/testing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@wvb-playground/testing",
"version": "0.0.0",
"description": "Platform-agnostic WebviewDriver abstraction plus Playwright/Selenium/Appium implementations for webview E2E testing.",
"type": "module",
"license": "MIT",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./playwright": {
"types": "./dist/playwright.d.mts",
"default": "./dist/playwright.mjs"
},
"./selenium": {
"types": "./dist/selenium.d.mts",
"default": "./dist/selenium.mjs"
},
"./appium": {
"types": "./dist/appium.d.mts",
"default": "./dist/appium.mjs"
},
"./package.json": "./package.json"
},
"files": [
"README.md",
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"prepack": "tsdown",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"playwright-core": "^1.40.0",
"selenium-webdriver": "^4.0.0",
"webdriverio": "^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"playwright-core": {
"optional": true
},
"selenium-webdriver": {
"optional": true
},
"webdriverio": {
"optional": true
}
},
"devDependencies": {
"@types/selenium-webdriver": "^4.1.28",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: @types/selenium-webdriver is redundant — selenium-webdriver v4+ bundles its own TypeScript types. This can cause type conflicts if the @types package version drifts from the actual selenium-webdriver version.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/testing/package.json, line 55:

<comment>`@types/selenium-webdriver` is redundant — `selenium-webdriver` v4+ bundles its own TypeScript types. This can cause type conflicts if the `@types` package version drifts from the actual `selenium-webdriver` version.</comment>

<file context>
@@ -0,0 +1,62 @@
+    }
+  },
+  "devDependencies": {
+    "@types/selenium-webdriver": "^4.1.28",
+    "playwright-core": "^1.49.0",
+    "selenium-webdriver": "^4.27.0",
</file context>

"playwright-core": "^1.49.0",
"selenium-webdriver": "^4.27.0",
"tsdown": "0.22.2",
"typescript": "6.0.3",
"webdriverio": "^9.0.0"
}
}
80 changes: 80 additions & 0 deletions packages/testing/src/appium.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Browser } from 'webdriverio';
import type { DriverOptions, WaitOptions, WebviewDriver } from './driver';
import { joinUrl } from './internal';

/**
* A {@link WebviewDriver} backed by an Appium session driven through WebdriverIO,
* for Android and iOS. The `browser` must already be switched into the WEBVIEW
* context (`browser.switchContext('WEBVIEW_…')`) so CSS selectors hit the DOM.
* `webdriverio` is an optional peer dependency.
*
* ```ts
* import { remote } from 'webdriverio';
* import { createAppiumDriver } from '@wvb-playground/testing/appium';
*
* const browser = await remote({ capabilities: { ... } });
* // … switch into the webview context …
* const driver = createAppiumDriver(browser, { baseURL: 'app://news.wvb.dev' });
* ```
*/
export function createAppiumDriver(browser: Browser, options: DriverOptions): WebviewDriver {
const { baseURL, defaultTimeoutMs = 10_000 } = options;
const timeout = (opts?: WaitOptions) => opts?.timeoutMs ?? defaultTimeoutMs;

async function firstVisible(selector: string): Promise<WebdriverIO.Element | null> {
for (const el of await browser.$$(selector)) {
if (await el.isDisplayed()) return el;
}
return null;
}

async function requireVisible(selector: string, timeoutMs: number): Promise<WebdriverIO.Element> {
let found: WebdriverIO.Element | null = null;
await browser.waitUntil(
async () => {
found = await firstVisible(selector);
return found !== null;
},
{ timeout: timeoutMs, timeoutMsg: `element never became visible: ${selector}` }
);
if (!found) throw new Error(`element never became visible: ${selector}`);
return found;
}

return {
async goto(path) {
await browser.url(joinUrl(baseURL, path));
},
location: () => browser.execute(() => location.pathname + location.search),
async click(selector) {
await (await requireVisible(selector, defaultTimeoutMs)).click();
},
async fill(selector, value) {
const el = await requireVisible(selector, defaultTimeoutMs);
await el.clearValue();
await el.setValue(value);
},
async text(selector) {
return (await (await requireVisible(selector, defaultTimeoutMs)).getText()).trim();
},
async getAttribute(selector, name) {
const el = await firstVisible(selector);
return el ? el.getAttribute(name) : null;
},
async count(selector) {
return (await browser.$$(selector)).length;
},
async isVisible(selector) {
return (await firstVisible(selector)) !== null;
},
async waitForVisible(selector, opts) {
await requireVisible(selector, timeout(opts));
},
async waitForHidden(selector, opts) {
await browser.waitUntil(async () => (await firstVisible(selector)) === null, {
timeout: timeout(opts),
timeoutMsg: `element never hid: ${selector}`,
});
},
};
}
78 changes: 78 additions & 0 deletions packages/testing/src/driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export interface WaitOptions {
/** Maximum time to wait, in milliseconds. */
timeoutMs?: number;
}

/**
* The minimal, platform-agnostic surface that drives a webview during E2E tests.
* Test suites are written against this interface; a per-platform implementation
* (see `@wvb-playground/testing/{playwright,selenium,appium}`) maps it onto a
* concrete automation tool:
*
* - Electron → Playwright (`page.locator(...)`)
* - Tauri → Selenium WebDriver (`driver.findElement(By.css(...))`)
* - Android → Appium / WebdriverIO in the WEBVIEW context (`$(...)`)
* - iOS → Appium / WebdriverIO in the WEBVIEW context (`$(...)`)
*
* Selector semantics
* ------------------
* `selector` is a CSS selector evaluated against the live webview DOM. All
* single-element operations (`click`, `fill`, `text`, `getAttribute`,
* `waitForVisible`) act on the **first visible match**. This lets a test id that
* appears in both the desktop and the mobile chrome (e.g. a theme toggle or a
* navigation link) resolve to whichever layout is currently on screen, so suites
* stay viewport-agnostic.
*
* Failure semantics
* -----------------
* Operations should reject if the target cannot be found/actioned within the
* host's timeout. The wait helpers reject on timeout. Rejected promises fail the
* surrounding test.
*/
export interface WebviewDriver {
/**
* Load the app at an in-app path with a full document load (resetting client
* state), e.g. `"/"`, `"/post/3"`, `"/u/byte_poet"`. The implementation resolves
* the path against a configured base URL (an `app://`/`tauri://` scheme, a dev
* server, a `file://` bundle, …).
*/
goto(path: string): Promise<void>;

/** The current in-app location as `pathname + search`, e.g. `"/post/3?tag=core"`. */
location(): Promise<string>;

/** Click the first visible element matching `selector`. */
click(selector: string): Promise<void>;

/** Replace the value of the first visible `<input>`/`<textarea>` matching `selector`. */
fill(selector: string, value: string): Promise<void>;

/** Trimmed text content of the first visible element matching `selector`. */
text(selector: string): Promise<string>;

/** Value of `name` on the first visible element matching `selector`, or `null`. */
getAttribute(selector: string, name: string): Promise<string | null>;

/** Number of elements in the DOM matching `selector`. */
count(selector: string): Promise<number>;

/** Whether at least one element matching `selector` is visible. */
isVisible(selector: string): Promise<boolean>;

/** Resolve once an element matching `selector` becomes visible; reject on timeout. */
waitForVisible(selector: string, options?: WaitOptions): Promise<void>;

/** Resolve once no element matching `selector` is visible; reject on timeout. */
waitForHidden(selector: string, options?: WaitOptions): Promise<void>;
}

/** Common options shared by the per-platform driver factories. */
export interface DriverOptions {
/**
* Base URL that in-app paths passed to {@link WebviewDriver.goto} are resolved
* against, e.g. `"http://localhost:4173"` or `"app://news.wvb.dev"`.
*/
baseURL: string;
/** Default timeout (ms) for waits when a call doesn't pass one. Defaults to 10000. */
defaultTimeoutMs?: number;
}
1 change: 1 addition & 0 deletions packages/testing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { DriverOptions, WaitOptions, WebviewDriver } from './driver';
4 changes: 4 additions & 0 deletions packages/testing/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Join a base URL with an in-app path, tolerant of custom schemes (`app://…`). */
export function joinUrl(baseURL: string, path: string): string {
return baseURL.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`);
}
56 changes: 56 additions & 0 deletions packages/testing/src/playwright.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Page } from 'playwright-core';
import type { DriverOptions, WaitOptions, WebviewDriver } from './driver';
import { joinUrl } from './internal';

/**
* A {@link WebviewDriver} backed by a Playwright `Page` — for Electron (via the
* `_electron` API, passing `app.firstWindow()`) or any Chromium/WebKit/Firefox
* page. `playwright-core` is an optional peer dependency.
*
* ```ts
* import { _electron as electron } from '@playwright/test';
* import { createPlaywrightDriver } from '@wvb-playground/testing/playwright';
*
* const app = await electron.launch({ args: [main] });
* const driver = createPlaywrightDriver(await app.firstWindow(), { baseURL: 'app://news.wvb.dev' });
* ```
*/
export function createPlaywrightDriver(page: Page, options: DriverOptions): WebviewDriver {
const { baseURL, defaultTimeoutMs } = options;
// `:visible` resolves the first *visible* match — important when a test id
// exists in both the desktop and mobile chrome.
const visible = (selector: string) => page.locator(`${selector}:visible`).first();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Compound CSS selectors (comma-separated) break under :visible — only the last segment gets the pseudo-class. E.g. visible('button, a') produces button, a:visible, matching all buttons regardless of visibility instead of only visible buttons/anchors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/testing/src/playwright.ts, line 22:

<comment>Compound CSS selectors (comma-separated) break under `:visible` — only the last segment gets the pseudo-class. E.g. `visible('button, a')` produces `button, a:visible`, matching all buttons regardless of visibility instead of only visible buttons/anchors.</comment>

<file context>
@@ -0,0 +1,56 @@
+  const { baseURL, defaultTimeoutMs } = options;
+  // `:visible` resolves the first *visible* match — important when a test id
+  // exists in both the desktop and mobile chrome.
+  const visible = (selector: string) => page.locator(`${selector}:visible`).first();
+  const timeout = (opts?: WaitOptions) => opts?.timeoutMs ?? defaultTimeoutMs;
+
</file context>
Suggested change
const visible = (selector: string) => page.locator(`${selector}:visible`).first();
const visible = (selector: string) => page.locator(`:is(${selector}):visible`).first();

const timeout = (opts?: WaitOptions) => opts?.timeoutMs ?? defaultTimeoutMs;

return {
async goto(path) {
await page.goto(joinUrl(baseURL, path));
},
location: () => page.evaluate(() => location.pathname + location.search),
async click(selector) {
await visible(selector).click({ timeout: defaultTimeoutMs });
},
async fill(selector, value) {
await visible(selector).fill(value, { timeout: defaultTimeoutMs });
},
async text(selector) {
const value = await visible(selector).innerText({ timeout: defaultTimeoutMs });
return value.trim();
},
getAttribute: (selector, name) =>
visible(selector).getAttribute(name, { timeout: defaultTimeoutMs }),
count: selector => page.locator(selector).count(),
async isVisible(selector) {
return (await page.locator(`${selector}:visible`).count()) > 0;
},
async waitForVisible(selector, opts) {
await visible(selector).waitFor({ state: 'visible', timeout: timeout(opts) });
},
async waitForHidden(selector, opts) {
await page
.locator(selector)
.first()
.waitFor({ state: 'hidden', timeout: timeout(opts) });
},
};
}
Loading