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: 1 addition & 1 deletion .ffi-version
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Bump this when upgrading the FFI, then run `node scripts/install.mjs`.
# (`ffi/<version>` for releases, `prerelease/<sha>` for prereleases).
# See releases from https://github.com/webview-bundle/webview-bundles.
prerelease/7e38986
prerelease/76b4cbf
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ build
local.properties

/lib/src/main/jniLibs/**/*

# misc
Comment thread
seokju-na marked this conversation as resolved.
.DS_Store*
24 changes: 13 additions & 11 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ Appium-driven end-to-end tests for the `testapp` module, mirroring the iOS e2e.
## How it works

The `testapp` (`MainActivity`) is a minimal WebView host: it serves the builtin
`hacker-news` bundle over `https://hacker-news.wvb/` and loads it on launch. It
carries **no** test logic of its own. The scenarios live in the shared
`@wvb-playground/webview-hacker-news` package (the same suite the iOS e2e runs)
and are expressed against the platform-agnostic `WebviewDriver` from
`@wvb-playground/testing`.
`testbed` bundle (0.2.0) over `https://testbed.wvb/` and loads it on launch, and
wires its updater to the live playground remote
(`https://playground-remote.wvb.dev`, which hosts 0.3.0). It carries **no** test
logic of its own. The scenarios live in the shared
`@wvb-playground/webview-testbed` package and are expressed against the
platform-agnostic `WebviewDriver` from `@wvb-playground/testdriver`.
Comment thread
seokju-na marked this conversation as resolved.

The Appium session is switched into the testapp's **WEBVIEW** context, then each
exported `testCase` is run through `createAppiumDriver(driver, { baseURL:
"https://hacker-news.wvb" })`, which drives the live DOM (CSS selectors,
in-app navigation via `goto`).
The Appium session is switched into the testapp's **WEBVIEW** context, then the
suite runs through `createAppiumDriver(driver, { baseURL: "https://testbed.wvb" })`,
which drives the live DOM (CSS selectors, in-app navigation via `goto`).

`vitest` orchestrates it:

Expand All @@ -24,8 +24,10 @@ in-app navigation via `goto`).
(or reuses) an emulator, starts an Appium server (with
`chromedriver_autodownload` enabled), opens a session that installs and
launches the testapp, and switches into its WEBVIEW context.
- **smoke.spec.ts** runs every `testCase` from
`@wvb-playground/webview-hacker-news/testing`.
- **testbed.spec.ts** registers the `@wvb-playground/webview-testbed/testing`
suite (`defineTestbedSuite`) — one case per `@wvb/bridge` method — plus a strict
end-to-end check that downloads the real remote bundle and updates the source
from the builtin 0.2.0 to the remote 0.3.0.

## Prerequisites

