snapshot of https://github.com/tauri-apps/tauri.git (dev, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Audit / audit-js (push) Failing after 0s
bench / bench (map[os:ubuntu-latest runner:xvfb-run --auto-servernum target:x86_64-unknown-linux-gnu], nightly) (push) Failing after 0s
covector version or publish / run-integration-tests (ubuntu-latest) (push) Failing after 0s
Audit / audit-rust (push) Failing after 0s
test `@tauri-apps/cli` / test (ubuntu-latest) (push) Failing after 0s
test `tauri-cli` / test (map[os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:ubuntu-latest target:x86_64-unknown-linux-gnu toolchain:1.90]) (push) Failing after 0s
test core / test (map[args:--no-default-features key:no-default], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
deploy schema worker / deploy (push) Failing after 0s
lint rust / clippy (map[cargo:cargo include: os:ubuntu-latest target:x86_64-unknown-linux-gnu]) (push) Failing after 0s
lint rust / clippy (map[cargo:cross include:--package api os:ubuntu-latest target:aarch64-linux-android]) (push) Failing after 0s
supply chain health status / check rust dependencies with cargo vet (push) Failing after 0s
test `@tauri-apps/api` e2e (mobile) / android (push) Failing after 0s
test `@tauri-apps/api` e2e / test (map[name:Linux os:ubuntu-latest]) (push) Failing after 0s
test core / test (map[args:--all-features key:all], map[cargo:cross command:build os:ubuntu-latest target:aarch64-linux-android toolchain:1.90]) (push) Failing after 0s
Udeps / changes (push) Failing after 0s
Udeps / setup (push) Has been skipped
Udeps / udeps (push) Has been skipped
bench / bench (map[os:macos-latest runner: target:aarch64-apple-darwin], nightly) (push) Has been cancelled
bench / bench (map[os:windows-latest runner: target:x86_64-pc-windows-msvc], nightly) (push) Has been cancelled
covector version or publish / run-integration-tests (macos-latest) (push) Has been cancelled
covector version or publish / run-integration-tests (windows-latest) (push) Has been cancelled
covector version or publish / version-or-publish (push) Has been cancelled
test `@tauri-apps/cli` / test (macos-latest) (push) Has been cancelled
test `tauri-cli` / test (map[args:--no-default-features --features native-tls-vendored os:windows-latest restore-only:true target:aarch64-pc-windows-msvc]) (push) Has been cancelled
test `tauri-cli` / test (map[os:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
test `tauri-cli` / test (map[os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include: os:windows-latest target:x86_64-pc-windows-msvc]) (push) Has been cancelled
lint rust / clippy (map[cargo:cargo include:--package api os:macos-latest target:aarch64-apple-ios]) (push) Has been cancelled
test `@tauri-apps/api` e2e (mobile) / ios (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:Windows os:windows-latest]) (push) Has been cancelled
test `@tauri-apps/api` e2e / test (map[name:macOS os:macos-latest]) (push) Has been cancelled
test `@tauri-apps/cli` / test (windows-latest) (push) Has been cancelled
test core / test (map[args:--all-features key:all], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:build os:macos-latest target:aarch64-apple-ios toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:macos-14 target:aarch64-apple-darwin toolchain:1.90]) (push) Has been cancelled
test core / test (map[args:--no-default-features key:no-default], map[cargo:cargo command:test os:windows-latest target:x86_64-pc-windows-msvc toolchain:1.90]) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 17:00:42 +05:00
commit feda81bf2f
1178 changed files with 156404 additions and 0 deletions

196
packages/api-e2e/README.md Normal file
View File

@@ -0,0 +1,196 @@
# `@tauri-apps/api` end-to-end tests
WebdriverIO suite that exercises every [`@tauri-apps/api`](../api) module 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 module
has its own spec file, shared by every platform, and adding coverage for a new API is
normally just dropping in one more spec.
## How it works
- The example app is built with `withGlobalTauri: true`, so the entire API surface is
reachable on `window.__TAURI__` inside the webview.
- 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 module's suite runs in isolation.
## Prerequisites
```sh
# from the repo root
pnpm install
pnpm build:api # examples/api resolves @tauri-apps/api from packages/api/dist
pnpm build:cli # examples/api's `tauri` script uses the local native CLI
```
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), 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. `tauri ios init` installs [xcodegen](https://github.com/yonaskolb/XcodeGen) through Homebrew if missing. The first session compiles WebDriverAgent (a few minutes). |
## Running
```sh
# 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 module's spec
pnpm exec wdio run ./wdio.conf.ts --spec test/specs/window.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 run builds the app with [`tauri.e2e.conf.json`](tauri.e2e.conf.json) as a config
override, which enables the example's `automation` feature; 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 feature whenever the CrabNebula Webdriver is in use (always
on macOS).
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)) initialize the example's Android/Xcode project if
`src-tauri/gen` is missing, then 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 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.
On iOS, the XCUITest driver compiles WebDriverAgent with xcodebuild in the first session, which
takes minutes on a CI runner. Appium's own release build of it for the simulator skips that:
`APPIUM_HOME=$PWD pnpm exec appium driver run xcuitest download-wda -- --outdir <dir> --platform iOS --kind sim`
(from this package), then `E2E_IOS_WDA=<dir>/WebDriverAgentRunner-Runner.app`, and the driver
installs and launches it as is (`appium:usePreinstalledWDA`).
The generated Gradle and Xcode projects call back into the CLI with `pnpm tauri …` from
`src-tauri` / `gen/apple`, which pnpm 12.0–12.3 could not resolve to the package's scripts
([pnpm/pnpm#14645](https://github.com/pnpm/pnpm/pull/14645)); the repo's `packageManager` pins a
fixed version, so run the suite through that pnpm (corepack) rather than an older global one.
## 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 module names to skip, e.g. `E2E_SKIP=tray,menu`. |
| `E2E_SKIP_WM` | Skip window-manager-dependent tests (minimize/maximize/fullscreen/position/hide). |
| `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_IOS_WDA` | Prebuilt `WebDriverAgentRunner-Runner.app` for the simulator, used instead of compiling WDA. |
| `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 API
1. **Add a spec.** Create `test/specs/<module>.spec.ts` and use `describeApi('<module>', …)`
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, describeApi } from '../helpers/index.js'
describeApi('app', () => {
it('reports the product name', async () => {
expect(await tauri((api) => api.app.getName())).toBe('Tauri API')
})
})
```
2. **Grant permissions if needed.** If the API calls a command that is not allowed by
default, add the permission to
[`examples/api/src-tauri/capabilities/run-app.json`](../../examples/api/src-tauri/capabilities/run-app.json).
The default permission sets are documented under
`crates/tauri/permissions/*/autogenerated/reference.md`.
3. **Add a Rust command if needed.** If the API needs a custom backend command, add it to
[`examples/api/src-tauri/src/cmd.rs`](../../examples/api/src-tauri/src/cmd.rs), register it
in the `generate_handler!` list in
[`src/lib.rs`](../../examples/api/src-tauri/src/lib.rs), add its permission, and grant it
in `run-app.json`.
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 a WM applies
asynchronously. Branch on `platform` from the helpers (never `process.platform`, which is
the host running the emulator/simulator on mobile) for platform-specific behavior.
5. **Gate what mobile does not have.** The same specs run on Android and iOS. Wrap tests of
desktop-only commands (`#[cfg(desktop)]` in the core plugins, or no-ops on mobile such as
the window title and size) in `itDesktop`, use `itOn('android', …)` / `itOn('ios', …)` for
platform-specific APIs, and pass `{ desktopOnly: true }` to `describeApi` for modules whose
plugin is not registered on mobile at all (`menu`, `tray`). 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).
- Restore any app state you mutate (title, size, theme, …); 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.

View File

@@ -0,0 +1,33 @@
{
"name": "@tauri-apps/api-e2e",
"private": true,
"version": "0.0.0",
"description": "End-to-end test suite for @tauri-apps/api, driven against the examples/api app via WebdriverIO.",
"license": "Apache-2.0 OR MIT",
"type": "module",
"scripts": {
"e2e": "wdio run ./wdio.conf.ts",
"e2e:skip-build": "cross-env E2E_SKIP_BUILD=true wdio run ./wdio.conf.ts",
"e2e:android": "wdio run ./wdio.android.conf.ts",
"e2e:ios": "wdio run ./wdio.ios.conf.ts",
"ts:check": "tsc --noEmit"
},
"devDependencies": {
"@crabnebula/tauri-driver": "^2.0.9",
"@crabnebula/test-runner-backend": "^0.3.0",
"@tauri-apps/api": "../api/dist",
"@types/node": "^22.10.0",
"@wdio/appium-service": "^9.31.9",
"@wdio/cli": "^9.31.9",
"@wdio/globals": "^9.31.3",
"@wdio/local-runner": "^9.31.9",
"@wdio/mocha-framework": "^9.31.9",
"@wdio/spec-reporter": "^9.31.2",
"appium": "^3.7.0",
"appium-uiautomator2-driver": "^8.7.0",
"appium-xcuitest-driver": "^12.12.4",
"cross-env": "^10.1.0",
"typescript": "^6.0.3",
"webdriverio": "^9.31.9"
}
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "../../crates/tauri-schema-generator/schemas/config.schema.json",
"build": {
"removeUnusedCommands": false,
"features": ["automation"]
},
"bundle": {
"icon": [
"../../.icons/32x32.png",
"../../.icons/128x128.png",
"../../.icons/128x128@2x.png",
"../../.icons/icon.icns",
"../../.icons/icon.ico"
]
}
}

View File

@@ -0,0 +1,243 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { browser } from '@wdio/globals'
import type * as TauriApi from '@tauri-apps/api'
/** The full `@tauri-apps/api` surface, as exposed on `window.__TAURI__`. */
export type Api = typeof TauriApi
/** OS the app under test runs on. */
export type Platform = NodeJS.Platform | 'android' | 'ios'
/**
* The platform of the app under test. The desktop suite drives an app on the
* host, so it is `process.platform`; the mobile configs (`wdio.android.conf.ts`,
* `wdio.ios.conf.ts`) drive an emulator/simulator and set `E2E_PLATFORM` for
* the spec workers instead.
*/
export const platform: Platform =
(process.env.E2E_PLATFORM as Platform | undefined) ?? process.platform
export const isMobile = platform === 'android' || platform === 'ios'
type PageOutcome<T> =
| { ok: true; value: T }
| { ok: false; error: string; stack?: string }
/** Thrown when the function passed to {@link tauri} rejects inside the webview. */
export class TauriPageError extends Error {
pageStack?: string
constructor(message: string, pageStack?: string) {
super(message)
this.name = 'TauriPageError'
this.pageStack = pageStack
}
}
/**
* Runs `fn` inside the app's webview with `window.__TAURI__` as its first argument
* and resolves with its (JSON-serializable) return value.
*
* `fn` is serialized with `Function.prototype.toString`, so it **cannot close over
* anything in the spec module** — every value it needs must be passed through `args`,
* and it may only reference `api`, those args, and browser globals (`window`,
* `document`, `setTimeout`, `Promise`, ...).
*
* @example
* const name = await tauri((api) => api.app.getName())
* const sum = await tauri((api, a, b) => a + b, 2, 3)
*/
export async function tauri<R, A extends unknown[]>(
fn: (api: Api, ...args: A) => R,
...args: A
): Promise<Awaited<R>> {
// A string body (rather than passing `fn` directly) keeps this working across
// both the classic and bidi WebDriver protocols and avoids any in-page eval of
// our own — the driver injects this script itself, which is exempt from the
// app's CSP. `executeAsync` is used because promise support in `execute` is not
// uniform across the platform drivers tauri-driver proxies to.
//
// The outcome crosses the driver as a JSON string rather than an object so no
// driver gets to interpret its shape: the Selenium atoms that Appium runs
// scripts through on iOS turn any object with a numeric `length` property
// into an array.
const script = `
var done = arguments[arguments.length - 1];
var args = Array.prototype.slice.call(arguments, 0, arguments.length - 1);
var fn = (${fn.toString()});
Promise.resolve()
.then(function () { return fn.apply(null, [window.__TAURI__].concat(args)); })
.then(
function (value) { return { ok: true, value: value === undefined ? null : value }; },
function (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
};
}
)
.then(function (outcome) {
try {
done(JSON.stringify(outcome));
} catch (error) {
done(JSON.stringify({ ok: false, error: 'result is not JSON-serializable: ' + error }));
}
});
`
const raw = await browser.executeAsync(script, ...args)
const outcome = (
typeof raw === 'string' ? JSON.parse(raw) : raw
) as PageOutcome<Awaited<R>> | null
if (!outcome || typeof outcome !== 'object' || !('ok' in outcome)) {
throw new Error(
`tauri() bridge returned an unexpected value: ${JSON.stringify(outcome)}`
)
}
if (!outcome.ok) {
throw new TauriPageError(outcome.error, outcome.stack)
}
return outcome.value
}
/**
* Asserts that the page-side call rejects and returns the rejection message,
* so specs can assert on it. Throws if the call unexpectedly resolves.
*/
export async function tauriError<A extends unknown[]>(
fn: (api: Api, ...args: A) => unknown,
...args: A
): Promise<string> {
try {
await tauri(fn, ...args)
} catch (error) {
if (error instanceof TauriPageError) {
return error.message
}
// Some platform drivers (notably the Linux WebKitWebDriver) surface a
// page-side `invoke` rejection as a WebDriver-level error on the
// `execute/async` command instead of letting the in-page bridge report it
// as an `{ ok: false }` outcome. Fall back to that error's message so the
// backend rejection is still assertable. This is safe for error-path specs:
// they match the message against an expected pattern, so a genuine driver
// failure (whose message won't match) still fails the test.
if (error instanceof Error) {
return error.message
}
throw error
}
throw new Error('expected the API call to reject, but it resolved')
}
/**
* Polls `check` until it returns without throwing or `timeout` elapses.
* Use for state that a window manager applies asynchronously (focus, minimize, ...).
*/
export async function eventually<T>(
check: () => T | Promise<T>,
{
timeout = 10_000,
interval = 250
}: { timeout?: number; interval?: number } = {}
): Promise<T> {
const deadline = Date.now() + timeout
let lastError: unknown
for (;;) {
try {
return await check()
} catch (error) {
lastError = error
}
if (Date.now() > deadline) {
throw lastError instanceof Error
? lastError
: new Error(String(lastError))
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
}
const skippedModules = (process.env.E2E_SKIP ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
export interface DescribeApiOptions {
/**
* The module's plugin is not registered at all on mobile (`menu`, `tray`),
* so the whole suite is skipped there.
*/
desktopOnly?: boolean
}
/**
* `describe` wrapper keyed by API module name. Any module listed in the
* comma-separated `E2E_SKIP` env var (e.g. `E2E_SKIP=tray,menu`) is skipped.
*/
export function describeApi(module: string, fn: () => void): void
export function describeApi(
module: string,
options: DescribeApiOptions,
fn: () => void
): void
export function describeApi(
module: string,
optionsOrFn: DescribeApiOptions | (() => void),
maybeFn?: () => void
): void {
const [options, fn] =
typeof optionsOrFn === 'function'
? [{} as DescribeApiOptions, optionsOrFn]
: [optionsOrFn, maybeFn!]
const title = `@tauri-apps/api/${module}`
if (skippedModules.includes(module) || (options.desktopOnly && isMobile)) {
describe.skip(title, fn)
} else {
describe(title, fn)
}
}
/**
* `it` restricted to the given platform(s); skipped (as pending) elsewhere.
* Use for behavior that only exists on one OS, e.g. `activityName()` on Android.
*/
export function itOn(
platforms: Platform | Platform[],
title: string,
fn: () => void | Promise<void>
): void {
const list = Array.isArray(platforms) ? platforms : [platforms]
if (list.includes(platform)) {
it(title, fn)
} else {
it.skip(title, fn)
}
}
/**
* `it` for desktop-only APIs. The mobile builds do not register the commands
* behind them (`#[cfg(desktop)]` in the core plugins) or implement them as
* no-ops (window title, size, resizability), so they are skipped on mobile.
*/
export function itDesktop(title: string, fn: () => void | Promise<void>): void {
if (isMobile) {
it.skip(title, fn)
} else {
it(title, fn)
}
}
/**
* `it` for assertions that depend on a real window manager (minimize, maximize,
* focus, ...). Skipped entirely when `E2E_SKIP_WM` is set (e.g. bare headless CI),
* and on mobile, which has no window manager (nor those commands).
*/
export function itWm(title: string, fn: () => void | Promise<void>): void {
if (process.env.E2E_SKIP_WM || isMobile) {
it.skip(title, fn)
} else {
it(title, fn)
}
}

View File

@@ -0,0 +1,64 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
eventually,
describeApi,
platform,
isMobile
} from '../helpers/index.js'
describeApi('app', () => {
it('getName returns the configured product name', async () => {
expect(await tauri((api) => api.app.getName())).toBe('Tauri API')
})
it('getVersion returns the configured version', async () => {
expect(await tauri((api) => api.app.getVersion())).toBe('1.0.0')
})
it('getTauriVersion returns a semver string', async () => {
expect(await tauri((api) => api.app.getTauriVersion())).toMatch(
/^\d+\.\d+\.\d+/
)
})
it('getIdentifier returns the bundle identifier', async () => {
expect(await tauri((api) => api.app.getIdentifier())).toBe('com.tauri.api')
})
it('defaultWindowIcon returns an image with a positive size', async () => {
const size = await tauri(async (api) => {
const icon = await api.app.defaultWindowIcon()
return icon ? await icon.size() : null
})
expect(size).not.toBeNull()
expect(size!.width).toBeGreaterThan(0)
expect(size!.height).toBeGreaterThan(0)
})
it('setTheme applies a theme and can be reset to the system default', async () => {
await tauri((api) => api.app.setTheme('dark'))
// On Linux the app-level theme is not observable through `window.theme()`:
// tao's event-loop `set_theme` only flips the GTK `prefer-dark` setting and
// never updates the window's stored theme, so `theme()` keeps reporting the
// portal/system value (and blocks on a 5s dbus timeout when no portal runs,
// as in headless CI). On mobile `set_theme` is a no-op and `theme()` always
// reports `light`. Elsewhere the change is reflected on the window.
if (platform !== 'linux' && !isMobile) {
await eventually(async () => {
const theme = await tauri((api) =>
api.window.getCurrentWindow().theme()
)
if (theme !== 'dark') {
throw new Error(`expected theme "dark", got "${theme}"`)
}
})
}
// restore
await tauri((api) => api.app.setTheme(null))
})
})

View File

@@ -0,0 +1,109 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describeApi, itOn } from '../helpers/index.js'
describeApi('core', () => {
it('isTauri reports true inside the app', async () => {
expect(await tauri((api) => api.core.isTauri())).toBe(true)
})
it('invoke round-trips a raw request through the echo command', async () => {
const echoed = await tauri((api) =>
api.core.invoke('echo', { some: 'payload', n: 42 })
)
expect(echoed).toEqual({ some: 'payload', n: 42 })
})
it('invoke rejects for a command that is not registered', async () => {
// `removeUnusedCommands` strips ungranted commands from the app entirely.
const message = await tauriError((api) =>
api.core.invoke('this_command_does_not_exist')
)
expect(message).toMatch(/not found|not allowed|denied/i)
})
it('command scopes gate log_operation by event', async () => {
// The run-app capability scopes `log_operation` to the `tauri-click` event.
await tauri((api) =>
api.core.invoke('log_operation', {
event: 'tauri-click',
payload: 'from e2e'
})
)
const message = await tauriError((api) =>
api.core.invoke('log_operation', { event: 'out-of-scope-event' })
)
expect(message).toMatch(/not allowed|denied/i)
})
it('Channel receives every message streamed from the backend in order', async () => {
// `spam` sends 1..=1000 over the channel.
const result = await tauri(
(api, count) =>
new Promise<{ length: number; ordered: boolean; last: number }>(
(resolve, reject) => {
const received: number[] = []
const channel = new api.core.Channel<number>()
channel.onmessage = (value: number) => {
received.push(value)
if (received.length === count) {
let ordered = true
for (let i = 0; i < received.length; i++) {
if (received[i] !== i + 1) {
ordered = false
break
}
}
resolve({
length: received.length,
ordered,
last: received[received.length - 1]
})
}
}
api.core.invoke('spam', { channel }).catch(reject)
setTimeout(
() =>
reject(
new Error(
`only received ${received.length} of ${count} messages`
)
),
15000
)
}
),
1000
)
expect(result.length).toBe(1000)
expect(result.ordered).toBe(true)
expect(result.last).toBe(1000)
})
itOn(
['android', 'ios'],
'a mobile plugin resolves JSArray and JSObject values as JSON arrays and objects',
async () => {
// On Android, the sample plugin's `jsValues` goes through
// `Invoke.resolveObject`, i.e. the plugin JSON mapper rather than
// org.json's own `toString`.
const values = await tauri((api) =>
api.core.invoke('plugin:sample|js_values')
)
expect(values).toEqual({
array: ['a', 1, true],
obj: { kind: 'object', nested: [1, 2] }
})
}
)
it('convertFileSrc builds a platform-appropriate asset URL', async () => {
const url = await tauri((api) =>
api.core.convertFileSrc('/path/to/file.png')
)
expect(url).toMatch(/(asset:\/\/localhost\/|https?:\/\/asset\.localhost\/)/)
})
})

View File

@@ -0,0 +1,54 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
describeApi('dpi', () => {
it('LogicalSize.toPhysical scales up by the factor', async () => {
const physical = await tauri((api) => {
const size = new api.dpi.LogicalSize(100, 50).toPhysical(2)
return { width: size.width, height: size.height }
})
expect(physical).toEqual({ width: 200, height: 100 })
})
it('PhysicalSize.toLogical scales down by the factor', async () => {
const logical = await tauri((api) => {
const size = new api.dpi.PhysicalSize(200, 100).toLogical(2)
return { width: size.width, height: size.height }
})
expect(logical).toEqual({ width: 100, height: 50 })
})
it('LogicalPosition.toPhysical and PhysicalPosition.toLogical round-trip', async () => {
const result = await tauri((api) => {
const physical = new api.dpi.LogicalPosition(10, 20).toPhysical(2)
const logical = new api.dpi.PhysicalPosition(10, 20).toLogical(2)
return {
physical: { x: physical.x, y: physical.y },
logical: { x: logical.x, y: logical.y }
}
})
expect(result.physical).toEqual({ x: 20, y: 40 })
expect(result.logical).toEqual({ x: 5, y: 10 })
})
it('Size and Position wrappers delegate conversions', async () => {
const result = await tauri((api) => {
const size = new api.dpi.Size(
new api.dpi.LogicalSize(100, 50)
).toPhysical(2)
const position = new api.dpi.Position(
new api.dpi.PhysicalPosition(20, 40)
).toLogical(2)
return {
size: { width: size.width, height: size.height },
position: { x: position.x, y: position.y }
}
})
expect(result.size).toEqual({ width: 200, height: 100 })
expect(result.position).toEqual({ x: 10, y: 20 })
})
})

View File

@@ -0,0 +1,125 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
describeApi('event', () => {
it('listen receives an emitted payload', async () => {
const payload = await tauri(
(api) =>
new Promise<{ hello: string }>((resolve, reject) => {
api.event
.listen<{ hello: string }>('e2e://ping', (event) =>
resolve(event.payload)
)
.then((unlisten) => {
api.event.emit('e2e://ping', { hello: 'world' }).catch(reject)
setTimeout(() => {
unlisten()
reject(new Error('event was not received'))
}, 5000)
})
.catch(reject)
})
)
expect(payload).toEqual({ hello: 'world' })
})
it('once fires exactly once even if the event is emitted twice', async () => {
const count = await tauri(
(api) =>
new Promise<number>((resolve, reject) => {
let calls = 0
api.event
.once('e2e://once', () => {
calls++
})
.then(async () => {
await api.event.emit('e2e://once')
await api.event.emit('e2e://once')
setTimeout(() => resolve(calls), 1000)
})
.catch(reject)
})
)
expect(count).toBe(1)
})
it('unlisten stops further delivery', async () => {
const count = await tauri(
(api) =>
new Promise<number>((resolve, reject) => {
let calls = 0
api.event
.listen('e2e://off', () => {
calls++
})
.then(async (unlisten) => {
await api.event.emit('e2e://off')
setTimeout(async () => {
await unlisten()
await api.event.emit('e2e://off')
setTimeout(() => resolve(calls), 500)
}, 500)
})
.catch(reject)
})
)
expect(count).toBe(1)
})
it('emitTo delivers only to the targeted label', async () => {
const result = await tauri(
(api) =>
new Promise<{ mainCount: number }>((resolve, reject) => {
let mainCount = 0
api.event
// The listener must be scoped to the `main` label: a default
// `listen()` registers the `Any` target, a catch-all that receives
// every emit regardless of the `emitTo` label (so it would also see
// the `does-not-exist` emit).
.listen(
'e2e://target',
() => {
mainCount++
},
{ target: 'main' }
)
.then(async (unlisten) => {
await api.event.emitTo('main', 'e2e://target') // delivered
await api.event.emitTo('does-not-exist', 'e2e://target') // dropped
setTimeout(async () => {
await unlisten()
resolve({ mainCount })
}, 800)
})
.catch(reject)
})
)
expect(result.mainCount).toBe(1)
})
it('bridges events to and from the Rust backend', async () => {
// examples/api listens for `js-event` and replies with `rust-event { data }`.
const reply = await tauri(
(api) =>
new Promise<{ data: string }>((resolve, reject) => {
api.event
.listen<{ data: string }>('rust-event', (event) =>
resolve(event.payload)
)
.then((unlisten) => {
api.event.emit('js-event', 'hello from e2e').catch(reject)
setTimeout(() => {
unlisten()
reject(new Error('no rust-event reply received'))
}, 5000)
})
.catch(reject)
})
)
expect(reply).toEqual({ data: 'something else' })
})
})

View File

@@ -0,0 +1,31 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
describeApi('image', () => {
it('Image.new round-trips raw rgba pixels', async () => {
// 2x1 image: red pixel, green pixel.
const rgba = [255, 0, 0, 255, 0, 255, 0, 255]
const result = await tauri(
async (api, pixels, width, height) => {
const image = await api.image.Image.new(pixels, width, height)
const size = await image.size()
const bytes = await image.rgba()
return {
width: size.width,
height: size.height,
byteLength: bytes.length
}
},
rgba,
2,
1
)
expect(result.width).toBe(2)
expect(result.height).toBe(1)
expect(result.byteLength).toBe(rgba.length)
})
})

View File

@@ -0,0 +1,88 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
// The menu commands are all allowed by default. These tests only build and
// inspect menu resources; they deliberately do not attach the menu to the app
// or window, to avoid disturbing the live example app. The menu plugin is
// desktop-only, so the whole module is skipped on mobile.
describeApi('menu', { desktopOnly: true }, () => {
it('Menu.new builds a menu with the given items', async () => {
const result = await tauri(async (api) => {
const item = await api.menu.MenuItem.new({ text: 'Item A' })
const check = await api.menu.CheckMenuItem.new({
text: 'Item B',
checked: true
})
const separator = await api.menu.PredefinedMenuItem.new({
item: 'Separator'
})
const submenu = await api.menu.Submenu.new({
text: 'More',
items: [await api.menu.MenuItem.new({ text: 'Nested' })]
})
const menu = await api.menu.Menu.new({
items: [item, check, separator, submenu]
})
const items = await menu.items()
return { count: items.length }
})
expect(result.count).toBe(4)
})
it('MenuItem text and enabled state can be read and updated', async () => {
const result = await tauri(async (api) => {
const item = await api.menu.MenuItem.new({
text: 'Original',
enabled: true
})
const before = await item.text()
await item.setText('Updated')
const after = await item.text()
const enabledBefore = await item.isEnabled()
await item.setEnabled(false)
const enabledAfter = await item.isEnabled()
return { before, after, enabledBefore, enabledAfter }
})
expect(result.before).toBe('Original')
expect(result.after).toBe('Updated')
expect(result.enabledBefore).toBe(true)
expect(result.enabledAfter).toBe(false)
})
it('CheckMenuItem tracks its checked state', async () => {
const result = await tauri(async (api) => {
const item = await api.menu.CheckMenuItem.new({
text: 'Toggle',
checked: false
})
// On Windows the checked state lives in the native menu the item belongs
// to, so it must be attached to a menu to be readable.
await api.menu.Menu.new({ items: [item] })
const before = await item.isChecked()
await item.setChecked(true)
const after = await item.isChecked()
return { before, after }
})
expect(result.before).toBe(false)
expect(result.after).toBe(true)
})
it('append and removeAt mutate the item list', async () => {
const result = await tauri(async (api) => {
const menu = await api.menu.Menu.new({
items: [await api.menu.MenuItem.new({ text: 'First' })]
})
await menu.append(await api.menu.MenuItem.new({ text: 'Second' }))
const afterAppend = (await menu.items()).length
await menu.removeAt(0)
const afterRemove = (await menu.items()).length
return { afterAppend, afterRemove }
})
expect(result.afterAppend).toBe(2)
expect(result.afterRemove).toBe(1)
})
})

View File

@@ -0,0 +1,62 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import nodePath from 'node:path'
import { expect } from '@wdio/globals'
import { tauri, describeApi, platform } from '../helpers/index.js'
// Node's path module for the platform the *app* runs on. That is the host for
// the desktop suite, but the mobile suites drive an Android/iOS app (POSIX
// paths) from whatever OS hosts the emulator/simulator.
const path = platform === 'win32' ? nodePath.win32 : nodePath.posix
describeApi('path', () => {
it('sep and delimiter match the target platform', async () => {
const separators = await tauri((api) => ({
sep: api.path.sep(),
delimiter: api.path.delimiter()
}))
expect(separators.sep).toBe(path.sep)
expect(separators.delimiter).toBe(path.delimiter)
})
it('join, dirname, basename and extname behave like the platform', async () => {
const result = await tauri(async (api) => ({
joined: await api.path.join('a', 'b', 'c.txt'),
dir: await api.path.dirname(await api.path.join('a', 'b', 'c.txt')),
base: await api.path.basename(await api.path.join('a', 'b', 'c.txt')),
ext: await api.path.extname(await api.path.join('a', 'b', 'c.txt'))
}))
expect(result.joined).toBe(path.join('a', 'b', 'c.txt'))
expect(result.base).toBe('c.txt')
expect(result.ext).toBe('txt')
expect(result.dir.endsWith(path.join('a', 'b'))).toBe(true)
})
it('normalize and isAbsolute work', async () => {
const result = await tauri(async (api) => ({
normalized: await api.path.normalize('a/b/../c'),
absoluteIsAbsolute: await api.path.isAbsolute(
await api.path.resolve('x')
),
relativeIsAbsolute: await api.path.isAbsolute('a/b')
}))
expect(result.normalized).toContain('c')
expect(result.absoluteIsAbsolute).toBe(true)
expect(result.relativeIsAbsolute).toBe(false)
})
it('app directories are absolute and scoped to the identifier', async () => {
const dirs = await tauri(async (api) => ({
appData: await api.path.appDataDir(),
appConfig: await api.path.appConfigDir(),
home: await api.path.homeDir(),
temp: await api.path.tempDir()
}))
expect(dirs.appData).toContain('com.tauri.api')
expect(dirs.appConfig.length).toBeGreaterThan(0)
expect(dirs.home.length).toBeGreaterThan(0)
expect(dirs.temp.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,43 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
// Tray commands are allowed by default, but a tray host is not always available
// in headless environments — set `E2E_SKIP=tray` there. The tray plugin is
// desktop-only, so the whole module is skipped on mobile.
describeApi('tray', { desktopOnly: true }, () => {
it('creates a tray icon, looks it up and removes it', async () => {
const created = await tauri(async (api) => {
const icon = await api.app.defaultWindowIcon()
const tray = await api.tray.TrayIcon.new({
id: 'e2e-tray',
icon: icon ?? undefined,
tooltip: 'e2e tray'
})
return tray.id
})
expect(created).toBe('e2e-tray')
const foundById = await tauri(async (api) => {
const tray = await api.tray.TrayIcon.getById('e2e-tray')
return tray !== null
})
expect(foundById).toBe(true)
await tauri(async (api) => {
const tray = await api.tray.TrayIcon.getById('e2e-tray')
await tray?.setVisible(true)
return null
})
const removed = await tauri(async (api) => {
await api.tray.TrayIcon.removeById('e2e-tray')
const tray = await api.tray.TrayIcon.getById('e2e-tray')
return tray === null
})
expect(removed).toBe(true)
})
})

View File

@@ -0,0 +1,44 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi, itDesktop } from '../helpers/index.js'
describeApi('webview', () => {
it('getCurrentWebview reports the main label', async () => {
expect(await tauri((api) => api.webview.getCurrentWebview().label)).toBe(
'main'
)
})
it('getAllWebviews includes the main webview', async () => {
const labels = await tauri(async (api) =>
(await api.webview.getAllWebviews()).map((w) => w.label)
)
expect(labels).toContain('main')
})
// `webview_position`/`webview_size` are desktop-only commands: on mobile the
// webview always fills the window.
itDesktop('exposes a physical position and size', async () => {
const rect = await tauri(async (api) => {
const wv = api.webview.getCurrentWebview()
const pos = await wv.position()
const size = await wv.size()
return { x: pos.x, y: pos.y, width: size.width, height: size.height }
})
expect(rect.width).toBeGreaterThan(0)
expect(rect.height).toBeGreaterThan(0)
})
itDesktop('setZoom adjusts and restores the zoom level', async () => {
// Requires `core:webview:allow-set-webview-zoom` in the run-app capability.
await tauri(async (api) => {
const wv = api.webview.getCurrentWebview()
await wv.setZoom(1.5)
await wv.setZoom(1)
return null
})
})
})

View File

@@ -0,0 +1,64 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, eventually, describeApi, itDesktop } from '../helpers/index.js'
describeApi('webviewWindow', () => {
it('getCurrentWebviewWindow reports the main label', async () => {
expect(
await tauri((api) => api.webviewWindow.getCurrentWebviewWindow().label)
).toBe('main')
})
// On mobile a new window is a new Android activity / iOS scene that takes
// over the foreground (and the automation session's webview context), so
// this is only exercised on desktop.
itDesktop('creates, finds and closes a new webview window', async () => {
// The label must match `main-*` to inherit the run-app capability.
const created = await tauri(
(api) =>
new Promise<boolean>((resolve, reject) => {
const w = new api.webviewWindow.WebviewWindow('main-e2e', {
width: 400,
height: 300
})
w.once('tauri://created', () => resolve(true)).catch(reject)
w.once('tauri://error', (event) =>
reject(
new Error(`window creation failed: ${String(event.payload)}`)
)
).catch(reject)
setTimeout(() => reject(new Error('window creation timed out')), 8000)
})
)
expect(created).toBe(true)
const found = await tauri(async (api) => {
const byLabel =
await api.webviewWindow.WebviewWindow.getByLabel('main-e2e')
const all = (await api.webviewWindow.getAllWebviewWindows()).map(
(w) => w.label
)
return { exists: byLabel !== null, all }
})
expect(found.exists).toBe(true)
expect(found.all).toContain('main-e2e')
await tauri(async (api) => {
const w = await api.webviewWindow.WebviewWindow.getByLabel('main-e2e')
await w?.close()
return null
})
await eventually(async () => {
const labels = await tauri(async (api) =>
(await api.webviewWindow.getAllWebviewWindows()).map((w) => w.label)
)
if (labels.includes('main-e2e')) {
throw new Error('window is still present after close')
}
})
})
})

View File

@@ -0,0 +1,295 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
eventually,
describeApi,
itWm,
itDesktop,
itOn
} from '../helpers/index.js'
describeApi('window', () => {
it('getCurrentWindow reports the main label', async () => {
expect(await tauri((api) => api.window.getCurrentWindow().label)).toBe(
'main'
)
})
it('getAllWindows includes the main window', async () => {
const labels = await tauri(async (api) =>
(await api.window.getAllWindows()).map((w) => w.label)
)
expect(labels).toContain('main')
})
// Mobile windows have no title bar: `title()` is always empty and
// `setTitle` a no-op there.
itDesktop('title can be read and set', async () => {
const original = await tauri((api) => api.window.getCurrentWindow().title())
await tauri((api) => api.window.getCurrentWindow().setTitle('e2e title'))
expect(await tauri((api) => api.window.getCurrentWindow().title())).toBe(
'e2e title'
)
// restore
await tauri(
(api, title) => api.window.getCurrentWindow().setTitle(title),
original
)
})
it('reports a positive scale factor and sizes', async () => {
const metrics = await tauri(async (api) => {
const w = api.window.getCurrentWindow()
const scale = await w.scaleFactor()
const inner = await w.innerSize()
const outer = await w.outerSize()
return {
scale,
inner: { width: inner.width, height: inner.height },
outer: { width: outer.width, height: outer.height }
}
})
expect(metrics.scale).toBeGreaterThan(0)
expect(metrics.inner.width).toBeGreaterThan(0)
expect(metrics.inner.height).toBeGreaterThan(0)
expect(metrics.outer.width).toBeGreaterThanOrEqual(metrics.inner.width)
})
itOn(
'android',
'activityName reports the activity hosting the window',
async () => {
const name = await tauri((api) =>
api.window.getCurrentWindow().activityName()
)
expect(name).toMatch(/MainActivity/)
}
)
// The window is the whole screen on mobile: `set_size` is a no-op.
itDesktop('setSize resizes the window', async () => {
const scale = await tauri((api) =>
api.window.getCurrentWindow().scaleFactor()
)
const original = await tauri(async (api) => {
const size = await api.window.getCurrentWindow().innerSize()
return { width: size.width, height: size.height }
})
await tauri(
(api, width, height) =>
api.window
.getCurrentWindow()
.setSize(new api.dpi.LogicalSize(width, height)),
600,
400
)
await eventually(async () => {
const inner = await tauri(async (api) => {
const size = await api.window.getCurrentWindow().innerSize()
return { width: size.width, height: size.height }
})
const tolerance = Math.ceil(scale) * 4
if (Math.abs(inner.width - 600 * scale) > tolerance) {
throw new Error(`width ${inner.width} not near ${600 * scale}`)
}
if (Math.abs(inner.height - 400 * scale) > tolerance) {
throw new Error(`height ${inner.height} not near ${400 * scale}`)
}
})
// restore the original physical size
await tauri(
(api, width, height) =>
api.window
.getCurrentWindow()
.setSize(new api.dpi.PhysicalSize(width, height)),
original.width,
original.height
)
})
// Mobile windows are never resizable (`is_resizable` is always `false`).
itDesktop('setResizable toggles resizability', async () => {
await tauri((api) => api.window.getCurrentWindow().setResizable(false))
expect(
await tauri((api) => api.window.getCurrentWindow().isResizable())
).toBe(false)
await tauri((api) => api.window.getCurrentWindow().setResizable(true))
expect(
await tauri((api) => api.window.getCurrentWindow().isResizable())
).toBe(true)
})
// Desktop-only commands (`#[cfg(desktop)]` in the window plugin).
itDesktop('assorted setters resolve without throwing', async () => {
await tauri(async (api) => {
const w = api.window.getCurrentWindow()
await w.setAlwaysOnTop(true)
await w.setAlwaysOnTop(false)
await w.setShadow(true)
await w.setContentProtected(false)
await w.setCursorVisible(true)
await w.requestUserAttention(null)
return null
})
})
itWm('setPosition moves the window', async () => {
const original = await tauri(async (api) => {
const pos = await api.window.getCurrentWindow().outerPosition()
return { x: pos.x, y: pos.y }
})
await tauri(
(api, x, y) =>
api.window
.getCurrentWindow()
.setPosition(new api.dpi.PhysicalPosition(x, y)),
original.x + 40,
original.y + 40
)
await eventually(async () => {
const pos = await tauri(async (api) => {
const p = await api.window.getCurrentWindow().outerPosition()
return { x: p.x, y: p.y }
})
// Window managers can nudge the final position; allow a generous tolerance.
if (
Math.abs(pos.x - (original.x + 40)) > 30
|| Math.abs(pos.y - (original.y + 40)) > 30
) {
throw new Error(
`position ${pos.x},${pos.y} not near ${original.x + 40},${original.y + 40}`
)
}
})
await tauri(
(api, x, y) =>
api.window
.getCurrentWindow()
.setPosition(new api.dpi.PhysicalPosition(x, y)),
original.x,
original.y
)
})
itWm('minimize and unminimize toggle the minimized state', async () => {
await tauri((api) => api.window.getCurrentWindow().minimize())
await eventually(async () => {
if (
!(await tauri((api) => api.window.getCurrentWindow().isMinimized()))
) {
throw new Error('window is not minimized')
}
})
await tauri((api) => api.window.getCurrentWindow().unminimize())
await eventually(async () => {
if (await tauri((api) => api.window.getCurrentWindow().isMinimized())) {
throw new Error('window is still minimized')
}
})
})
itWm('maximize and unmaximize toggle the maximized state', async () => {
await tauri((api) => api.window.getCurrentWindow().maximize())
await eventually(async () => {
if (
!(await tauri((api) => api.window.getCurrentWindow().isMaximized()))
) {
throw new Error('window is not maximized')
}
})
await tauri((api) => api.window.getCurrentWindow().unmaximize())
await eventually(async () => {
if (await tauri((api) => api.window.getCurrentWindow().isMaximized())) {
throw new Error('window is still maximized')
}
})
})
itWm('setFullscreen toggles fullscreen', async () => {
await tauri((api) => api.window.getCurrentWindow().setFullscreen(true))
await eventually(async () => {
if (
!(await tauri((api) => api.window.getCurrentWindow().isFullscreen()))
) {
throw new Error('window is not fullscreen')
}
})
await tauri((api) => api.window.getCurrentWindow().setFullscreen(false))
await eventually(async () => {
if (await tauri((api) => api.window.getCurrentWindow().isFullscreen())) {
throw new Error('window is still fullscreen')
}
})
})
itWm('setFullscreenOnMonitor goes fullscreen on that monitor', async () => {
// Use the monitor the window is on, so the assertion holds on single-monitor CI.
const origin = await tauri(async (api) => {
const monitor = await api.window.currentMonitor()
return monitor ? { x: monitor.position.x, y: monitor.position.y } : null
})
expect(origin).not.toBeNull()
await tauri(
(api, origin) =>
api.window
.getCurrentWindow()
.setFullscreenOnMonitor(
new api.dpi.PhysicalPosition(origin.x, origin.y)
),
origin!
)
await eventually(async () => {
const state = await tauri(async (api) => {
const w = api.window.getCurrentWindow()
const position = await w.outerPosition()
return {
fullscreen: await w.isFullscreen(),
x: position.x,
y: position.y
}
})
if (!state.fullscreen) {
throw new Error('window is not fullscreen')
}
// a fullscreen window sits at its monitor's origin
if (state.x !== origin!.x || state.y !== origin!.y) {
throw new Error(
`window is at (${state.x}, ${state.y}), expected (${origin!.x}, ${origin!.y})`
)
}
})
await tauri((api) => api.window.getCurrentWindow().setFullscreen(false))
await eventually(async () => {
if (await tauri((api) => api.window.getCurrentWindow().isFullscreen())) {
throw new Error('window is still fullscreen')
}
})
})
itWm('hide and show toggle visibility', async () => {
await tauri((api) => api.window.getCurrentWindow().hide())
await eventually(async () => {
if (await tauri((api) => api.window.getCurrentWindow().isVisible())) {
throw new Error('window is still visible')
}
})
await tauri((api) => api.window.getCurrentWindow().show())
await eventually(async () => {
if (!(await tauri((api) => api.window.getCurrentWindow().isVisible()))) {
throw new Error('window is not visible')
}
})
})
})

17
packages/api-e2e/test/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// The examples/api app is built with `withGlobalTauri: true`, so the whole
// `@tauri-apps/api` surface is available on `window.__TAURI__` inside the webview.
// This mirrors that for the functions we serialize and run in the page.
import type * as TauriApi from '@tauri-apps/api'
declare global {
interface Window {
__TAURI__: typeof TauriApi
}
}
export {}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
// The specs run through tsx (esbuild, pulled in by @wdio/cli), and
// @tauri-apps/api's published type definitions use extensionless relative
// imports, so bundler-style resolution is what matches the runtime and
// resolves the api types.
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM"],
"types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["wdio.*.ts", "test/**/*.ts"]
}

View File

@@ -0,0 +1,10 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// `@tauri-apps/api` e2e suite against the examples/api app on an Android
// device or emulator, through Appium's UiAutomator2 driver. See wdio.mobile.ts.
import { mobileConfig } from './wdio.mobile.js'
export const config = mobileConfig('android')

View File

@@ -0,0 +1,296 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import path from 'node:path'
import fs from 'node:fs'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { waitTauriDriverReady } from '@crabnebula/tauri-driver'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(dirname, '..', '..')
const appDir = path.join(repoRoot, 'examples', 'api')
const targetDir = process.env.CARGO_TARGET_DIR ?? path.join(repoRoot, 'target')
// macOS has no native WebDriver for WKWebView, so the CrabNebula Webdriver
// (backed by tauri-plugin-automation + the test-runner-backend) is required there.
// It can be opted into on the other platforms via E2E_CN_WEBDRIVER for parity.
const useCrabNebulaWebdriver =
process.platform === 'darwin' || !!process.env.E2E_CN_WEBDRIVER
// Path passed to the driver as `tauri:options.application`.
const application =
process.env.E2E_APP_PATH
?? (process.platform === 'darwin'
? path.join(targetDir, 'debug', 'bundle', 'macos', 'Tauri API.app')
: process.platform === 'win32'
? path.join(targetDir, 'debug', 'api.exe')
: path.join(targetDir, 'debug', 'api'))
let tauriDriver: ChildProcess | undefined
let killedTauriDriver = false
let testRunnerBackend: ChildProcess | undefined
let killedTestRunnerBackend = false
export const config: WebdriverIO.Config = {
hostname: '127.0.0.1',
port: 4444,
specs: ['./test/specs/**/*.spec.ts'],
// The driver spawns a single app instance and speaks WebDriver to it, so the
// suite must run serially.
maxInstances: 1,
capabilities: [
{
maxInstances: 1,
// `tauri:options` is understood by tauri-driver, not by the WebdriverIO types.
'tauri:options': { application }
} as unknown as WebdriverIO.Capabilities
],
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000
},
connectionRetryCount: 0,
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
onPrepare: async () => {
// The example resolves `@tauri-apps/api` from `packages/api/dist`, and its
// frontend build (vite) needs it too. Fail early with a clear message.
if (
!fs.existsSync(path.join(repoRoot, 'packages', 'api', 'dist', 'index.js'))
) {
throw new Error(
'packages/api/dist is missing — run `pnpm build:api` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// `examples/api`'s `tauri` script is `node ../../packages/cli/tauri.js`,
// which requires the native CLI to be built (`pnpm build:cli`).
//
// The override config does two things for this (debug, test-only) build:
// - enables the example's off-by-default `automation` feature, which
// registers tauri-plugin-automation;
// - disables `removeUnusedCommands`, which the example otherwise sets.
// It strips command handlers that no static capability grants, and the
// plugin adds its `resolve` command through a *runtime* capability, so
// it would be stripped — breaking the CrabNebula Webdriver on macOS.
// Passed as an appDir-relative path to sidestep shell quoting.
const overrideConfig = path.relative(
appDir,
path.join(dirname, 'tauri.e2e.conf.json')
)
const buildArgs = [
'tauri',
'build',
'--debug',
'--config',
overrideConfig,
...(process.platform === 'darwin'
? ['--bundles', 'app'] // the .app bundle is needed for tauri:options
: ['--no-bundle'])
]
const build = spawnSync('pnpm', buildArgs, {
cwd: appDir,
stdio: 'inherit',
shell: true
})
if (build.status !== 0) {
throw new Error(
`\`pnpm ${buildArgs.join(' ')}\` failed with status ${build.status}`
)
}
}
if (!fs.existsSync(application)) {
throw new Error(
`app not found at ${application} — build it (unset E2E_SKIP_BUILD) or point E2E_APP_PATH at an existing build.`
)
}
if (useCrabNebulaWebdriver) {
if (!process.env.CN_API_KEY) {
throw new Error(
'CN_API_KEY is required for the CrabNebula Webdriver (mandatory on macOS, or when E2E_CN_WEBDRIVER=1).'
)
}
testRunnerBackend = spawn('pnpm', ['exec', 'test-runner-backend'], {
cwd: dirname,
stdio: 'inherit',
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm ->
// test-runner-backend) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
testRunnerBackend.on('error', (error) => {
console.error('test-runner-backend error:', error)
process.exit(1)
})
testRunnerBackend.on('exit', (code) => {
if (!killedTestRunnerBackend) {
console.error('test-runner-backend exited with code:', code)
process.exit(1)
}
})
const { waitTestRunnerBackendReady } =
await import('@crabnebula/test-runner-backend')
await waitTestRunnerBackendReady()
process.env.REMOTE_WEBDRIVER_URL = 'http://127.0.0.1:3000'
}
},
// A fresh tauri-driver (and therefore a fresh app instance) per spec file,
// so each module's suite runs in isolation.
beforeSession: async () => {
const args = ['exec', 'tauri-driver']
if (process.env.E2E_NATIVE_DRIVER) {
args.push('--native-driver', process.env.E2E_NATIVE_DRIVER)
}
// Reset before each (re)spawn so the `exit` handler below still treats an
// unexpected driver crash as fatal on retried spec files.
killedTauriDriver = false
tauriDriver = spawn('pnpm', args, {
cwd: dirname,
stdio: [null, process.stdout, process.stderr],
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm -> tauri-driver
// -> native webdriver) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
tauriDriver.on('error', (error) => {
console.error('tauri-driver error:', error)
process.exit(1)
})
tauriDriver.on('exit', (code) => {
if (!killedTauriDriver) {
console.error('tauri-driver exited with code:', code)
process.exit(1)
}
})
await waitTauriDriverReady()
},
// The session is created as soon as the app's window exists, which can be
// before the webview has navigated to the app's page: WebView2 on a cold start
// (the first launches on a fresh Windows runner) still shows `about:blank`
// for a few seconds, so the first spec's scripts ran in a page without
// `window.__TAURI__`. Every spec goes through that global, so block until
// it exists.
before: async (_capabilities, _specs, browser) => {
await browser.waitUntil(
async () => {
try {
return (await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)) as boolean
} catch {
// A command issued mid-navigation can fail on a stale execution
// context; that just means "not ready yet".
return false
}
},
{
timeout: 30_000,
interval: 250,
timeoutMsg:
'window.__TAURI__ never became available — the app did not load its page.'
}
)
},
// Awaited so the driver (and its port) is fully gone before the next spec's
// beforeSession spawns a new one on the same port.
afterSession: async () => {
await closeTauriDriver()
},
onComplete: () => {
closeAll()
}
}
/**
* Kills a shell-spawned child and everything it started. `child.kill()` only
* signals the `sh`/`cmd` wrapper, orphaning the real process (which keeps
* holding the WebDriver port and poisons every later spec), so we take down the
* whole process group/tree instead.
*/
function killProcessTree(
child: ChildProcess | undefined,
signal: NodeJS.Signals = 'SIGTERM'
): void {
const pid = child?.pid
if (!pid || child?.exitCode !== null) return
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
stdio: 'ignore'
})
} else {
try {
// Negative pid targets the whole process group (see `detached` above).
process.kill(-pid, signal)
} catch {
try {
child!.kill(signal)
} catch {
// already gone
}
}
}
}
async function closeTauriDriver(): Promise<void> {
killedTauriDriver = true
const driver = tauriDriver
tauriDriver = undefined
if (!driver || driver.exitCode !== null) return
const exited = new Promise<void>((resolve) =>
driver.once('exit', () => resolve())
)
killProcessTree(driver)
// Give it a moment to release the port, then escalate to SIGKILL.
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
exited,
new Promise<void>((resolve) => {
timer = setTimeout(() => {
killProcessTree(driver, 'SIGKILL')
resolve()
}, 5000)
})
])
if (timer) clearTimeout(timer)
}
function closeAll() {
killedTauriDriver = true
killProcessTree(tauriDriver)
tauriDriver = undefined
killedTestRunnerBackend = true
killProcessTree(testRunnerBackend)
testRunnerBackend = undefined
}
function onShutdown(fn: () => void) {
const cleanup = () => {
try {
fn()
} finally {
process.exit()
}
}
for (const signal of [
'exit',
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGBREAK'
] as const) {
process.on(signal, cleanup)
}
}
onShutdown(closeAll)

