diff --git a/.changes/fix-geo-haptics-specta-skip.md b/.changes/fix-geo-haptics-specta-skip.md deleted file mode 100644 index c52adc335..000000000 --- a/.changes/fix-geo-haptics-specta-skip.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -haptics: patch -geolocation: patch ---- - -Fixed a build failure when the `specta` feature is enabled: the `Error` type used the `#[serde(skip)]` helper attribute, which recent `specta` versions no longer register. It now uses `#[specta(skip)]`. diff --git a/.changes/global-api-script-reuse-core.md b/.changes/global-api-script-reuse-core.md deleted file mode 100644 index 7fd096bcf..000000000 --- a/.changes/global-api-script-reuse-core.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"autostart": patch -"autostart-js": patch -"barcode-scanner": patch -"barcode-scanner-js": patch -"biometric": patch -"biometric-js": patch -"cli": patch -"cli-js": patch -"clipboard-manager": patch -"clipboard-manager-js": patch -"deep-link": patch -"deep-link-js": patch -"dialog": patch -"dialog-js": patch -"fs": patch -"fs-js": patch -"geolocation": patch -"geolocation-js": patch -"global-shortcut": patch -"global-shortcut-js": patch -"haptics": patch -"haptics-js": patch -"http": patch -"http-js": patch -"log": patch -"log-js": patch -"nfc": patch -"nfc-js": patch -"notification": patch -"notification-js": patch -"opener": patch -"opener-js": patch -"os": patch -"os-js": patch -"positioner": patch -"positioner-js": patch -"process": patch -"process-js": patch -"shell": patch -"shell-js": patch -"sql": patch -"sql-js": patch -"store": patch -"store-js": patch -"stronghold": patch -"stronghold-js": patch -"updater": patch -"updater-js": patch -"upload": patch -"upload-js": patch -"websocket": patch -"websocket-js": patch -"window-state": patch -"window-state-js": patch ---- - -The plugin's global API script (used with `app.withGlobalTauri`) now resolves the core API from `window.__TAURI__` instead of bundling its own copy of `@tauri-apps/api`. Values created with the core API are now accepted by plugin APIs in global mode (e.g. an `Image` from `window.__TAURI__.image` passed to `clipboardManager.writeImage`, which previously failed the `instanceof` check against the plugin's private copy), and the script is considerably smaller. diff --git a/.github/workflows/audit-javascript.yml b/.github/workflows/audit-javascript.yml index 1c9942b0e..ce56c0265 100644 --- a/.github/workflows/audit-javascript.yml +++ b/.github/workflows/audit-javascript.yml @@ -40,18 +40,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Cache pnpm modules - uses: actions/cache@v6 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - uses: actions/setup-node@v7 with: node-version: 'lts/*' - uses: pnpm/action-setup@v6 with: run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow - name: audit run: pnpm audit diff --git a/.github/workflows/check-generated-files.yml b/.github/workflows/check-generated-files.yml index 0ea70c49b..11f40d1b1 100644 --- a/.github/workflows/check-generated-files.yml +++ b/.github/workflows/check-generated-files.yml @@ -158,19 +158,13 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Cache pnpm modules - uses: actions/cache@v6 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - uses: actions/setup-node@v7 with: node-version: 'lts/*' - uses: pnpm/action-setup@v6 with: run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow - name: build api working-directory: plugins/${{ matrix.package }} diff --git a/.github/workflows/covector-version-or-publish.yml b/.github/workflows/covector-version-or-publish.yml index 3c056cad4..d85a98c9c 100644 --- a/.github/workflows/covector-version-or-publish.yml +++ b/.github/workflows/covector-version-or-publish.yml @@ -20,7 +20,51 @@ permissions: pull-requests: write jobs: + # covector publishes once no pending change files are left (the version update PR consumed them) + # and otherwise opens that PR, which does not need the tests below. In pre-release mode the + # change files stay in `.changes` and `pre.json` lists the ones already released. + check-publish: + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.check.outputs.publish }} + steps: + - uses: actions/checkout@v7 + - id: check + run: | + released=$(if [ -f .changes/pre.json ]; then jq -r '.changes[]' .changes/pre.json; fi) + # grep exits 1 when every change file was released; any other failure fails the step + pending=$(find .changes -maxdepth 1 -name '*.md' ! -iname 'readme.md' \ + | { grep -vxF -e "$released" || [ $? -eq 1 ]; }) + if [ -n "$pending" ]; then + printf 'pending change files:\n%s\n' "$pending" + echo "publish=false" >> "$GITHUB_OUTPUT" + else + echo "publish=true" >> "$GITHUB_OUTPUT" + fi + + integration-tests: + needs: check-publish + if: needs.check-publish.outputs.publish == 'true' + uses: ./.github/workflows/integration-tests.yml + + test-api-e2e: + needs: check-publish + if: needs.check-publish.outputs.publish == 'true' + uses: ./.github/workflows/test-api-e2e.yml + secrets: inherit + + test-api-e2e-mobile: + needs: check-publish + if: needs.check-publish.outputs.publish == 'true' + uses: ./.github/workflows/test-api-e2e-mobile.yml + version-or-publish: + needs: + - integration-tests + - test-api-e2e + - test-api-e2e-mobile + # the tests are skipped when covector only versions + if: ${{ !cancelled() && !failure() }} runs-on: ubuntu-latest timeout-minutes: 65 outputs: diff --git a/.github/workflows/fmt.yml b/.github/workflows/fmt.yml index b6023261f..bc23cdcef 100644 --- a/.github/workflows/fmt.yml +++ b/.github/workflows/fmt.yml @@ -29,19 +29,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Cache pnpm modules - uses: actions/cache@v6 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - uses: actions/setup-node@v7 with: node-version: 'lts/*' - uses: pnpm/action-setup@v6 with: run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow - run: pnpm format:check taplo: diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c82fa21a5..350fee796 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -6,6 +6,8 @@ name: integration tests on: workflow_dispatch: + # run before publishing, see covector-version-or-publish.yml + workflow_call: push: branches: - v1 @@ -14,6 +16,7 @@ on: paths: - '.github/workflows/integration-tests.yml' - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' pull_request: branches: - v1 @@ -22,10 +25,14 @@ on: paths: - '.github/workflows/integration-tests.yml' - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' permissions: contents: read +env: + CARGO_PROFILE_DEV_DEBUG: 0 # no debuginfo: faster builds and a much smaller target dir to cache + jobs: run-integration-tests: runs-on: ${{ matrix.platform }} @@ -47,12 +54,21 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y webkit2gtk-4.0 libwebkit2gtk-4.1-dev libgtk-4-dev libayatana-appindicator3-dev libfuse2 + sudo apt-get install -y webkit2gtk-4.0 libwebkit2gtk-4.1-dev libgtk-4-dev libayatana-appindicator3-dev libfuse2 rpm - uses: Swatinem/rust-cache@v2 + with: + # 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 Tauri CLI - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch dev + # `--debug --target-dir target`: a dev-profile build in the workspace target dir reuses + # the cached dependencies (shared with the test build below) instead of a from-scratch + # release build in a temporary dir. `--locked` keeps the dependency versions stable + # between runs, so the cached artifacts stay valid. + run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch dev --locked --debug --target-dir target - name: run integration tests run: cargo test --test '*' -- --ignored diff --git a/.github/workflows/lint-javascript.yml b/.github/workflows/lint-javascript.yml index 81a764b6e..3e530398b 100644 --- a/.github/workflows/lint-javascript.yml +++ b/.github/workflows/lint-javascript.yml @@ -13,6 +13,7 @@ on: paths: - '.github/workflows/lint-javascript.yml' - 'plugins/*/guest-js/**' + - 'packages/**' - '.eslintignore' - '.eslintrc.json' - '.prettierignore' @@ -25,6 +26,7 @@ on: paths: - '.github/workflows/lint-javascript.yml' - 'plugins/*/guest-js/**' + - 'packages/**' - '.eslintignore' - '.eslintrc.json' - '.prettierignore' @@ -42,18 +44,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Cache pnpm modules - uses: actions/cache@v6 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - uses: actions/setup-node@v7 with: node-version: 'lts/*' - uses: pnpm/action-setup@v6 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/lint-rust.yml b/.github/workflows/lint-rust.yml index 097ebedb1..2ef372532 100644 --- a/.github/workflows/lint-rust.yml +++ b/.github/workflows/lint-rust.yml @@ -40,6 +40,7 @@ jobs: pull-requests: read outputs: packages: ${{ steps.filter.outputs.changes }} + saver: ${{ steps.saver.outputs.saver }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v2 @@ -131,6 +132,22 @@ jobs: - .github/workflows/lint-rust.yml - plugins/window-state/** + # Every package job for a platform restores the same rust-cache entry, and exactly one + # of them saves it (see the `save-if` of the rust-cache step). Prefer the packages whose + # dependency tree covers most of the other plugins' dependencies, so the entry is as + # useful as possible for the packages that did not save it. + - name: pick the package that saves the cache + id: saver + env: + PACKAGES: ${{ steps.filter.outputs.changes }} + run: | + saver="$(jq -rn --argjson packages "${PACKAGES:-[]}" ' + ["tauri-plugin-upload", "tauri-plugin-sql", "tauri-plugin-updater", "tauri-plugin-single-instance"] + | map(select(. as $p | $packages | index($p) != null)) + | first // $packages[0] // empty + ')" + echo "saver=$saver" >> "$GITHUB_OUTPUT" + clippy: needs: changes if: ${{ needs.changes.outputs.packages != '[]' && needs.changes.outputs.packages != '' }} @@ -154,6 +171,11 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 + with: + # One entry shared by every package job (the default key is per job, not per + # matrix entry), only written from `v2`/`v3` and by a single package job; see + # test-rust.yml for the reasoning. + save-if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && matrix.package == needs.changes.outputs.saver }} - name: clippy ${{ matrix.package }} run: cargo clippy --package ${{ matrix.package }} --all-targets -- -D warnings diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 31aebb06a..643262c08 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -28,14 +28,6 @@ jobs: - name: Fetch git tags run: git fetch origin 'refs/tags/*:refs/tags/*' - - name: Cache pnpm modules - uses: actions/cache@v6 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}- - - uses: actions/setup-node@v7 with: node-version: 'lts/*' @@ -43,6 +35,7 @@ jobs: - uses: pnpm/action-setup@v6 with: run_install: true + cache: true # the pnpm store, keyed by OS and lockfile hash and shared by every workflow - name: Build packages run: pnpm build diff --git a/.github/workflows/test-android.yml b/.github/workflows/test-android.yml index e1ff429a6..9045e984c 100644 --- a/.github/workflows/test-android.yml +++ b/.github/workflows/test-android.yml @@ -27,6 +27,9 @@ jobs: matrix: platform: [ubuntu-latest, macos-latest, windows-latest] + # No caches on purpose: this workflow only runs on pull requests, so anything it saved + # would live on the merge ref where no other run can restore it, while still counting + # against the repository's 10 GB cache limit and evicting entries that `v2`/`v3` saved. steps: - uses: actions/checkout@v7 @@ -44,14 +47,12 @@ jobs: with: distribution: temurin java-version: 21 - cache: gradle - 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 @@ -67,8 +68,6 @@ jobs: echo "Changed $(basename "$link") from $current_target to $new_target" done - - uses: Swatinem/rust-cache@v2 - - name: build API run: pnpm build diff --git a/.github/workflows/test-api-e2e-mobile.yml b/.github/workflows/test-api-e2e-mobile.yml new file mode 100644 index 000000000..7a97a15be --- /dev/null +++ b/.github/workflows/test-api-e2e-mobile.yml @@ -0,0 +1,239 @@ +# 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: + # run before publishing, see covector-version-or-publish.yml + workflow_call: + 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: + # `github.workflow` is the caller's name in a `workflow_call` run, so the suffix keeps the + # desktop and mobile suites from sharing (and cancelling) one group there. + group: ${{ github.workflow }}-api-e2e-mobile-${{ 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 }} + # a debug build without debug info, which would otherwise grow the APK + # past what the emulator can install (see androidBuildEnv in wdio.mobile.ts) + CARGO_PROFILE_DEV_DEBUG: '0' + 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..248997345 --- /dev/null +++ b/.github/workflows/test-api-e2e.yml @@ -0,0 +1,180 @@ +# 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: + # run before publishing, see covector-version-or-publish.yml + workflow_call: + 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: + # `github.workflow` is the caller's name in a `workflow_call` run, so the suffix keeps the + # desktop and mobile suites from sharing (and cancelling) one group there. + group: ${{ github.workflow }}-api-e2e-${{ 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 \ + desktop-file-utils \ + xdg-utils + + # 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/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 4ed59067e..303cb9c12 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -28,6 +28,10 @@ on: - '**/Cargo.toml' - '**/Cargo.lock' +env: + CARGO_PROFILE_DEV_DEBUG: 0 # no debuginfo: faster builds and a much smaller target dir to cache + CROSS_REV: 51f46f296253d8122c927c5bb933e3c4f27cc317 + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -42,6 +46,7 @@ jobs: pull-requests: read outputs: packages: ${{ steps.filter.outputs.changes }} + saver: ${{ steps.saver.outputs.saver }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v2 @@ -188,6 +193,22 @@ jobs: - Cargo.lock - plugins/window-state/** + # Every package job for a platform restores the same rust-cache entry, and exactly one + # of them saves it (see the `save-if` of the rust-cache step). Prefer the packages whose + # dependency tree covers most of the other plugins' dependencies, so the entry is as + # useful as possible for the packages that did not save it. + - name: pick the package that saves the cache + id: saver + env: + PACKAGES: ${{ steps.filter.outputs.changes }} + run: | + saver="$(jq -rn --argjson packages "${PACKAGES:-[]}" ' + ["tauri-plugin-upload", "tauri-plugin-sql", "tauri-plugin-updater", "tauri-plugin-single-instance"] + | map(select(. as $p | $packages | index($p) != null)) + | first // $packages[0] // empty + ')" + echo "saver=$saver" >> "$GITHUB_OUTPUT" + test: needs: changes if: ${{ needs.changes.outputs.packages != '[]' && needs.changes.outputs.packages != '' }} @@ -244,11 +265,34 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - key: cache-${{ matrix.package }}-${{ matrix.platform.target }} + # One entry per platform, shared by every package job: the plugins share most of + # their dependency tree (tauri, wry, tao...), and an entry per package and platform + # (26 × 5 entries of 500-800 MB each) never fits the repository's 10 GB cache limit. + key: ${{ matrix.platform.target }} + # 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). + # A single package job saves the shared entry, so the jobs don't race for the key. + save-if: ${{ (github.ref == 'refs/heads/v2' || github.ref == 'refs/heads/v3') && matrix.package == needs.changes.outputs.saver }} + + # `cross` is built from a pinned git revision (its latest release predates the Android + # targets we need). Keep the binary in its own small cache entry (accessed by every run, + # so it stays hot) instead of rebuilding it from source on every run. + - name: restore cross + if: ${{ matrix.platform.runner == 'cross' }} + id: cross-cache + uses: actions/cache@v6 + with: + path: ${{ runner.tool_cache }}/cross + key: cross-${{ runner.os }}-${{ env.CROSS_REV }} - name: install cross + if: ${{ matrix.platform.runner == 'cross' && steps.cross-cache.outputs.cache-hit != 'true' }} + run: cargo +stable install cross --git https://github.com/cross-rs/cross --rev ${{ env.CROSS_REV }} --locked --root ${{ runner.tool_cache }}/cross + + - name: add cross to PATH if: ${{ matrix.platform.runner == 'cross' }} - run: cargo +stable install cross --git https://github.com/cross-rs/cross + run: echo "${{ runner.tool_cache }}/cross/bin" >> $GITHUB_PATH - name: test ${{ matrix.package }} if: ${{ matrix.package != 'tauri-plugin-http' && matrix.package != 'tauri-plugin-dialog' }} @@ -257,3 +301,13 @@ jobs: - name: test ${{ matrix.package }} if: ${{ matrix.package == 'tauri-plugin-http' || matrix.package == 'tauri-plugin-dialog' }} run: ${{ matrix.platform.runner }} ${{ matrix.platform.command }} --package ${{ matrix.package }} --target ${{ matrix.platform.target }} --all-targets + + # `--all-targets` above does not include doc tests, so they are run separately. + # Only on the platforms we actually test on, since cargo skips doc tests when cross compiling. + - name: doc test ${{ matrix.package }} + if: ${{ matrix.platform.command == 'test' && matrix.package != 'tauri-plugin-http' && matrix.package != 'tauri-plugin-dialog' }} + run: ${{ matrix.platform.runner }} test --doc --package ${{ matrix.package }} --target ${{ matrix.platform.target }} --all-features + + - name: doc test ${{ matrix.package }} + if: ${{ matrix.platform.command == 'test' && (matrix.package == 'tauri-plugin-http' || matrix.package == 'tauri-plugin-dialog') }} + run: ${{ matrix.platform.runner }} test --doc --package ${{ matrix.package }} --target ${{ matrix.platform.target }} 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 516a33784..199f32065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,13 +70,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -201,10 +216,13 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-automation", + "tauri-plugin-autostart", "tauri-plugin-barcode-scanner", "tauri-plugin-biometric", "tauri-plugin-cli", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-geolocation", @@ -216,11 +234,14 @@ dependencies = [ "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-os", + "tauri-plugin-positioner", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-sql", "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-upload", + "tauri-plugin-websocket", "tauri-plugin-window-state", "tauri-runtime-cef", "tauri-runtime-wry", @@ -709,9 +730,20 @@ version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", + "brotli-decompressor 5.0.3", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", ] [[package]] @@ -720,8 +752,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -1171,7 +1213,7 @@ version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" dependencies = [ - "brotli", + "brotli 8.0.4", "compression-core", "flate2", "memchr", @@ -4692,9 +4734,15 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", "objc2-quartz-core", ] @@ -4725,6 +4773,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -4794,8 +4843,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.2", + "objc2", "objc2-core-foundation", "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -7647,6 +7698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e73e30ecc62ae079d8a0f2c0d33963d7c360396e7795d748e005497e9276cab4" dependencies = [ "base64 0.22.1", + "brotli 9.0.0", "ico", "json-patch", "plist", @@ -7696,6 +7748,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-automation" +version = "3.0.0-alpha.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5c599a6e27903f61b1078b0c614070e3a906aaac5acff37cbcb86fac3e3040" +dependencies = [ + "libloading 0.8.9", + "objc2-app-kit", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tokio", + "tracing", +] + [[package]] name = "tauri-plugin-autostart" version = "3.0.0-alpha.1" @@ -7939,8 +8007,6 @@ dependencies = [ "thiserror 2.0.20", "time", "url", - "win7-notifications", - "windows-version", ] [[package]] @@ -8278,6 +8344,7 @@ checksum = "643fe91e92f59e637be07901565e0a5c11bc3355f74b78281de77b4e964f9a0c" dependencies = [ "aes-gcm", "anyhow", + "brotli 9.0.0", "cargo_metadata 0.19.2", "ctor", "dom_query", @@ -8410,7 +8477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -9575,16 +9642,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "win7-notifications" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b4745047a00800bd8f2b8fb4b0eb6f7d96822084127f0ff7d68d07f692fe38" -dependencies = [ - "once_cell", - "windows-sys 0.59.0", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index b97e94519..2113eb56b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ glob = "0.3" zbus = "5.9" [workspace.package] -edition = "2021" +edition = "2024" authors = ["Tauri Programme within The Commons Conservancy"] license = "Apache-2.0 OR MIT" rust-version = "1.95" diff --git a/examples/api/CHANGELOG.md b/examples/api/CHANGELOG.md index 10deb85ef..758f4f714 100644 --- a/examples/api/CHANGELOG.md +++ b/examples/api/CHANGELOG.md @@ -44,6 +44,28 @@ - Upgraded to `store-js@3.0.0-alpha.0` - Upgraded to `updater-js@3.0.0-alpha.0` +## [2.0.46] + +### Dependencies + +- Upgraded to `fs-js@2.6.0` +- Upgraded to `notification-js@2.5.0` +- Upgraded to `barcode-scanner-js@2.5.0` +- Upgraded to `biometric-js@2.4.0` +- Upgraded to `cli-js@2.5.0` +- Upgraded to `clipboard-manager-js@2.4.0` +- Upgraded to `dialog-js@2.8.0` +- Upgraded to `global-shortcut-js@2.4.0` +- Upgraded to `http-js@2.7.0` +- Upgraded to `log-js@2.10.0` +- Upgraded to `nfc-js@2.4.0` +- Upgraded to `opener-js@2.6.0` +- Upgraded to `os-js@2.4.0` +- Upgraded to `process-js@2.4.0` +- Upgraded to `shell-js@2.4.0` +- Upgraded to `store-js@2.5.0` +- Upgraded to `updater-js@2.13.0` + ## [2.0.45] ### Dependencies diff --git a/examples/api/package.json b/examples/api/package.json index 4605ba17e..fa94b4a66 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -15,6 +15,7 @@ "@tauri-apps/plugin-biometric": "workspace:*", "@tauri-apps/plugin-cli": "workspace:*", "@tauri-apps/plugin-clipboard-manager": "workspace:*", + "@tauri-apps/plugin-deep-link": "workspace:*", "@tauri-apps/plugin-dialog": "workspace:*", "@tauri-apps/plugin-fs": "workspace:*", "@tauri-apps/plugin-geolocation": "workspace:*", @@ -25,11 +26,14 @@ "@tauri-apps/plugin-notification": "workspace:*", "@tauri-apps/plugin-opener": "workspace:*", "@tauri-apps/plugin-os": "workspace:*", + "@tauri-apps/plugin-positioner": "workspace:*", "@tauri-apps/plugin-process": "workspace:*", "@tauri-apps/plugin-shell": "workspace:*", + "@tauri-apps/plugin-sql": "workspace:*", "@tauri-apps/plugin-store": "workspace:*", "@tauri-apps/plugin-updater": "workspace:*", "@tauri-apps/plugin-upload": "workspace:*", + "@tauri-apps/plugin-websocket": "workspace:*", "@zerodevx/svelte-json-view": "2.0.0" }, "devDependencies": { diff --git a/examples/api/src-tauri/CHANGELOG.md b/examples/api/src-tauri/CHANGELOG.md index 1a49b7f01..34229d040 100644 --- a/examples/api/src-tauri/CHANGELOG.md +++ b/examples/api/src-tauri/CHANGELOG.md @@ -50,6 +50,30 @@ - Upgraded to `haptics@3.0.0-alpha.0` - Upgraded to `geolocation@3.0.0-alpha.0` +## [2.0.50] + +### Dependencies + +- Upgraded to `barcode-scanner@2.5.0` +- Upgraded to `biometric@2.4.0` +- Upgraded to `clipboard-manager@2.4.0` +- Upgraded to `dialog@2.8.0` +- Upgraded to `fs@2.6.0` +- Upgraded to `geolocation@2.4.0` +- Upgraded to `haptics@2.4.0` +- Upgraded to `nfc@2.4.0` +- Upgraded to `notification@2.5.0` +- Upgraded to `opener@2.6.0` +- Upgraded to `shell@2.4.0` +- Upgraded to `cli@2.5.0` +- Upgraded to `global-shortcut@2.4.0` +- Upgraded to `http@2.8.0` +- Upgraded to `log@2.10.0` +- Upgraded to `os@2.4.0` +- Upgraded to `process@2.4.0` +- Upgraded to `store@2.5.0` +- Upgraded to `updater@2.13.0` + ## [2.0.49] ### Dependencies diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 6cecb51c9..cfe18e449 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "api" publish = false version = "3.0.0-alpha.1" description = "An example Tauri Application showcasing the api" -edition = "2021" +edition = "2024" rust-version = { workspace = true } license = "Apache-2.0 OR MIT" @@ -42,6 +42,17 @@ tauri-plugin-opener = { path = "../../../plugins/opener", version = "3.0.0-alpha tauri-plugin-shell = { path = "../../../plugins/shell", version = "3.0.0-alpha.1" } tauri-plugin-store = { path = "../../../plugins/store", version = "3.0.0-alpha.1" } tauri-plugin-upload = { path = "../../../plugins/upload", version = "3.0.0-alpha.1" } +tauri-plugin-deep-link = { path = "../../../plugins/deep-link", version = "3.0.0-alpha.1" } +tauri-plugin-sql = { path = "../../../plugins/sql", version = "3.0.0-alpha.1", features = [ + "sqlite", +] } +tauri-plugin-websocket = { path = "../../../plugins/websocket", version = "3.0.0-alpha.1" } + +# 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 = "3.0.0-alpha.2", optional = true } [dependencies.tauri] workspace = true @@ -69,8 +80,12 @@ optional = true features = ["macos-private-api"] [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] +tauri-plugin-autostart = { path = "../../../plugins/autostart", version = "3.0.0-alpha.1" } tauri-plugin-cli = { path = "../../../plugins/cli", version = "3.0.0-alpha.1" } tauri-plugin-global-shortcut = { path = "../../../plugins/global-shortcut", version = "3.0.0-alpha.1" } +tauri-plugin-positioner = { path = "../../../plugins/positioner", version = "3.0.0-alpha.1", features = [ + "tray-icon", +] } tauri-plugin-updater = { path = "../../../plugins/updater", version = "3.0.0-alpha.1" } tauri-plugin-window-state = { path = "../../../plugins/window-state", version = "3.0.0-alpha.1" } @@ -82,6 +97,8 @@ tauri-plugin-geolocation = { path = "../../../plugins/geolocation/", version = " tauri-plugin-haptics = { path = "../../../plugins/haptics/", version = "3.0.0-alpha.1" } [features] +# Enables the WebDriver automation bridge; set by the e2e suite's build (packages/api-e2e). +automation = ["dep:tauri-plugin-automation"] default = ["wry"] # wry is a GTK 3 application on Linux, so it can use the plugin's GTK 3 dialogs. wry = ["dep:tauri-runtime-wry", "tauri-plugin-dialog/gtk3"] diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index ff81cc970..d0f6be1a1 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:allow-kill", "shell:allow-stdin-write", "process:allow-exit", @@ -56,6 +84,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", @@ -65,6 +95,11 @@ "fs:allow-mkdir", "fs:allow-remove", "fs:allow-write-file", + "fs:allow-create", + "fs:allow-copy-file", + "fs:allow-seek", + "fs:allow-truncate", + "fs:allow-ftruncate", "fs:scope-download-recursive", "fs:scope-resource-recursive", { @@ -94,6 +129,13 @@ "identifier": "opener:allow-open-path", "allow": [{ "path": "$APPDATA" }, { "path": "$APPDATA/**" }] }, - "upload:default" + "upload:default", + "deep-link:default", + "deep-link:allow-register", + "deep-link:allow-unregister", + "deep-link:allow-is-registered", + "sql:default", + "sql:allow-execute", + "websocket:default" ] } diff --git a/examples/api/src-tauri/capabilities/desktop.json b/examples/api/src-tauri/capabilities/desktop.json index 2c6fcf8ad..26722c2fd 100644 --- a/examples/api/src-tauri/capabilities/desktop.json +++ b/examples/api/src-tauri/capabilities/desktop.json @@ -5,11 +5,15 @@ "windows": ["main"], "platforms": ["linux", "macOS", "windows"], "permissions": [ + "autostart:default", "cli:default", + "positioner:default", "updater:default", "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 464e43c80..7479eda1a 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -10,8 +10,8 @@ mod tray; use serde::Serialize; use tauri::{ - webview::{PageLoadEvent, WebviewWindowBuilder}, App, AppHandle, Emitter, Listener, RunEvent, WebviewUrl, + webview::{PageLoadEvent, WebviewWindowBuilder}, }; #[derive(Clone, Serialize)] @@ -33,11 +33,21 @@ pub fn run() { #[cfg(not(feature = "cef"))] let builder = tauri::Builder::default().runtime(tauri_runtime_wry::Wry::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"))] + let 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()) @@ -51,12 +61,33 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_upload::init()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_websocket::init()) + .plugin( + tauri_plugin_sql::Builder::new() + .add_migrations( + "sqlite:api.db", + vec![tauri_plugin_sql::Migration { + version: 1, + description: "create_todos_table", + sql: "CREATE TABLE todos (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0);", + kind: tauri_plugin_sql::MigrationKind::Up, + }], + ) + .build(), + ) .setup(move |app| { #[cfg(desktop)] { + // registered before the tray, whose events it tracks + app.handle().plugin(tauri_plugin_positioner::init())?; #[cfg(not(feature = "cef"))] tray::create_tray(app.handle())?; app.handle().plugin(tauri_plugin_cli::init())?; + app.handle().plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + None, + ))?; app.handle() .plugin(tauri_plugin_global_shortcut::Builder::new().build())?; app.handle() @@ -98,10 +129,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") { @@ -185,12 +213,12 @@ pub fn run() { // Only the tray icon keeps this app useful without windows, and there is // no tray on CEF. #[cfg(all(desktop, not(feature = "cef")))] - if let RunEvent::ExitRequested { code, api, .. } = &_event { - if code.is_none() { - // Keep the event loop running even if all windows are closed - // This allow us to catch system tray events when there is no window - api.prevent_exit(); - } + if let RunEvent::ExitRequested { code, api, .. } = &_event + && code.is_none() + { + // Keep the event loop running even if all windows are closed + // This allow us to catch system tray events when there is no window + api.prevent_exit(); } }) } diff --git a/examples/api/src-tauri/src/tray.rs b/examples/api/src-tauri/src/tray.rs index 7b1321a57..f0fdff344 100644 --- a/examples/api/src-tauri/src/tray.rs +++ b/examples/api/src-tauri/src/tray.rs @@ -4,9 +4,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{ + Manager, Runtime, WebviewUrl, WebviewWindowBuilder, menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - Manager, Runtime, WebviewUrl, WebviewWindowBuilder, }; pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { @@ -107,6 +107,8 @@ pub fn create_tray(app: &tauri::AppHandle) -> tauri::Result<()> { _ => {} }) .on_tray_icon_event(|tray, event| { + // lets the positioner plugin place windows relative to the tray icon + tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event); if let TrayIconEvent::Click { button_state: MouseButtonState::Down, button: MouseButton::Left, diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 56dccd6bb..5c0dd8c32 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -104,7 +104,7 @@ } }, "iOS": { - "minimumSystemVersion": "14.0" + "minimumSystemVersion": "15.0" } } } diff --git a/examples/api/src/App.svelte b/examples/api/src/App.svelte index 00b914810..205b46f07 100644 --- a/examples/api/src/App.svelte +++ b/examples/api/src/App.svelte @@ -33,6 +33,11 @@ import Geolocation from './views/Geolocation.svelte' import Haptics from './views/Haptics.svelte' import Nfc from './views/Nfc.svelte' + import Autostart from './views/Autostart.svelte' + import DeepLink from './views/DeepLink.svelte' + import Positioner from './views/Positioner.svelte' + import Sql from './views/Sql.svelte' + import WebSocket from './views/WebSocket.svelte' import TitleBar from './lib/TitleBar.svelte' @@ -131,6 +136,31 @@ component: WebRTC, icon: 'i-ph-broadcast' }, + { + label: 'WebSocket', + component: WebSocket, + icon: 'i-ph-plugs-connected' + }, + { + label: 'SQL', + component: Sql, + icon: 'i-ph-database' + }, + { + label: 'Deep link', + component: DeepLink, + icon: 'i-ph-link' + }, + !isMobile && { + label: 'Autostart', + component: Autostart, + icon: 'i-ph-power' + }, + !isMobile && { + label: 'Positioner', + component: Positioner, + icon: 'i-ph-arrows-out-cardinal' + }, isMobile && { label: 'Scanner', component: Scanner, diff --git a/examples/api/src/views/Autostart.svelte b/examples/api/src/views/Autostart.svelte new file mode 100644 index 000000000..1f8c557d3 --- /dev/null +++ b/examples/api/src/views/Autostart.svelte @@ -0,0 +1,30 @@ + + +
+ + Launching at login is {enabled ? 'enabled' : 'disabled'} +
diff --git a/examples/api/src/views/DeepLink.svelte b/examples/api/src/views/DeepLink.svelte new file mode 100644 index 000000000..9a1a19153 --- /dev/null +++ b/examples/api/src/views/DeepLink.svelte @@ -0,0 +1,67 @@ + + +
+
+ Current deep link: {current ? current.join(', ') : 'none'} +
+ +
+ + + + +
+
diff --git a/examples/api/src/views/Positioner.svelte b/examples/api/src/views/Positioner.svelte new file mode 100644 index 000000000..1f79167c1 --- /dev/null +++ b/examples/api/src/views/Positioner.svelte @@ -0,0 +1,40 @@ + + +
+

