mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
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:
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+50
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
}
|
||||
},
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
"minimumSystemVersion": "15.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
await store.reload({ overrideDefaults: true })
|
||||
await store.reload({ ignoreDefaults: true })
|
||||
} catch (error) {
|
||||
onMessage(error)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 @@
|
||||
!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")})}()}();
|
||||
|
||||
Generated
+5656
-74
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user