Files
tauri-plugins-workspace/packages/api-e2e/README.md
T

252 lines
22 KiB
Markdown

# Plugins end-to-end tests
WebdriverIO suite that exercises the JavaScript API of the plugins in this
repository against a **real** Tauri app — the [`examples/api`](../../examples/api)
validation app — rather than a mocked backend, on desktop (Linux, macOS, Windows)
and mobile (Android, iOS). Each plugin has its own spec file, shared by every
platform, and adding coverage for a new plugin API is normally just dropping in
one more spec.
It mirrors the [`@tauri-apps/api` e2e suite](https://github.com/tauri-apps/tauri/tree/dev/packages/api-e2e)
in the core repository; the `@tauri-apps/api` modules themselves are covered there.
## How it works
- The example app is built with `withGlobalTauri: true`, so the `@tauri-apps/api` surface
is reachable on `window.__TAURI__` inside the webview, and every plugin's `api-iife.js`
registers its API next to it (`window.__TAURI__.fs`, `window.__TAURI__.clipboardManager`,
…, the package name without the `@tauri-apps/plugin-` prefix, camel-cased).
- On desktop, WebdriverIO drives the app through [`@crabnebula/tauri-driver`](https://www.npmjs.com/package/@crabnebula/tauri-driver),
which bridges the WebDriver protocol to each platform's webview:
- **macOS** — the CrabNebula Webdriver, which needs [`tauri-plugin-automation`](https://crates.io/crates/tauri-plugin-automation)
(registered in `examples/api` behind its off-by-default `automation` Cargo feature, which
the suite's build enables) and a locally-running `@crabnebula/test-runner-backend`,
authenticated with `CN_API_KEY`.
- **Linux** — `webkit2gtk-driver` (`WebKitWebDriver` on `PATH`).
- **Windows** — `msedgedriver.exe` on `PATH`. It hands the app the `--remote-debugging-port`
it attaches to through `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, which WebView2 ignores in an
elevated process ([wry#1782](https://github.com/tauri-apps/wry/issues/1782)), so the suite has
to run unelevated.
- On mobile, WebdriverIO drives the app through [Appium](https://appium.io) (started by
`@wdio/appium-service`; the drivers are plain devDependencies of this package, which Appium
picks up on its own):
- **Android** — the UiAutomator2 driver. The suite switches to the app's `WEBVIEW_*`
context, which chromedriver reaches through the WebView's debugging socket. Debug builds
turn that on (`setWebContentsDebuggingEnabled`), so the suite builds a debug APK. A
chromedriver matching the device's WebView is downloaded on demand (see `E2E_CHROMEDRIVER`).
- **iOS** — the XCUITest driver on a simulator, attaching to the WKWebView through the
WebKit remote inspector. Debug builds mark the webview `isInspectable`, so the suite
builds an unsigned debug simulator app. The inspector identifies an app by the
`application-identifier` entitlement that Xcode embeds when it code signs a simulator
build; an unsigned one has none and is listed as `process-<executable name>` instead of
its bundle identifier, so the config has the driver match that name too
(`appium:additionalWebviewBundleIds`). The driver also starts with a script timeout of
0, which the config raises to the 30s the other drivers default to, or every
`executeAsync` would time out at once.
- Specs never `eval` in the page. They pass a function to the [`tauri()`](test/helpers/index.ts)
helper, which serializes it and runs it via the driver's own (CSP-exempt) script injection,
handing it `window.__TAURI__` as the first argument and returning its JSON result.
- Each spec file gets its own session — a fresh `tauri-driver` (and therefore a fresh app
instance) on desktop, a fresh Appium session (which relaunches the app) on mobile — so
each plugin's suite runs in isolation.
- A small [fixture server](test/helpers/server.ts) is started for the whole run. It serves
the updater manifest the desktop e2e build points the updater at (see
[`tauri.e2e.conf.json`](tauri.e2e.conf.json)) and the upload/download fixtures. It listens
on the host's loopback; the iOS simulator shares that network stack, and on Android the
mobile config runs `adb reverse` so the same `127.0.0.1` URL works on the device. The
`http` specs use the echo server the example app itself spawns on port 3003, since that
is the only `http://` origin in the example's http scope. The fixture server also has a
WebSocket echo endpoint (`ws://127.0.0.1:3004/ws`) for the `websocket` specs.
## What is (not) covered
The example only registers each plugin on the platforms it supports, so the suites
split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are
skipped on desktop, and the rest run everywhere with the odd test gated.
| Plugin | Coverage | On mobile |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
| `stronghold` | Store records (persisted across reloads), wrong-password and unknown-client errors, vault secrets, BIP39/SLIP10 derivation and Ed25519 signing. | Same. |
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
The notification permission specs are desktop-only: a mobile app starts out ungranted and
`requestPermission` puts up a system dialog the session would then block on. The rest of the
notification API (action types, channels, the pending/active lists, listeners) only exists on
mobile, and is covered there without the permission.
The [`plugins.spec.ts`](test/specs/plugins.spec.ts) spec additionally asserts that every
plugin the platform registers injects its global API with its documented members, and that
the _other_ platform's plugins are absent. That is what catches a plugin whose
`global_api_script_path` is missing from its `build.rs`, or a crate that is not target-gated
in the example's `Cargo.toml`.
## Prerequisites
```sh
# from the repo root
pnpm install
pnpm build # examples/api resolves the plugins' JS from their dist-js output
```
Platform driver dependencies:
| Platform | Requirement |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS | `CN_API_KEY` env var (CrabNebula Cloud). The automation plugin and test-runner-backend are wired up already. |
| Linux | `webkit2gtk-driver` package (provides `WebKitWebDriver`). |
| Windows | `msedgedriver.exe` matching your Edge version, on `PATH`. Run the suite unelevated. |
| Android | The usual Tauri Android setup (`ANDROID_HOME`, `NDK_HOME`, a JDK), which also provides the `adb` the suite shells out to, plus a running emulator or a connected device with USB debugging. Network access the first time, for the chromedriver download. |
| iOS | macOS with Xcode and an iOS simulator runtime. The first session compiles WebDriverAgent (a few minutes). |
## Running
```sh
# desktop, from the repo root
pnpm test:api-e2e
# or from this package
pnpm e2e
# iterate without rebuilding the app every run
E2E_SKIP_BUILD=1 pnpm e2e
# run a single plugin's spec
pnpm exec wdio run ./wdio.conf.ts --spec test/specs/fs.spec.ts
# mobile (from the repo root; or `pnpm e2e:android` / `pnpm e2e:ios` from this package)
pnpm test:api-e2e:android
pnpm test:api-e2e:ios
```
The first desktop run builds the app with [`tauri.e2e.conf.json`](tauri.e2e.conf.json) as a
config override, which enables the example's `automation` feature and points the updater at
the fixture server; afterwards use `E2E_SKIP_BUILD=1` to reuse the existing binary. A binary
supplied through `E2E_SKIP_BUILD` or `E2E_APP_PATH` must have been built with that override:
the `updater` specs rely on its endpoint, and the CrabNebula Webdriver (always on macOS)
relies on the automation feature.
The mobile configs ([`wdio.android.conf.ts`](wdio.android.conf.ts), [`wdio.ios.conf.ts`](wdio.ios.conf.ts),
sharing [`wdio.mobile.ts`](wdio.mobile.ts)) run `tauri android build --debug --apk` /
`tauri ios build --debug --target aarch64-sim --no-sign`, compiling only the Rust target the
device runs (the Android one is read from the connected device through `adb`). The Android
Studio and Xcode projects are committed under `examples/api/src-tauri/gen`, so they are only
initialized if that directory is missing. The app must be a **debug** build — release builds
have webview debugging off, and Appium cannot see the page. `E2E_SKIP_BUILD` and
`E2E_APP_PATH` (an `.apk` / simulator `.app`) work as on desktop. No config override is
passed there: both things it turns on belong to desktop-only plugins.
## Environment variables
| Variable | Purpose |
| ------------------- | ---------------------------------------------------------------------------------------- |
| `CN_API_KEY` | CrabNebula Cloud key. Required on macOS (and whenever `E2E_CN_WEBDRIVER=1`). |
| `E2E_SKIP_BUILD` | Skip the `tauri build` step and reuse the existing binary. |
| `E2E_APP_PATH` | Absolute path to a prebuilt app/binary to test (also implies skip-build). |
| `E2E_SKIP` | Comma-separated plugin names to skip, e.g. `E2E_SKIP=clipboard-manager,global-shortcut`. |
| `E2E_SKIP_WM` | Skip window-manager-dependent tests (the window-state size restore). |
| `E2E_SPEC_RETRIES` | Retry count for flaky spec files (default `0`). |
| `E2E_CN_WEBDRIVER` | Use the CrabNebula Webdriver on Linux/Windows too (instead of the native driver). |
| `E2E_NATIVE_DRIVER` | Path passed to `tauri-driver --native-driver` (e.g. a specific chromedriver). |
| `CARGO_TARGET_DIR` | Override the target dir the app binary is looked up in. |
Mobile only:
| Variable | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `E2E_ANDROID_TARGET` | Rust target for the APK (`aarch64`, `armv7`, `i686`, `x86_64`); default: the connected device's ABI. |
| `E2E_ANDROID_DEVICE` | `adb` serial of the device/emulator to use (`appium:udid`); default: the first connected one. |
| `E2E_ANDROID_AVD` | Name of an AVD for Appium to boot (`appium:avd`) instead of using an already-running emulator. |
| `E2E_CHROMEDRIVER` | chromedriver binary matching the device's WebView, instead of letting Appium download one. |
| `E2E_IOS_TARGET` | Rust target for the simulator app (`aarch64-sim` or `x86_64`); default: the host architecture. |
| `E2E_IOS_DEVICE` | Simulator UDID or name (as in `xcrun simctl list`); default: a booted iPhone, else the newest one. |
| `E2E_PLATFORM` | Set by the mobile configs for the spec workers (`android`/`ios`) — see `platform` in the helpers. |
Appium's own log is written to `logs/wdio-appium.log` in this package.
## Adding tests for a new plugin API
1. **Add a spec.** Create `test/specs/<plugin>.spec.ts` and use `describePlugin('<plugin>', …)`
with the `tauri()` helper. It is picked up automatically by the `test/specs/**/*.spec.ts`
glob. Minimal example:
```ts
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
describePlugin('os', () => {
it('reports the platform', async () => {
expect(await tauri((api) => api.os.platform())).toBe('linux')
})
})
```
If the plugin is new to the example, register it in
[`examples/api/src-tauri/src/lib.rs`](../../examples/api/src-tauri/src/lib.rs), add it to
the example's `package.json`/`Cargo.toml`, add its `workspace:*` package to this package's
`devDependencies` and its namespace to the matching interface in
[`test/helpers/index.ts`](test/helpers/index.ts) — `CommonPluginApi`, `DesktopPluginApi` or
`MobilePluginApi` — plus the member list in `plugins.spec.ts`.
2. **Grant permissions if needed.** If the API calls a command that the example does not
allow yet, add the permission to
[`examples/api/src-tauri/capabilities/base.json`](../../examples/api/src-tauri/capabilities/base.json)
(or `desktop.json`/`mobile.json` for platform-specific plugins). Each plugin documents its
permissions under `plugins/<plugin>/permissions/autogenerated/reference.md`.
3. **Need a server?** Add a route to the [fixture server](test/helpers/server.ts) rather than
hitting the network; its URL is exported as `FIXTURE_SERVER_URL`.
4. **Handle environment-sensitive cases.** Use `itWm` (instead of `it`) for assertions that
depend on a real window manager, and `eventually()` to poll for state that is applied
asynchronously. Branch on `platform` from the helpers (never `process.platform`, which is
the host running the emulator/simulator on mobile) for platform-specific behavior.
Files go under `scratchDir('<plugin>')`, relative to `BaseDirectory.AppData`, which the
example's fs scope allows.
5. **Gate what mobile does not have.** The same specs run on Android and iOS. Wrap tests of
desktop-only behavior — a command the mobile build does not expose, a mobile implementation
that answers "Unsupported on this platform", or a permission only the desktop capability
grants — in `itDesktop`, use `itOn('android', …)` / `itOn('ios', …)` for platform-specific
APIs, and pass `{ desktopOnly: true }` (or `{ mobileOnly: true }`) to `describePlugin` for
plugins the example does not register on the other side. Skipped tests show up as pending
rather than silently disappearing.
### Rules for `tauri()` page functions
The function you pass to `tauri()` runs **inside the webview**, serialized as a string:
- It **cannot** close over anything from the spec module — pass every value it needs through
the trailing `tauri(fn, ...args)` arguments.
- It may only reference `api` (the `window.__TAURI__` object), those args, and browser
globals (`window`, `document`, `setTimeout`, `Promise`, …).
- Its return value must be JSON-serializable — return plain objects/primitives, not class
instances (call methods and return their results instead), and remember that `undefined`
values are dropped.
- Restore any app state you mutate (clipboard, registered shortcuts, window size, …); tests
within a spec file share the same app instance.
- For in-page waiting, wrap logic in a `Promise` with an explicit `setTimeout` rejection so a
failure surfaces as a message rather than an opaque driver timeout.
Use `tauriError(fn, ...args)` to assert that a call rejects; it returns the rejection message.