+ The tray positions only resolve once the tray icon has been clicked, which + reports its location to the plugin. +

+
+ + + +
+
diff --git a/examples/api/src/views/Sql.svelte b/examples/api/src/views/Sql.svelte new file mode 100644 index 000000000..45e656e44 --- /dev/null +++ b/examples/api/src/views/Sql.svelte @@ -0,0 +1,68 @@ + + +
+
+ + +
+ + {#each todos as todo (todo.id)} +
+ toggle(todo)} + /> + {todo.title} + +
+ {/each} +
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/examples/api/src/views/WebSocket.svelte b/examples/api/src/views/WebSocket.svelte new file mode 100644 index 000000000..9a91f89ab --- /dev/null +++ b/examples/api/src/views/WebSocket.svelte @@ -0,0 +1,55 @@ + + +
+ {#if ws} +
+ + + +
+ {:else} +
+ + +
+ {/if} +
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..07e97920c --- /dev/null +++ b/packages/api-e2e/README.md @@ -0,0 +1,250 @@ +# 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. The fixture server also has a + WebSocket echo endpoint (`ws://127.0.0.1:3004/ws`) for the `websocket` specs. + +## 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 | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. | +| `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. | +| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. | +| `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 and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. | +| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. | +| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. | +| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. | +| `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. | +| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. | +| `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). | +| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | 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 rest of the +notification API (action types, channels, the pending/active lists, listeners) only exists on +mobile, and is covered there without the permission. + +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..00ea969a9 --- /dev/null +++ b/packages/api-e2e/package.json @@ -0,0 +1,61 @@ +{ + "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.3.0", + "@tauri-apps/api": "^3.0.0-alpha.1", + "@tauri-apps/plugin-autostart": "workspace:*", + "@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-deep-link": "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-positioner": "workspace:*", + "@tauri-apps/plugin-process": "workspace:*", + "@tauri-apps/plugin-shell": "workspace:*", + "@tauri-apps/plugin-sql": "workspace:*", + "@tauri-apps/plugin-store": "workspace:*", + "@tauri-apps/plugin-updater": "workspace:*", + "@tauri-apps/plugin-upload": "workspace:*", + "@tauri-apps/plugin-websocket": "workspace:*", + "@tauri-apps/plugin-window-state": "workspace:*", + "@types/node": "^22.10.0", + "@types/ws": "^8.18.1", + "@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", + "ws": "^8.18.0" + } +} 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..519f2aa96 --- /dev/null +++ b/packages/api-e2e/test/helpers/index.ts @@ -0,0 +1,351 @@ +// 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 Autostart from '@tauri-apps/plugin-autostart' +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 DeepLink from '@tauri-apps/plugin-deep-link' +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 Positioner from '@tauri-apps/plugin-positioner' +import type * as Process from '@tauri-apps/plugin-process' +import type * as Shell from '@tauri-apps/plugin-shell' +import type * as Sql from '@tauri-apps/plugin-sql' +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 WebSocket from '@tauri-apps/plugin-websocket' +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). `sql` and `websocket` + * only have a default export, so their global is that class itself. + */ +export interface CommonPluginApi { + clipboardManager: typeof ClipboardManager + deepLink: typeof DeepLink + 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 + sql: typeof Sql.default + store: typeof Store + upload: typeof Upload + websocket: typeof WebSocket.default +} + +/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */ +export interface DesktopPluginApi { + autostart: typeof Autostart + cli: typeof Cli + globalShortcut: typeof GlobalShortcut + positioner: typeof Positioner + 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) { + // Mobile plugins reject with a \`{ message, code?, data? }\` object + // rather than a string or an Error. + var message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && typeof error.message === 'string' + ? error.message + : typeof error === 'object' + ? JSON.stringify(error) + : String(error); + return { + ok: false, + error: message, + 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 (`autostart`, `cli`, + * `global-shortcut`, `positioner`, `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..230d92df3 --- /dev/null +++ b/packages/api-e2e/test/helpers/server.ts @@ -0,0 +1,158 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import http from 'node:http' +import { WebSocketServer } from 'ws' + +/** + * 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) + +/** WebSocket endpoint of the fixture server (the `ws://` twin of {@link FIXTURE_SERVER_URL}). */ +export const WEBSOCKET_FIXTURE_URL = `ws://127.0.0.1:${FIXTURE_SERVER_PORT}/ws` +/** Text message that makes the `/ws` endpoint close the connection with {@link WEBSOCKET_CLOSE_CODE}. */ +export const WEBSOCKET_CLOSE_REQUEST = 'close-me' +export const WEBSOCKET_CLOSE_CODE = 4000 +export const WEBSOCKET_CLOSE_REASON = 'closed by the fixture server' + +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`). + * - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent + * back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the + * connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server + * answers the first message with the upgrade request's headers as a JSON + * text message instead (not sent on connect, where it could arrive before + * the client attached a listener). + */ +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() + }) + }) + + const wss = new WebSocketServer({ noServer: true }) + server.on('upgrade', (req, socket, head) => { + const { pathname } = new URL(req.url ?? '/', FIXTURE_SERVER_URL) + if (pathname !== '/ws' && pathname !== '/ws/headers') { + socket.destroy() + return + } + wss.handleUpgrade(req, socket, head, (ws) => { + let sendHeaders = pathname === '/ws/headers' + ws.on('message', (data, isBinary) => { + if (sendHeaders) { + sendHeaders = false + ws.send(JSON.stringify(req.headers)) + } else if ( + !isBinary + && Buffer.isBuffer(data) + && data.toString('utf8') === WEBSOCKET_CLOSE_REQUEST + ) { + ws.close(WEBSOCKET_CLOSE_CODE, WEBSOCKET_CLOSE_REASON) + } else { + ws.send(data, { binary: isBinary }) + } + }) + }) + }) + + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => { + server.off('error', reject) + resolve({ + close: () => { + for (const client of wss.clients) client.terminate() + wss.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/autostart.spec.ts b/packages/api-e2e/test/specs/autostart.spec.ts new file mode 100644 index 000000000..b45a0af68 --- /dev/null +++ b/packages/api-e2e/test/specs/autostart.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 } from '../helpers/index.js' + +// Enabling autostart registers the app with the host for real (a Launch Agent +// on macOS, an XDG autostart entry on Linux, a `Run` registry value on +// Windows), so the spec puts back whatever state it found. + +describePlugin('autostart', { desktopOnly: true }, () => { + let initiallyEnabled = false + + before(async () => { + initiallyEnabled = await tauri((api) => api.autostart.isEnabled()) + }) + + after(async () => { + await tauri( + (api, enabled) => + enabled ? api.autostart.enable() : api.autostart.disable(), + initiallyEnabled + ) + }) + + it('enable and disable toggle isEnabled', async () => { + const states = await tauri(async (api) => { + await api.autostart.enable() + const enabled = await api.autostart.isEnabled() + await api.autostart.disable() + return { enabled, disabled: await api.autostart.isEnabled() } + }) + expect(states).toEqual({ enabled: true, disabled: false }) + }) + + it('enabling twice keeps it enabled', async () => { + const enabled = await tauri(async (api) => { + await api.autostart.enable() + await api.autostart.enable() + const enabled = await api.autostart.isEnabled() + await api.autostart.disable() + return enabled + }) + expect(enabled).toBe(true) + }) + + it('disable resolves when autostart is already disabled', async () => { + // Windows used to fail here, having no `Run` registry value to delete + const disabled = await tauri(async (api) => { + await api.autostart.enable() + await api.autostart.disable() + await api.autostart.disable() + return !(await api.autostart.isEnabled()) + }) + expect(disabled).toBe(true) + }) +}) 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..f5bf0e428 --- /dev/null +++ b/packages/api-e2e/test/specs/clipboard-manager.spec.ts @@ -0,0 +1,158 @@ +// 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, + isMobile +} 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).toMatch( + isMobile + ? /Unsupported on this platform/ + : /not available in the requested format/ + ) + }) + + 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/deep-link.spec.ts b/packages/api-e2e/test/specs/deep-link.spec.ts new file mode 100644 index 000000000..1deb711b5 --- /dev/null +++ b/packages/api-e2e/test/specs/deep-link.spec.ts @@ -0,0 +1,92 @@ +// 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, itOn } from '../helpers/index.js' + +// The suite launches the app without a URL, so there is no current deep link, +// and it cannot open one through the OS either. `onOpenUrl` is exercised by +// emitting the event the plugin itself emits when the app is opened with one. +// +// Registering a scheme at runtime is only implemented on Linux (a `.desktop` +// handler plus `xdg-mime`) and Windows (the registry); macOS and mobile read +// the schemes from the bundle and report the runtime APIs as unsupported. + +/** A scheme no real app handles, so registering it cannot clobber anything. */ +const scheme = 'tauri-plugins-e2e' + +describePlugin('deep-link', () => { + it('getCurrent is null when the app was not opened with a URL', async () => { + expect(await tauri((api) => api.deepLink.getCurrent())).toBeNull() + }) + + it('onOpenUrl delivers the opened URLs until unlistened', async () => { + const received = await tauri( + async (api, urls) => { + const received: string[][] = [] + const unlisten = await api.deepLink.onOpenUrl((urls) => + received.push(urls) + ) + await api.event.emit('deep-link://new-url', urls) + await new Promise((resolve) => setTimeout(resolve, 500)) + unlisten() + await api.event.emit('deep-link://new-url', ['ignored://']) + await new Promise((resolve) => setTimeout(resolve, 500)) + return received + }, + [`${scheme}://open/path?query=1`] + ) + expect(received).toEqual([[`${scheme}://open/path?query=1`]]) + }) + + itOn( + ['linux', 'win32'], + 'register and unregister toggle the scheme handler', + async () => { + const result = await tauri(async (api, scheme) => { + await api.deepLink.register(scheme) + const registered = await api.deepLink.isRegistered(scheme) + await api.deepLink.unregister(scheme) + return { + registered, + afterUnregister: await api.deepLink.isRegistered(scheme) + } + }, scheme) + expect(result).toEqual({ registered: true, afterUnregister: false }) + } + ) + + itOn( + ['linux', 'win32'], + 'isRegistered is false for an unknown scheme', + async () => { + expect( + await tauri( + (api, scheme) => api.deepLink.isRegistered(scheme), + `${scheme}-unknown` + ) + ).toBe(false) + } + ) + + itOn( + ['darwin', 'android', 'ios'], + 'runtime registration is unsupported', + async () => { + for (const command of [ + 'register', + 'unregister', + 'isRegistered' + ] as const) { + const error = await tauriError( + // eslint-disable-next-line security/detect-object-injection + (api, command, scheme) => api.deepLink[command](scheme), + command, + scheme + ) + expect(error).toMatch(/unsupported platform/i) + } + } + ) +}) 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..dcf52e0ac --- /dev/null +++ b/packages/api-e2e/test/specs/fs.spec.ts @@ -0,0 +1,447 @@ +// 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 writeTextFile work without options', async () => { + const result = await tauri(async (api, dir) => { + const path = await api.path.join( + await api.path.appDataDir(), + dir, + 'no-options.txt' + ) + await api.fs.writeTextFile(path, 'text') + const text = await api.fs.readTextFile(path) + await api.fs.writeFile(path, new Uint8Array([98, 121, 116, 101, 115])) + return { text, bytes: await api.fs.readTextFile(path) } + }, dir) + expect(result).toEqual({ text: 'text', bytes: 'bytes' }) + }) + + 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: await api.fs.size(file, { baseDir }), + absoluteSize: await api.fs.size( + await api.path.join(await api.path.appDataDir(), file) + ), + dirSize: await api.fs.size(dir, { baseDir }), + 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.absoluteSize).toBe(10) + expect(result.dirSize).toBeGreaterThanOrEqual(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('readTextFileLines closes the file when a loop exits early', async () => { + const result = await tauri(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.writeTextFile(path, 'one\ntwo\nthree', { baseDir }) + const lines = await api.fs.readTextFileLines(path, { baseDir }) + // the iterator keeps the id of the open file in `rid` + const state = lines as unknown as { rid: number | null } + let rid: number | null = null + let first: string | null = null + for await (const line of lines) { + first = line + rid = state.rid + break + } + let closeError: string | null = null + try { + await api.core.invoke('plugin:resources|close', { rid }) + } catch (error) { + closeError = String(error) + } + // iterating again starts over + const all: string[] = [] + for await (const line of lines) { + all.push(line) + } + return { first, ridAfter: state.rid, closeError, all } + }, `${dir}/lines-break.txt`) + expect(result.first).toBe('one') + expect(result.ridAfter).toBeNull() + // the resource was already closed by the iterator + expect(result.closeError).toMatch(/resource id \d+ is invalid/) + expect(result.all).toEqual(['one', 'two', 'three']) + }) + + it('readTextFileLines rejects when the file cannot be read', async () => { + // a directory: opening it fails on Windows, reading it fails elsewhere, + // which used to yield empty lines forever + const message = await tauriError(async (api, path) => { + const baseDir = api.fs.BaseDirectory.AppData + await api.fs.mkdir(path, { baseDir, recursive: true }) + const lines = await api.fs.readTextFileLines(path, { baseDir }) + for (let i = 0; i < 1000; i++) { + const { done } = await lines.next() + if (done) return + } + throw new Error('readTextFileLines kept yielding lines for a directory') + }, `${dir}/lines-dir`) + expect(message).not.toMatch(/kept yielding/) + expect(message).toMatch(/failed to (read line|open file)/) + }) + + 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).toMatch(/resource id \d+ is invalid/) + }) + + 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 watcher = await api.fs.watchImmediate( + watched, + (event) => { + events.push({ kind: event.type, 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)) + } + await watcher.close() + return events + }, `${dir}/watched`) + expect( + result.some((e) => e.paths.some((p) => p.endsWith('touched.txt'))) + ).toBe(true) + } + ) + + itDesktop( + 'watch debounces and closing the watcher 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 watcher = 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 + await watcher.close() + 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..89aa30076 --- /dev/null +++ b/packages/api-e2e/test/specs/global-shortcut.spec.ts @@ -0,0 +1,103 @@ +// 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' + +// 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', () => {}) + try { + await api.globalShortcut.register('Alt+Shift+F11', () => {}) + } finally { + await api.globalShortcut.unregister('Alt+Shift+F11') + } + }) + // macOS does not check for duplicates itself: the OS rejects the second + // `RegisterEventHotKey` call and the error is the generic one + expect(message).toMatch( + platform === 'darwin' + ? /RegisterEventHotKey failed for F11/ + : /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..cd283200a --- /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).toMatch(/error sending request/) + }) +}) 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..c67256c44 --- /dev/null +++ b/packages/api-e2e/test/specs/notification.spec.ts @@ -0,0 +1,141 @@ +// 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, + itOn +} 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, and sending is fire-and-forget, so +// only the permission model is covered on desktop. +// +// The permission specs are desktop-only: mobile starts out ungranted and +// `requestPermission` puts up a system dialog the session would then block on. +// +// Scheduling, action types, channels, the pending/active lists and the +// listeners are only implemented on mobile. None of them need the permission, +// so they are covered there on an app that has not been granted it. + +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' }) + }) + + itOn( + ['android', 'ios'], + 'action types register, and cancel/remove leave nothing pending or active', + async () => { + const result = await tauri(async (api) => { + await api.notification.registerActionTypes([ + { + id: 'e2e-actions', + actions: [ + { id: 'reply', title: 'Reply', input: true }, + { id: 'dismiss', title: 'Dismiss', destructive: true } + ] + } + ]) + await api.notification.cancel([424242]) + await api.notification.cancelAll() + await api.notification.removeActive([{ id: 424242 }]) + await api.notification.removeAllActive() + return { + pending: await api.notification.pending(), + active: await api.notification.active() + } + }) + expect(result).toEqual({ pending: [], active: [] }) + } + ) + + itOn('android', 'channels can be created, listed and removed', async () => { + const result = await tauri(async (api) => { + const { Importance, Visibility } = api.notification + await api.notification.createChannel({ + id: 'e2e-channel', + name: 'e2e channel', + description: 'created by the e2e suite', + importance: Importance.High, + visibility: Visibility.Public, + vibration: true + }) + const created = (await api.notification.channels()).find( + (channel) => channel.id === 'e2e-channel' + ) + await api.notification.removeChannel('e2e-channel') + const removed = (await api.notification.channels()).some( + (channel) => channel.id === 'e2e-channel' + ) + return { + created: created && { + name: created.name, + description: created.description, + importance: created.importance, + vibration: created.vibration + }, + removed + } + }) + expect(result).toEqual({ + created: { + name: 'e2e channel', + description: 'created by the e2e suite', + importance: 4, // Importance.High + vibration: true + }, + removed: false + }) + }) + + itOn('ios', 'channels are not implemented', async () => { + for (const call of ['create', 'remove', 'list'] as const) { + const error = await tauriError( + (api, call) => + call === 'create' + ? api.notification.createChannel({ id: 'e2e', name: 'e2e' }) + : call === 'remove' + ? api.notification.removeChannel('e2e') + : api.notification.channels(), + call + ) + expect(error).toMatch(/not implemented/) + } + }) + + itOn( + ['android', 'ios'], + 'onNotificationReceived and onAction listeners register and unregister', + async () => { + const result = await tauri(async (api) => { + const received = await api.notification.onNotificationReceived(() => {}) + const action = await api.notification.onAction(() => {}) + await received.unregister() + await action.unregister() + return { received: received.event, action: action.event } + }) + expect(result).toEqual({ + received: 'notification', + action: 'actionPerformed' + }) + } + ) +}) 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..cc636e7bb --- /dev/null +++ b/packages/api-e2e/test/specs/opener.spec.ts @@ -0,0 +1,47 @@ +// 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('revealItemsInDir rejects paths that do not exist', async () => { + const message = await tauriError(async (api) => + api.opener.revealItemsInDir( + await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e') + ) + ) + // the `std::io` NotFound message on Unix, the shell path lookup's on Windows + expect(message).toMatch(/os error 2|path doesn't exist/) + }) +}) 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..82484752e --- /dev/null +++ b/packages/api-e2e/test/specs/os.spec.ts @@ -0,0 +1,109 @@ +// 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('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]+$/) + } + } + }) + + 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..834282f1e --- /dev/null +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -0,0 +1,233 @@ +// 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' + ], + deepLink: [ + 'getCurrent', + 'register', + 'unregister', + 'isRegistered', + 'onOpenUrl' + ], + 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', 'revealItemsInDir'], + os: [ + 'eol', + 'platform', + 'family', + 'version', + 'type', + 'arch', + 'locale', + 'exeExtension', + 'hostname' + ], + process: ['exit', 'relaunch'], + shell: ['Command', 'Child', 'EventEmitter'], + // the `Database` class itself + sql: ['load', 'get'], + store: ['load', 'getStore', 'LazyStore', 'Store'], + upload: ['download', 'upload', 'HttpMethod'], + // the `WebSocket` class itself + websocket: ['connect'] +} + +/** Plugins the example only registers on desktop. */ +const desktopSurface: Surface = { + autostart: ['enable', 'disable', 'isEnabled'], + cli: ['getMatches'], + globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'], + positioner: [ + 'Position', + 'moveWindow', + 'moveWindowConstrained', + 'handleIconState' + ], + 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 + const global = (api as unknown as Record)[plugin] + // a module namespace, or the class a default-export-only module (sql, websocket) defines + return typeof global !== 'object' && typeof global !== 'function' + }), + 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/positioner.spec.ts b/packages/api-e2e/test/specs/positioner.spec.ts new file mode 100644 index 000000000..d0e7986d3 --- /dev/null +++ b/packages/api-e2e/test/specs/positioner.spec.ts @@ -0,0 +1,145 @@ +// 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, itWm } from '../helpers/index.js' + +// Positions are computed from the window's current monitor and outer size, so +// the specs compute the expected physical position the same way and compare +// once the window manager has applied the move. The tray positions need the +// tray icon's rect, which normally comes from a click on the icon; the spec +// hands one in through `handleIconState`, as a JS tray event handler would. +// The moved window is not put back: each spec file gets a fresh app. + +interface Geometry { + monitor: { x: number; y: number; width: number; height: number } + window: { width: number; height: number } +} + +/** Moves the main window and reports where it ended up along with the geometry the position is derived from. */ +function moveAndMeasure(position: string, constrained = false) { + return tauri( + async (api, position, constrained) => { + const win = api.window.getCurrentWindow() + const { Position } = api.positioner + const value = Position[position as keyof typeof Position] + await (constrained + ? api.positioner.moveWindowConstrained(value) + : api.positioner.moveWindow(value)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const monitor = await api.window.currentMonitor() + const size = await win.outerSize() + const outer = await win.outerPosition() + return { + monitor: { + x: monitor!.position.x, + y: monitor!.position.y, + width: monitor!.size.width, + height: monitor!.size.height + }, + window: { width: size.width, height: size.height }, + position: { x: outer.x, y: outer.y } + } + }, + position, + constrained + ) +} + +function center({ monitor, window }: Geometry) { + return { + x: monitor.x + Math.trunc(monitor.width / 2) - Math.trunc(window.width / 2), + y: + monitor.y + Math.trunc(monitor.height / 2) - Math.trunc(window.height / 2) + } +} + +describePlugin('positioner', { desktopOnly: true }, () => { + // must run before any tray rect is handed in + it('tray positions are rejected until the tray icon reported its rect', async () => { + const error = await tauriError((api) => + api.positioner.moveWindow(api.positioner.Position.TrayCenter) + ) + expect(error).toMatch(/Tray position not set/) + }) + + it('the Position enum matches the plugin', async () => { + const names = await tauri((api) => + Object.keys(api.positioner.Position).filter((key) => isNaN(Number(key))) + ) + expect(names).toEqual([ + 'TopLeft', + 'TopRight', + 'BottomLeft', + 'BottomRight', + 'TopCenter', + 'BottomCenter', + 'LeftCenter', + 'RightCenter', + 'Center', + 'TrayLeft', + 'TrayBottomLeft', + 'TrayRight', + 'TrayBottomRight', + 'TrayCenter', + 'TrayBottomCenter' + ]) + }) + + itWm('moveWindow centers the window on its monitor', async () => { + const result = await moveAndMeasure('Center') + expect(result.position).toEqual(center(result)) + }) + + itWm( + 'moveWindowConstrained behaves like moveWindow for screen positions', + async () => { + const result = await moveAndMeasure('Center', true) + expect(result.position).toEqual(center(result)) + } + ) + + itWm('tray positions are relative to the reported tray rect', async () => { + // The example's 1000x800 window barely fits the CI screens (1024x768 on + // Windows, 1280x1024 on Xvfb), so wherever the tray "icon" is it sticks out + // of the monitor and `moveWindowConstrained` clamps it back on both axes. + // The window is shrunk and the tray placed near the monitor's top left + // corner, far enough from the edges that both positions keep the window on + // screen and neither the OS-specific flips nor the constraint kick in. + const tray = await tauri(async (api) => { + const win = api.window.getCurrentWindow() + await win.setSize(new api.dpi.LogicalSize(600, 400)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const monitor = await api.window.currentMonitor() + const size = await win.outerSize() + const rect = { + x: monitor!.position.x + Math.trunc(size.width / 2) + 50, + y: monitor!.position.y + 50, + width: 20, + height: 20 + } + await api.positioner.handleIconState({ + type: 'Click', + id: 'e2e', + position: new api.dpi.PhysicalPosition(rect.x, rect.y), + rect: { + position: new api.dpi.PhysicalPosition(rect.x, rect.y), + size: new api.dpi.PhysicalSize(rect.width, rect.height) + }, + button: 'Left', + buttonState: 'Down' + }) + return rect + }) + + const bottomLeft = await moveAndMeasure('TrayBottomLeft') + expect(bottomLeft.position).toEqual({ x: tray.x, y: tray.y }) + + const bottomCenter = await moveAndMeasure('TrayBottomCenter', true) + expect(bottomCenter.position).toEqual({ + x: tray.x + tray.width / 2 - Math.trunc(bottomCenter.window.width / 2), + y: tray.y + }) + }) +}) 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..80af53cd3 --- /dev/null +++ b/packages/api-e2e/test/specs/shell.spec.ts @@ -0,0 +1,230 @@ +// 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