From 684feb2510f468f002c57f7bdebf675cf5c1b1d9 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Tue, 22 Sep 2026 18:58:14 -0300 Subject: [PATCH] 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] --- .../fix-notification-windows-permission.md | 6 + .github/workflows/lint-javascript.yml | 8 + .github/workflows/test-api-e2e-mobile.yml | 232 + .github/workflows/test-api-e2e.yml | 174 + .scripts/ci/collect-macos-crash-reports.sh | 50 + Cargo.lock | 94 +- examples/api/src-tauri/Cargo.toml | 10 + examples/api/src-tauri/capabilities/base.json | 39 +- .../api/src-tauri/capabilities/desktop.json | 2 + examples/api/src-tauri/gen/apple/Podfile | 2 +- .../gen/apple/api.xcodeproj/project.pbxproj | 12 +- examples/api/src-tauri/gen/apple/project.yml | 6 +- examples/api/src-tauri/src/lib.rs | 22 +- examples/api/src-tauri/tauri.conf.json | 2 +- examples/api/src/views/Store.svelte | 2 +- package.json | 4 + packages/api-e2e/README.md | 242 + packages/api-e2e/package.json | 54 + packages/api-e2e/tauri.e2e.conf.json | 13 + packages/api-e2e/test/helpers/index.ts | 329 + packages/api-e2e/test/helpers/server.ts | 116 + packages/api-e2e/test/specs/app.spec.ts | 71 + packages/api-e2e/test/specs/cli.spec.ts | 33 + .../test/specs/clipboard-manager.spec.ts | 153 + packages/api-e2e/test/specs/fs.spec.ts | 380 ++ .../test/specs/global-shortcut.spec.ts | 88 + packages/api-e2e/test/specs/http.spec.ts | 149 + packages/api-e2e/test/specs/log.spec.ts | 188 + .../api-e2e/test/specs/notification.spec.ts | 58 + packages/api-e2e/test/specs/opener.spec.ts | 46 + packages/api-e2e/test/specs/os.spec.ts | 120 + packages/api-e2e/test/specs/plugins.spec.ts | 215 + packages/api-e2e/test/specs/shell.spec.ts | 238 + packages/api-e2e/test/specs/store.spec.ts | 261 + packages/api-e2e/test/specs/updater.spec.ts | 77 + packages/api-e2e/test/specs/upload.spec.ts | 161 + .../api-e2e/test/specs/window-state.spec.ts | 130 + packages/api-e2e/test/types/global.d.ts | 18 + packages/api-e2e/tsconfig.json | 20 + packages/api-e2e/wdio.android.conf.ts | 10 + packages/api-e2e/wdio.conf.ts | 306 + packages/api-e2e/wdio.ios.conf.ts | 10 + packages/api-e2e/wdio.mobile.ts | 486 ++ plugins/notification/guest-js/init.ts | 10 +- plugins/notification/src/init-iife.js | 2 +- pnpm-lock.yaml | 5730 ++++++++++++++++- pnpm-workspace.yaml | 20 + 47 files changed, 10292 insertions(+), 107 deletions(-) create mode 100644 .changes/fix-notification-windows-permission.md create mode 100644 .github/workflows/test-api-e2e-mobile.yml create mode 100644 .github/workflows/test-api-e2e.yml create mode 100755 .scripts/ci/collect-macos-crash-reports.sh create mode 100644 packages/api-e2e/README.md create mode 100644 packages/api-e2e/package.json create mode 100644 packages/api-e2e/tauri.e2e.conf.json create mode 100644 packages/api-e2e/test/helpers/index.ts create mode 100644 packages/api-e2e/test/helpers/server.ts create mode 100644 packages/api-e2e/test/specs/app.spec.ts create mode 100644 packages/api-e2e/test/specs/cli.spec.ts create mode 100644 packages/api-e2e/test/specs/clipboard-manager.spec.ts create mode 100644 packages/api-e2e/test/specs/fs.spec.ts create mode 100644 packages/api-e2e/test/specs/global-shortcut.spec.ts create mode 100644 packages/api-e2e/test/specs/http.spec.ts create mode 100644 packages/api-e2e/test/specs/log.spec.ts create mode 100644 packages/api-e2e/test/specs/notification.spec.ts create mode 100644 packages/api-e2e/test/specs/opener.spec.ts create mode 100644 packages/api-e2e/test/specs/os.spec.ts create mode 100644 packages/api-e2e/test/specs/plugins.spec.ts create mode 100644 packages/api-e2e/test/specs/shell.spec.ts create mode 100644 packages/api-e2e/test/specs/store.spec.ts create mode 100644 packages/api-e2e/test/specs/updater.spec.ts create mode 100644 packages/api-e2e/test/specs/upload.spec.ts create mode 100644 packages/api-e2e/test/specs/window-state.spec.ts create mode 100644 packages/api-e2e/test/types/global.d.ts create mode 100644 packages/api-e2e/tsconfig.json create mode 100644 packages/api-e2e/wdio.android.conf.ts create mode 100644 packages/api-e2e/wdio.conf.ts create mode 100644 packages/api-e2e/wdio.ios.conf.ts create mode 100644 packages/api-e2e/wdio.mobile.ts diff --git a/.changes/fix-notification-windows-permission.md b/.changes/fix-notification-windows-permission.md new file mode 100644 index 000000000..8dc1a4034 --- /dev/null +++ b/.changes/fix-notification-windows-permission.md @@ -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`. diff --git a/.github/workflows/lint-javascript.yml b/.github/workflows/lint-javascript.yml index c3fce74c1..c6dbce4d4 100644 --- a/.github/workflows/lint-javascript.yml +++ b/.github/workflows/lint-javascript.yml @@ -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 diff --git a/.github/workflows/test-api-e2e-mobile.yml b/.github/workflows/test-api-e2e-mobile.yml new file mode 100644 index 000000000..301a5a09c --- /dev/null +++ b/.github/workflows/test-api-e2e-mobile.yml @@ -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 diff --git a/.github/workflows/test-api-e2e.yml b/.github/workflows/test-api-e2e.yml new file mode 100644 index 000000000..f5316a362 --- /dev/null +++ b/.github/workflows/test-api-e2e.yml @@ -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 diff --git a/.scripts/ci/collect-macos-crash-reports.sh b/.scripts/ci/collect-macos-crash-reports.sh new file mode 100755 index 000000000..17067077a --- /dev/null +++ b/.scripts/ci/collect-macos-crash-reports.sh @@ -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" diff --git a/Cargo.lock b/Cargo.lock index 9ede4439e..1830efd8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index dc15465c3..24f576f9f 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -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"] diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 637bfa496..773c0ec58 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -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", { diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json index 2c6fcf8ad..425ecb973 100644 --- a/examples/api/src-tauri/capabilities/desktop.json +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -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": ["*", "**/*"] } ] } diff --git a/examples/api/src-tauri/gen/apple/Podfile b/examples/api/src-tauri/gen/apple/Podfile index 24605d24a..f4d3303b8 100644 --- a/examples/api/src-tauri/gen/apple/Podfile +++ b/examples/api/src-tauri/gen/apple/Podfile @@ -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 diff --git a/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj index 11be7126e..e01e3a610 100644 --- a/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj +++ b/examples/api/src-tauri/gen/apple/api.xcodeproj/project.pbxproj @@ -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; diff --git a/examples/api/src-tauri/gen/apple/project.yml b/examples/api/src-tauri/gen/apple/project.yml index d789500eb..55c95eff2 100644 --- a/examples/api/src-tauri/gen/apple/project.yml +++ b/examples/api/src-tauri/gen/apple/project.yml @@ -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] diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 3c58f2c81..11a375684 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -23,10 +23,25 @@ pub type OnEvent = Box; #[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") { diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index be4918de3..e30e3364a 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -107,7 +107,7 @@ } }, "iOS": { - "minimumSystemVersion": "14.0" + "minimumSystemVersion": "15.0" } } } diff --git a/examples/api/src/views/Store.svelte b/examples/api/src/views/Store.svelte index b03f563db..6ba9a9a59 100644 --- a/examples/api/src/views/Store.svelte +++ b/examples/api/src/views/Store.svelte @@ -61,7 +61,7 @@ async function reload() { try { - await store.reload({ overrideDefaults: true }) + await store.reload({ ignoreDefaults: true }) } catch (error) { onMessage(error) } diff --git a/package.json b/package.json index 7fe38ce34..73d79771f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md new file mode 100644 index 000000000..b5e63929d --- /dev/null +++ b/packages/api-e2e/README.md @@ -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-` 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/.spec.ts` and use `describePlugin('', …)` + 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//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('')`, 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. diff --git a/packages/api-e2e/package.json b/packages/api-e2e/package.json new file mode 100644 index 000000000..f0e29eac6 --- /dev/null +++ b/packages/api-e2e/package.json @@ -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" + } +} diff --git a/packages/api-e2e/tauri.e2e.conf.json b/packages/api-e2e/tauri.e2e.conf.json new file mode 100644 index 000000000..278624b3a --- /dev/null +++ b/packages/api-e2e/tauri.e2e.conf.json @@ -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}}" + ] + } + } +} diff --git a/packages/api-e2e/test/helpers/index.ts b/packages/api-e2e/test/helpers/index.ts new file mode 100644 index 000000000..8177f5bba --- /dev/null +++ b/packages/api-e2e/test/helpers/index.ts @@ -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 = + | { 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( + fn: (api: Api, ...args: A) => R, + ...args: A +): Promise> { + // 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> | 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( + fn: (api: Api, ...args: A) => unknown, + ...args: A +): Promise { + 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( + check: () => T | Promise, + { + timeout = 10_000, + interval = 250 + }: { timeout?: number; interval?: number } = {} +): Promise { + 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 { + 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 { + 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 { + 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}` +} diff --git a/packages/api-e2e/test/helpers/server.ts b/packages/api-e2e/test/helpers/server.ts new file mode 100644 index 000000000..1c0f9d649 --- /dev/null +++ b/packages/api-e2e/test/helpers/server.ts @@ -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 { + 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) +} diff --git a/packages/api-e2e/test/specs/app.spec.ts b/packages/api-e2e/test/specs/app.spec.ts new file mode 100644 index 000000000..a76604ede --- /dev/null +++ b/packages/api-e2e/test/specs/app.spec.ts @@ -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' }) + }) +}) diff --git a/packages/api-e2e/test/specs/cli.spec.ts b/packages/api-e2e/test/specs/cli.spec.ts new file mode 100644 index 000000000..e817bcc2b --- /dev/null +++ b/packages/api-e2e/test/specs/cli.spec.ts @@ -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() + }) +}) diff --git a/packages/api-e2e/test/specs/clipboard-manager.spec.ts b/packages/api-e2e/test/specs/clipboard-manager.spec.ts new file mode 100644 index 000000000..e4be7c59a --- /dev/null +++ b/packages/api-e2e/test/specs/clipboard-manager.spec.ts @@ -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( + 'bold from e2e', + '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('') + }) +}) diff --git a/packages/api-e2e/test/specs/fs.spec.ts b/packages/api-e2e/test/specs/fs.spec.ts new file mode 100644 index 000000000..5f1b39154 --- /dev/null +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -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) + }) +}) diff --git a/packages/api-e2e/test/specs/global-shortcut.spec.ts b/packages/api-e2e/test/specs/global-shortcut.spec.ts new file mode 100644 index 000000000..c0b0980ee --- /dev/null +++ b/packages/api-e2e/test/specs/global-shortcut.spec.ts @@ -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/) + }) +}) diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts new file mode 100644 index 000000000..9ccfbc656 --- /dev/null +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -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) + }) +}) diff --git a/packages/api-e2e/test/specs/log.spec.ts b/packages/api-e2e/test/specs/log.spec.ts new file mode 100644 index 000000000..74467f687 --- /dev/null +++ b/packages/api-e2e/test/specs/log.spec.ts @@ -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((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((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((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((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((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') + }) +}) diff --git a/packages/api-e2e/test/specs/notification.spec.ts b/packages/api-e2e/test/specs/notification.spec.ts new file mode 100644 index 000000000..d4d95476f --- /dev/null +++ b/packages/api-e2e/test/specs/notification.spec.ts @@ -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) + }) +}) diff --git a/packages/api-e2e/test/specs/opener.spec.ts b/packages/api-e2e/test/specs/opener.spec.ts new file mode 100644 index 000000000..27201c0c8 --- /dev/null +++ b/packages/api-e2e/test/specs/opener.spec.ts @@ -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) + }) +}) diff --git a/packages/api-e2e/test/specs/os.spec.ts b/packages/api-e2e/test/specs/os.spec.ts new file mode 100644 index 000000000..fcad729df --- /dev/null +++ b/packages/api-e2e/test/specs/os.spec.ts @@ -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 = { + linux: 'linux', + win32: 'windows', + darwin: 'macos', + freebsd: 'freebsd', + openbsd: 'openbsd', + android: 'android', + ios: 'ios' +} + +const nodeArchToTauri: Record = { + 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()) + }) +}) diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts new file mode 100644 index 000000000..79a97a287 --- /dev/null +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -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__.`. 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 = { [P in keyof T]: (keyof T[P])[] } + +/** Plugins the example registers on every platform. */ +const commonSurface: Surface = { + 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 = { + 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 = { + 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 + +/** 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)[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)[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 + > + // eslint-disable-next-line security/detect-object-injection + const namespace = namespaces[plugin] + return members.filter((member) => !(member in namespace)) + }, + plugin, + members + ) + expect(missing).toEqual([]) + }) + } +}) diff --git a/packages/api-e2e/test/specs/shell.spec.ts b/packages/api-e2e/test/specs/shell.spec.ts new file mode 100644 index 000000000..bfdedacd8 --- /dev/null +++ b/packages/api-e2e/test/specs/shell.spec.ts @@ -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