mirror of
https://github.com/aphrody-labs/tauri.git
synced 2026-10-17 12:45:56 +00:00
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:
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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+50
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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\/)/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
Generated
+3361
-19
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
Reference in new issue
Block a user