From 838cc84de25841883c382f3f6047cb215694217f Mon Sep 17 00:00:00 2001 From: Seokju Na Date: Sun, 14 Jun 2026 23:30:15 +0900 Subject: [PATCH 1/2] add testing packages --- .gitignore | 2 + .yarnrc.yml | 2 + package.json | 4 +- packages/testing/README.md | 87 + packages/testing/package.json | 62 + packages/testing/src/appium.ts | 80 + packages/testing/src/driver.ts | 78 + packages/testing/src/index.ts | 1 + packages/testing/src/internal.ts | 4 + packages/testing/src/playwright.ts | 56 + packages/testing/src/selenium.ts | 77 + packages/testing/tsconfig.json | 8 + packages/testing/tsdown.config.ts | 12 + webviews/hacker-news/package.json | 46 +- .../src/components/CommentTree.tsx | 3 + .../hacker-news/src/components/Header.tsx | 3 + .../src/components/LeftSidebar.tsx | 1 + .../hacker-news/src/components/PostRow.tsx | 14 +- .../hacker-news/src/components/VoteColumn.tsx | 3 + webviews/hacker-news/src/routes/__root.tsx | 2 + webviews/hacker-news/src/routes/index.tsx | 9 +- .../hacker-news/src/routes/post.$postId.tsx | 12 +- .../hacker-news/src/routes/u.$username.tsx | 10 +- webviews/hacker-news/testing/assert.ts | 39 + webviews/hacker-news/testing/cases.ts | 201 ++ webviews/hacker-news/testing/index.ts | 4 + webviews/hacker-news/testing/selectors.ts | 82 + webviews/hacker-news/tsconfig.json | 7 +- webviews/hacker-news/tsdown.config.ts | 12 + yarn.lock | 3122 ++++++++++++++++- 30 files changed, 3880 insertions(+), 163 deletions(-) create mode 100644 packages/testing/README.md create mode 100644 packages/testing/package.json create mode 100644 packages/testing/src/appium.ts create mode 100644 packages/testing/src/driver.ts create mode 100644 packages/testing/src/index.ts create mode 100644 packages/testing/src/internal.ts create mode 100644 packages/testing/src/playwright.ts create mode 100644 packages/testing/src/selenium.ts create mode 100644 packages/testing/tsconfig.json create mode 100644 packages/testing/tsdown.config.ts create mode 100644 webviews/hacker-news/testing/assert.ts create mode 100644 webviews/hacker-news/testing/cases.ts create mode 100644 webviews/hacker-news/testing/index.ts create mode 100644 webviews/hacker-news/testing/selectors.ts create mode 100644 webviews/hacker-news/tsdown.config.ts diff --git a/.gitignore b/.gitignore index 8de6bd0..103f99d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,7 @@ node_modules # outputs dist/ dist-ssr/ +dist-testing/ .tanstack/ .wvb/ +.wrangler/ diff --git a/.yarnrc.yml b/.yarnrc.yml index caa687d..d5decbe 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -14,3 +14,5 @@ supportedArchitectures: - darwin - linux - win32 + +npmMinimalAgeGate: 0 diff --git a/package.json b/package.json index a50d858..46559d7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,9 @@ "name": "webview-bundle-playground", "private": true, "workspaces": [ - "webviews/*" + "packages/*", + "webviews/*", + "remotes/*" ], "packageManager": "yarn@4.16.0", "scripts": { diff --git a/packages/testing/README.md b/packages/testing/README.md new file mode 100644 index 0000000..4260125 --- /dev/null +++ b/packages/testing/README.md @@ -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; // full load of an in-app path + location(): Promise; // pathname + search + click(selector: string): Promise; + fill(selector: string, value: string): Promise; + text(selector: string): Promise; + getAttribute(selector: string, name: string): Promise; + count(selector: string): Promise; + isVisible(selector: string): Promise; + waitForVisible(selector: string, options?: { timeoutMs?: number }): Promise; + waitForHidden(selector: string, options?: { timeoutMs?: number }): Promise; +} +``` + +`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)); +``` diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 0000000..d56441b --- /dev/null +++ b/packages/testing/package.json @@ -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", + "playwright-core": "^1.49.0", + "selenium-webdriver": "^4.27.0", + "tsdown": "0.22.2", + "typescript": "6.0.3", + "webdriverio": "^9.0.0" + } +} diff --git a/packages/testing/src/appium.ts b/packages/testing/src/appium.ts new file mode 100644 index 0000000..7caee95 --- /dev/null +++ b/packages/testing/src/appium.ts @@ -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 { + for (const el of await browser.$$(selector)) { + if (await el.isDisplayed()) return el; + } + return null; + } + + async function requireVisible(selector: string, timeoutMs: number): Promise { + 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}`, + }); + }, + }; +} diff --git a/packages/testing/src/driver.ts b/packages/testing/src/driver.ts new file mode 100644 index 0000000..f36be7c --- /dev/null +++ b/packages/testing/src/driver.ts @@ -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; + + /** The current in-app location as `pathname + search`, e.g. `"/post/3?tag=core"`. */ + location(): Promise; + + /** Click the first visible element matching `selector`. */ + click(selector: string): Promise; + + /** Replace the value of the first visible ``/`