View File

@@ -0,0 +1,10 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// `@tauri-apps/api` e2e suite against the examples/api app on an iOS
// simulator, through Appium's XCUITest driver. See wdio.mobile.ts.
import { mobileConfig } from './wdio.mobile.js'
export const config = mobileConfig('ios')

View File

@@ -0,0 +1,425 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Shared WebdriverIO configuration for the mobile suites (`wdio.android.conf.ts`
// and `wdio.ios.conf.ts`). Instead of tauri-driver, the app is driven through
// Appium: the UiAutomator2 driver (Android; chromedriver attaches to the
// WebView) or the XCUITest driver (iOS simulator; WebKit remote inspector).
// Debug builds enable webview debugging on both platforms, which is what makes
// the `WEBVIEW_*` context — and `browser.executeAsync` inside it — available.
import path from 'node:path'
import fs from 'node:fs'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
export type MobilePlatform = 'android' | 'ios'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(dirname, '..', '..')
const appDir = path.join(repoRoot, 'examples', 'api')
const tauriDir = path.join(appDir, 'src-tauri')
/** `identifier` in examples/api's tauri.conf.json. */
const appId = 'com.tauri.api'
/** Where Appium looks for drivers; they are devDependencies of this package. */
process.env.APPIUM_HOME ??= dirname
export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
const ios = platform === 'ios' ? iosTarget() : undefined
// Path passed to the driver as `appium:app`.
const application =
process.env.E2E_APP_PATH
?? (ios
? path.join(
tauriDir,
'gen',
'apple',
'build',
ios.outputArch,
'Tauri API.app'
)
: path.join(
tauriDir,
'gen',
'android',
'app',
'build',
'outputs',
'apk',
'universal',
'debug',
'app-universal-debug.apk'
))
return {
specs: ['./test/specs/**/*.spec.ts'],
// One device/simulator, one app instance: the suite runs serially.
maxInstances: 1,
capabilities: [
platform === 'android'
? androidCapabilities(application)
: iosCapabilities(application)
],
services: [
[
'appium',
{
args: {
address: '127.0.0.1',
// Appium 3 requires insecure features to be scoped to a driver.
// chromedriver_autodownload lets the UiAutomator2 driver fetch a
// chromedriver matching the device's WebView (see E2E_CHROMEDRIVER).
allowInsecure: 'uiautomator2:chromedriver_autodownload'
},
// First-session setup (chromedriver download, WebDriverAgent build)
// can be slow, hence the generous timeout.
appiumStartTimeout: 120_000,
logPath: path.join(dirname, 'logs')
}
]
],
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000
},
connectionRetryCount: 0,
// The first session boots the simulator/emulator, installs the app and, on
// iOS, compiles WebDriverAgent — well over the 120s default that the
// request to create the session would otherwise be cut at (the driver
// timeouts in the capabilities below are what actually bound it).
connectionRetryTimeout: 600_000,
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
// Tells the specs (which run in worker processes) what the app runs on;
// `process.platform` there is the host. See `platform` in test/helpers.
runnerEnv: { E2E_PLATFORM: platform },
onPrepare: async () => {
if (
!fs.existsSync(
path.join(repoRoot, 'packages', 'api', 'dist', 'index.js')
)
) {
throw new Error(
'packages/api/dist is missing — run `pnpm build:api` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// `examples/api`'s `tauri` script is `node ../../packages/cli/tauri.js`,
// which requires the native CLI to be built (`pnpm build:cli`).
//
// The generated Android Studio / Xcode project lives in `src-tauri/gen`
// (gitignored), so it is initialized on a fresh checkout. The build
// installs the Rust target it needs itself, so init skips that.
if (
!fs.existsSync(path.join(tauriDir, 'gen', ios ? 'apple' : 'android'))
) {
tauriCli([platform, 'init', '--ci', '--skip-targets-install'])
}
// A debug build, so wry turns on webview debugging (Android
// `setWebContentsDebuggingEnabled`, iOS `isInspectable`), which is what
// lets Appium reach the page. Only the target that the device/emulator
// actually runs is compiled.
tauriCli(
ios
? [
'ios',
'build',
'--debug',
'--target',
ios.name,
// Simulator builds are not code signed (see
// `additionalWebviewBundleIds` in `iosCapabilities`).
'--no-sign'
]
: [
'android',
'build',
'--debug',
'--apk',
'--target',
androidTarget()
]
)
}
if (!fs.existsSync(application)) {
throw new Error(
`app not found at ${application} — build it (unset E2E_SKIP_BUILD) or point E2E_APP_PATH at an existing build.`
)
}
},
// The session starts in the native (`NATIVE_APP`) context. Every spec goes
// through `window.__TAURI__`, so switch to the app's webview as soon as it
// is attachable and then block until the page has loaded (as the desktop
// config does).
before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => {
let webview: string | undefined
await browser.waitUntil(
async () => {
const contexts = (await browser.getAppiumContexts())
// Detailed objects are only returned with `appium:fullContextList`.
.map((context) =>
typeof context === 'string' ? context : context.id
)
// Android names the context after the package, and lists every
// debuggable WebView on the device (other apps included), so match
// ours exactly. iOS names it `WEBVIEW_<pid>.<n>` and only lists the
// app under test's webviews, so the first one is it.
webview =
contexts.find((name) => name === `WEBVIEW_${appId}`)
?? (platform === 'ios'
? contexts.find((name) => name.startsWith('WEBVIEW_'))
: undefined)
return webview !== undefined
},
{
// Covers a cold app start plus, on Android, the on-demand chromedriver
// download for the first session.
timeout: 120_000,
interval: 1000,
timeoutMsg:
'no WEBVIEW context appeared — is the app a debug build (webview debugging enabled)?'
}
)
await browser.switchAppiumContext(webview!)
// The specs run the page through `executeAsync`, and the XCUITest driver
// starts with a script timeout of 0 (every async script times out at
// once) rather than the 30s the other drivers default to.
await browser.setTimeout({ script: 30_000 })
await browser.waitUntil(
async () => {
try {
return (await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)) as boolean
} catch {
// A command issued mid-navigation can fail on a stale execution
// context; that just means "not ready yet".
return false
}
},
{
timeout: 30_000,
interval: 250,
timeoutMsg:
'window.__TAURI__ never became available — the app did not load its page.'
}
)
}
}
}
/** Runs `pnpm tauri <args>` in examples/api, failing loudly. */
function tauriCli(args: string[]): void {
const result = spawnSync('pnpm', ['tauri', ...args], {
cwd: appDir,
stdio: 'inherit',
shell: true
})
if (result.status !== 0) {
throw new Error(
`\`pnpm tauri ${args.join(' ')}\` failed with status ${result.status}`
)
}
}
// --- Android -----------------------------------------------------------------
function androidCapabilities(app: string): WebdriverIO.Capabilities {
return {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:app': app,
'appium:appPackage': appId,
'appium:appActivity': '.MainActivity',
// Which device/emulator to use; Appium picks the first connected one
// otherwise. E2E_ANDROID_AVD instead boots that AVD.
...(process.env.E2E_ANDROID_DEVICE
? { 'appium:udid': process.env.E2E_ANDROID_DEVICE }
: {}),
...(process.env.E2E_ANDROID_AVD
? { 'appium:avd': process.env.E2E_ANDROID_AVD }
: {}),
// chromedriver must match the WebView's Chrome version: either a specific
// binary, or one Appium downloads on demand.
...(process.env.E2E_CHROMEDRIVER
? { 'appium:chromedriverExecutable': process.env.E2E_CHROMEDRIVER }
: { 'appium:chromedriverAutodownload': true }),
'appium:autoGrantPermissions': true,
// Every build keeps the same version, so without this Appium sees the app
// as already installed and runs the previous build instead of this one.
'appium:enforceAppInstall': true,
// Emulators in CI are slow; give the UiAutomator2 server and adb room.
'appium:uiautomator2ServerInstallTimeout': 120_000,
'appium:uiautomator2ServerLaunchTimeout': 120_000,
'appium:adbExecTimeout': 60_000,
'appium:newCommandTimeout': 300
} as WebdriverIO.Capabilities
}
const androidTargets: Record<string, string> = {
// `ro.product.cpu.abi` -> `tauri android build --target`
'arm64-v8a': 'aarch64',
'armeabi-v7a': 'armv7',
x86_64: 'x86_64',
x86: 'i686'
}
/**
* The Rust target to build the APK for: `E2E_ANDROID_TARGET`, else the ABI of
* the connected device/emulator (via adb), else the host's (emulators run the
* host architecture).
*/
function androidTarget(): string {
if (process.env.E2E_ANDROID_TARGET) {
return process.env.E2E_ANDROID_TARGET
}
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
const adb = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
const abi = spawnSync(
adb,
[
...(process.env.E2E_ANDROID_DEVICE
? ['-s', process.env.E2E_ANDROID_DEVICE]
: []),
'shell',
'getprop',
'ro.product.cpu.abi'
],
{ encoding: 'utf8', timeout: 10_000 }
)
const detected =
abi.status === 0 ? androidTargets[abi.stdout.trim()] : undefined
return detected ?? (process.arch === 'arm64' ? 'aarch64' : 'x86_64')
}
// --- iOS ---------------------------------------------------------------------
function iosCapabilities(app: string): WebdriverIO.Capabilities {
return {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:app': app,
'appium:bundleId': appId,
// The driver looks the app up in the simulator's Web Inspector listing by
// bundle identifier. The inspector identifies an app by the
// `application-identifier` entitlement Xcode embeds when it code signs a
// simulator build (`__TEXT,__entitlements`); `--no-sign` skips signing
// altogether (`CODE_SIGNING_ALLOWED=NO`), so the entitlement is missing and
// the inspector falls back to `process-<executable name>`. Match that too.
'appium:additionalWebviewBundleIds': [
`process-${path.basename(app, '.app')}`
],
'appium:udid': iosSimulator(),
// As on Android: the version never changes between builds.
'appium:enforceAppInstall': true,
// No Simulator.app window. Besides not needing one, the driver otherwise
// shuts a simulator that is booted without a visible UI down to relaunch it
// with one, on every session — and that shutdown regularly outlasts the
// driver's 15s limit on it.
'appium:isHeadless': true,
// A prebuilt WebDriverAgent (Appium's release build for the simulator, see
// `appium driver run xcuitest download-wda -- --kind sim`) is installed and
// launched as is, instead of being compiled with xcodebuild on the first
// session, which takes minutes on a CI runner.
...(process.env.E2E_IOS_WDA
? {
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': process.env.E2E_IOS_WDA
}
: {}),
// Without one, WebDriverAgent is compiled on the first session, which takes
// minutes on a CI runner.
'appium:wdaLaunchTimeout': 240_000,
'appium:wdaStartupRetries': 3,
'appium:simulatorStartupTimeout': 240_000,
'appium:newCommandTimeout': 300
} as WebdriverIO.Capabilities
}
/**
* The Rust target to build the simulator app for (`E2E_IOS_TARGET`, else the
* host's architecture) and the directory the CLI exports it to.
*/
function iosTarget(): { name: string; outputArch: string } {
const name =
process.env.E2E_IOS_TARGET
?? (process.arch === 'arm64' ? 'aarch64-sim' : 'x86_64')
// `tauri ios build` writes to gen/apple/build/<arch>/ (cargo-mobile2's
// `arch` for the target).
const outputArch = name === 'aarch64-sim' ? 'arm64-sim' : name
return { name, outputArch }
}
interface SimctlDevice {
name: string
udid: string
state: string
isAvailable: boolean
deviceTypeIdentifier?: string
}
/**
* UDID of the simulator to run on: `E2E_IOS_DEVICE` (a UDID or device name),
* else an already-booted iPhone, else the iPhone on the newest installed
* runtime. Resolved through `simctl` so nothing has to be hardcoded per Xcode
* version.
*/
function iosSimulator(): string {
const requested = process.env.E2E_IOS_DEVICE
if (requested && /^[0-9A-F-]{36}$/i.test(requested)) {
return requested
}
const list = spawnSync(
'xcrun',
['simctl', 'list', 'devices', 'available', '--json'],
{ encoding: 'utf8' }
)
if (list.status !== 0) {
throw new Error(
`\`xcrun simctl list\` failed — is Xcode installed?\n${list.stderr}`
)
}
const runtimes = JSON.parse(list.stdout).devices as Record<
string,
SimctlDevice[]
>
const iphones = Object.entries(runtimes)
// e.g. `com.apple.CoreSimulator.SimRuntime.iOS-18-2`
.filter(([runtime]) => runtime.includes('.iOS-'))
.sort(([a], [b]) => runtimeVersion(b) - runtimeVersion(a))
.flatMap(([, devices]) =>
devices.filter(
(device) =>
device.isAvailable
&& (device.deviceTypeIdentifier ?? device.name).includes('iPhone')
)
)
const device = requested
? iphones.find((candidate) => candidate.name === requested)
: (iphones.find((candidate) => candidate.state === 'Booted') ?? iphones[0])
if (!device) {
throw new Error(
requested
? `no available iPhone simulator named "${requested}" (E2E_IOS_DEVICE)`
: 'no available iPhone simulator found — install an iOS runtime in Xcode.'
)
}
return device.udid
}
function runtimeVersion(runtime: string): number {
const [major = '0', minor = '0'] = (runtime.split('.iOS-')[1] ?? '').split(
'-'
)
return Number(major) * 100 + Number(minor)
}