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
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:
196
packages/api-e2e/README.md
Normal file
196
packages/api-e2e/README.md
Normal 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.
|
||||
33
packages/api-e2e/package.json
Normal file
33
packages/api-e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
packages/api-e2e/tauri.e2e.conf.json
Normal file
16
packages/api-e2e/tauri.e2e.conf.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
243
packages/api-e2e/test/helpers/index.ts
Normal file
243
packages/api-e2e/test/helpers/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
64
packages/api-e2e/test/specs/app.spec.ts
Normal file
64
packages/api-e2e/test/specs/app.spec.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
109
packages/api-e2e/test/specs/core.spec.ts
Normal file
109
packages/api-e2e/test/specs/core.spec.ts
Normal 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\/)/)
|
||||
})
|
||||
})
|
||||
54
packages/api-e2e/test/specs/dpi.spec.ts
Normal file
54
packages/api-e2e/test/specs/dpi.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
125
packages/api-e2e/test/specs/event.spec.ts
Normal file
125
packages/api-e2e/test/specs/event.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
31
packages/api-e2e/test/specs/image.spec.ts
Normal file
31
packages/api-e2e/test/specs/image.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
88
packages/api-e2e/test/specs/menu.spec.ts
Normal file
88
packages/api-e2e/test/specs/menu.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
packages/api-e2e/test/specs/path.spec.ts
Normal file
62
packages/api-e2e/test/specs/path.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
43
packages/api-e2e/test/specs/tray.spec.ts
Normal file
43
packages/api-e2e/test/specs/tray.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
44
packages/api-e2e/test/specs/webview.spec.ts
Normal file
44
packages/api-e2e/test/specs/webview.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
64
packages/api-e2e/test/specs/webviewWindow.spec.ts
Normal file
64
packages/api-e2e/test/specs/webviewWindow.spec.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
295
packages/api-e2e/test/specs/window.spec.ts
Normal file
295
packages/api-e2e/test/specs/window.spec.ts
Normal 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
17
packages/api-e2e/test/types/global.d.ts
vendored
Normal 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 {}
|
||||
20
packages/api-e2e/tsconfig.json
Normal file
20
packages/api-e2e/tsconfig.json
Normal 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"]
|
||||
}
|
||||
10
packages/api-e2e/wdio.android.conf.ts
Normal file
10
packages/api-e2e/wdio.android.conf.ts
Normal 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')
|
||||
296
packages/api-e2e/wdio.conf.ts
Normal file
296
packages/api-e2e/wdio.conf.ts
Normal 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)
|
||||
10
packages/api-e2e/wdio.ios.conf.ts
Normal file
10
packages/api-e2e/wdio.ios.conf.ts
Normal 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')
|
||||
425
packages/api-e2e/wdio.mobile.ts
Normal file
425
packages/api-e2e/wdio.mobile.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user