feat: add e2e tests for API (#15760)

* feat: add e2e tests for API

* fix pnpm

* ci

* test on push for now

* workflow_dispatch

* fix lint

* fix CI build on mac

* fix audit

* fix tests

* fix windows CI

* fix tests

* install driver fix

* fix windows test

* test ci

* test

* test

* fix ci

* actually fix windows ci test

* test

* pin

* fix tests

* remove api env override

* bump actions, fix workaround

* code review

* bump deps
This commit is contained in:
Lucas Fernandes Nogueira authored and GitHub committed 2026-09-16 13:36:41 -03:00
1 parent 51433d740f
commit 0a28d9b764
31 files changed
+5282 -40

No files matched your search

@@ -0,0 +1,5 @@
---
"tauri-runtime-wry": patch:bug
---
On macOS, `App::set_theme` now also updates the theme each window reports, so `Window::theme` (and `getCurrentWindow().theme()` in JavaScript) reflects the app-level theme instead of keeping the previous value until the system appearance changes.
+2
View File
@@ -40,4 +40,6 @@ jobs:
node-version: 'lts/*'
cache: 'pnpm'
- run: pnpm i --frozen-lockfile
- run: pnpm build
working-directory: packages/api
- run: pnpm ts:check
+143
View File
@@ -0,0 +1,143 @@
# Copyright 2019-2024 Tauri Programme within The Commons Conservancy
# SPDX-License-Identifier: Apache-2.0
# SPDX-License-Identifier: MIT
name: test `@tauri-apps/api` e2e
on:
workflow_dispatch:
push:
branches:
- dev
pull_request:
paths:
- '.github/workflows/test-api-e2e.yml'
- 'packages/api/**'
- 'packages/api-e2e/**'
- 'examples/api/**'
env:
RUST_BACKTRACE: 1
CARGO_PROFILE_DEV_DEBUG: 0 # keeps the target folder small for better cache efficiency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ${{ matrix.platform.os }}
strategy:
fail-fast: false
matrix:
platform:
- { name: Linux, os: ubuntu-latest }
- { name: Windows, os: windows-latest }
- { name: macOS, os: macos-latest }
steps:
- uses: actions/checkout@v7
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- run: npm i -g --force corepack
- name: setup node
uses: actions/setup-node@v7
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
webkit2gtk-driver \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
xvfb \
fluxbox
# install a matching Microsoft Edge Driver version using msedgedriver-tool
- name: install msedgedriver (Windows)
if: runner.os == 'Windows'
run: |
cargo install --git https://github.com/chippers/msedgedriver-tool
& "$HOME/.cargo/bin/msedgedriver-tool.exe"
$PWD.Path >> $env:GITHUB_PATH
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.platform.os }}
- name: install dependencies
run: pnpm i --frozen-lockfile
- name: build @tauri-apps/api
run: pnpm build:api
- name: build @tauri-apps/cli
run: pnpm build:cli
- name: run e2e tests (Linux)
if: runner.os == 'Linux'
timeout-minutes: 45
# A real window manager (fluxbox) is started so that window-state tests
# (minimize/maximize/fullscreen/position) behave. The tray host is absent
# in this environment, so the tray module is skipped.
run: |
xvfb-run --auto-servernum -- bash -c '
fluxbox >/dev/null 2>&1 &
sleep 1
E2E_SKIP=tray E2E_SPEC_RETRIES=1 pnpm test:api-e2e
'
- name: run e2e tests (Windows)
if: runner.os == 'Windows'
timeout-minutes: 45
shell: pwsh
# msedgedriver hands the app the `--remote-debugging-port` it attaches to through
# `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, and WebView2 ignores that variable in an
# elevated process, which is what the runner gives us. gsudo runs the suite at medium
# integrity instead, and the workspace is opened up so the de-elevated process can use it.
# See https://github.com/tauri-apps/wry/issues/1782 and
# https://github.com/MicrosoftEdge/WebView2Feedback/issues/5645.
# The runner has an interactive desktop session, so window-state tests run as-is;
# the tray module is skipped to match the Linux job.
env:
E2E_SKIP: tray
E2E_SPEC_RETRIES: '1'
run: |
winget install gerardog.gsudo --accept-source-agreements --accept-package-agreements
icacls "$env:GITHUB_WORKSPACE" /grant "Everyone:(OI)(CI)F"
sudo --integrity Medium pnpm test:api-e2e
- name: run e2e tests (macOS)
if: runner.os == 'macOS' && env.CN_API_KEY != ''
timeout-minutes: 45
# macOS has no native WebDriver for WKWebView, so the app is driven through the
# CrabNebula Webdriver (tauri-plugin-automation + test-runner-backend), which
# authenticates with CN_API_KEY. wdio.conf enables it automatically on darwin and
# builds the `.app` bundle it needs. The tray module is skipped to match the other jobs.
run: E2E_SKIP=tray E2E_SPEC_RETRIES=1 pnpm test:api-e2e
env:
CN_API_KEY: ${{ secrets.TAURI_E2E_CN_API_KEY }}
- name: collect app crash reports (macOS)
if: runner.os == 'macOS' && failure()
run: .scripts/ci/collect-macos-crash-reports.sh "$RUNNER_TEMP/crash-reports"
- name: upload app crash reports (macOS)
if: runner.os == 'macOS' && failure()
uses: actions/upload-artifact@v7
with:
name: macos-crash-reports
path: ${{ runner.temp }}/crash-reports
if-no-files-found: ignore
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Copyright 2019-2024 Tauri Programme within The Commons Conservancy
# SPDX-License-Identifier: Apache-2.0
# SPDX-License-Identifier: MIT
# Collects and prints macOS crash reports after a failed `@tauri-apps/api` e2e run.
#
# The CrabNebula Webdriver proxies every command to a server running *inside* the app
# process, so when the app dies the suite only ever reports `connection refused` — the
# actual cause is never in the job log. The app's stdout/stderr is inherited and no panic
# message is printed, which means it goes down on a signal. macOS records the reason in a
# crash report, and that is the only place it survives the run.
#
# Copies every report into $1 (default: $RUNNER_TEMP/crash-reports) for upload.
set -euo pipefail
dest="${1:-${RUNNER_TEMP:-/tmp}/crash-reports}"
mkdir -p "$dest"
# ReportCrash writes asynchronously; give the last crash a moment to land.
sleep 5
found=0
for f in "$HOME/Library/Logs/DiagnosticReports"/*.ips \
"$HOME/Library/Logs/DiagnosticReports/Retired"/*.ips; do
[ -e "$f" ] || continue
found=$((found + 1))
cp "$f" "$dest/"
echo "::group::$(basename "$f")"
# An .ips file is a one-line JSON header followed by the JSON report body.
head -n 1 "$f"
tail -n +2 "$f" | jq -r '
"exception: \(.exception // {} | tojson)",
"termination: \(.termination // {} | tojson)",
"asi: \(.asi // {} | tojson)",
"faulting thread \(.faultingThread // 0):",
(. as $r
| $r.threads[$r.faultingThread // 0].frames[]?
| " \($r.usedImages[.imageIndex].name // "?") \(.symbol // "?") +\(.imageOffset)"),
"last ObjC exception:",
(. as $r
| $r.lastExceptionBacktrace[]?
| " \($r.usedImages[.imageIndex].name // "?") \(.symbol // "?") +\(.imageOffset)")
' || cat "$f"
echo "::endgroup::"
done
[ "$found" -gt 0 ] || echo "no crash reports under $HOME/Library/Logs/DiagnosticReports"
Generated
+50 -2
View File
@@ -240,6 +240,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-automation",
"tauri-plugin-log",
"tauri-plugin-sample",
]
@@ -1202,9 +1203,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -4423,6 +4424,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libloading"
version = "0.9.0"
@@ -5222,9 +5233,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
@@ -5244,6 +5263,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.0",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
@@ -5304,6 +5324,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.0",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -9177,6 +9210,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-automation"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "314d4ba88ce4a5e17c5f1e2dad990edb5956643b6e04a6d127d39aa64d0f2a7d"
dependencies = [
"libloading 0.8.9",
"objc2-app-kit",
"serde_json",
"tauri",
"tauri-plugin",
"tokio",
"tracing",
]
[[package]]
name = "tauri-plugin-log"
version = "2.9.0"
+11
View File
@@ -4052,6 +4052,17 @@ fn handle_user_message<T: UserEvent>(
}
EventLoopWindowTargetMessage::SetTheme(theme) => {
event_loop.set_theme(to_tao_theme(theme));
// On macOS tao caches each window's theme and only refreshes it from the
// system-wide appearance change notification, which the app-level
// `NSApp.setAppearance` call above never posts, so `Window::theme()`
// would keep reporting the previous value. tao's window-level setter
// does update the cache, so push the theme through it as well.
#[cfg(target_os = "macos")]
for window in windows.0.borrow().values() {
if let Some(inner) = &window.inner {
inner.set_theme(to_tao_theme(theme));
}
}
}
EventLoopWindowTargetMessage::SetDeviceEventFilter(filter) => {
event_loop.set_device_event_filter(DeviceEventFilterWrapper::from(filter).0);
+8
View File
@@ -23,6 +23,12 @@ log = "0.4.21"
tauri-plugin-sample = { path = "./tauri-plugin-sample/" }
tauri-plugin-log = "2"
# WebDriver automation bridge, used by the `@tauri-apps/api` e2e suite (packages/api-e2e).
# Desktop-only and behind the off-by-default `automation` feature so it never ships in a
# regular build.
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-automation = { version = "0.1.4", optional = true }
[dependencies.tauri]
workspace = true
default-features = true
@@ -41,3 +47,5 @@ features = ["test"]
[features]
prod = ["tauri/custom-protocol"]
# Enables the WebDriver automation bridge; set by the e2e suite's build (packages/api-e2e).
automation = ["dep:tauri-plugin-automation"]
@@ -66,6 +66,7 @@
"core:window:allow-set-icon",
"core:window:allow-toggle-maximize",
"core:webview:allow-create-webview-window",
"core:webview:allow-print"
"core:webview:allow-print",
"core:webview:allow-set-webview-zoom"
]
}
+8 -4
View File
@@ -38,6 +38,13 @@ pub fn run_app<R: Runtime, F: FnOnce(&App<R>) + Send + 'static>(
builder: tauri::Builder<R>,
setup: F,
) {
// WebDriver automation bridge for the `@tauri-apps/api` e2e suite (packages/api-e2e).
// Registered as early as possible per the plugin's docs. Behind the off-by-default
// `automation` feature, and `not(test)` so it never interferes with the mock-runtime
// unit test below.
#[cfg(all(desktop, feature = "automation", not(test)))]
let builder = builder.plugin(tauri_plugin_automation::init());
let builder = builder
.plugin(
tauri_plugin_log::Builder::default()
@@ -105,10 +112,7 @@ pub fn run_app<R: Runtime, F: FnOnce(&App<R>) + Send + 'static>(
});
}
let webview = window_builder.build()?;
#[cfg(debug_assertions)]
webview.open_devtools();
let _webview = window_builder.build()?;
let value = Some("test".to_string());
let response = app.sample().ping(PingRequest {
+11 -14
View File
@@ -85,7 +85,7 @@
(localStorage.getItem('theme') as Theme | null) || 'auto'
)
async function switchTheme() {
function switchTheme() {
switch (theme) {
case 'dark':
theme = 'light'
@@ -97,24 +97,21 @@
theme = 'dark'
break
}
applyTheme()
}
function applyTheme() {
// Follows the selection, or the system when it is `auto`.
$effect(() => {
const isDark = theme === 'auto' ? preferDark.current : theme === 'dark'
if (isDark) {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
document.documentElement.classList.toggle('dark', isDark)
document.documentElement.classList.toggle('light', !isDark)
})
// Only the selection drives the app-wide theme. Reading `preferDark` here would
// make a system (or externally applied) theme change run this again and reset the
// app back to `auto`, undoing whatever just set it.
$effect(() => {
setTheme(theme === 'auto' ? null : theme)
localStorage.setItem('theme', theme)
}
$effect(() => {
applyTheme()
})
// Console
+1
View File
@@ -22,6 +22,7 @@
"build:cli": "pnpm run --filter \"@tauri-apps/cli\" build",
"build:cli:debug": "pnpm run --filter \"@tauri-apps/cli\" build:debug",
"test": "pnpm run -r test",
"test:api-e2e": "pnpm run --filter \"@tauri-apps/api-e2e\" e2e",
"example:api:dev": "pnpm run --filter \"api\" tauri dev"
},
"devDependencies": {
+130
View File
@@ -0,0 +1,130 @@
# `@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. Each module has its own spec file, 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.
- 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.
- 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.
- A fresh `tauri-driver` (and therefore a fresh app instance) is started per spec file, 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. |
## 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
```
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).
## 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. |
## 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 `process.platform` (Node side) for platform-specific behavior.
### 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.
+26
View File
@@ -0,0 +1,26 @@
{
"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",
"ts:check": "tsc --noEmit"
},
"devDependencies": {
"@crabnebula/tauri-driver": "^2.0.9",
"@crabnebula/test-runner-backend": "^0.2.9",
"@tauri-apps/api": "../api/dist",
"@types/node": "^22.10.0",
"@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",
"expect-webdriverio": "^5.0.0",
"typescript": "^6.0.3",
"webdriverio": "^9.31.9"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "../../crates/tauri-schema-generator/schemas/config.schema.json",
"build": {
"removeUnusedCommands": false,
"features": ["automation"]
},
"bundle": {
"icon": [
"../../.icons/32x32.png",
"../../.icons/128x128.png",
"../../.icons/128x128@2x.png",
"../../.icons/icon.icns",
"../../.icons/icon.ico"
]
}
}
+163
View File
@@ -0,0 +1,163 @@
// 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
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.
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) { done({ ok: true, value: value === undefined ? null : value }); },
function (error) {
done({
ok: false,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
}
);
`
const outcome = (await browser.executeAsync(script, ...args)) 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)
/**
* `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 {
const title = `@tauri-apps/api/${module}`
if (skippedModules.includes(module)) {
describe.skip(title, fn)
} else {
describe(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).
*/
export function itWm(title: string, fn: () => void | Promise<void>): void {
if (process.env.E2E_SKIP_WM) {
it.skip(title, fn)
} else {
it(title, fn)
}
}
+57
View File
@@ -0,0 +1,57 @@
// 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 } 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). Elsewhere the change is reflected on the window.
if (process.platform !== 'linux') {
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))
})
})
+92
View File
@@ -0,0 +1,92 @@
// 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 } 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)
})
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\/)/)
})
})
+65
View File
@@ -0,0 +1,65 @@
// 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 })
})
it('window sizes convert consistently against the real scale factor', async () => {
const consistent = await tauri(async (api) => {
const w = api.window.getCurrentWindow()
const scale = await w.scaleFactor()
const inner = await w.innerSize()
const logical = inner.toLogical(scale)
return Math.abs(logical.width * scale - inner.width) < 1
})
expect(consistent).toBe(true)
})
})
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
describeApi('event', () => {
it('listen receives an emitted payload', async () => {
const payload = await tauri(
(api) =>
new Promise<{ hello: string }>((resolve, reject) => {
api.event
.listen<{ hello: string }>('e2e://ping', (event) =>
resolve(event.payload)
)
.then((unlisten) => {
api.event.emit('e2e://ping', { hello: 'world' }).catch(reject)
setTimeout(() => {
unlisten()
reject(new Error('event was not received'))
}, 5000)
})
.catch(reject)
})
)
expect(payload).toEqual({ hello: 'world' })
})
it('once fires exactly once even if the event is emitted twice', async () => {
const count = await tauri(
(api) =>
new Promise<number>((resolve, reject) => {
let calls = 0
api.event
.once('e2e://once', () => {
calls++
})
.then(async () => {
await api.event.emit('e2e://once')
await api.event.emit('e2e://once')
setTimeout(() => resolve(calls), 1000)
})
.catch(reject)
})
)
expect(count).toBe(1)
})
it('unlisten stops further delivery', async () => {
const count = await tauri(
(api) =>
new Promise<number>((resolve, reject) => {
let calls = 0
api.event
.listen('e2e://off', () => {
calls++
})
.then(async (unlisten) => {
await api.event.emit('e2e://off')
setTimeout(async () => {
await unlisten()
await api.event.emit('e2e://off')
setTimeout(() => resolve(calls), 500)
}, 500)
})
.catch(reject)
})
)
expect(count).toBe(1)
})
it('emitTo delivers only to the targeted label', async () => {
const result = await tauri(
(api) =>
new Promise<{ mainCount: number }>((resolve, reject) => {
let mainCount = 0
api.event
// The listener must be scoped to the `main` label: a default
// `listen()` registers the `Any` target, a catch-all that receives
// every emit regardless of the `emitTo` label (so it would also see
// the `does-not-exist` emit).
.listen(
'e2e://target',
() => {
mainCount++
},
{ target: 'main' }
)
.then(async (unlisten) => {
await api.event.emitTo('main', 'e2e://target') // delivered
await api.event.emitTo('does-not-exist', 'e2e://target') // dropped
setTimeout(async () => {
await unlisten()
resolve({ mainCount })
}, 800)
})
.catch(reject)
})
)
expect(result.mainCount).toBe(1)
})
it('bridges events to and from the Rust backend', async () => {
// examples/api listens for `js-event` and replies with `rust-event { data }`.
const reply = await tauri(
(api) =>
new Promise<{ data: string }>((resolve, reject) => {
api.event
.listen<{ data: string }>('rust-event', (event) =>
resolve(event.payload)
)
.then((unlisten) => {
api.event.emit('js-event', 'hello from e2e').catch(reject)
setTimeout(() => {
unlisten()
reject(new Error('no rust-event reply received'))
}, 5000)
})
.catch(reject)
})
)
expect(reply).toEqual({ data: 'something else' })
})
})
+41
View File
@@ -0,0 +1,41 @@
// 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)
})
it('the default window icon is a usable image', 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)
})
})
+95
View File
@@ -0,0 +1,95 @@
// 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.
describeApi('menu', () => {
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)
})
it('Menu.default builds the platform default menu', async () => {
const count = await tauri(async (api) => {
const menu = await api.menu.Menu.default()
return (await menu.items()).length
})
expect(count).toBeGreaterThanOrEqual(0)
})
})
+57
View File
@@ -0,0 +1,57 @@
// 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 { expect } from '@wdio/globals'
import { tauri, describeApi } from '../helpers/index.js'
describeApi('path', () => {
it('sep and delimiter match the host 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)
})
})
+42
View File
@@ -0,0 +1,42 @@
// 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.
describeApi('tray', () => {
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)
})
})
@@ -0,0 +1,42 @@
// 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('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')
})
it('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)
})
it('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
})
})
})
@@ -0,0 +1,61 @@
// 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 } from '../helpers/index.js'
describeApi('webviewWindow', () => {
it('getCurrentWebviewWindow reports the main label', async () => {
expect(
await tauri((api) => api.webviewWindow.getCurrentWebviewWindow().label)
).toBe('main')
})
it('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')
}
})
})
})
+254
View File
@@ -0,0 +1,254 @@
// 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 } 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')
})
it('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)
})
it('reports a theme', async () => {
const theme = await tauri((api) => api.window.getCurrentWindow().theme())
expect(['light', 'dark']).toContain(theme)
})
it('monitor queries resolve to sane shapes', async () => {
const monitors = await tauri(async (api) => {
const current = await api.window.currentMonitor()
const primary = await api.window.primaryMonitor()
const available = await api.window.availableMonitors()
return {
currentHasSize: current
? current.size.width > 0 && current.size.height > 0
: null,
primaryHasSize: primary
? primary.size.width > 0 && primary.size.height > 0
: null,
availableCount: available.length
}
})
// In a headed environment there is at least one monitor; in bare headless
// there may be none, so only assert shape when present.
if (monitors.currentHasSize !== null) {
expect(monitors.currentHasSize).toBe(true)
}
expect(monitors.availableCount).toBeGreaterThanOrEqual(0)
})
it('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
)
})
it('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)
})
it('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('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
View File
@@ -0,0 +1,17 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// The examples/api app is built with `withGlobalTauri: true`, so the whole
// `@tauri-apps/api` surface is available on `window.__TAURI__` inside the webview.
// This mirrors that for the functions we serialize and run in the page.
import type * as TauriApi from '@tauri-apps/api'
declare global {
interface Window {
__TAURI__: typeof TauriApi
}
}
export {}
+25
View File
@@ -0,0 +1,25 @@
{
"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",
"expect-webdriverio"
],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["wdio.conf.ts", "test/**/*.ts"]
}
+296
View File
@@ -0,0 +1,296 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import path from 'node:path'
import fs from 'node:fs'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { waitTauriDriverReady } from '@crabnebula/tauri-driver'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(dirname, '..', '..')
const appDir = path.join(repoRoot, 'examples', 'api')
const targetDir = process.env.CARGO_TARGET_DIR ?? path.join(repoRoot, 'target')
// macOS has no native WebDriver for WKWebView, so the CrabNebula Webdriver
// (backed by tauri-plugin-automation + the test-runner-backend) is required there.
// It can be opted into on the other platforms via E2E_CN_WEBDRIVER for parity.
const useCrabNebulaWebdriver =
process.platform === 'darwin' || !!process.env.E2E_CN_WEBDRIVER
// Path passed to the driver as `tauri:options.application`.
const application =
process.env.E2E_APP_PATH
?? (process.platform === 'darwin'
? path.join(targetDir, 'debug', 'bundle', 'macos', 'Tauri API.app')
: process.platform === 'win32'
? path.join(targetDir, 'debug', 'api.exe')
: path.join(targetDir, 'debug', 'api'))
let tauriDriver: ChildProcess | undefined
let killedTauriDriver = false
let testRunnerBackend: ChildProcess | undefined
let killedTestRunnerBackend = false
export const config: WebdriverIO.Config = {
hostname: '127.0.0.1',
port: 4444,
specs: ['./test/specs/**/*.spec.ts'],
// The driver spawns a single app instance and speaks WebDriver to it, so the
// suite must run serially.
maxInstances: 1,
capabilities: [
{
maxInstances: 1,
// `tauri:options` is understood by tauri-driver, not by the WebdriverIO types.
'tauri:options': { application }
} as unknown as WebdriverIO.Capabilities
],
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000
},
connectionRetryCount: 0,
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
onPrepare: async () => {
// The example resolves `@tauri-apps/api` from `packages/api/dist`, and its
// frontend build (vite) needs it too. Fail early with a clear message.
if (
!fs.existsSync(path.join(repoRoot, 'packages', 'api', 'dist', 'index.js'))
) {
throw new Error(
'packages/api/dist is missing — run `pnpm build:api` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// `examples/api`'s `tauri` script is `node ../../packages/cli/tauri.js`,
// which requires the native CLI to be built (`pnpm build:cli`).
//
// The override config does two things for this (debug, test-only) build:
// - enables the example's off-by-default `automation` feature, which
// registers tauri-plugin-automation;
// - disables `removeUnusedCommands`, which the example otherwise sets.
// It strips command handlers that no static capability grants, and the
// plugin adds its `resolve` command through a *runtime* capability, so
// it would be stripped — breaking the CrabNebula Webdriver on macOS.
// Passed as an appDir-relative path to sidestep shell quoting.
const overrideConfig = path.relative(
appDir,
path.join(dirname, 'tauri.e2e.conf.json')
)
const buildArgs = [
'tauri',
'build',
'--debug',
'--config',
overrideConfig,
...(process.platform === 'darwin'
? ['--bundles', 'app'] // the .app bundle is needed for tauri:options
: ['--no-bundle'])
]
const build = spawnSync('pnpm', buildArgs, {
cwd: appDir,
stdio: 'inherit',
shell: true
})
if (build.status !== 0) {
throw new Error(
`\`pnpm ${buildArgs.join(' ')}\` failed with status ${build.status}`
)
}
}
if (!fs.existsSync(application)) {
throw new Error(
`app not found at ${application} — build it (unset E2E_SKIP_BUILD) or point E2E_APP_PATH at an existing build.`
)
}
if (useCrabNebulaWebdriver) {
if (!process.env.CN_API_KEY) {
throw new Error(
'CN_API_KEY is required for the CrabNebula Webdriver (mandatory on macOS, or when E2E_CN_WEBDRIVER=1).'
)
}
testRunnerBackend = spawn('pnpm', ['exec', 'test-runner-backend'], {
cwd: dirname,
stdio: 'inherit',
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm ->
// test-runner-backend) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
testRunnerBackend.on('error', (error) => {
console.error('test-runner-backend error:', error)
process.exit(1)
})
testRunnerBackend.on('exit', (code) => {
if (!killedTestRunnerBackend) {
console.error('test-runner-backend exited with code:', code)
process.exit(1)
}
})
const { waitTestRunnerBackendReady } =
await import('@crabnebula/test-runner-backend')
await waitTestRunnerBackendReady()
process.env.REMOTE_WEBDRIVER_URL = 'http://127.0.0.1:3000'
}
},
// A fresh tauri-driver (and therefore a fresh app instance) per spec file,
// so each module's suite runs in isolation.
beforeSession: async () => {
const args = ['exec', 'tauri-driver']
if (process.env.E2E_NATIVE_DRIVER) {
args.push('--native-driver', process.env.E2E_NATIVE_DRIVER)
}
// Reset before each (re)spawn so the `exit` handler below still treats an
// unexpected driver crash as fatal on retried spec files.
killedTauriDriver = false
tauriDriver = spawn('pnpm', args, {
cwd: dirname,
stdio: [null, process.stdout, process.stderr],
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm -> tauri-driver
// -> native webdriver) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
tauriDriver.on('error', (error) => {
console.error('tauri-driver error:', error)
process.exit(1)
})
tauriDriver.on('exit', (code) => {
if (!killedTauriDriver) {
console.error('tauri-driver exited with code:', code)
process.exit(1)
}
})
await waitTauriDriverReady()
},
// The session is created as soon as the app's window exists, which can be
// before the webview has navigated to the app's page: WebView2 on a cold start
// (the first launches on a fresh Windows runner) still shows `about:blank`
// for a few seconds, so the first spec's scripts ran in a page without
// `window.__TAURI__`. Every spec goes through that global, so block until
// it exists.
before: async (_capabilities, _specs, browser) => {
await browser.waitUntil(
async () => {
try {
return (await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)) as boolean
} catch {
// A command issued mid-navigation can fail on a stale execution
// context; that just means "not ready yet".
return false
}
},
{
timeout: 30_000,
interval: 250,
timeoutMsg:
'window.__TAURI__ never became available — the app did not load its page.'
}
)
},
// Awaited so the driver (and its port) is fully gone before the next spec's
// beforeSession spawns a new one on the same port.
afterSession: async () => {
await closeTauriDriver()
},
onComplete: () => {
closeAll()
}
}
/**
* Kills a shell-spawned child and everything it started. `child.kill()` only
* signals the `sh`/`cmd` wrapper, orphaning the real process (which keeps
* holding the WebDriver port and poisons every later spec), so we take down the
* whole process group/tree instead.
*/
function killProcessTree(
child: ChildProcess | undefined,
signal: NodeJS.Signals = 'SIGTERM'
): void {
const pid = child?.pid
if (!pid || child?.exitCode !== null) return
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
stdio: 'ignore'
})
} else {
try {
// Negative pid targets the whole process group (see `detached` above).
process.kill(-pid, signal)
} catch {
try {
child!.kill(signal)
} catch {
// already gone
}
}
}
}
async function closeTauriDriver(): Promise<void> {
killedTauriDriver = true
const driver = tauriDriver
tauriDriver = undefined
if (!driver || driver.exitCode !== null) return
const exited = new Promise<void>((resolve) =>
driver.once('exit', () => resolve())
)
killProcessTree(driver)
// Give it a moment to release the port, then escalate to SIGKILL.
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
exited,
new Promise<void>((resolve) => {
timer = setTimeout(() => {
killProcessTree(driver, 'SIGKILL')
resolve()
}, 5000)
})
])
if (timer) clearTimeout(timer)
}
function closeAll() {
killedTauriDriver = true
killProcessTree(tauriDriver)
tauriDriver = undefined
killedTestRunnerBackend = true
killProcessTree(testRunnerBackend)
testRunnerBackend = undefined
}
function onShutdown(fn: () => void) {
const cleanup = () => {
try {
fn()
} finally {
process.exit()
}
}
for (const signal of [
'exit',
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGBREAK'
] as const) {
process.on(signal, cleanup)
}
}
onShutdown(closeAll)
+3361 -19
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -1,5 +1,6 @@
packages:
- packages/api
- packages/api-e2e
- packages/cli
- crates/tauri-schema-worker
- examples/api
@@ -7,12 +8,37 @@ packages:
- examples/file-associations
allowBuilds:
# transitive deps of @wdio/utils; their postinstall downloads browser drivers
# the Tauri suite never uses (it drives the app via tauri-driver)
edgedriver: false
esbuild: true
geckodriver: false
sharp: true
workerd: true
minimumReleaseAgeExclude:
- serialize-javascript@7.0.5
- brace-expansion@1.1.16 || 2.1.2 || 5.0.7
- js-yaml@4.3.0
- sharp@0.35.0
- fast-xml-parser@5.10.1
overrides:
brace-expansion@<1.1.16: ^1.1.16
brace-expansion@>=2.0.0 <2.1.2: ^2.1.2
brace-expansion@>=3.0.0 <5.0.7: ^5.0.7
diff@>=6.0.0 <8.0.3: ^8.0.3
esbuild@>=0.27.3 <0.28.1: '>=0.28.1'
ip-address@<10.3.1: ^10.3.1
serialize-javascript@<7.0.5: ^7.0.5
sharp@<0.35.4: ^0.35.4
undici@>=7.0.0 <7.29.0: ^7.29.0
ws@>=8.0.0 <8.20.1: '>=8.20.1'
auditConfig:
ignoreGhsas:
# extract-zip symlink path traversal; no patched release exists (latest is
# still 2.0.1). Reached via @wdio/utils > @puppeteer/browsers, which only
# unpacks the browser drivers we never download (see allowBuilds above)
- GHSA-jmr9-qjv8-65gv
- GHSA-7pqw-9j4j-h8q3