Expand Down
4 changes: 2 additions & 2 deletions e2e/appium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export async function startAppiumServer(
"/",
"--log-level",
"error",
// Lets UiAutomator2 download a chromedriver matching the device's WebView
// when one isn't already cached, so the WEBVIEW context can be entered.
// Lets UiAutomator2 auto-download a chromedriver matching the device's
// WebView, so the WEBVIEW context can be entered.
// Appium 3 requires the feature be namespaced by driver (or `*` for all).
"--allow-insecure",
"*:chromedriver_autodownload",
Expand Down
18 changes: 7 additions & 11 deletions e2e/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface TestContext {
driver: Driver;
}

/** Locates the built debug APK (fixed output path, then a glob fallback). */
/** Locates the built debug APK. */
export async function findApk(): Promise<string | undefined> {
const fixed = path.join(
ROOT,
Expand Down Expand Up @@ -86,19 +86,20 @@ export async function createTestContext(): Promise<TestContext> {
platformName: "Android",
"appium:automationName": "UiAutomator2",
"appium:udid": device.serial,
// Appium installs the APK and launches the activity.
"appium:app": apkPath,
"appium:appPackage": APP_PACKAGE,
"appium:appActivity": APP_ACTIVITY,
"appium:appWaitActivity": "*",
// The testapp's versionCode is constant, so without a full reset Appium
// skips reinstalling a rebuilt APK and keeps stale bundles/assets.
"appium:fullReset": true,
"appium:autoGrantPermissions": true,
"appium:newCommandTimeout": 240,
"appium:adbExecTimeout": 120_000,
"appium:uiautomator2ServerInstallTimeout": 120_000,
"appium:uiautomator2ServerLaunchTimeout": 120_000,
// The testapp hosts a debuggable WebView; let UiAutomator2 fetch a matching
// chromedriver for it (enabled by the server's `chromedriver_autodownload`
// insecure feature — see `startAppiumServer`).
// Requires the server's `chromedriver_autodownload` insecure feature
// (see `startAppiumServer`).
"appium:chromedriverAutodownload": true,
"appium:ensureWebviewsHavePages": true,
"appium:nativeWebScreenshot": true,
Expand All @@ -113,12 +114,7 @@ export async function createTestContext(): Promise<TestContext> {
/** ms to wait for the testapp's WebView to expose a debuggable WEBVIEW context. */
const WEBVIEW_CONTEXT_TIMEOUT = process.env.CI ? 120_000 : 60_000;

/**
* Switches `driver` into the testapp's WEBVIEW context so the
* `@wvb-playground/testing` Appium driver can run CSS selectors and navigate via
* `browser.url(...)`. The WebView loads `https://hacker-news.wvb/` on launch, so its
* context appears shortly after the session starts.
*/
/** Switches `driver` into the testapp's WEBVIEW context. */
async function switchToWebview(driver: Driver): Promise<void> {
let contexts: string[] = [];
await driver.waitUntil(
Expand Down
6 changes: 2 additions & 4 deletions e2e/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export interface AndroidDevice {
shutdown: () => Promise<void>;
}

/** Locates the Android SDK from the env or the platform's default install path. */
function resolveSdk(): string {
const fromEnv = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT;
if (fromEnv && existsSync(fromEnv)) {
Expand Down Expand Up @@ -47,7 +46,6 @@ async function adb(
return { stdout: stdout ?? "" };
}

/** Serials of devices currently in the `device` (online) state. */
async function listSerials(): Promise<string[]> {
const { stdout } = await adb(["devices"]);
return stdout
Expand Down Expand Up @@ -156,8 +154,8 @@ async function bootEmulator(avd: string): Promise<AndroidDevice> {
}

/**
* Reuses an already-booted device/emulator, or boots an AVD (`ANDROID_AVD`, or the
* first available) and waits for it to finish booting.
* Reuses an already-booted device/emulator, or boots an AVD and waits for it to
* finish booting.
*/
export async function ensureAndroidDevice(
avdName = process.env.ANDROID_AVD,
Expand Down
4 changes: 2 additions & 2 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
"test": "vitest run"
},
"dependencies": {
"@wvb-playground/testing": "0.0.1",
"@wvb-playground/webview-hacker-news": "0.0.0"
"@wvb-playground/testdriver": "0.1.0",
"@wvb-playground/webview-testbed": "0.3.0"
},
"devDependencies": {
"@types/node": "^25",
Expand Down
17 changes: 0 additions & 17 deletions e2e/smoke.spec.ts

This file was deleted.

134 changes: 134 additions & 0 deletions e2e/testbed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { createAppiumDriver } from "@wvb-playground/testdriver/appium";
import {
methodSel,
sel,
testCases,
type WebviewDriver,
} from "@wvb-playground/webview-testbed/testing";
import { beforeAll, describe, expect, test } from "vitest";
import { type Driver, getTestContext } from "./context.js";

// Drives the `@wvb-playground/webview-testbed` bundle inside the Android WebView. The
// testapp ships the builtin `testbed` bundle and points its updater at the live
// playground remote (https://playground-remote.wvb.dev).
describe("testbed (android)", () => {
const BASE_URL = "https://testbed.wvb";
const BUNDLE = "testbed";
const BUILTIN_VERSION = "0.2.0";
const REMOTE_VERSION = "0.3.0";

// Loading the app shell (a full navigation) and the network download need more
// headroom than the driver's default.
const NAV_TIMEOUT = 30_000;
const RESULT_TIMEOUT = 90_000;

let browser: Driver;
let driver: WebviewDriver;

beforeAll(() => {
browser = getTestContext().driver;
driver = createAppiumDriver(browser, {
baseURL: BASE_URL,
defaultTimeoutMs: NAV_TIMEOUT,
});
});

async function open(): Promise<void> {
await driver.goto("/");
await driver.waitForVisible(sel.appShell, { timeoutMs: NAV_TIMEOUT });
}

/**
* Runs one testbed method card on a freshly-loaded page and returns its terminal
* outcome. Reloading first guarantees the card starts idle (its result element is
* only rendered once a call settles), so waiting for the result can never observe a
* stale value left by an earlier run of the same method.
*/
async function runMethod(
id: string,
params: Record<string, string> = {},
): Promise<{ status: string | null; text: string }> {
await open();
Comment thread
seokju-na marked this conversation as resolved.
for (const [name, value] of Object.entries(params)) {
await driver.fill(methodSel.param(id, name), value);
}
await driver.click(methodSel.run(id));
await driver.waitForVisible(methodSel.result(id), { timeoutMs: RESULT_TIMEOUT });
const outcome = {
status: await driver.getAttribute(methodSel.result(id), "data-status"),
text: await driver.text(methodSel.result(id)),
};
console.log(`[testbed] ${id} -> ${outcome.status}: ${outcome.text}`);
return outcome;
}

function parseJson(text: string): Record<string, unknown> {
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`expected a JSON result, got: ${text}`);
}
}

// The only test that *asserts* the real remote download succeeds. Skipped on CI:
// this fill + reload + download sequence hangs on the GitHub Actions Android emulator
// (the network is reachable — the generic suite below downloads over it — but this
// specific flow stalls there). It passes locally, and the generic suite still
// exercises the download (as ok|error) on CI.
test.skipIf(!!process.env.CI)(
"downloads + installs the real remote update (builtin 0.2.0 -> remote 0.3.0)",
async () => {
await open();
expect(await driver.text(sel.platformType)).toBe("android");

const before = parseJson(
(await runMethod("source.loadVersion", { bundleName: BUNDLE })).text,
);
expect(before).toEqual({ type: "builtin", version: BUILTIN_VERSION });

const update = await runMethod("updater.getUpdate", { bundleName: BUNDLE });
expect(update.status).toBe("ok");
const info = parseJson(update.text);
expect(info.name).toBe(BUNDLE);
expect(info.version).toBe(REMOTE_VERSION);
expect(info.isAvailable).toBe(true);
expect(info.localVersion).toBe(BUILTIN_VERSION);

const download = await runMethod("updater.download", { bundleName: BUNDLE });
expect(download.status).toBe("ok");
expect(parseJson(download.text).version).toBe(REMOTE_VERSION);

const install = await runMethod("updater.install", {
bundleName: BUNDLE,
version: REMOTE_VERSION,
});
expect(install.status).toBe("ok");

const after = parseJson(
(await runMethod("source.loadVersion", { bundleName: BUNDLE })).text,
);
expect(after).toEqual({ type: "remote", version: REMOTE_VERSION });

// Drop the cached descriptor so the next request loads the freshly-installed
// version, and cache-bust so the WebView can't serve stale HTML.
await runMethod("source.unloadDescriptor", { bundleName: BUNDLE });
await driver.goto(`/?updated=${Date.now()}`);
await driver.waitForVisible(sel.appShell, { timeoutMs: NAV_TIMEOUT });
expect(await driver.text(".app__version")).toContain(REMOTE_VERSION);
},
240_000,
);

// The platform-agnostic suite the testbed package ships (each `@wvb/bridge` method
// asserted to render an ok|error outcome). The "raw invoke" case is the only one that
// fills a text input, which hangs on the CI Android emulator; skip it there (its
// coverage overlaps source.listBundles). Runs after the strict update test so it
// can't perturb that test's clean starting state.
for (const testCase of testCases) {
const register =
process.env.CI && testCase.name.startsWith("raw invoke") ? test.skip : test;
register(testCase.name, async () => {
await testCase.run(driver);
});
}
});
3 changes: 3 additions & 0 deletions e2e/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const config: ViteUserConfig = defineConfig({
fileParallelism: false,
pool: "forks",
testTimeout: 120_000,
// Retry flaky cases on CI (the Android emulator + Appium webview intermittently
// stalls); no retries locally so real failures surface fast.
retry: process.env.CI ? 2 : 0,
hookTimeout: 1_800_000,
globalSetup: ["./vitest.global-setup.ts"],
setupFiles: ["./vitest.setup.ts"],
Expand Down
Loading