feat(tests): add api e2e tests (#3617)

* feat(tests): add api e2e tests

* lockfile

* mobile

* try linux fix [skip ci]

* ios fix

* fix test on windows

* improve cache

* fix pnpm audit [skip ci]
This commit is contained in:
Lucas Fernandes Nogueira
2026-09-22 18:58:14 -03:00
committed by GitHub
parent 7f87091288
commit 684feb2510
47 changed files with 10292 additions and 107 deletions
@@ -0,0 +1,6 @@
---
notification: patch
notification-js: patch
---
Fixed `isPermissionGranted()` always resolving to `false` on Windows. The initialization script short-circuited the permission check to avoid invoking the backend, but read the permission back before it had been set, so it settled on `denied` instead of `granted`.
+8
View File
@@ -12,6 +12,7 @@ on:
paths:
- '.github/workflows/lint-javascript.yml'
- 'plugins/*/guest-js/**'
- 'packages/**'
- '.eslintignore'
- '.eslintrc.json'
- '.prettierignore'
@@ -23,6 +24,7 @@ on:
paths:
- '.github/workflows/lint-javascript.yml'
- 'plugins/*/guest-js/**'
- 'packages/**'
- '.eslintignore'
- '.eslintrc.json'
- '.prettierignore'
@@ -47,5 +49,11 @@ jobs:
with:
run_install: true
cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow
# the type-checked lint rules (and packages/api-e2e) resolve the plugins'
# types from their dist-js build output
- name: build
run: pnpm build
- name: eslint
run: pnpm lint
- name: ts:check
run: pnpm ts:check
+232
View File
@@ -0,0 +1,232 @@
# Copyright 2019-2023 Tauri Programme within The Commons Conservancy
# SPDX-License-Identifier: Apache-2.0
# SPDX-License-Identifier: MIT
name: test plugins e2e (mobile)
on:
workflow_dispatch:
push:
branches:
- v2
paths:
- '.github/workflows/test-api-e2e-mobile.yml'
- 'packages/api-e2e/**'
- 'examples/api/**'
- 'plugins/*/guest-js/**'
- 'plugins/*/src/**'
- 'plugins/*/android/**'
- 'plugins/*/ios/**'
- 'plugins/*/permissions/**'
- 'plugins/*/build.rs'
pull_request:
branches:
- v2
paths:
- '.github/workflows/test-api-e2e-mobile.yml'
- 'packages/api-e2e/**'
- 'examples/api/**'
- 'plugins/*/guest-js/**'
- 'plugins/*/src/**'
- 'plugins/*/android/**'
- 'plugins/*/ios/**'
- 'plugins/*/permissions/**'
- 'plugins/*/build.rs'
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:
android:
runs-on: ubuntu-latest
env:
# The emulator runs the host architecture, so only that target is built.
E2E_ANDROID_TARGET: x86_64
steps:
- uses: actions/checkout@v7
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-linux-android
- name: setup node
uses: actions/setup-node@v7
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v6
with:
cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
# Same rule as the rust-cache step below: every run restores the Gradle cache and only
# `v2`/`v3` save it. setup-java's own `cache: gradle` has no such switch, so the restore
# and save steps are spelled out here (same paths and key inputs as setup-java uses).
- name: restore gradle cache
id: gradle-cache
uses: actions/cache/restore@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: Setup NDK
uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r25
local-cache: true
# TODO check after https://github.com/nttld/setup-ndk/issues/518 is fixed
- name: Restore Android Symlinks
run: |
directory="${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin"
find "$directory" -type l | while read link; do
current_target=$(readlink "$link")
new_target="$directory/$(basename "$current_target")"
ln -sf "$new_target" "$link"
echo "Changed $(basename "$link") from $current_target to $new_target"
done
# Hardware acceleration for the emulator.
- name: enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- uses: Swatinem/rust-cache@v2
with:
key: android
# Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its
# merge ref, where nothing else can restore it, and every write evicts an entry
# that other runs would have hit (10 GB repository limit, LRU eviction).
save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }}
- name: install dependencies
run: pnpm i --frozen-lockfile
- name: build plugins
run: pnpm build
# Built before the emulator is up so it does not sit idle (and time out)
# during the Rust and Gradle builds; the suite then reuses the APK.
- name: build the APK
working-directory: ./examples/api
env:
NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
run: pnpm tauri android build --debug --apk --target $E2E_ANDROID_TARGET
- name: save gradle cache
if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && steps.gradle-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v6
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
- name: run e2e tests
uses: reactivecircus/android-emulator-runner@v2
timeout-minutes: 60
with:
api-level: 35
arch: x86_64
# google_apis images ship an up-to-date Android System WebView.
target: google_apis
profile: pixel_6
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: E2E_SKIP_BUILD=1 E2E_SPEC_RETRIES=1 pnpm test:api-e2e:android
- name: upload appium logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: android-appium-logs
path: packages/api-e2e/logs
if-no-files-found: ignore
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios-sim
- name: setup node
uses: actions/setup-node@v7
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v6
with:
cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow
- uses: Swatinem/rust-cache@v2
with:
key: ios
# Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its
# merge ref, where nothing else can restore it, and every write evicts an entry
# that other runs would have hit (10 GB repository limit, LRU eviction).
save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }}
- name: install dependencies
run: pnpm i --frozen-lockfile
- name: build plugins
run: pnpm build
# Simulator build (`aarch64-sim` on the Apple Silicon runners), unsigned.
# Built ahead of the suite for symmetry with the Android job.
- name: build the simulator app
working-directory: ./examples/api
run: pnpm tauri ios build --debug --target aarch64-sim --no-sign
# wdio.ios.conf picks the iPhone simulator on the newest installed runtime
# (no device name is hardcoded) and Appium boots it; the XCUITest driver
# compiles WebDriverAgent on the first session.
- name: run e2e tests
timeout-minutes: 60
env:
E2E_SKIP_BUILD: '1'
E2E_SPEC_RETRIES: '1'
run: pnpm test:api-e2e:ios
- name: upload appium logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: ios-appium-logs
path: packages/api-e2e/logs
if-no-files-found: ignore
- name: collect app crash reports
if: failure()
run: .scripts/ci/collect-macos-crash-reports.sh "$RUNNER_TEMP/crash-reports"
- name: upload app crash reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: ios-crash-reports
path: ${{ runner.temp }}/crash-reports
if-no-files-found: ignore
+174
View File
@@ -0,0 +1,174 @@
# Copyright 2019-2023 Tauri Programme within The Commons Conservancy
# SPDX-License-Identifier: Apache-2.0
# SPDX-License-Identifier: MIT
name: test plugins e2e
on:
workflow_dispatch:
push:
branches:
- v2
paths:
- '.github/workflows/test-api-e2e.yml'
- 'packages/api-e2e/**'
- 'examples/api/**'
- 'plugins/*/guest-js/**'
- 'plugins/*/src/**'
- 'plugins/*/permissions/**'
- 'plugins/*/build.rs'
pull_request:
branches:
- v2
paths:
- '.github/workflows/test-api-e2e.yml'
- 'packages/api-e2e/**'
- 'examples/api/**'
- 'plugins/*/guest-js/**'
- 'plugins/*/src/**'
- 'plugins/*/permissions/**'
- 'plugins/*/build.rs'
env:
RUST_BACKTRACE: 1
CARGO_PROFILE_DEV_DEBUG: 0 # keeps the target folder small for better cache efficiency
MSEDGEDRIVER_TOOL_REV: 8c4b34f51b45f5cf08013366d703de464ab871d1
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
- name: setup node
uses: actions/setup-node@v7
with:
node-version: 'lts/*'
- uses: pnpm/action-setup@v6
with:
cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow
- 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 \
dbus \
xvfb \
fluxbox
# msedgedriver has to match the installed WebView2 runtime, so msedgedriver-tool
# downloads the right version at run time. The tool itself is built from a pinned git
# revision and kept in its own small cache entry (accessed by every Windows run, so it
# stays hot) instead of being rebuilt from source on every run.
- name: restore msedgedriver-tool (Windows)
if: runner.os == 'Windows'
id: msedgedriver-tool-cache
uses: actions/cache@v6
with:
path: ${{ runner.tool_cache }}/msedgedriver-tool
key: msedgedriver-tool-${{ runner.os }}-${{ env.MSEDGEDRIVER_TOOL_REV }}
- name: install msedgedriver-tool (Windows)
if: runner.os == 'Windows' && steps.msedgedriver-tool-cache.outputs.cache-hit != 'true'
run: cargo install --git https://github.com/chippers/msedgedriver-tool --rev ${{ env.MSEDGEDRIVER_TOOL_REV }} --locked --root ${{ runner.tool_cache }}/msedgedriver-tool
- name: install msedgedriver (Windows)
if: runner.os == 'Windows'
run: |
& "${{ runner.tool_cache }}/msedgedriver-tool/bin/msedgedriver-tool.exe"
$PWD.Path >> $env:GITHUB_PATH
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.platform.os }}
# Only `v2`/`v3` write to the cache: an entry saved from a pull request lives on its
# merge ref, where nothing else can restore it, and every write evicts an entry
# that other runs would have hit (10 GB repository limit, LRU eviction).
save-if: ${{ github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3' }}
- name: install dependencies
run: pnpm i --frozen-lockfile
- name: build plugins
run: pnpm build
- name: run e2e tests (Linux)
if: runner.os == 'Linux'
timeout-minutes: 45
# A real window manager (fluxbox) is started so that window-state tests
# (size restore) behave, and a session D-Bus so the notification and
# clipboard plugins can reach their desktop services.
env:
E2E_SPEC_RETRIES: '1'
run: |
xvfb-run --auto-servernum -- dbus-run-session -- bash -c '
fluxbox >/dev/null 2>&1 &
sleep 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.
env:
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
env:
CN_API_KEY: ${{ secrets.TAURI_E2E_CN_API_KEY }}
E2E_SPEC_RETRIES: '1'
# 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.
run: pnpm test:api-e2e
- 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-2023 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 plugins 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
+82 -12
View File
@@ -214,6 +214,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-automation",
"tauri-plugin-barcode-scanner",
"tauri-plugin-biometric",
"tauri-plugin-cli",
@@ -972,7 +973,7 @@ dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
"windows-link 0.1.1",
]
[[package]]
@@ -1416,6 +1417,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "deep-link-example"
version = "0.0.0"
@@ -1585,7 +1597,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [
"libloading",
"libloading 0.7.4",
]
[[package]]
@@ -3329,7 +3341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading",
"libloading 0.7.4",
"once_cell",
]
@@ -3339,6 +3351,15 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libflate"
version = "2.1.0"
@@ -3373,6 +3394,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 = "libm"
version = "0.2.11"
@@ -3826,7 +3857,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
"syn 2.0.100",
@@ -3875,10 +3906,15 @@ checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb"
dependencies = [
"bitflags 2.9.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-foundation 0.3.0",
"objc2-quartz-core 0.3.0",
]
[[package]]
@@ -3898,6 +3934,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.4",
"objc2-foundation 0.3.0",
]
@@ -6246,6 +6283,7 @@ dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.25.0",
"crossbeam-channel",
"dbus",
"dispatch2",
"dlopen2",
"dpi",
@@ -6393,6 +6431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5"
dependencies = [
"base64 0.22.1",
"brotli",
"ico",
"json-patch",
"plist",
@@ -6442,6 +6481,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-autostart"
version = "2.5.1"
@@ -6990,6 +7044,7 @@ checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887"
dependencies = [
"aes-gcm",
"anyhow",
"brotli",
"cargo_metadata",
"ctor",
"dom_query",
@@ -8234,7 +8289,7 @@ dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-link",
"windows-link 0.1.1",
"windows-numerics",
]
@@ -8255,7 +8310,7 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-link 0.1.1",
"windows-result",
"windows-strings",
]
@@ -8267,7 +8322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
"windows-core",
"windows-link",
"windows-link 0.1.1",
]
[[package]]
@@ -8298,6 +8353,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
@@ -8305,7 +8366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core",
"windows-link",
"windows-link 0.1.1",
]
[[package]]
@@ -8314,7 +8375,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e"
dependencies = [
"windows-link",
"windows-link 0.1.1",
"windows-result",
"windows-strings",
]
@@ -8325,7 +8386,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
dependencies = [
"windows-link",
"windows-link 0.1.1",
]
[[package]]
@@ -8334,7 +8395,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
dependencies = [
"windows-link",
"windows-link 0.1.1",
]
[[package]]
@@ -8382,6 +8443,15 @@ dependencies = [
"windows-targets 0.53.2",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
@@ -8450,7 +8520,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04a5c6627e310a23ad2358483286c7df260c964eb2d003d8efd6d0f4e79265c"
dependencies = [
"windows-link",
"windows-link 0.1.1",
]
[[package]]
+10
View File
@@ -40,6 +40,12 @@ tauri-plugin-shell = { path = "../../../plugins/shell", version = "2.3.6" }
tauri-plugin-store = { path = "../../../plugins/store", version = "2.4.5" }
tauri-plugin-upload = { path = "../../../plugins/upload", version = "2.3.0" }
# WebDriver automation bridge, used by the plugins 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
features = [
@@ -66,3 +72,7 @@ tauri-plugin-nfc = { path = "../../../plugins/nfc", version = "2.3.6" }
tauri-plugin-biometric = { path = "../../../plugins/biometric/", version = "2.3.3" }
tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = "2.3.3" }
tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "2.3.3" }
[features]
# Enables the WebDriver automation bridge; set by the e2e suite's build (packages/api-e2e).
automation = ["dep:tauri-plugin-automation"]
+38 -1
View File
@@ -18,10 +18,13 @@
"core:app:allow-set-app-theme",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-size",
"core:window:allow-close",
"core:webview:allow-create-webview-window",
"core:window:allow-start-dragging",
"notification:default",
"os:allow-platform",
"os:default",
"os:allow-hostname",
"dialog:default",
{
"identifier": "shell:allow-spawn",
@@ -48,6 +51,31 @@
}
]
},
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "sh",
"cmd": "sh",
"args": [
"-c",
{
"validator": ".+"
}
]
},
{
"name": "cmd",
"cmd": "cmd",
"args": [
"/C",
{
"validator": ".+"
}
]
}
]
},
"shell:default",
"shell:allow-kill",
"shell:allow-stdin-write",
@@ -57,6 +85,8 @@
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-write-image",
"clipboard-manager:allow-write-html",
"clipboard-manager:allow-clear",
"fs:default",
"fs:read-meta",
"fs:allow-open",
@@ -66,6 +96,13 @@
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-write-text-file",
"fs:allow-write-file",
"fs:allow-create",
"fs:allow-copy-file",
"fs:allow-seek",
"fs:allow-truncate",
"fs:allow-ftruncate",
"fs:allow-unwatch",
"fs:scope-download-recursive",
"fs:scope-resource-recursive",
{
@@ -10,6 +10,8 @@
"global-shortcut:allow-unregister",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister-all",
"global-shortcut:allow-is-registered",
"window-state:default",
{ "identifier": "fs:allow-watch", "allow": ["*", "**/*"] }
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
# Uncomment the next line to define a global platform for your project
target 'api_iOS' do
platform :ios, '14.0'
platform :ios, '15.0'
# Pods for api_iOS
end
@@ -304,7 +304,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -366,7 +366,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -400,8 +400,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = com.tauri.api;
PRODUCT_NAME = "Tauri API";
SDKROOT = iphoneos;
@@ -432,8 +432,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = com.tauri.api;
PRODUCT_NAME = "Tauri API";
SDKROOT = iphoneos;
+3 -3
View File
@@ -2,7 +2,7 @@ name: api
options:
bundleIdPrefix: com.tauri.api
deploymentTarget:
iOS: 14.0
iOS: 15.0
fileGroups: [../../src]
configs:
debug: debug
@@ -65,8 +65,8 @@ targets:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
+17 -5
View File
@@ -23,10 +23,25 @@ pub type OnEvent = Box<dyn FnMut(&AppHandle, RunEvent)>;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
let mut builder = tauri::Builder::default();
// WebDriver automation bridge for the plugins e2e suite (packages/api-e2e).
// Registered as early as possible per the plugin's docs, behind the
// off-by-default `automation` feature.
#[cfg(all(desktop, feature = "automation"))]
{
builder = builder.plugin(tauri_plugin_automation::init());
}
#[allow(unused_mut)]
let mut builder = builder
.plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
// forward records to the webview so `attachLogger`/`attachConsole` work
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Webview,
))
.build(),
)
.plugin(tauri_plugin_fs::init())
@@ -86,10 +101,7 @@ pub fn run() {
webview_window_builder = webview_window_builder.transparent(true);
}
let webview = webview_window_builder.build().unwrap();
#[cfg(debug_assertions)]
webview.open_devtools();
let _webview = webview_window_builder.build().unwrap();
std::thread::spawn(|| {
let server = match tiny_http::Server::http("localhost:3003") {
+1 -1
View File
@@ -107,7 +107,7 @@
}
},
"iOS": {
"minimumSystemVersion": "14.0"
"minimumSystemVersion": "15.0"
}
}
}
+1 -1
View File
@@ -61,7 +61,7 @@
async function reload() {
try {
await store.reload({ overrideDefaults: true })
await store.reload({ ignoreDefaults: true })
} catch (error) {
onMessage(error)
}
+4
View File
@@ -8,6 +8,10 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"ts:check": "pnpm run -r ts:check",
"test:api-e2e": "pnpm run --filter \"api-e2e\" e2e",
"test:api-e2e:android": "pnpm run --filter \"api-e2e\" e2e:android",
"test:api-e2e:ios": "pnpm run --filter \"api-e2e\" e2e:ios",
"example:api:dev": "pnpm run --filter \"api\" tauri dev"
},
"devDependencies": {
+242
View File
@@ -0,0 +1,242 @@
# Plugins end-to-end tests
WebdriverIO suite that exercises the JavaScript API of the plugins in this
repository against a **real** Tauri app — the [`examples/api`](../../examples/api)
validation app — rather than a mocked backend, on desktop (Linux, macOS, Windows)
and mobile (Android, iOS). Each plugin has its own spec file, shared by every
platform, and adding coverage for a new plugin API is normally just dropping in
one more spec.
It mirrors the [`@tauri-apps/api` e2e suite](https://github.com/tauri-apps/tauri/tree/dev/packages/api-e2e)
in the core repository; the `@tauri-apps/api` modules themselves are covered there.
## How it works
- The example app is built with `withGlobalTauri: true`, so the `@tauri-apps/api` surface
is reachable on `window.__TAURI__` inside the webview, and every plugin's `api-iife.js`
registers its API next to it (`window.__TAURI__.fs`, `window.__TAURI__.clipboardManager`,
…, the package name without the `@tauri-apps/plugin-` prefix, camel-cased).
- On desktop, WebdriverIO drives the app through [`@crabnebula/tauri-driver`](https://www.npmjs.com/package/@crabnebula/tauri-driver),
which bridges the WebDriver protocol to each platform's webview:
- **macOS** — the CrabNebula Webdriver, which needs [`tauri-plugin-automation`](https://crates.io/crates/tauri-plugin-automation)
(registered in `examples/api` behind its off-by-default `automation` Cargo feature, which
the suite's build enables) and a locally-running `@crabnebula/test-runner-backend`,
authenticated with `CN_API_KEY`.
- **Linux** — `webkit2gtk-driver` (`WebKitWebDriver` on `PATH`).
- **Windows** — `msedgedriver.exe` on `PATH`. It hands the app the `--remote-debugging-port`
it attaches to through `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, which WebView2 ignores in an
elevated process ([wry#1782](https://github.com/tauri-apps/wry/issues/1782)), so the suite has
to run unelevated.
- On mobile, WebdriverIO drives the app through [Appium](https://appium.io) (started by
`@wdio/appium-service`; the drivers are plain devDependencies of this package, which Appium
picks up on its own):
- **Android** — the UiAutomator2 driver. The suite switches to the app's `WEBVIEW_*`
context, which chromedriver reaches through the WebView's debugging socket. Debug builds
turn that on (`setWebContentsDebuggingEnabled`), so the suite builds a debug APK. A
chromedriver matching the device's WebView is downloaded on demand (see `E2E_CHROMEDRIVER`).
- **iOS** — the XCUITest driver on a simulator, attaching to the WKWebView through the
WebKit remote inspector. Debug builds mark the webview `isInspectable`, so the suite
builds an unsigned debug simulator app. The inspector identifies an app by the
`application-identifier` entitlement that Xcode embeds when it code signs a simulator
build; an unsigned one has none and is listed as `process-<executable name>` instead of
its bundle identifier, so the config has the driver match that name too
(`appium:additionalWebviewBundleIds`). The driver also starts with a script timeout of
0, which the config raises to the 30s the other drivers default to, or every
`executeAsync` would time out at once.
- Specs never `eval` in the page. They pass a function to the [`tauri()`](test/helpers/index.ts)
helper, which serializes it and runs it via the driver's own (CSP-exempt) script injection,
handing it `window.__TAURI__` as the first argument and returning its JSON result.
- Each spec file gets its own session — a fresh `tauri-driver` (and therefore a fresh app
instance) on desktop, a fresh Appium session (which relaunches the app) on mobile — so
each plugin's suite runs in isolation.
- A small [fixture server](test/helpers/server.ts) is started for the whole run. It serves
the updater manifest the desktop e2e build points the updater at (see
[`tauri.e2e.conf.json`](tauri.e2e.conf.json)) and the upload/download fixtures. It listens
on the host's loopback; the iOS simulator shares that network stack, and on Android the
mobile config runs `adb reverse` so the same `127.0.0.1` URL works on the device. The
`http` specs use the echo server the example app itself spawns on port 3003, since that
is the only `http://` origin in the example's http scope.
## What is (not) covered
The example only registers each plugin on the platforms it supports, so the suites
split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are
skipped on desktop, and the rest run everywhere with the odd test gated.
| Plugin | Coverage | On mobile |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
| `notification` | Permission model, `window.Notification` override, `sendNotification`. Display is not observable. | Sending only — see the note below. |
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
| `os` | Every function, compared against what the app was built for. | Same, minus the hostname/host comparison. |
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
The notification permission specs are desktop-only: a mobile app starts out ungranted and
`requestPermission` puts up a system dialog the session would then block on.
The [`plugins.spec.ts`](test/specs/plugins.spec.ts) spec additionally asserts that every
plugin the platform registers injects its global API with its documented members, and that
the _other_ platform's plugins are absent. That is what catches a plugin whose
`global_api_script_path` is missing from its `build.rs`, or a crate that is not target-gated
in the example's `Cargo.toml`.
## Prerequisites
```sh
# from the repo root
pnpm install
pnpm build # examples/api resolves the plugins' JS from their dist-js output
```
Platform driver dependencies:
| Platform | Requirement |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS | `CN_API_KEY` env var (CrabNebula Cloud). The automation plugin and test-runner-backend are wired up already. |
| Linux | `webkit2gtk-driver` package (provides `WebKitWebDriver`). |
| Windows | `msedgedriver.exe` matching your Edge version, on `PATH`. Run the suite unelevated. |
| Android | The usual Tauri Android setup (`ANDROID_HOME`, `NDK_HOME`, a JDK), which also provides the `adb` the suite shells out to, plus a running emulator or a connected device with USB debugging. Network access the first time, for the chromedriver download. |
| iOS | macOS with Xcode and an iOS simulator runtime. The first session compiles WebDriverAgent (a few minutes). |
## Running
```sh
# desktop, from the repo root
pnpm test:api-e2e
# or from this package
pnpm e2e
# iterate without rebuilding the app every run
E2E_SKIP_BUILD=1 pnpm e2e
# run a single plugin's spec
pnpm exec wdio run ./wdio.conf.ts --spec test/specs/fs.spec.ts
# mobile (from the repo root; or `pnpm e2e:android` / `pnpm e2e:ios` from this package)
pnpm test:api-e2e:android
pnpm test:api-e2e:ios
```
The first desktop run builds the app with [`tauri.e2e.conf.json`](tauri.e2e.conf.json) as a
config override, which enables the example's `automation` feature and points the updater at
the fixture server; afterwards use `E2E_SKIP_BUILD=1` to reuse the existing binary. A binary
supplied through `E2E_SKIP_BUILD` or `E2E_APP_PATH` must have been built with that override:
the `updater` specs rely on its endpoint, and the CrabNebula Webdriver (always on macOS)
relies on the automation feature.
The mobile configs ([`wdio.android.conf.ts`](wdio.android.conf.ts), [`wdio.ios.conf.ts`](wdio.ios.conf.ts),
sharing [`wdio.mobile.ts`](wdio.mobile.ts)) run `tauri android build --debug --apk` /
`tauri ios build --debug --target aarch64-sim --no-sign`, compiling only the Rust target the
device runs (the Android one is read from the connected device through `adb`). The Android
Studio and Xcode projects are committed under `examples/api/src-tauri/gen`, so they are only
initialized if that directory is missing. The app must be a **debug** build — release builds
have webview debugging off, and Appium cannot see the page. `E2E_SKIP_BUILD` and
`E2E_APP_PATH` (an `.apk` / simulator `.app`) work as on desktop. No config override is
passed there: both things it turns on belong to desktop-only plugins.
## Environment variables
| Variable | Purpose |
| ------------------- | ---------------------------------------------------------------------------------------- |
| `CN_API_KEY` | CrabNebula Cloud key. Required on macOS (and whenever `E2E_CN_WEBDRIVER=1`). |
| `E2E_SKIP_BUILD` | Skip the `tauri build` step and reuse the existing binary. |
| `E2E_APP_PATH` | Absolute path to a prebuilt app/binary to test (also implies skip-build). |
| `E2E_SKIP` | Comma-separated plugin names to skip, e.g. `E2E_SKIP=clipboard-manager,global-shortcut`. |
| `E2E_SKIP_WM` | Skip window-manager-dependent tests (the window-state size restore). |
| `E2E_SPEC_RETRIES` | Retry count for flaky spec files (default `0`). |
| `E2E_CN_WEBDRIVER` | Use the CrabNebula Webdriver on Linux/Windows too (instead of the native driver). |
| `E2E_NATIVE_DRIVER` | Path passed to `tauri-driver --native-driver` (e.g. a specific chromedriver). |
| `CARGO_TARGET_DIR` | Override the target dir the app binary is looked up in. |
Mobile only:
| Variable | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `E2E_ANDROID_TARGET` | Rust target for the APK (`aarch64`, `armv7`, `i686`, `x86_64`); default: the connected device's ABI. |
| `E2E_ANDROID_DEVICE` | `adb` serial of the device/emulator to use (`appium:udid`); default: the first connected one. |
| `E2E_ANDROID_AVD` | Name of an AVD for Appium to boot (`appium:avd`) instead of using an already-running emulator. |
| `E2E_CHROMEDRIVER` | chromedriver binary matching the device's WebView, instead of letting Appium download one. |
| `E2E_IOS_TARGET` | Rust target for the simulator app (`aarch64-sim` or `x86_64`); default: the host architecture. |
| `E2E_IOS_DEVICE` | Simulator UDID or name (as in `xcrun simctl list`); default: a booted iPhone, else the newest one. |
| `E2E_PLATFORM` | Set by the mobile configs for the spec workers (`android`/`ios`) — see `platform` in the helpers. |
Appium's own log is written to `logs/wdio-appium.log` in this package.
## Adding tests for a new plugin API
1. **Add a spec.** Create `test/specs/<plugin>.spec.ts` and use `describePlugin('<plugin>', …)`
with the `tauri()` helper. It is picked up automatically by the `test/specs/**/*.spec.ts`
glob. Minimal example:
```ts
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
describePlugin('os', () => {
it('reports the platform', async () => {
expect(await tauri((api) => api.os.platform())).toBe('linux')
})
})
```
If the plugin is new to the example, register it in
[`examples/api/src-tauri/src/lib.rs`](../../examples/api/src-tauri/src/lib.rs), add it to
the example's `package.json`/`Cargo.toml`, add its `workspace:*` package to this package's
`devDependencies` and its namespace to the matching interface in
[`test/helpers/index.ts`](test/helpers/index.ts) — `CommonPluginApi`, `DesktopPluginApi` or
`MobilePluginApi` — plus the member list in `plugins.spec.ts`.
2. **Grant permissions if needed.** If the API calls a command that the example does not
allow yet, add the permission to
[`examples/api/src-tauri/capabilities/base.json`](../../examples/api/src-tauri/capabilities/base.json)
(or `desktop.json`/`mobile.json` for platform-specific plugins). Each plugin documents its
permissions under `plugins/<plugin>/permissions/autogenerated/reference.md`.
3. **Need a server?** Add a route to the [fixture server](test/helpers/server.ts) rather than
hitting the network; its URL is exported as `FIXTURE_SERVER_URL`.
4. **Handle environment-sensitive cases.** Use `itWm` (instead of `it`) for assertions that
depend on a real window manager, and `eventually()` to poll for state that is applied
asynchronously. Branch on `platform` from the helpers (never `process.platform`, which is
the host running the emulator/simulator on mobile) for platform-specific behavior.
Files go under `scratchDir('<plugin>')`, relative to `BaseDirectory.AppData`, which the
example's fs scope allows.
5. **Gate what mobile does not have.** The same specs run on Android and iOS. Wrap tests of
desktop-only behavior — a command the mobile build does not expose, a mobile implementation
that answers "Unsupported on this platform", or a permission only the desktop capability
grants — in `itDesktop`, use `itOn('android', …)` / `itOn('ios', …)` for platform-specific
APIs, and pass `{ desktopOnly: true }` (or `{ mobileOnly: true }`) to `describePlugin` for
plugins the example does not register on the other side. Skipped tests show up as pending
rather than silently disappearing.
### Rules for `tauri()` page functions
The function you pass to `tauri()` runs **inside the webview**, serialized as a string:
- It **cannot** close over anything from the spec module — pass every value it needs through
the trailing `tauri(fn, ...args)` arguments.
- It may only reference `api` (the `window.__TAURI__` object), those args, and browser
globals (`window`, `document`, `setTimeout`, `Promise`, …).
- Its return value must be JSON-serializable — return plain objects/primitives, not class
instances (call methods and return their results instead), and remember that `undefined`
values are dropped.
- Restore any app state you mutate (clipboard, registered shortcuts, window size, …); tests
within a spec file share the same app instance.
- For in-page waiting, wrap logic in a `Promise` with an explicit `setTimeout` rejection so a
failure surfaces as a message rather than an opaque driver timeout.
Use `tauriError(fn, ...args)` to assert that a call rejects; it returns the rejection message.
+54
View File
@@ -0,0 +1,54 @@
{
"name": "api-e2e",
"private": true,
"version": "0.0.0",
"description": "End-to-end test suite for the plugins' JavaScript APIs, driven against the examples/api app via WebdriverIO (tauri-driver on desktop, Appium on mobile).",
"license": "MIT OR Apache-2.0",
"type": "module",
"scripts": {
"e2e": "wdio run ./wdio.conf.ts",
"e2e:skip-build": "cross-env E2E_SKIP_BUILD=true wdio run ./wdio.conf.ts",
"e2e:android": "wdio run ./wdio.android.conf.ts",
"e2e:ios": "wdio run ./wdio.ios.conf.ts",
"ts:check": "tsc --noEmit"
},
"devDependencies": {
"@crabnebula/tauri-driver": "^2.0.9",
"@crabnebula/test-runner-backend": "^0.2.9",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-barcode-scanner": "workspace:*",
"@tauri-apps/plugin-biometric": "workspace:*",
"@tauri-apps/plugin-cli": "workspace:*",
"@tauri-apps/plugin-clipboard-manager": "workspace:*",
"@tauri-apps/plugin-dialog": "workspace:*",
"@tauri-apps/plugin-fs": "workspace:*",
"@tauri-apps/plugin-geolocation": "workspace:*",
"@tauri-apps/plugin-global-shortcut": "workspace:*",
"@tauri-apps/plugin-haptics": "workspace:*",
"@tauri-apps/plugin-http": "workspace:*",
"@tauri-apps/plugin-log": "workspace:*",
"@tauri-apps/plugin-nfc": "workspace:*",
"@tauri-apps/plugin-notification": "workspace:*",
"@tauri-apps/plugin-opener": "workspace:*",
"@tauri-apps/plugin-os": "workspace:*",
"@tauri-apps/plugin-process": "workspace:*",
"@tauri-apps/plugin-shell": "workspace:*",
"@tauri-apps/plugin-store": "workspace:*",
"@tauri-apps/plugin-updater": "workspace:*",
"@tauri-apps/plugin-upload": "workspace:*",
"@tauri-apps/plugin-window-state": "workspace:*",
"@types/node": "^22.10.0",
"@wdio/appium-service": "^9.31.9",
"@wdio/cli": "^9.31.9",
"@wdio/globals": "^9.31.3",
"@wdio/local-runner": "^9.31.9",
"@wdio/mocha-framework": "^9.31.9",
"@wdio/spec-reporter": "^9.31.2",
"appium": "^3.7.0",
"appium-uiautomator2-driver": "^8.7.0",
"appium-xcuitest-driver": "^12.12.4",
"cross-env": "^10.1.0",
"typescript": "6.0.3",
"webdriverio": "^9.31.9"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../../examples/api/node_modules/@tauri-apps/cli/config.schema.json",
"build": {
"features": ["automation"]
},
"plugins": {
"updater": {
"endpoints": [
"http://127.0.0.1:3004/updater/{{target}}/{{arch}}/{{current_version}}"
]
}
}
}
+329
View File
@@ -0,0 +1,329 @@
// Copyright 2019-2023 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'
import type * as BarcodeScanner from '@tauri-apps/plugin-barcode-scanner'
import type * as Biometric from '@tauri-apps/plugin-biometric'
import type * as Cli from '@tauri-apps/plugin-cli'
import type * as ClipboardManager from '@tauri-apps/plugin-clipboard-manager'
import type * as Dialog from '@tauri-apps/plugin-dialog'
import type * as Fs from '@tauri-apps/plugin-fs'
import type * as Geolocation from '@tauri-apps/plugin-geolocation'
import type * as GlobalShortcut from '@tauri-apps/plugin-global-shortcut'
import type * as Haptics from '@tauri-apps/plugin-haptics'
import type * as Http from '@tauri-apps/plugin-http'
import type * as Log from '@tauri-apps/plugin-log'
import type * as Nfc from '@tauri-apps/plugin-nfc'
import type * as Notification from '@tauri-apps/plugin-notification'
import type * as Opener from '@tauri-apps/plugin-opener'
import type * as Os from '@tauri-apps/plugin-os'
import type * as Process from '@tauri-apps/plugin-process'
import type * as Shell from '@tauri-apps/plugin-shell'
import type * as Store from '@tauri-apps/plugin-store'
import type * as Updater from '@tauri-apps/plugin-updater'
import type * as Upload from '@tauri-apps/plugin-upload'
import type * as WindowState from '@tauri-apps/plugin-window-state'
/**
* The plugin APIs the example registers on every platform, keyed by the name
* each plugin's `api-iife.js` defines on `window.__TAURI__` (the package name
* without the `@tauri-apps/plugin-` prefix, camel-cased).
*/
export interface CommonPluginApi {
clipboardManager: typeof ClipboardManager
dialog: typeof Dialog
fs: typeof Fs
http: typeof Http
log: typeof Log
notification: typeof Notification
opener: typeof Opener
os: typeof Os
process: typeof Process
shell: typeof Shell
store: typeof Store
upload: typeof Upload
}
/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */
export interface DesktopPluginApi {
cli: typeof Cli
globalShortcut: typeof GlobalShortcut
updater: typeof Updater
windowState: typeof WindowState
}
/** The plugin APIs the example only registers on mobile (`#[cfg(mobile)]`). */
export interface MobilePluginApi {
barcodeScanner: typeof BarcodeScanner
biometric: typeof Biometric
geolocation: typeof Geolocation
haptics: typeof Haptics
nfc: typeof Nfc
}
/**
* Every plugin API the example can register. Only the platform-appropriate
* half is actually on `window.__TAURI__` at runtime — see `describePlugin`'s
* `desktopOnly`/`mobileOnly` options and `plugins.spec.ts`.
*/
export type PluginApi = CommonPluginApi & DesktopPluginApi & MobilePluginApi
/** The `@tauri-apps/api` surface plus every plugin, as exposed on `window.__TAURI__`. */
export type Api = typeof TauriApi & PluginApi
/** OS the app under test runs on. */
export type Platform = NodeJS.Platform | 'android' | 'ios'
/**
* The platform of the app under test. The desktop suite drives an app on the
* host, so it is `process.platform`; the mobile configs (`wdio.android.conf.ts`,
* `wdio.ios.conf.ts`) drive an emulator/simulator and set `E2E_PLATFORM` for
* the spec workers instead.
*/
export const platform: Platform =
(process.env.E2E_PLATFORM as Platform | undefined) ?? process.platform
export const isMobile = platform === 'android' || platform === 'ios'
type PageOutcome<T> =
| { ok: true; value: T }
| { ok: false; error: string; stack?: string }
/** Thrown when the function passed to {@link tauri} rejects inside the webview. */
export class TauriPageError extends Error {
pageStack?: string
constructor(message: string, pageStack?: string) {
super(message)
this.name = 'TauriPageError'
this.pageStack = pageStack
}
}
/**
* Runs `fn` inside the app's webview with `window.__TAURI__` as its first argument
* and resolves with its (JSON-serializable) return value.
*
* `fn` is serialized with `Function.prototype.toString`, so it **cannot close over
* anything in the spec module** — every value it needs must be passed through `args`,
* and it may only reference `api`, those args, and browser globals (`window`,
* `document`, `setTimeout`, `Promise`, ...).
*
* @example
* const platform = await tauri((api) => api.os.platform())
* 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 and Appium proxy to.
//
// The outcome crosses the driver as a JSON string rather than an object so no
// driver gets to interpret its shape: the Selenium atoms that Appium runs
// scripts through on iOS turn any object with a numeric `length` property
// into an array.
const script = `
var done = arguments[arguments.length - 1];
var args = Array.prototype.slice.call(arguments, 0, arguments.length - 1);
var fn = (${fn.toString()});
Promise.resolve()
.then(function () { return fn.apply(null, [window.__TAURI__].concat(args)); })
.then(
function (value) { return { ok: true, value: value === undefined ? null : value }; },
function (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
};
}
)
.then(function (outcome) {
try {
done(JSON.stringify(outcome));
} catch (error) {
done(JSON.stringify({ ok: false, error: 'result is not JSON-serializable: ' + error }));
}
});
`
const raw: unknown = await browser.executeAsync(script, ...args)
const outcome = (
typeof raw === 'string' ? JSON.parse(raw) : raw
) as PageOutcome<Awaited<R>> | null
if (!outcome || typeof outcome !== 'object' || !('ok' in outcome)) {
throw new Error(
`tauri() bridge returned an unexpected value: ${JSON.stringify(outcome)}`
)
}
if (!outcome.ok) {
throw new TauriPageError(outcome.error, outcome.stack)
}
return outcome.value
}
/**
* Asserts that the page-side call rejects and returns the rejection message,
* so specs can assert on it. Throws if the call unexpectedly resolves.
*/
export async function tauriError<A extends unknown[]>(
fn: (api: Api, ...args: A) => unknown,
...args: A
): Promise<string> {
try {
await tauri(fn, ...args)
} catch (error) {
if (error instanceof TauriPageError) {
return error.message
}
// Some platform drivers (notably the Linux WebKitWebDriver) surface a
// page-side `invoke` rejection as a WebDriver-level error on the
// `execute/async` command instead of letting the in-page bridge report it
// as an `{ ok: false }` outcome. Fall back to that error's message so the
// backend rejection is still assertable. This is safe for error-path specs:
// they match the message against an expected pattern, so a genuine driver
// failure (whose message won't match) still fails the test.
if (error instanceof Error) {
return error.message
}
throw error
}
throw new Error('expected the API call to reject, but it resolved')
}
/**
* Polls `check` until it returns without throwing or `timeout` elapses.
* Use for state that is applied asynchronously (window manager, file watcher, ...).
*/
export async function eventually<T>(
check: () => T | Promise<T>,
{
timeout = 10_000,
interval = 250
}: { timeout?: number; interval?: number } = {}
): Promise<T> {
const deadline = Date.now() + timeout
let lastError: unknown
for (;;) {
try {
return await check()
} catch (error) {
lastError = error
}
if (Date.now() > deadline) {
throw lastError instanceof Error
? lastError
: new Error(String(lastError))
}
await new Promise((resolve) => setTimeout(resolve, interval))
}
}
const skippedModules = (process.env.E2E_SKIP ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
export interface DescribePluginOptions {
/**
* The example only registers the plugin on desktop (`cli`, `global-shortcut`,
* `updater`, `window-state`), so the whole suite is skipped on mobile.
*/
desktopOnly?: boolean
/**
* The example only registers the plugin on mobile (`barcode-scanner`,
* `biometric`, `geolocation`, `haptics`, `nfc`), so the whole suite is
* skipped on desktop.
*/
mobileOnly?: boolean
}
/**
* `describe` wrapper keyed by plugin name (the `@tauri-apps/plugin-*` suffix).
* Any plugin listed in the comma-separated `E2E_SKIP` env var
* (e.g. `E2E_SKIP=clipboard-manager,global-shortcut`) is skipped.
*/
export function describePlugin(plugin: string, fn: () => void): void
export function describePlugin(
plugin: string,
options: DescribePluginOptions,
fn: () => void
): void
export function describePlugin(
plugin: string,
optionsOrFn: DescribePluginOptions | (() => void),
maybeFn?: () => void
): void {
const [options, fn] =
typeof optionsOrFn === 'function'
? [{} as DescribePluginOptions, optionsOrFn]
: [optionsOrFn, maybeFn!]
const title = `@tauri-apps/plugin-${plugin}`
if (
skippedModules.includes(plugin)
|| (options.desktopOnly && isMobile)
|| (options.mobileOnly && !isMobile)
) {
describe.skip(title, fn)
} else {
describe(title, fn)
}
}
/**
* `it` restricted to the given platform(s); skipped (as pending) elsewhere.
* Use for behavior that only exists on one OS.
*/
export function itOn(
platforms: Platform | Platform[],
title: string,
fn: () => void | Promise<void>
): void {
const list = Array.isArray(platforms) ? platforms : [platforms]
if (list.includes(platform)) {
it(title, fn)
} else {
it.skip(title, fn)
}
}
/**
* `it` for desktop-only behavior of a plugin that *is* registered on mobile:
* a command the mobile build does not expose (`#[cfg(desktop)]`), a mobile
* implementation that answers "Unsupported on this platform", or a permission
* the example only grants in its desktop capability.
*/
export function itDesktop(title: string, fn: () => void | Promise<void>): void {
if (isMobile) {
it.skip(title, fn)
} else {
it(title, fn)
}
}
/**
* `it` for assertions that depend on a real window manager (window size and
* position restore, ...). Skipped entirely when `E2E_SKIP_WM` is set (e.g.
* bare headless CI), and on mobile, which has no window manager.
*/
export function itWm(title: string, fn: () => void | Promise<void>): void {
if (process.env.E2E_SKIP_WM || isMobile) {
it.skip(title, fn)
} else {
it(title, fn)
}
}
/**
* A scratch directory the fs-backed specs may freely write to. It is relative
* to `BaseDirectory.AppData` (`$APPDATA`), which the example's fs scope allows
* recursively; `name` keeps each spec file's files apart.
*/
export function scratchDir(name: string): string {
return `e2e/${name}`
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import http from 'node:http'
/**
* Where the fixture server listens. The port is fixed because the updater
* endpoint is baked into the app at build time (see `tauri.e2e.conf.json`).
*/
export const FIXTURE_SERVER_PORT = 3004
export const FIXTURE_SERVER_URL = `http://127.0.0.1:${FIXTURE_SERVER_PORT}`
/** Version the `/updater` endpoint advertises by default. */
export const UPDATER_FIXTURE_VERSION = '2.1.0'
export const UPDATER_FIXTURE_NOTES = 'Test update from the e2e fixture server'
/** `{{target}}` values with special behavior on the `/updater` endpoint. */
export const UPDATER_TARGET_NO_UPDATE = 'e2e-no-update'
export const UPDATER_TARGET_OLDER = 'e2e-older'
export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0'
/** Body served by `GET /download`. */
export const DOWNLOAD_FIXTURE_BODY =
'hello from the plugins e2e fixture server\n'.repeat(64)
export interface FixtureServer {
close(): void
}
/**
* A tiny HTTP server the network-facing specs (updater, upload) talk to:
*
* - `GET /updater/{{target}}/{{arch}}/{{current_version}}` — an updater
* manifest in the dynamic format. Advertises {@link UPDATER_FIXTURE_VERSION},
* or {@link UPDATER_FIXTURE_OLDER_VERSION} when the target is
* {@link UPDATER_TARGET_OLDER}, and replies `204 No Content` when it is
* {@link UPDATER_TARGET_NO_UPDATE}.
* - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`.
* - `* /echo` — a JSON description of the request (`method`, `url`, `headers`
* and the utf-8 `body`).
*/
export function startFixtureServer(): Promise<FixtureServer> {
const server = http.createServer((req, res) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => {
const body = Buffer.concat(chunks)
const url = new URL(req.url ?? '/', FIXTURE_SERVER_URL)
const [, route, ...rest] = url.pathname.split('/')
if (route === 'updater' && req.method === 'GET') {
const [target] = rest
if (target === UPDATER_TARGET_NO_UPDATE) {
res.writeHead(204).end()
return
}
json(res, {
version:
target === UPDATER_TARGET_OLDER
? UPDATER_FIXTURE_OLDER_VERSION
: UPDATER_FIXTURE_VERSION,
notes: UPDATER_FIXTURE_NOTES,
pub_date: '2026-03-01T14:04:20Z',
url: `${FIXTURE_SERVER_URL}/download`,
signature: ''
})
return
}
if (route === 'download' && req.method === 'GET') {
res
.writeHead(200, {
'content-type': 'text/plain',
'content-length': Buffer.byteLength(DOWNLOAD_FIXTURE_BODY)
})
.end(DOWNLOAD_FIXTURE_BODY)
return
}
if (route === 'echo') {
json(res, {
method: req.method,
url: req.url,
headers: req.headers,
body: body.toString('utf8')
})
return
}
res.writeHead(404).end()
})
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => {
server.off('error', reject)
resolve({
close: () => {
server.closeAllConnections()
server.close()
}
})
})
})
}
function json(res: http.ServerResponse, value: unknown) {
const payload = JSON.stringify(value)
res
.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload)
})
.end(payload)
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri } from '../helpers/index.js'
// The example app's own commands and events, which its "Communication" view
// drives; they double as a check that the app under test is the right one.
describe('examples/api', () => {
it('is built from the example config', async () => {
const info = await tauri(async (api) => ({
name: await api.app.getName(),
version: await api.app.getVersion(),
identifier: await api.app.getIdentifier(),
label: api.window.getCurrentWindow().label
}))
expect(info).toEqual({
name: 'Tauri API',
version: '2.0.0',
identifier: 'com.tauri.api',
label: 'main'
})
})
it('perform_request returns the backend response', async () => {
const response = await tauri((api) =>
api.core.invoke('perform_request', {
endpoint: 'dummy endpoint arg',
body: { id: 5, name: 'test' }
})
)
expect(response).toBe('message response')
})
it('log_operation accepts an optional payload', async () => {
await tauri(async (api) => {
await api.core.invoke('log_operation', { event: 'tauri-click' })
await api.core.invoke('log_operation', {
event: 'tauri-click',
payload: 'from e2e'
})
return null
})
})
it('js-event is answered with rust-event', async () => {
const reply = await tauri(
(api) =>
new Promise<{ data: string }>((resolve, reject) => {
const webview = api.webview.getCurrentWebview()
webview
.listen<{ data: string }>('rust-event', (event) =>
resolve(event.payload)
)
.then((unlisten) => {
webview
.emit('js-event', 'this is the payload string')
.catch(reject)
setTimeout(() => {
unlisten()
reject(new Error('no rust-event reply received'))
}, 5000)
})
.catch(reject)
})
)
expect(reply).toEqual({ data: 'something else' })
})
})
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
// The driver launches the app without arguments, so the matches reflect the
// CLI definition in the example's `tauri.conf.json` with nothing set. The
// example only registers the plugin on desktop, so the suite is skipped on
// mobile (which has no command line to begin with).
describePlugin('cli', { desktopOnly: true }, () => {
it('getMatches reports every defined argument as unset', async () => {
const matches = await tauri((api) => api.cli.getMatches())
expect(Object.keys(matches.args).sort()).toEqual([
'config',
'theme',
'verbose'
])
// flags resolve to `false`, arguments taking a value to `null`
expect(matches.args.verbose).toEqual({ value: false, occurrences: 0 })
expect(matches.args.config).toEqual({ value: null, occurrences: 0 })
expect(matches.args.theme).toEqual({ value: null, occurrences: 0 })
})
it('getMatches reports no subcommand', async () => {
const subcommand = await tauri(
async (api) => (await api.cli.getMatches()).subcommand
)
expect(subcommand).toBeNull()
})
})
@@ -0,0 +1,153 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
itDesktop
} from '../helpers/index.js'
// The mobile implementation only carries plain text: `write_html`, `write_image`
// and `read_image` answer "Unsupported on this platform" there.
describePlugin('clipboard-manager', () => {
it('writeText and readText round-trip', async () => {
const text = 'clipboard text from e2e — ✓'
expect(
await tauri(async (api, text) => {
await api.clipboardManager.writeText(text)
return api.clipboardManager.readText()
}, text)
).toBe(text)
})
it('writeText replaces the previous contents', async () => {
expect(
await tauri(async (api) => {
await api.clipboardManager.writeText('first')
await api.clipboardManager.writeText('second')
return api.clipboardManager.readText()
})
).toBe('second')
})
itDesktop('writeHtml exposes the alt text as plain text', async () => {
expect(
await tauri(async (api) => {
await api.clipboardManager.writeHtml(
'<b>bold from e2e</b>',
'bold from e2e (alt)'
)
return api.clipboardManager.readText()
})
).toBe('bold from e2e (alt)')
})
itDesktop('writeImage and readImage round-trip pixels', async () => {
// a 2x2 PNG: red, green / blue, white
const png = [
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2,
0, 0, 0, 2, 8, 6, 0, 0, 0, 114, 182, 13, 36, 0, 0, 0, 18, 73, 68, 65, 84,
120, 218, 99, 248, 207, 192, 240, 31, 12, 129, 52, 24, 0, 0, 73, 200, 9,
247, 3, 217, 100, 241, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130
]
const rgba = [
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255
]
const result = await tauri(async (api, png) => {
// encoded bytes are decoded by the plugin before hitting the clipboard
await api.clipboardManager.writeImage(new Uint8Array(png))
const read = await api.clipboardManager.readImage()
const size = await read.size()
const bytes = Array.from(await read.rgba())
await read.close()
return { size, bytes }
}, png)
expect(result.size).toEqual({ width: 2, height: 2 })
expect(result.bytes).toEqual(rgba)
})
itDesktop(
'writeImage accepts an Image built with the core image API',
async () => {
// `window.__TAURI__.image.Image` and the class the plugin's global script
// sees must be the same one for `transformImage`'s `instanceof` to hold.
const rgba = [
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255
]
const result = await tauri(async (api, rgba) => {
const image = await api.image.Image.new(rgba, 2, 2)
await api.clipboardManager.writeImage(image)
const read = await api.clipboardManager.readImage()
const size = await read.size()
const bytes = Array.from(await read.rgba())
await image.close()
await read.close()
return { size, bytes }
}, rgba)
expect(result.size).toEqual({ width: 2, height: 2 })
expect(result.bytes).toEqual(rgba)
}
)
itDesktop(
'an Image read from the clipboard can be written back',
async () => {
const result = await tauri(async (api) => {
const read = await api.clipboardManager.readImage()
await api.clipboardManager.writeText('replaced by text')
// `Image` instances are passed by resource id
await api.clipboardManager.writeImage(read)
const again = await api.clipboardManager.readImage()
const size = await again.size()
await read.close()
await again.close()
return size
})
expect(result).toEqual({ width: 2, height: 2 })
}
)
itDesktop('writeImage accepts a 1x1 image', async () => {
// a 1x1 transparent PNG
const png = [
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1,
0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84,
120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73,
69, 78, 68, 174, 66, 96, 130
]
const size = await tauri(async (api, png) => {
await api.clipboardManager.writeImage(new Uint8Array(png))
const read = await api.clipboardManager.readImage()
const size = await read.size()
await read.close()
return size
}, png)
expect(size).toEqual({ width: 1, height: 1 })
})
it('readImage rejects when the clipboard holds text', async () => {
const message = await tauriError(async (api) => {
await api.clipboardManager.writeText('not an image')
await api.clipboardManager.readImage()
})
expect(message.length).toBeGreaterThan(0)
})
it('clear empties the clipboard', async () => {
// reading an empty clipboard rejects on some platforms and yields an
// empty string on others
const text = await tauri(async (api) => {
await api.clipboardManager.writeText('to be cleared')
await api.clipboardManager.clear()
try {
return await api.clipboardManager.readText()
} catch {
return ''
}
})
expect(text).toBe('')
})
})
+380
View File
@@ -0,0 +1,380 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
itDesktop,
scratchDir
} from '../helpers/index.js'
// Every path below is relative to `BaseDirectory.AppData`, which the example's
// fs scope allows recursively (`fs:scope-appdata-recursive`).
const dir = scratchDir('fs')
describePlugin('fs', () => {
before(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
await api.fs.mkdir(dir, { baseDir, recursive: true })
}, dir)
})
after(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
}, dir)
})
it('mkdir and exists report the scratch directory', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
return {
dir: await api.fs.exists(dir, { baseDir }),
missing: await api.fs.exists(`${dir}/does-not-exist`, { baseDir })
}
}, dir)
expect(result).toEqual({ dir: true, missing: false })
})
it('writeTextFile and readTextFile round-trip utf-8 text', async () => {
const text = 'Hello from the e2e suite — olá, 世界! 🎉\nsecond line\n'
const read = await tauri(
async (api, path, text) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, text, { baseDir })
return api.fs.readTextFile(path, { baseDir })
},
`${dir}/text.txt`,
text
)
expect(read).toBe(text)
})
it('writeTextFile appends when asked to', async () => {
const read = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, 'first', { baseDir })
await api.fs.writeTextFile(path, ' second', { baseDir, append: true })
return api.fs.readTextFile(path, { baseDir })
}, `${dir}/append.txt`)
expect(read).toBe('first second')
})
it('writeFile and readFile round-trip binary data', async () => {
const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255]
const read = await tauri(
async (api, path, bytes) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeFile(path, new Uint8Array(bytes), { baseDir })
return Array.from(await api.fs.readFile(path, { baseDir }))
},
`${dir}/binary.bin`,
bytes
)
expect(read).toEqual(bytes)
})
it('stat, lstat and size describe files and directories', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const file = `${dir}/stat.txt`
await api.fs.writeTextFile(file, '0123456789', { baseDir })
const fileStat = await api.fs.stat(file, { baseDir })
const fileLstat = await api.fs.lstat(file, { baseDir })
const dirStat = await api.fs.stat(dir, { baseDir })
return {
file: {
isFile: fileStat.isFile,
isDirectory: fileStat.isDirectory,
isSymlink: fileStat.isSymlink,
size: fileStat.size,
hasMtime: fileStat.mtime instanceof Date
},
lstatSize: fileLstat.size,
// `size` only takes absolute paths
size: await api.fs.size(
await api.path.join(await api.path.appDataDir(), file)
),
dir: { isFile: dirStat.isFile, isDirectory: dirStat.isDirectory }
}
}, dir)
expect(result.file).toEqual({
isFile: true,
isDirectory: false,
isSymlink: false,
size: 10,
hasMtime: true
})
expect(result.lstatSize).toBe(10)
expect(result.size).toBe(10)
expect(result.dir).toEqual({ isFile: false, isDirectory: true })
})
it('copyFile, rename and readDir', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const sub = `${dir}/tree`
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
await api.fs.writeTextFile(`${sub}/a.txt`, 'a', { baseDir })
await api.fs.copyFile(`${sub}/a.txt`, `${sub}/b.txt`, {
fromPathBaseDir: baseDir,
toPathBaseDir: baseDir
})
await api.fs.rename(`${sub}/b.txt`, `${sub}/c.txt`, {
oldPathBaseDir: baseDir,
newPathBaseDir: baseDir
})
const entries = await api.fs.readDir(sub, { baseDir })
return {
entries: entries
.map((e) => ({
name: e.name,
isFile: e.isFile,
isDirectory: e.isDirectory
}))
.sort((x, y) => x.name.localeCompare(y.name)),
copied: await api.fs.readTextFile(`${sub}/c.txt`, { baseDir }),
renamedAway: await api.fs.exists(`${sub}/b.txt`, { baseDir })
}
}, dir)
expect(result.entries).toEqual([
{ name: 'a.txt', isFile: true, isDirectory: false },
{ name: 'c.txt', isFile: true, isDirectory: false },
{ name: 'nested', isFile: false, isDirectory: true }
])
expect(result.copied).toBe('a')
expect(result.renamedAway).toBe(false)
})
it('remove deletes files and (recursively) directories', async () => {
const result = await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
const sub = `${dir}/to-remove`
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
await api.fs.writeTextFile(`${sub}/nested/file.txt`, 'x', { baseDir })
await api.fs.remove(`${sub}/nested/file.txt`, { baseDir })
const fileGone = !(await api.fs.exists(`${sub}/nested/file.txt`, {
baseDir
}))
await api.fs.writeTextFile(`${sub}/nested/other.txt`, 'x', { baseDir })
let nonRecursiveError: string | null = null
try {
await api.fs.remove(sub, { baseDir })
} catch (error) {
nonRecursiveError = String(error)
}
await api.fs.remove(sub, { baseDir, recursive: true })
return {
fileGone,
nonRecursiveError,
dirGone: !(await api.fs.exists(sub, { baseDir }))
}
}, dir)
expect(result.fileGone).toBe(true)
// a non-empty directory cannot be removed without `recursive`
expect(result.nonRecursiveError).not.toBeNull()
expect(result.dirGone).toBe(true)
})
it('truncate shortens a file', async () => {
const result = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, '0123456789', { baseDir })
await api.fs.truncate(path, 4, { baseDir })
const shortened = await api.fs.readTextFile(path, { baseDir })
await api.fs.truncate(path, undefined, { baseDir })
return {
shortened,
emptied: await api.fs.readTextFile(path, { baseDir })
}
}, `${dir}/truncate.txt`)
expect(result).toEqual({ shortened: '0123', emptied: '' })
})
it('readTextFileLines iterates a file line by line', async () => {
const lines = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(path, 'one\ntwo\r\nthree', { baseDir })
const result: string[] = []
for await (const line of await api.fs.readTextFileLines(path, {
baseDir
})) {
result.push(line)
}
return result
}, `${dir}/lines.txt`)
expect(lines).toEqual(['one', 'two', 'three'])
})
it('FileHandle supports write, seek, read, stat and truncate', async () => {
const result = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const created = await api.fs.create(path, { baseDir })
const written = await created.write(encoder.encode('hello world'))
const statAfterWrite = await created.stat()
await created.close()
const file = await api.fs.open(path, { baseDir, read: true, write: true })
// seek past "hello " and read the rest
const position = await file.seek(6, api.fs.SeekMode.Start)
const buffer = new Uint8Array(32)
const read = await file.read(buffer)
const rest = decoder.decode(buffer.subarray(0, read ?? 0))
// at EOF, read reports null
const atEof = await file.read(new Uint8Array(8))
// relative and end-relative seeks
const fromEnd = await file.seek(-5, api.fs.SeekMode.End)
const relative = await file.seek(-1, api.fs.SeekMode.Current)
await file.truncate(5)
const statAfterTruncate = await file.stat()
await file.close()
return {
written,
sizeAfterWrite: statAfterWrite.size,
position,
read,
rest,
atEof,
fromEnd,
relative,
sizeAfterTruncate: statAfterTruncate.size,
contents: await api.fs.readTextFile(path, { baseDir })
}
}, `${dir}/handle.txt`)
expect(result).toEqual({
written: 11,
sizeAfterWrite: 11,
position: 6,
read: 5,
rest: 'world',
atEof: null,
fromEnd: 6,
relative: 5,
sizeAfterTruncate: 5,
contents: 'hello'
})
})
it('a closed FileHandle cannot be used again', async () => {
const message = await tauriError(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const file = await api.fs.create(path, { baseDir })
await file.close()
await file.stat()
}, `${dir}/closed.txt`)
expect(message.length).toBeGreaterThan(0)
})
it('rejects paths outside the configured scope', async () => {
// `$HOME` itself is not in the example's fs scope (only the app dirs,
// `$DOWNLOAD` and `$RESOURCE` are).
const message = await tauriError(async (api) =>
api.fs.readTextFile('e2e-forbidden.txt', {
baseDir: api.fs.BaseDirectory.Home
})
)
expect(message).toMatch(/forbidden path/)
})
it('rejects paths escaping the scope through `..`', async () => {
const message = await tauriError(async (api) =>
api.fs.writeTextFile('../../e2e-escape.txt', 'nope', {
baseDir: api.fs.BaseDirectory.AppData
})
)
expect(message).toMatch(/cannot traverse directory|forbidden path/)
})
// The watch specs are desktop-only: `fs:allow-watch` is granted in the
// example's desktop capability only.
itDesktop(
'watchImmediate reports changes in a watched directory',
async () => {
const result = await tauri(async (api, watched) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.mkdir(watched, { baseDir, recursive: true })
const events: { kind: string; paths: string[] }[] = []
const unwatch = await api.fs.watchImmediate(
watched,
(event) => {
events.push({
kind:
typeof event.type === 'string'
? event.type
: Object.keys(event.type)[0],
paths: event.paths
})
},
{ baseDir, recursive: true }
)
await api.fs.writeTextFile(`${watched}/touched.txt`, 'watched', {
baseDir
})
// give the notifier a moment to deliver
const deadline = Date.now() + 10_000
while (
!events.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
) {
if (Date.now() > deadline) {
throw new Error(
`no watch event for touched.txt, got ${JSON.stringify(events)}`
)
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
unwatch()
return events
}, `${dir}/watched`)
expect(result.length).toBeGreaterThan(0)
expect(result.every((e) => typeof e.kind === 'string')).toBe(true)
}
)
itDesktop('watch debounces and unwatch stops delivery', async () => {
const result = await tauri(async (api, watched) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.mkdir(watched, { baseDir, recursive: true })
let count = 0
const unwatch = await api.fs.watch(
watched,
() => {
count++
},
{ baseDir, delayMs: 200 }
)
await api.fs.writeTextFile(`${watched}/debounced.txt`, 'a', {
baseDir
})
const deadline = Date.now() + 10_000
while (count === 0) {
if (Date.now() > deadline) {
throw new Error('no debounced watch event received')
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
const afterFirst = count
unwatch()
await api.fs.writeTextFile(`${watched}/after-unwatch.txt`, 'b', {
baseDir
})
await new Promise((resolve) => setTimeout(resolve, 1000))
return { afterFirst, afterUnwatch: count }
}, `${dir}/debounced`)
expect(result.afterFirst).toBeGreaterThan(0)
expect(result.afterUnwatch).toBe(result.afterFirst)
})
})
@@ -0,0 +1,88 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
// Triggering a shortcut needs OS-level synthetic input the WebDriver session
// cannot produce, so the specs cover the registry (register / isRegistered /
// unregister / unregisterAll) and the error paths. The plugin is desktop-only,
// so the whole suite is skipped on mobile.
describePlugin('global-shortcut', { desktopOnly: true }, () => {
afterEach(async () => {
await tauri((api) => api.globalShortcut.unregisterAll())
})
it('register and unregister update isRegistered', async () => {
const result = await tauri(async (api) => {
const shortcut = 'CommandOrControl+Shift+F9'
const before = await api.globalShortcut.isRegistered(shortcut)
await api.globalShortcut.register(shortcut, () => {})
const registered = await api.globalShortcut.isRegistered(shortcut)
await api.globalShortcut.unregister(shortcut)
const after = await api.globalShortcut.isRegistered(shortcut)
return { before, registered, after }
})
expect(result).toEqual({ before: false, registered: true, after: false })
})
it('register accepts a list of shortcuts and unregister a list too', async () => {
const result = await tauri(async (api) => {
const shortcuts = ['Alt+Shift+F7', 'Alt+Shift+F8']
await api.globalShortcut.register(shortcuts, () => {})
const registered = await Promise.all(
shortcuts.map((s) => api.globalShortcut.isRegistered(s))
)
await api.globalShortcut.unregister(shortcuts)
const after = await Promise.all(
shortcuts.map((s) => api.globalShortcut.isRegistered(s))
)
return { registered, after }
})
expect(result.registered).toEqual([true, true])
expect(result.after).toEqual([false, false])
})
it('unregisterAll clears every registration', async () => {
const result = await tauri(async (api) => {
await api.globalShortcut.register('Alt+Shift+F5', () => {})
await api.globalShortcut.register('Alt+Shift+F6', () => {})
await api.globalShortcut.unregisterAll()
return [
await api.globalShortcut.isRegistered('Alt+Shift+F5'),
await api.globalShortcut.isRegistered('Alt+Shift+F6')
]
})
expect(result).toEqual([false, false])
})
it('shortcut names are normalized when checking registrations', async () => {
const result = await tauri(async (api) => {
await api.globalShortcut.register('CmdOrCtrl+Alt+F10', () => {})
return {
aliased: await api.globalShortcut.isRegistered(
'CommandOrControl+Alt+F10'
),
reordered: await api.globalShortcut.isRegistered('Alt+CmdOrCtrl+F10')
}
})
expect(result).toEqual({ aliased: true, reordered: true })
})
it('registering the same shortcut twice rejects', async () => {
const message = await tauriError(async (api) => {
await api.globalShortcut.register('Alt+Shift+F11', () => {})
await api.globalShortcut.register('Alt+Shift+F11', () => {})
})
expect(message).toMatch(/already registered/i)
})
it('rejects shortcuts that cannot be parsed', async () => {
const message = await tauriError((api) =>
api.globalShortcut.register('NotAKey+Nope', () => {})
)
expect(message).toMatch(/NotAKey/)
})
})
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
// The example spawns an echo server on this port: it replies with the request
// body and the request headers, and sets a `session-token` cookie on requests
// that do not carry one. It is also the only `http://` origin in the example's
// http scope.
const echoServer = 'http://localhost:3003'
describePlugin('http', () => {
it('fetch performs a GET and exposes status, url and headers', async () => {
const response = await tauri(async (api, url) => {
const response = await api.http.fetch(url, {
headers: { 'x-e2e-header': 'present' }
})
return {
ok: response.ok,
status: response.status,
url: response.url,
// the echo server mirrors the request headers back
echoedHeader: response.headers.get('x-e2e-header'),
body: await response.text()
}
}, echoServer)
expect(response.ok).toBe(true)
expect(response.status).toBe(200)
expect(response.url).toBe(`${echoServer}/`)
expect(response.echoedHeader).toBe('present')
expect(response.body).toBe('')
})
it('fetch sends a JSON body and parses the echoed response', async () => {
const payload = { message: 'hello from e2e', nested: { list: [1, 2, 3] } }
const response = await tauri(
async (api, url, payload) => {
const response = await api.http.fetch(`${url}/json`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
return {
status: response.status,
contentType: response.headers.get('content-type'),
body: (await response.json()) as unknown
}
},
echoServer,
payload
)
expect(response.status).toBe(200)
expect(response.contentType).toBe('application/json')
expect(response.body).toEqual(payload)
})
it('fetch sends binary bodies and reads them back as bytes', async () => {
const bytes = [0, 1, 2, 127, 128, 254, 255]
const echoed = await tauri(
async (api, url, bytes) => {
const response = await api.http.fetch(`${url}/bytes`, {
method: 'PUT',
body: new Uint8Array(bytes)
})
return Array.from(new Uint8Array(await response.arrayBuffer()))
},
echoServer,
bytes
)
expect(echoed).toEqual(bytes)
})
it('fetch sends multipart form data', async () => {
const body = await tauri(async (api, url) => {
const form = new FormData()
form.append('foo', 'baz')
form.append('bar', 'qux')
const response = await api.http.fetch(`${url}/form`, {
method: 'POST',
body: form
})
return {
contentType: response.headers.get('content-type'),
text: await response.text()
}
}, echoServer)
expect(body.contentType).toMatch(/^multipart\/form-data; boundary=/)
expect(body.text).toContain('name="foo"')
expect(body.text).toContain('baz')
expect(body.text).toContain('name="bar"')
expect(body.text).toContain('qux')
})
it('the cookie jar stores and replays cookies across requests', async () => {
const result = await tauri(async (api, url) => {
// The jar is persisted in the app data dir, so an earlier run (or the
// requests above) may already hold the cookie: the first request then
// replays it instead of being handed a new one.
const first = await api.http.fetch(`${url}/cookies`)
await first.text()
// Either way the jar attaches it to the next request, which the echo
// server mirrors back as a `cookie` header without setting a new one.
const second = await api.http.fetch(`${url}/cookies`)
await second.text()
return {
setCookie: first.headers.get('set-cookie'),
replayedFirst: first.headers.get('cookie'),
replayed: second.headers.get('cookie'),
setAgain: second.headers.get('set-cookie')
}
}, echoServer)
if (result.replayedFirst === null) {
expect(result.setCookie).toMatch(/^session-token=test-value/)
} else {
expect(result.replayedFirst).toContain('session-token=test-value')
}
expect(result.replayed).toContain('session-token=test-value')
expect(result.setAgain).toBeNull()
})
it('fetch can be aborted', async () => {
const message = await tauriError(async (api, url) => {
const controller = new AbortController()
controller.abort()
await api.http.fetch(`${url}/aborted`, { signal: controller.signal })
}, echoServer)
expect(message).toMatch(/abort|cancel/i)
})
it('rejects URLs outside the configured scope', async () => {
const message = await tauriError((api) =>
api.http.fetch('http://localhost:3999/not-in-scope')
)
expect(message).toMatch(/url not allowed on the configured scope/)
})
it('network failures reject', async () => {
// Every in-scope origin is reachable, so route the request through a proxy
// nothing listens on to force a connection error.
const message = await tauriError(
(api, url) =>
api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }),
echoServer
)
expect(message.length).toBeGreaterThan(0)
})
})
+188
View File
@@ -0,0 +1,188 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin, isMobile } from '../helpers/index.js'
// The example registers the plugin with a `Webview` target, so records logged
// from the page (and from Rust) are forwarded back to `attachLogger`.
//
// The plugin's default format differs per platform: `[date][time][target][level]
// message` on desktop, and just `[target] message` on mobile, where the platform
// logger (logcat / os_log) already stamps the time and level.
interface Record {
level: number
message: string
}
type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace'
/** Logs `message` at `level` and resolves with the records `attachLogger` saw. */
function logAndCollect(level: Level, message: string) {
return tauri(
(api, level, message) =>
new Promise<Record[]>((resolve, reject) => {
const records: Record[] = []
api.log
.attachLogger((record) => {
if (record.message.includes(message)) {
records.push(record)
}
})
.then(async (detach) => {
// eslint-disable-next-line security/detect-object-injection
await api.log[level](message)
setTimeout(() => {
detach()
resolve(records)
}, 1000)
})
.catch(reject)
}),
level,
message
)
}
describePlugin('log', () => {
it('attachLogger receives records logged from the webview', async () => {
const records = await logAndCollect('info', 'info record from e2e')
expect(records).toHaveLength(1)
expect(records[0].level).toBe(3) // LogLevel.Info
expect(records[0].message).toMatch(
isMobile
? /\[webview[^\]]*\] info record from e2e$/
: /\[webview[^\]]*\]\[INFO\] info record from e2e$/
)
})
it('records carry the level they were logged at', async () => {
const error = await logAndCollect('error', 'error record from e2e')
const warn = await logAndCollect('warn', 'warn record from e2e')
expect(error[0].level).toBe(5) // LogLevel.Error
expect(warn[0].level).toBe(4) // LogLevel.Warn
if (!isMobile) {
// the level is only part of the formatted message on desktop
expect(error[0].message).toContain('[ERROR]')
expect(warn[0].message).toContain('[WARN]')
}
})
it('records below the configured level are dropped', async () => {
// the example sets the level filter to Info
const debug = await logAndCollect('debug', 'debug record from e2e')
const trace = await logAndCollect('trace', 'trace record from e2e')
expect(debug).toHaveLength(0)
expect(trace).toHaveLength(0)
})
it('log options are accepted', async () => {
const records = await tauri(
(api, needle) =>
new Promise<Record[]>((resolve, reject) => {
const records: Record[] = []
api.log
.attachLogger((record) => {
if (record.message.includes(needle)) records.push(record)
})
.then(async (detach) => {
await api.log.info(needle, {
file: 'e2e.spec.ts',
line: 42,
keyValues: { suite: 'plugins-e2e' }
})
setTimeout(() => {
detach()
resolve(records)
}, 1000)
})
.catch(reject)
}),
'record with options from e2e'
)
expect(records).toHaveLength(1)
expect(records[0].level).toBe(3)
})
it('detaching the logger stops delivery', async () => {
const count = await tauri(
(api, needle) =>
new Promise<number>((resolve, reject) => {
let count = 0
api.log
.attachLogger((record) => {
if (record.message.includes(needle)) count++
})
.then(async (detach) => {
await api.log.info(needle)
await new Promise((r) => setTimeout(r, 500))
detach()
await api.log.info(needle)
await new Promise((r) => setTimeout(r, 1000))
resolve(count)
})
.catch(reject)
}),
'detached record from e2e'
)
expect(count).toBe(1)
})
it('attachConsole forwards records to the console', async () => {
const forwarded = await tauri(
(api, needle) =>
new Promise<string[]>((resolve, reject) => {
const seen: string[] = []
// Info records are forwarded to `console.info`
const original = console.info
console.info = (...args: unknown[]) => {
const text = args.map(String).join(' ')
if (text.includes(needle)) seen.push(text)
original.apply(console, args)
}
api.log
.attachConsole()
.then(async (detach) => {
await api.log.info(needle)
setTimeout(() => {
detach()
console.info = original
resolve(seen)
}, 1000)
})
.catch(reject)
}),
'console record from e2e'
)
expect(forwarded.length).toBeGreaterThan(0)
})
it('records logged from Rust are forwarded to the webview too', async () => {
// the example's `log_operation` command logs its arguments at Info
const message = await tauri(
(api, needle) =>
new Promise<string>((resolve, reject) => {
api.log
.attachLogger((record) => {
if (record.message.includes(needle)) resolve(record.message)
})
.then(() =>
api.core.invoke('log_operation', {
event: 'tauri-click',
payload: needle
})
)
.catch(reject)
setTimeout(() => reject(new Error('record not received')), 5000)
}),
'rust log from e2e'
)
expect(message).toContain(
isMobile ? '[api_lib::cmd] tauri-click' : '[INFO] tauri-click'
)
expect(message).toContain('rust log from e2e')
expect(message).not.toContain('[webview')
})
})
@@ -0,0 +1,58 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin, itDesktop } from '../helpers/index.js'
// Whether a notification actually shows up depends on the desktop session
// (a notification daemon on Linux, the app's registration on Windows and
// macOS), which the suite cannot observe. The specs cover the permission
// model and that sending does not error out synchronously.
//
// The permission specs are desktop-only: mobile starts out ungranted and
// `requestPermission` puts up a system dialog the session would then block on.
describePlugin('notification', () => {
itDesktop('permission is granted on desktop', async () => {
const result = await tauri(async (api) => ({
granted: await api.notification.isPermissionGranted(),
requested: await api.notification.requestPermission()
}))
expect(result).toEqual({ granted: true, requested: 'granted' })
})
itDesktop('the plugin overrides window.Notification', async () => {
const result = await tauri(async () => ({
permission: window.Notification.permission,
requested: await window.Notification.requestPermission()
}))
expect(result).toEqual({ permission: 'granted', requested: 'granted' })
})
it('sendNotification accepts a title string and an options object', async () => {
const result = await tauri((api) => {
api.notification.sendNotification('notification from e2e')
api.notification.sendNotification({
title: 'notification from e2e',
body: 'with a body',
sound: 'default'
})
return true
})
expect(result).toBe(true)
})
it('new Notification() goes through the plugin', async () => {
const result = await tauri(() => {
const notification = new window.Notification(
'window.Notification from e2e',
{
body: 'created through the DOM API'
}
)
return typeof notification === 'object'
})
expect(result).toBe(true)
})
})
@@ -0,0 +1,46 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauriError, describePlugin } from '../helpers/index.js'
// A successful open launches an external application (browser, file manager)
// the suite cannot control or close, so only the scope enforcement is covered.
// The example allows `mailto:`, `tel:`, `http(s)://` URLs (opener:default),
// `https://` URLs specifically with `inAppBrowser`, and paths under `$APPDATA`.
describePlugin('opener', () => {
it('openUrl rejects URL schemes outside the scope', async () => {
const message = await tauriError((api) =>
api.opener.openUrl('ftp://example.com/file')
)
expect(message).toMatch(
/Not allowed to open url ftp:\/\/example\.com\/file/
)
})
it('openUrl rejects an app that is not in the scope for the URL', async () => {
// `inAppBrowser` is only allowed for `https://` URLs
const message = await tauriError((api) =>
api.opener.openUrl('http://example.com', 'inAppBrowser')
)
expect(message).toMatch(/Not allowed to open url http:\/\/example\.com/)
})
it('openPath rejects paths outside the scope', async () => {
const message = await tauriError(async (api) =>
api.opener.openPath(await api.path.join(await api.path.homeDir(), 'e2e'))
)
expect(message).toMatch(/Not allowed to open path/)
})
it('revealItemInDir rejects paths that do not exist', async () => {
const message = await tauriError(async (api) =>
api.opener.revealItemInDir(
await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e')
)
)
expect(message.length).toBeGreaterThan(0)
})
})
+120
View File
@@ -0,0 +1,120 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import os from 'node:os'
import { expect } from '@wdio/globals'
import {
tauri,
describePlugin,
itDesktop,
platform,
isMobile
} from '../helpers/index.js'
// Everything the plugin reports is baked in at compile time, so it describes
// the *app's* platform — the host for the desktop suite, but the emulator or
// simulator for the mobile ones, which is why nothing here compares against
// Node's view of the host unless the two are the same machine.
const nodePlatformToTauri: Record<string, string> = {
linux: 'linux',
win32: 'windows',
darwin: 'macos',
freebsd: 'freebsd',
openbsd: 'openbsd',
android: 'android',
ios: 'ios'
}
const nodeArchToTauri: Record<string, string> = {
x64: 'x86_64',
ia32: 'x86',
arm64: 'aarch64',
arm: 'arm',
riscv64: 'riscv64',
ppc64: 'powerpc64',
s390x: 's390x'
}
// `Arch` in the plugin's guest-js.
const architectures = [
'x86',
'x86_64',
'arm',
'aarch64',
'mips',
'mips64',
'powerpc',
'powerpc64',
'riscv64',
's390x',
'sparc64'
]
describePlugin('os', () => {
it('platform, type and family match the target', async () => {
const info = await tauri((api) => ({
platform: api.os.platform(),
type: api.os.type(),
family: api.os.family()
}))
// eslint-disable-next-line security/detect-object-injection
expect(info.platform).toBe(nodePlatformToTauri[platform])
// eslint-disable-next-line security/detect-object-injection
expect(info.type).toBe(nodePlatformToTauri[platform])
expect(info.family).toBe(platform === 'win32' ? 'windows' : 'unix')
})
it('arch reports a known architecture', async () => {
const arch = await tauri((api) => api.os.arch())
if (isMobile) {
// The device/simulator is not necessarily the host's architecture (see
// `E2E_ANDROID_TARGET` / `E2E_IOS_TARGET`).
expect(architectures).toContain(arch)
} else {
expect(arch).toBe(nodeArchToTauri[process.arch])
}
})
it('eol and exeExtension match the platform conventions', async () => {
const info = await tauri((api) => ({
eol: api.os.eol(),
exeExtension: api.os.exeExtension()
}))
expect(info.eol).toBe(platform === 'win32' ? '\r\n' : '\n')
expect(info.exeExtension).toBe(platform === 'win32' ? 'exe' : '')
})
it('version reports a non-empty OS version', async () => {
const version = await tauri((api) => api.os.version())
expect(version.length).toBeGreaterThan(0)
})
it('locale is null or a language tag', async () => {
const locale = await tauri((api) => api.os.locale())
// The plugin forwards the environment's POSIX locale as-is, so a host with
// no locale configured (CI runners default to `LANG=C.UTF-8`) reports the
// POSIX default instead of a language tag.
if (locale !== null && locale !== 'C' && locale !== 'POSIX') {
// e.g. `en-US`
const [language, ...subtags] = locale.split(/[-_]/)
expect(language).toMatch(/^[A-Za-z]{2,3}$/)
for (const subtag of subtags) {
expect(subtag).toMatch(/^[A-Za-z0-9]+$/)
}
}
})
it('hostname reports a name', async () => {
const hostname = await tauri((api) => api.os.hostname())
expect(hostname).not.toBeNull()
expect(hostname!.length).toBeGreaterThan(0)
})
itDesktop('hostname matches the host', async () => {
const hostname = await tauri((api) => api.os.hostname())
// Windows can report the name in a different case than Node does.
expect(hostname!.toLowerCase()).toBe(os.hostname().toLowerCase())
})
})
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
isMobile,
type CommonPluginApi,
type DesktopPluginApi,
type MobilePluginApi
} from '../helpers/index.js'
/**
* The members each plugin's `api-iife.js` is expected to define on
* `window.__TAURI__.<plugin>`. This is the one place the suite covers the
* plugins whose commands cannot be driven from a WebDriver session (dialog
* blocks on native UI, process terminates the app, the mobile plugins need
* hardware or native UI), and it catches a plugin whose global API script is
* missing from its `build.rs`.
*/
type Surface<T> = { [P in keyof T]: (keyof T[P])[] }
/** Plugins the example registers on every platform. */
const commonSurface: Surface<CommonPluginApi> = {
clipboardManager: [
'writeText',
'readText',
'writeHtml',
'clear',
'readImage',
'writeImage'
],
dialog: ['open', 'save', 'message', 'ask', 'confirm'],
fs: [
'BaseDirectory',
'FileHandle',
'SeekMode',
'create',
'open',
'copyFile',
'mkdir',
'readDir',
'readFile',
'readTextFile',
'readTextFileLines',
'remove',
'rename',
'stat',
'lstat',
'truncate',
'writeFile',
'writeTextFile',
'exists',
'watch',
'watchImmediate',
'size'
],
http: ['fetch'],
log: [
'LogLevel',
'error',
'warn',
'info',
'debug',
'trace',
'attachLogger',
'attachConsole'
],
notification: [
'isPermissionGranted',
'requestPermission',
'sendNotification',
'registerActionTypes',
'pending',
'cancel',
'cancelAll',
'active',
'removeActive',
'removeAllActive',
'createChannel',
'removeChannel',
'channels',
'onNotificationReceived',
'onAction'
],
opener: ['openUrl', 'openPath', 'revealItemInDir'],
os: [
'eol',
'platform',
'family',
'version',
'type',
'arch',
'locale',
'exeExtension',
'hostname'
],
process: ['exit', 'relaunch'],
shell: ['Command', 'Child', 'EventEmitter', 'open'],
store: ['load', 'getStore', 'LazyStore', 'Store'],
upload: ['download', 'upload', 'HttpMethod']
}
/** Plugins the example only registers on desktop. */
const desktopSurface: Surface<DesktopPluginApi> = {
cli: ['getMatches'],
globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'],
updater: ['check', 'Update'],
windowState: [
'StateFlags',
'restoreState',
'restoreStateCurrent',
'saveWindowState',
'filename'
]
}
/** Plugins the example only registers on mobile. */
const mobileSurface: Surface<MobilePluginApi> = {
barcodeScanner: [
'Format',
'scan',
'cancel',
'checkPermissions',
'requestPermissions',
'openAppSettings'
],
biometric: ['BiometryType', 'checkStatus', 'authenticate'],
geolocation: [
'watchPosition',
'getCurrentPosition',
'clearWatch',
'checkPermissions',
'requestPermissions'
],
// `ImpactFeedbackStyle` and `NotificationFeedbackType` are type aliases, so
// they are not part of the runtime namespace.
haptics: [
'vibrate',
'impactFeedback',
'notificationFeedback',
'selectionFeedback'
],
nfc: [
'NFCTypeNameFormat',
'TechKind',
'RTD_TEXT',
'RTD_URI',
'record',
'textRecord',
'uriRecord',
'scan',
'write',
'isAvailable'
]
}
const surface = {
...commonSurface,
...(isMobile ? mobileSurface : desktopSurface)
} as Record<string, string[]>
/** The other platform's plugins, which must *not* be in this build. */
const foreign = Object.keys(isMobile ? desktopSurface : mobileSurface)
describe('plugin globals', () => {
it('every plugin this platform registers exposes its API on window.__TAURI__', async () => {
const missing = await tauri(
(api, plugins) =>
plugins.filter(
(plugin) =>
// eslint-disable-next-line security/detect-object-injection
typeof (api as unknown as Record<string, unknown>)[plugin]
!== 'object'
),
Object.keys(surface)
)
expect(missing).toEqual([])
})
it('the other platform’s plugins are not in the build', async () => {
// Their Rust crates are target-gated in the example's Cargo.toml, so their
// `global_api_script_path` is never injected either.
const present = await tauri(
(api, plugins) =>
plugins.filter(
(plugin) =>
// eslint-disable-next-line security/detect-object-injection
(api as unknown as Record<string, unknown>)[plugin] !== undefined
),
foreign
)
expect(present).toEqual([])
})
for (const [plugin, members] of Object.entries(surface)) {
it(`${plugin} exposes its documented members`, async () => {
const missing = await tauri(
(api, plugin, members) => {
const namespaces = api as unknown as Record<
string,
Record<string, unknown>
>
// eslint-disable-next-line security/detect-object-injection
const namespace = namespaces[plugin]
return members.filter((member) => !(member in namespace))
},
plugin,
members
)
expect(missing).toEqual([])
})
}
})
+238
View File
@@ -0,0 +1,238 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
platform
} from '../helpers/index.js'
// The example's shell scope allows `sh -c <script>` and `cmd /C <script>`.
const shell =
platform === 'win32'
? { program: 'cmd', flag: '/C' }
: { program: 'sh', flag: '-c' }
// Running a child process works on desktop and on Android (`/system/bin/sh`),
// but iOS does not let an app spawn one at all. The scope specs below stay on
// every platform: `prepare_cmd` rejects before anything is executed.
const itSpawns = platform === 'ios' ? it.skip : it
/**
* Puts a directory in the form the two sides of the working directory
* assertion can be compared in: the shell may print it with a different path
* style, and on Android `/data/user/<n>/<pkg>` (which `appDataDir` reports) is
* a symlink to `/data/data/<pkg>` (which `pwd` resolves it to).
*/
function normalizeDir(dir: string): string {
return dir
.replace(/[\\/]+$/, '')
.replace(/\\/g, '/')
.replace(/^\/data\/user\/\d+\//, '/data/data/')
.toLowerCase()
}
describePlugin('shell', () => {
itSpawns('execute collects stdout, stderr and the exit code', async () => {
const output = await tauri(
async (api, program, flag, script) => {
const result = await api.shell.Command.create(program, [
flag,
script
]).execute()
return {
code: result.code,
signal: result.signal,
stdout: result.stdout.trim(),
stderr: result.stderr.trim()
}
},
shell.program,
shell.flag,
platform === 'win32'
? 'echo hello from e2e && echo warning 1>&2 && exit 3'
: 'echo "hello from e2e"; echo "warning" >&2; exit 3'
)
expect(output.code).toBe(3)
expect(output.signal).toBeNull()
expect(output.stdout).toBe('hello from e2e')
expect(output.stderr).toBe('warning')
})
itSpawns(
'execute passes environment variables and the working directory',
async () => {
const cwd = await tauri((api) => api.path.appDataDir())
const output = await tauri(
async (api, program, flag, script, cwd) => {
const result = await api.shell.Command.create(
program,
[flag, script],
{
cwd,
env: { E2E_VALUE: 'from-e2e' }
}
).execute()
return { code: result.code, stdout: result.stdout.trim() }
},
shell.program,
shell.flag,
platform === 'win32'
? 'echo %E2E_VALUE% && cd'
: 'echo "$E2E_VALUE"; pwd',
cwd
)
expect(output.code).toBe(0)
// `cmd` echoes everything between `echo ` and `&&`, the space before the
// separator included, so every line is trimmed and not just the ends of
// the output as a whole.
const [value, reportedCwd] = output.stdout
.split(/\r?\n/)
.map((line) => line.trim())
expect(value).toBe('from-e2e')
expect(normalizeDir(reportedCwd ?? '')).toBe(normalizeDir(cwd))
}
)
itSpawns(
'spawn streams stdout and stderr lines and reports close',
async () => {
const events = await tauri(
(api, program, flag, script) =>
new Promise<{
stdout: string[]
stderr: string[]
close: { code: number | null; signal: number | null }
pid: number
}>((resolve, reject) => {
const stdout: string[] = []
const stderr: string[] = []
let pid = 0
const command = api.shell.Command.create(program, [flag, script])
command.stdout.on('data', (line) => stdout.push(line.trim()))
command.stderr.on('data', (line) => stderr.push(line.trim()))
command.on('error', (error) => reject(new Error(error)))
command.on('close', (payload) =>
resolve({
stdout,
stderr,
close: { code: payload.code, signal: payload.signal },
pid
})
)
command
.spawn()
.then((child) => {
pid = child.pid
})
.catch(reject)
setTimeout(() => reject(new Error('command never closed')), 15000)
}),
shell.program,
shell.flag,
platform === 'win32'
? 'echo one && echo two && echo err 1>&2'
: 'echo one; echo two; echo err >&2'
)
expect(events.pid).toBeGreaterThan(0)
expect(events.stdout).toEqual(['one', 'two'])
expect(events.stderr).toEqual(['err'])
expect(events.close.code).toBe(0)
}
)
itSpawns('write sends to stdin', async () => {
const output = await tauri(
(api, program, flag, script) =>
new Promise<string>((resolve, reject) => {
let out = ''
const command = api.shell.Command.create(program, [flag, script])
command.stdout.on('data', (line) => {
out += line
})
command.on('error', (error) => reject(new Error(error)))
command.on('close', () => resolve(out.trim()))
command
.spawn()
.then((child) => child.write('ping from e2e\n'))
.catch(reject)
setTimeout(() => reject(new Error('command never closed')), 15000)
}),
shell.program,
shell.flag,
// `call` makes cmd expand `%line%` after `set /p` ran, rather than
// when the line is parsed
platform === 'win32'
? 'set /p line= & call echo got: %line%'
: 'read line; echo "got: $line"'
)
expect(output).toBe('got: ping from e2e')
})
itSpawns('kill terminates a running child', async () => {
const result = await tauri(
(api, program, flag, script) =>
new Promise<{ code: number | null; signal: number | null }>(
(resolve, reject) => {
const command = api.shell.Command.create(program, [flag, script])
command.on('error', (error) => reject(new Error(error)))
command.on('close', (payload) =>
resolve({ code: payload.code, signal: payload.signal })
)
command
.spawn()
.then((child) =>
// give the shell a moment to start before killing it
new Promise((r) => setTimeout(r, 500)).then(() => child.kill())
)
.catch(reject)
setTimeout(() => reject(new Error('child was not killed')), 15000)
}
),
shell.program,
shell.flag,
// The script has to keep the shell itself busy rather than start another
// process: `kill` only signals the direct child, and a surviving
// grandchild holds the stdout/stderr pipes open, which withholds the
// `close` event until it exits on its own. Both shells block on their
// built-in stdin read, and the test never writes to stdin.
platform === 'win32' ? 'set /p killme=' : 'read killme'
)
if (platform === 'win32') {
// TerminateProcess sets an exit code of 1
expect(result.code).not.toBe(0)
} else {
// killed by SIGKILL, so no exit code
expect(result.code).toBeNull()
expect(result.signal).toBe(9)
}
})
it('rejects programs that are not in the scope', async () => {
const message = await tauriError((api) =>
api.shell.Command.create('e2e-not-allowed').execute()
)
expect(message).toMatch(/program not allowed on the configured shell scope/)
})
it('rejects arguments that do not match the scope', async () => {
// the scope only allows `-c`/`/C` followed by a non-empty script
const message = await tauriError(
(api, program) =>
api.shell.Command.create(program, ['--version']).execute(),
shell.program
)
expect(message).toMatch(/not allowed|validator|scope/i)
})
it('open rejects URLs outside the default scope', async () => {
// `shell:default` only allows http(s), mailto and tel URLs
const message = await tauriError((api) =>
api.shell.open('ftp://example.com')
)
expect(message.length).toBeGreaterThan(0)
})
})
+261
View File
@@ -0,0 +1,261 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin, scratchDir } from '../helpers/index.js'
// Store paths are relative to `$APPDATA`, which is also inside the example's
// fs scope, so the specs can inspect what the plugin persists.
const dir = scratchDir('store')
const storePath = `${dir}/e2e.json`
describePlugin('store', () => {
before(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
await api.fs.mkdir(dir, { baseDir, recursive: true })
}, dir)
})
after(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
}, dir)
})
it('set, get, has, keys, values, entries, length and delete', async () => {
const result = await tauri(async (api, path) => {
const store = await api.store.load(path, { autoSave: false })
await store.set('string', 'value')
await store.set('number', 42)
await store.set('object', { nested: [1, 2, 3] })
const snapshot = {
string: await store.get<string>('string'),
number: await store.get<number>('number'),
object: await store.get<{ nested: number[] }>('object'),
// `undefined` is normalized so the result survives serialization
missing: (await store.get('missing')) ?? null,
hasString: await store.has('string'),
hasMissing: await store.has('missing'),
keys: (await store.keys()).sort(),
values: await store.values(),
entries: (await store.entries()).sort(([a], [b]) => a.localeCompare(b)),
length: await store.length()
}
const deleted = await store.delete('number')
const deletedAgain = await store.delete('number')
const lengthAfterDelete = await store.length()
await store.clear()
const lengthAfterClear = await store.length()
await store.close()
return {
...snapshot,
deleted,
deletedAgain,
lengthAfterDelete,
lengthAfterClear
}
}, storePath)
expect(result.string).toBe('value')
expect(result.number).toBe(42)
expect(result.object).toEqual({ nested: [1, 2, 3] })
expect(result.missing).toBeNull()
expect(result.hasString).toBe(true)
expect(result.hasMissing).toBe(false)
expect(result.keys).toEqual(['number', 'object', 'string'])
expect(result.values).toHaveLength(3)
expect(result.entries).toEqual([
['number', 42],
['object', { nested: [1, 2, 3] }],
['string', 'value']
])
expect(result.length).toBe(3)
expect(result.deleted).toBe(true)
expect(result.deletedAgain).toBe(false)
expect(result.lengthAfterDelete).toBe(2)
expect(result.lengthAfterClear).toBe(0)
})
it('save persists to disk and load reads it back', async () => {
const result = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const store = await api.store.load(path, { autoSave: false })
await store.set('persisted', { answer: 42 })
await store.save()
await store.close()
const onDisk = JSON.parse(
await api.fs.readTextFile(path, { baseDir })
) as Record<string, unknown>
const reloaded = await api.store.load(path, { autoSave: false })
const value = await reloaded.get<{ answer: number }>('persisted')
await reloaded.close()
return { onDisk, value }
}, `${dir}/persisted.json`)
expect(result.onDisk).toEqual({ persisted: { answer: 42 } })
expect(result.value).toEqual({ answer: 42 })
})
it('autoSave writes changes without an explicit save', async () => {
const onDisk = await tauri(async (api, path) => {
const baseDir = api.fs.BaseDirectory.AppData
const store = await api.store.load(path, { autoSave: 50 })
await store.set('auto', true)
// autoSave is debounced; wait for it to flush
const deadline = Date.now() + 10_000
while (!(await api.fs.exists(path, { baseDir }))) {
if (Date.now() > deadline) {
throw new Error('store was never auto-saved')
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
const contents = JSON.parse(
await api.fs.readTextFile(path, { baseDir })
) as Record<string, unknown>
await store.close()
return contents
}, `${dir}/autosave.json`)
expect(onDisk).toEqual({ auto: true })
})
it('defaults apply on load and reset restores them', async () => {
const result = await tauri(async (api, path) => {
const store = await api.store.load(path, {
autoSave: false,
defaults: { theme: 'dark', volume: 5 }
})
const initial = {
theme: await store.get('theme'),
volume: await store.get('volume')
}
await store.set('theme', 'light')
await store.set('extra', 1)
await store.reset()
const afterReset = {
theme: await store.get('theme'),
volume: await store.get('volume'),
extra: (await store.get('extra')) ?? null,
length: await store.length()
}
await store.close()
return { initial, afterReset }
}, `${dir}/defaults.json`)
expect(result.initial).toEqual({ theme: 'dark', volume: 5 })
expect(result.afterReset).toEqual({
theme: 'dark',
volume: 5,
extra: null,
length: 2
})
})
it('reload merges the on-disk state, or replaces it with ignoreDefaults', async () => {
const result = await tauri(async (api, path) => {
const store = await api.store.load(path, { autoSave: false })
await store.set('saved', 1)
await store.save()
await store.set('saved', 2)
await store.set('unsaved', true)
// a plain reload only re-applies what is on disk on top of the cache
await store.reload()
const merged = {
saved: await store.get('saved'),
unsaved: (await store.get('unsaved')) ?? null
}
// ignoreDefaults makes the store match the disk exactly
await store.set('unsaved', true)
await store.reload({ ignoreDefaults: true })
const replaced = {
saved: await store.get('saved'),
unsaved: (await store.get('unsaved')) ?? null
}
await store.close()
return { merged, replaced }
}, `${dir}/reload.json`)
expect(result.merged).toEqual({ saved: 1, unsaved: true })
expect(result.replaced).toEqual({ saved: 1, unsaved: null })
})
it('getStore returns the already-loaded instance, or null', async () => {
const result = await tauri(async (api, path) => {
const before = await api.store.getStore(path)
const store = await api.store.load(path, { autoSave: false })
await store.set('shared', 'yes')
const existing = await api.store.getStore(path)
const sharedValue = existing ? await existing.get<string>('shared') : null
await store.close()
const afterClose = await api.store.getStore(path)
return { before, sharedValue, afterClose }
}, `${dir}/get-store.json`)
expect(result.before).toBeNull()
expect(result.sharedValue).toBe('yes')
expect(result.afterClose).toBeNull()
})
it('LazyStore initializes on first use and errors after close', async () => {
const result = await tauri(async (api, path) => {
const store = new api.store.LazyStore(path, { autoSave: false })
await store.set('lazy', 'loaded')
const value = await store.get<string>('lazy')
await store.close()
let closedError: string | null = null
try {
await store.get('lazy')
} catch (error) {
closedError = String(error)
}
return { value, closedError }
}, `${dir}/lazy.json`)
expect(result.value).toBe('loaded')
expect(result.closedError).not.toBeNull()
})
it('change listeners fire for set and delete', async () => {
const changes = await tauri(async (api, path) => {
const store = await api.store.load(path, { autoSave: false })
const changes: { key: string; value: unknown }[] = []
const unlisten = await store.onChange((key, value) => {
// `undefined` does not survive JSON serialization
changes.push({ key, value: value === undefined ? null : value })
})
await store.set('watched', 1)
await store.delete('watched')
// events are delivered asynchronously
await new Promise((resolve) => setTimeout(resolve, 500))
unlisten()
await store.close()
return changes
}, `${dir}/on-change.json`)
expect(changes).toEqual([
{ key: 'watched', value: 1 },
{ key: 'watched', value: null }
])
})
it('onKeyChange only fires for the watched key', async () => {
const values = await tauri(async (api, path) => {
const store = await api.store.load(path, { autoSave: false })
const values: unknown[] = []
const unlisten = await store.onKeyChange('watched', (value) => {
values.push(value === undefined ? null : value)
})
await store.set('other', 'ignored')
await store.set('watched', 'a')
await store.set('watched', 'b')
await new Promise((resolve) => setTimeout(resolve, 500))
unlisten()
await store.close()
return values
}, `${dir}/on-key-change.json`)
expect(values).toEqual(['a', 'b'])
})
})
@@ -0,0 +1,77 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
import {
UPDATER_FIXTURE_VERSION,
UPDATER_FIXTURE_NOTES,
UPDATER_TARGET_NO_UPDATE,
UPDATER_TARGET_OLDER
} from '../helpers/server.js'
// The e2e build points the updater endpoint at the fixture server (see
// `tauri.e2e.conf.json`), which answers based on the `{{target}}` placeholder.
// Only `check` is exercised: installing would replace the binary under test.
// The plugin is desktop-only, so the whole suite is skipped on mobile (the
// mobile builds are not built with the override config either).
describePlugin('updater', { desktopOnly: true }, () => {
it('check finds a newer release on the endpoint', async () => {
const update = await tauri(async (api) => {
const update = await api.updater.check()
if (!update) return null
const info = {
available: update.available,
currentVersion: update.currentVersion,
version: update.version,
body: update.body,
date: update.date,
rawVersion: (update.rawJson as { version?: string }).version
}
await update.close()
return info
})
expect(update).not.toBeNull()
expect(update!.available).toBe(true)
expect(update!.currentVersion).toBe('2.0.0')
expect(update!.version).toBe(UPDATER_FIXTURE_VERSION)
expect(update!.body).toBe(UPDATER_FIXTURE_NOTES)
expect(update!.date).toContain('2026-03-01')
expect(update!.rawVersion).toBe(UPDATER_FIXTURE_VERSION)
})
it('check resolves null when the endpoint has no update (204)', async () => {
const update = await tauri(
(api, target) => api.updater.check({ target }),
UPDATER_TARGET_NO_UPDATE
)
expect(update).toBeNull()
})
it('check ignores a release older than the current version', async () => {
// Downgrades are a build-time decision (the plugin's `allowDowngrades`
// config), not something `check` can be asked for, so the older manifest
// can only be checked for the update being ignored.
const update = await tauri(
(api, target) => api.updater.check({ target }),
UPDATER_TARGET_OLDER
)
expect(update).toBeNull()
})
it('check forwards custom headers and honors the timeout option', async () => {
// a successful check with extra headers and a generous timeout
const version = await tauri(async (api) => {
const update = await api.updater.check({
headers: { 'x-e2e-updater': 'yes' },
timeout: 30_000
})
const version = update?.version ?? null
await update?.close()
return version
})
expect(version).toBe(UPDATER_FIXTURE_VERSION)
})
})
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
scratchDir
} from '../helpers/index.js'
import { FIXTURE_SERVER_URL, DOWNLOAD_FIXTURE_BODY } from '../helpers/server.js'
// The plugin only takes absolute paths; these are resolved against `$APPDATA`
// inside the page, which is inside the example's fs scope so the specs can
// prepare and inspect the files.
const dir = scratchDir('upload')
interface Progress {
progress: number
progressTotal: number
total: number
transferSpeed: number
}
describePlugin('upload', () => {
before(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
await api.fs.mkdir(dir, { baseDir, recursive: true })
}, dir)
})
after(async () => {
await tauri(async (api, dir) => {
const baseDir = api.fs.BaseDirectory.AppData
if (await api.fs.exists(dir, { baseDir })) {
await api.fs.remove(dir, { baseDir, recursive: true })
}
}, dir)
})
it('download writes the response to disk and reports progress', async () => {
const result = await tauri(
async (api, url, relativePath) => {
const baseDir = api.fs.BaseDirectory.AppData
const path = await api.path.join(
await api.path.appDataDir(),
relativePath
)
const events: Progress[] = []
await api.upload.download(
url,
path,
(progress) => events.push(progress),
new Map([['x-e2e-download', 'yes']])
)
return {
contents: await api.fs.readTextFile(relativePath, { baseDir }),
events
}
},
`${FIXTURE_SERVER_URL}/download`,
`${dir}/downloaded.txt`
)
expect(result.contents).toBe(DOWNLOAD_FIXTURE_BODY)
expect(result.events.length).toBeGreaterThan(0)
const last = result.events[result.events.length - 1]
const expectedSize = Buffer.byteLength(DOWNLOAD_FIXTURE_BODY)
// the fixture server sends a Content-Length, so the total is known
expect(last.total).toBe(expectedSize)
expect(last.progressTotal).toBe(expectedSize)
expect(result.events.reduce((sum, event) => sum + event.progress, 0)).toBe(
expectedSize
)
})
it('download rejects on a non-success status', async () => {
const message = await tauriError(
async (api, url, relativePath) =>
api.upload.download(
url,
await api.path.join(await api.path.appDataDir(), relativePath)
),
`${FIXTURE_SERVER_URL}/does-not-exist`,
`${dir}/missing.txt`
)
expect(message).toMatch(/404/)
})
it('upload streams a file with the requested method and headers', async () => {
const contents = 'upload me\n'.repeat(1000)
const result = await tauri(
async (api, url, relativePath, contents) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(relativePath, contents, { baseDir })
const path = await api.path.join(
await api.path.appDataDir(),
relativePath
)
const events: Progress[] = []
const response = await api.upload.upload(
url,
path,
(progress) => events.push(progress),
new Map([['x-e2e-upload', 'yes']]),
api.upload.HttpMethod.Put
)
return { response: JSON.parse(response) as unknown, events }
},
`${FIXTURE_SERVER_URL}/echo`,
`${dir}/to-upload.txt`,
contents
)
const echoed = result.response as {
method: string
headers: Record<string, string>
body: string
}
expect(echoed.method).toBe('PUT')
expect(echoed.headers['x-e2e-upload']).toBe('yes')
expect(echoed.body).toBe(contents)
const size = Buffer.byteLength(contents)
expect(result.events.length).toBeGreaterThan(0)
expect(result.events[result.events.length - 1].total).toBe(size)
expect(result.events[result.events.length - 1].progressTotal).toBe(size)
})
it('upload defaults to POST', async () => {
const method = await tauri(
async (api, url, relativePath) => {
const baseDir = api.fs.BaseDirectory.AppData
await api.fs.writeTextFile(relativePath, 'post me', { baseDir })
const response = await api.upload.upload(
url,
await api.path.join(await api.path.appDataDir(), relativePath)
)
return (JSON.parse(response) as { method: string }).method
},
`${FIXTURE_SERVER_URL}/echo`,
`${dir}/to-post.txt`
)
expect(method).toBe('POST')
})
it('upload rejects when the file does not exist', async () => {
const message = await tauriError(
async (api, url, relativePath) =>
api.upload.upload(
url,
await api.path.join(await api.path.appDataDir(), relativePath)
),
`${FIXTURE_SERVER_URL}/echo`,
`${dir}/does-not-exist.txt`
)
expect(message.length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,130 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, eventually, describePlugin, itWm } from '../helpers/index.js'
// The plugin is desktop-only: a mobile window is the whole screen and has no
// state to persist, so the whole suite is skipped there.
describePlugin('window-state', { desktopOnly: true }, () => {
it('filename reports the state file name', async () => {
expect(await tauri((api) => api.windowState.filename())).toBe(
'.window-state.json'
)
})
it('saveWindowState and restoreState resolve', async () => {
await tauri(async (api) => {
await api.windowState.saveWindowState(api.windowState.StateFlags.ALL)
await api.windowState.restoreState('main', api.windowState.StateFlags.ALL)
await api.windowState.restoreStateCurrent()
return null
})
})
it('StateFlags combine as a bit set', async () => {
const flags = await tauri((api) => api.windowState.StateFlags)
expect(flags.ALL).toBe(
flags.SIZE
| flags.POSITION
| flags.MAXIMIZED
| flags.VISIBLE
| flags.DECORATIONS
| flags.FULLSCREEN
)
})
itWm(
'a new window is restored to the size it was last saved with',
async () => {
// The plugin applies the cached state of a label whenever a window with
// that label is created, which is what persists sizes across sessions.
const label = 'e2e-window-state'
const scale = await tauri((api) =>
api.window.getCurrentWindow().scaleFactor()
)
const create = (width: number, height: number) =>
tauri(
(api, label, width, height) =>
new Promise<void>((resolve, reject) => {
const window = new api.webviewWindow.WebviewWindow(label, {
width,
height
})
window.once('tauri://created', () => resolve()).catch(reject)
window
.once('tauri://error', (event) =>
reject(
new Error(
`window creation failed: ${String(event.payload)}`
)
)
)
.catch(reject)
setTimeout(
() => reject(new Error('window creation timed out')),
8000
)
}),
label,
width,
height
)
const innerSize = () =>
tauri(async (api, label) => {
const window = await api.webviewWindow.WebviewWindow.getByLabel(label)
if (!window) throw new Error(`window ${label} not found`)
const size = await window.innerSize()
return { width: size.width, height: size.height }
}, label)
const close = async () => {
await tauri(async (api, label) => {
const window = await api.webviewWindow.WebviewWindow.getByLabel(label)
await window?.close()
return null
}, label)
await eventually(async () => {
const labels = await tauri(async (api) =>
(await api.webviewWindow.getAllWebviewWindows()).map((w) => w.label)
)
if (labels.includes(label)) {
throw new Error('window is still present after close')
}
})
}
const expectSize = (width: number, height: number) =>
eventually(async () => {
const size = await innerSize()
const tolerance = Math.ceil(scale) * 8
if (
Math.abs(size.width - width) > tolerance
|| Math.abs(size.height - height) > tolerance
) {
throw new Error(
`size ${size.width}x${size.height} not near ${width}x${height}`
)
}
})
// create the window at one size and save that state
await create(500, 400)
await expectSize(500 * scale, 400 * scale)
const saved = await innerSize()
await tauri((api) =>
api.windowState.saveWindowState(api.windowState.StateFlags.SIZE)
)
await close()
// a new window with the same label asks for another size, and gets the
// saved one back
await create(700, 600)
try {
await expectSize(saved.width, saved.height)
} finally {
await close()
}
}
)
})
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2019-2023 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, plus every plugin's API (registered by its
// `api-iife.js`), is available on `window.__TAURI__` inside the webview.
// This mirrors that for the functions we serialize and run in the page.
import type { Api } from '../helpers/index.js'
declare global {
interface Window {
__TAURI__: Api
}
}
export {}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
// The specs run through tsx (esbuild, pulled in by @wdio/cli), and the
// plugins' published type definitions use extensionless relative imports,
// so bundler-style resolution is what matches the runtime and resolves
// the plugin types.
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM", "DOM.AsyncIterable"],
"types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["wdio.*.ts", "test/**/*.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Plugins e2e suite against the examples/api app on an Android device or
// emulator, through Appium's UiAutomator2 driver. See wdio.mobile.ts.
import { mobileConfig } from './wdio.mobile.js'
export const config = mobileConfig('android')
+306
View File
@@ -0,0 +1,306 @@
// Copyright 2019-2023 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'
import {
startFixtureServer,
type FixtureServer
} from './test/helpers/server.js'
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
let fixtureServer: FixtureServer | undefined
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 (and the specs' type-checking) resolve the plugins' JS
// packages from their `dist-js` build output. Fail early with a clear
// message instead of a confusing vite/tsc error.
if (
!fs.existsSync(
path.join(repoRoot, 'plugins', 'fs', 'dist-js', 'index.js')
)
) {
throw new Error(
'the plugins are not built — run `pnpm build` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// The override config enables the example's off-by-default `automation`
// feature (which registers tauri-plugin-automation) and points the updater
// at the fixture server, for this (debug, test-only) build.
// 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}`
)
}
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
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.`
)
}
// Serves the updater manifest and the upload/download fixtures the
// network-facing specs hit. Lives in the launcher process so it outlives
// the per-spec worker processes.
fixtureServer = await startFixtureServer()
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 plugin'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: WebdriverIO.Browser) => {
await browser.waitUntil(
async () => {
try {
const ready: unknown = await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)
return ready === true
} 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
fixtureServer?.close()
fixtureServer = undefined
}
function onShutdown(fn: () => void) {
const cleanup = () => {
try {
fn()
} finally {
process.exit()
}
}
for (const signal of [
'exit',
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGBREAK'
] as const) {
process.on(signal, cleanup)
}
}
onShutdown(closeAll)
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Plugins e2e suite against the examples/api app on an iOS simulator, through
// Appium's XCUITest driver. See wdio.mobile.ts.
import { mobileConfig } from './wdio.mobile.js'
export const config = mobileConfig('ios')
+486
View File
@@ -0,0 +1,486 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// Shared WebdriverIO configuration for the mobile suites (`wdio.android.conf.ts`
// and `wdio.ios.conf.ts`). Instead of tauri-driver, the example app is driven
// through Appium: the UiAutomator2 driver (Android; chromedriver attaches to the
// WebView) or the XCUITest driver (iOS simulator; WebKit remote inspector).
// Debug builds enable webview debugging on both platforms, which is what makes
// the `WEBVIEW_*` context — and `browser.executeAsync` inside it — available.
import path from 'node:path'
import fs from 'node:fs'
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
startFixtureServer,
FIXTURE_SERVER_PORT,
type FixtureServer
} from './test/helpers/server.js'
export type MobilePlatform = 'android' | 'ios'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(dirname, '..', '..')
const appDir = path.join(repoRoot, 'examples', 'api')
const tauriDir = path.join(appDir, 'src-tauri')
/** `identifier` in examples/api's tauri.conf.json. */
const appId = 'com.tauri.api'
/** Where Appium looks for drivers; they are devDependencies of this package. */
process.env.APPIUM_HOME ??= dirname
let fixtureServer: FixtureServer | undefined
export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
const ios = platform === 'ios' ? iosTarget() : undefined
// Path passed to the driver as `appium:app`.
const application =
process.env.E2E_APP_PATH
?? (ios
? path.join(
tauriDir,
'gen',
'apple',
'build',
ios.outputArch,
'Tauri API.app'
)
: path.join(
tauriDir,
'gen',
'android',
'app',
'build',
'outputs',
'apk',
'universal',
'debug',
'app-universal-debug.apk'
))
return {
specs: ['./test/specs/**/*.spec.ts'],
// One device/simulator, one app instance: the suite runs serially.
maxInstances: 1,
capabilities: [
platform === 'android'
? androidCapabilities(application)
: iosCapabilities(application)
],
services: [
[
'appium',
{
args: {
address: '127.0.0.1',
// Appium 3 requires insecure features to be scoped to a driver.
// chromedriver_autodownload lets the UiAutomator2 driver fetch a
// chromedriver matching the device's WebView (see E2E_CHROMEDRIVER).
allowInsecure: 'uiautomator2:chromedriver_autodownload'
},
// First-session setup (chromedriver download, WebDriverAgent build)
// can be slow, hence the generous timeout.
appiumStartTimeout: 120_000,
logPath: path.join(dirname, 'logs')
}
]
],
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000
},
connectionRetryCount: 0,
// The first session boots the simulator/emulator, installs the app and, on
// iOS, compiles WebDriverAgent — well over the 120s default that the
// request to create the session would otherwise be cut at (the driver
// timeouts in the capabilities below are what actually bound it).
connectionRetryTimeout: 600_000,
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
// Tells the specs (which run in worker processes) what the app runs on;
// `process.platform` there is the host. See `platform` in test/helpers.
runnerEnv: { E2E_PLATFORM: platform },
onPrepare: async () => {
// The example (and the specs' type-checking) resolve the plugins' JS
// packages from their `dist-js` build output. Fail early with a clear
// message instead of a confusing vite/tsc error.
const pluginsBuilt = fs.existsSync(
path.join(repoRoot, 'plugins', 'fs', 'dist-js', 'index.js')
)
if (!pluginsBuilt) {
throw new Error(
'the plugins are not built — run `pnpm build` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// The Android Studio / Xcode projects are committed in this repository
// (`examples/api/src-tauri/gen`), so this only runs if they were wiped.
// The build installs the Rust target it needs itself, so init skips that.
// eslint-disable-next-line security/detect-non-literal-fs-filename
const projectGenerated = fs.existsSync(
path.join(tauriDir, 'gen', ios ? 'apple' : 'android')
)
if (!projectGenerated) {
tauriCli([platform, 'init', '--ci', '--skip-targets-install'])
}
// `tauri ios build` exports the simulator app with `fs::rename`, which
// fails with "Directory not empty" when a previous build is still
// there, so clear it out first.
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (ios && fs.existsSync(application)) {
fs.rmSync(application, { recursive: true, force: true })
}
// A debug build, so wry turns on webview debugging (Android
// `setWebContentsDebuggingEnabled`, iOS `isInspectable`), which is what
// lets Appium reach the page. Only the target that the device/emulator
// actually runs is compiled.
//
// Unlike the desktop suite this passes no `tauri.e2e.conf.json`
// override: both things it turns on (the automation plugin and the
// updater endpoint) belong to desktop-only plugins.
tauriCli(
ios
? [
'ios',
'build',
'--debug',
'--target',
ios.name,
// Simulator builds are not code signed (see
// `additionalWebviewBundleIds` in `iosCapabilities`).
'--no-sign'
]
: [
'android',
'build',
'--debug',
'--apk',
'--target',
androidTarget()
]
)
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
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.`
)
}
// Serves the upload/download fixtures the `upload` specs hit. Lives in
// the launcher process so it outlives the per-spec worker processes.
// (The updater manifest it also serves is only used on desktop, where
// the updater plugin is registered.)
fixtureServer = await startFixtureServer()
},
// The session starts in the native (`NATIVE_APP`) context. Every spec goes
// through `window.__TAURI__`, so switch to the app's webview as soon as it
// is attachable and then block until the page has loaded (as the desktop
// config does).
before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => {
if (platform === 'android') {
// The fixture server listens on the host's loopback, which on a device
// (or emulator) is the device's own. `adb reverse` forwards the same
// port from the device back to the host so `FIXTURE_SERVER_URL` works
// unchanged in the page. The iOS simulator shares the host's network
// stack, so it needs nothing. Re-run per spec file (it is idempotent)
// because Appium may only have booted the emulator with the first
// session.
adbReverse(FIXTURE_SERVER_PORT)
}
let webview: string | undefined
await browser.waitUntil(
async () => {
const contexts = (await browser.getAppiumContexts())
// Detailed objects are only returned with `appium:fullContextList`.
.map((context) =>
typeof context === 'string' ? context : context.id
)
// Android names the context after the package, and lists every
// debuggable WebView on the device (other apps included), so match
// ours exactly. iOS names it `WEBVIEW_<pid>.<n>` and only lists the
// app under test's webviews, so the first one is it.
webview =
contexts.find((name) => name === `WEBVIEW_${appId}`)
?? (platform === 'ios'
? contexts.find((name) => name.startsWith('WEBVIEW_'))
: undefined)
return webview !== undefined
},
{
// Covers a cold app start plus, on Android, the on-demand chromedriver
// download for the first session.
timeout: 120_000,
interval: 1000,
timeoutMsg:
'no WEBVIEW context appeared — is the app a debug build (webview debugging enabled)?'
}
)
await browser.switchAppiumContext(webview!)
// The specs run the page through `executeAsync`, and the XCUITest driver
// starts with a script timeout of 0 (every async script times out at
// once) rather than the 30s the other drivers default to.
await browser.setTimeout({ script: 30_000 })
await browser.waitUntil(
async () => {
try {
const ready: unknown = await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)
return ready === true
} 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.'
}
)
},
onComplete: () => {
closeFixtureServer()
}
}
}
function closeFixtureServer(): void {
fixtureServer?.close()
fixtureServer = undefined
}
for (const signal of [
'exit',
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGBREAK'
] as const) {
process.on(signal, () => {
try {
closeFixtureServer()
} finally {
process.exit()
}
})
}
/** Runs `pnpm tauri <args>` in examples/api, failing loudly. */
function tauriCli(args: string[]): void {
const result = spawnSync('pnpm', ['tauri', ...args], {
cwd: appDir,
stdio: 'inherit',
shell: true
})
if (result.status !== 0) {
throw new Error(
`\`pnpm tauri ${args.join(' ')}\` failed with status ${result.status}`
)
}
}
// --- Android -----------------------------------------------------------------
/** `adb` from the Android SDK, else whatever is on `PATH`. */
function adb(args: string[]): SpawnSyncReturns<string> {
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
return spawnSync(
binary,
[
...(process.env.E2E_ANDROID_DEVICE
? ['-s', process.env.E2E_ANDROID_DEVICE]
: []),
...args
],
{ encoding: 'utf8', timeout: 20_000 }
)
}
function adbReverse(port: number): void {
const result = adb(['reverse', `tcp:${port}`, `tcp:${port}`])
if (result.status !== 0) {
console.warn(
`\`adb reverse tcp:${port}\` failed — the upload specs will not reach the fixture server.\n${result.stderr ?? ''}`
)
}
}
function androidCapabilities(app: string): WebdriverIO.Capabilities {
return {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:app': app,
'appium:appPackage': appId,
'appium:appActivity': '.MainActivity',
// Which device/emulator to use; Appium picks the first connected one
// otherwise. E2E_ANDROID_AVD instead boots that AVD.
...(process.env.E2E_ANDROID_DEVICE
? { 'appium:udid': process.env.E2E_ANDROID_DEVICE }
: {}),
...(process.env.E2E_ANDROID_AVD
? { 'appium:avd': process.env.E2E_ANDROID_AVD }
: {}),
// chromedriver must match the WebView's Chrome version: either a specific
// binary, or one Appium downloads on demand.
...(process.env.E2E_CHROMEDRIVER
? { 'appium:chromedriverExecutable': process.env.E2E_CHROMEDRIVER }
: { 'appium:chromedriverAutodownload': true }),
// Grants the runtime permissions the manifest declares (POST_NOTIFICATIONS
// among them) so the notification specs do not stop on a system dialog.
'appium:autoGrantPermissions': true,
// Emulators in CI are slow; give the UiAutomator2 server and adb room.
'appium:uiautomator2ServerInstallTimeout': 120_000,
'appium:uiautomator2ServerLaunchTimeout': 120_000,
'appium:adbExecTimeout': 60_000,
'appium:newCommandTimeout': 300
}
}
const androidTargets: Record<string, string> = {
// `ro.product.cpu.abi` -> `tauri android build --target`
'arm64-v8a': 'aarch64',
'armeabi-v7a': 'armv7',
x86_64: 'x86_64',
x86: 'i686'
}
/**
* The Rust target to build the APK for: `E2E_ANDROID_TARGET`, else the ABI of
* the connected device/emulator (via adb), else the host's (emulators run the
* host architecture).
*/
function androidTarget(): string {
if (process.env.E2E_ANDROID_TARGET) {
return process.env.E2E_ANDROID_TARGET
}
const abi = adb(['shell', 'getprop', 'ro.product.cpu.abi'])
const detected =
abi.status === 0 ? androidTargets[abi.stdout.trim()] : undefined
return detected ?? (process.arch === 'arm64' ? 'aarch64' : 'x86_64')
}
// --- iOS ---------------------------------------------------------------------
function iosCapabilities(app: string): WebdriverIO.Capabilities {
return {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:app': app,
'appium:bundleId': appId,
// The driver looks the app up in the simulator's Web Inspector listing by
// bundle identifier. The inspector identifies an app by the
// `application-identifier` entitlement Xcode embeds when it code signs a
// simulator build (`__TEXT,__entitlements`); `--no-sign` skips signing
// altogether (`CODE_SIGNING_ALLOWED=NO`), so the entitlement is missing and
// the inspector falls back to `process-<executable name>`. Match that too.
'appium:additionalWebviewBundleIds': [
`process-${path.basename(app, '.app')}`
],
'appium:udid': iosSimulator(),
// No Simulator.app window. Besides not needing one, the driver otherwise
// shuts a simulator that is booted without a visible UI down to relaunch it
// with one, on every session — and that shutdown regularly outlasts the
// driver's 15s limit on it.
'appium:isHeadless': true,
// WebDriverAgent is compiled on the first session, which takes minutes on
// a CI runner.
'appium:wdaLaunchTimeout': 240_000,
'appium:wdaStartupRetries': 3,
'appium:simulatorStartupTimeout': 240_000,
'appium:newCommandTimeout': 300
}
}
/**
* The Rust target to build the simulator app for (`E2E_IOS_TARGET`, else the
* host's architecture) and the directory the CLI exports it to.
*/
function iosTarget(): { name: string; outputArch: string } {
const name =
process.env.E2E_IOS_TARGET
?? (process.arch === 'arm64' ? 'aarch64-sim' : 'x86_64')
// `tauri ios build` writes to gen/apple/build/<arch>/ (cargo-mobile2's
// `arch` for the target).
const outputArch = name === 'aarch64-sim' ? 'arm64-sim' : name
return { name, outputArch }
}
interface SimctlDevice {
name: string
udid: string
state: string
isAvailable: boolean
deviceTypeIdentifier?: string
}
/**
* UDID of the simulator to run on: `E2E_IOS_DEVICE` (a UDID or device name),
* else an already-booted iPhone, else the iPhone on the newest installed
* runtime. Resolved through `simctl` so nothing has to be hardcoded per Xcode
* version.
*/
function iosSimulator(): string {
const requested = process.env.E2E_IOS_DEVICE
if (requested && /^[0-9A-F-]{36}$/i.test(requested)) {
return requested
}
const list = spawnSync(
'xcrun',
['simctl', 'list', 'devices', 'available', '--json'],
{ encoding: 'utf8' }
)
if (list.status !== 0) {
throw new Error(
`\`xcrun simctl list\` failed — is Xcode installed?\n${list.stderr}`
)
}
const { devices: runtimes } = JSON.parse(list.stdout) as {
devices: Record<string, SimctlDevice[]>
}
const iphones = Object.entries(runtimes)
// e.g. `com.apple.CoreSimulator.SimRuntime.iOS-18-2`
.filter(([runtime]) => runtime.includes('.iOS-'))
.sort(([a], [b]) => runtimeVersion(b) - runtimeVersion(a))
.flatMap(([, devices]) =>
devices.filter(
(device) =>
device.isAvailable
&& (device.deviceTypeIdentifier ?? device.name).includes('iPhone')
)
)
const device = requested
? iphones.find((candidate) => candidate.name === requested)
: (iphones.find((candidate) => candidate.state === 'Booted') ?? iphones[0])
if (!device) {
throw new Error(
requested
? `no available iPhone simulator named "${requested}" (E2E_IOS_DEVICE)`
: 'no available iPhone simulator found — install an iOS runtime in Xcode.'
)
}
return device.udid
}
function runtimeVersion(runtime: string): number {
const [major = '0', minor = '0'] = (runtime.split('.iOS-')[1] ?? '').split(
'-'
)
return Number(major) * 100 + Number(minor)
}
+8 -2
View File
@@ -21,10 +21,16 @@ import type { Options } from './index'
let permissionValue = 'default'
async function isPermissionGranted(): Promise<boolean> {
// @ts-expect-error __TEMPLATE_windows__ will be replaced in rust before it's injected.
if (window.Notification.permission !== 'default' || __TEMPLATE_windows__) {
if (window.Notification.permission !== 'default') {
return await Promise.resolve(window.Notification.permission === 'granted')
}
// Windows always grants the permission, and asking the backend for it from
// the init script makes the WebView throw a STATUS_ACCESS_VIOLATION on
// remote websites, so answer it here instead of invoking.
// @ts-expect-error __TEMPLATE_windows__ will be replaced in rust before it's injected.
if (__TEMPLATE_windows__) {
return true
}
return await invoke('plugin:notification|is_permission_granted')
}
+1 -1
View File
@@ -1 +1 @@
!function(){"use strict";async function i(i,n={},t){return window.__TAURI_INTERNALS__.invoke(i,n,t)}"function"==typeof SuppressedError&&SuppressedError,function(){let n=!1,t="default";function o(i){n=!0,window.Notification.permission=i,n=!1}window.Notification=function(n,t){const o=t||{};!async function(n){"object"==typeof n&&Object.freeze(n),await i("plugin:notification|notify",{options:"string"==typeof n?{title:n}:n})}(Object.assign(o,{title:n}))},window.Notification.requestPermission=async function(){return await i("plugin:notification|request_permission").then(i=>(o("prompt"===i||"prompt-with-rationale"===i?"default":i),i))},Object.defineProperty(window.Notification,"permission",{enumerable:!0,get:()=>t,set:i=>{if(!n)throw new Error("Readonly property");t=i}}),async function(){return"default"!==window.Notification.permission||__TEMPLATE_windows__?await Promise.resolve("granted"===window.Notification.permission):await i("plugin:notification|is_permission_granted")}().then(function(i){o(null===i?"default":i?"granted":"denied")})}()}();
!function(){"use strict";async function i(i,n={},t){return window.__TAURI_INTERNALS__.invoke(i,n,t)}"function"==typeof SuppressedError&&SuppressedError,function(){let n=!1,t="default";function o(i){n=!0,window.Notification.permission=i,n=!1}window.Notification=function(n,t){const o=t||{};!async function(n){"object"==typeof n&&Object.freeze(n),await i("plugin:notification|notify",{options:"string"==typeof n?{title:n}:n})}(Object.assign(o,{title:n}))},window.Notification.requestPermission=async function(){return await i("plugin:notification|request_permission").then(i=>(o("prompt"===i||"prompt-with-rationale"===i?"default":i),i))},Object.defineProperty(window.Notification,"permission",{enumerable:!0,get:()=>t,set:i=>{if(!n)throw new Error("Readonly property");t=i}}),async function(){return"default"!==window.Notification.permission?await Promise.resolve("granted"===window.Notification.permission):!!__TEMPLATE_windows__||await i("plugin:notification|is_permission_granted")}().then(function(i){o(null===i?"default":i?"granted":"denied")})}()}();
+5656 -74
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -1,10 +1,30 @@
packages:
- packages/*
- plugins/*
- plugins/*/examples/*
- examples/*
allowBuilds:
# appium's postinstall only auto-installs drivers for global installs; ours
# are regular devDependencies of packages/api-e2e, which appium picks up on
# its own, so there is nothing for it to do
appium: false
# transitive deps of @wdio/utils; their postinstall downloads browser drivers
# the desktop e2e suite never uses (it drives the app via tauri-driver)
edgedriver: false
esbuild: true
geckodriver: false
overrides:
# mocha (via @wdio/mocha-framework) pins a vulnerable range
serialize-javascript@<7.0.5: ^7.0.5
devalue@<5.9.1: ^5.9.1
morgan@<1.12.0: ^1.12.0
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