Files
tauri-plugins-workspace/packages/api-e2e
Lucas Fernandes Nogueira b46a88ff55 fix(fs): reject malformed write_file options instead of ignoring them (#3622)
In plugins/fs/src/commands.rs write_file_inner, a malformed or non-ASCII
`options` header was dropped with .ok(), so baseDir/append/createNew were
silently ignored and the call became a truncating write to the raw path;
the JSON array body fallback truncated values above 255 and dropped
non-numbers.

The header is parsed with parse_write_file_options: empty, `undefined`
(what fetch sends for JSON.stringify(undefined)) and `null` mean no
options, anything else must parse. Array bodies must only contain bytes.
Unit test for the header parser; e2e spec for writes without options,
which is the case the `undefined` handling keeps working.
2026-09-23 12:57:49 -03:00
..
2026-09-22 21:54:21 -03:00
2026-09-22 21:54:21 -03:00
2026-09-22 21:54:21 -03:00

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 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 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, which bridges the WebDriver protocol to each platform's webview:
    • macOS — the CrabNebula Webdriver, which needs 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), so the suite has to run unelevated.
  • On mobile, WebdriverIO drives the app through Appium (started by @wdio/appium-service; the drivers are plain devDependencies of this package, which Appium picks up on its own):
    • Android — the UiAutomator2 driver. The suite switches to the app's WEBVIEW_* context, which chromedriver reaches through the WebView's debugging socket. Debug builds turn that on (setWebContentsDebuggingEnabled), so the suite builds a debug APK. A chromedriver matching the device's WebView is downloaded on demand (see E2E_CHROMEDRIVER).
    • iOS — the XCUITest driver on a simulator, attaching to the WKWebView through the WebKit remote inspector. Debug builds mark the webview isInspectable, so the suite builds an unsigned debug simulator app. The inspector identifies an app by the application-identifier entitlement that Xcode embeds when it code signs a simulator build; an unsigned one has none and is listed as process-<executable name> instead of its bundle identifier, so the config has the driver match that name too (appium:additionalWebviewBundleIds). The driver also starts with a script timeout of 0, which the config raises to the 30s the other drivers default to, or every executeAsync would time out at once.
  • Specs never eval in the page. They pass a function to the tauri() 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 is started for the whole run. It serves the updater manifest the desktop e2e build points the updater at (see 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.
stronghold Store records (persisted across reloads), wrong-password and unknown-client errors, vault secrets, BIP39/SLIP10 derivation and Ed25519 signing. 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 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

# 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

# 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 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.ios.conf.ts, sharing wdio.mobile.ts) run tauri android build --debug --apk / tauri ios build --debug --target aarch64-sim --no-sign, compiling only the Rust target the device runs (the Android one is read from the connected device through adb). The Android Studio and Xcode projects are committed under examples/api/src-tauri/gen, so they are only initialized if that directory is missing. The app must be a debug build — release builds have webview debugging off, and Appium cannot see the page. E2E_SKIP_BUILD and E2E_APP_PATH (an .apk / simulator .app) work as on desktop. No config override is passed there: both things it turns on belong to desktop-only plugins.

Environment variables

Variable Purpose
CN_API_KEY CrabNebula Cloud key. Required on macOS (and whenever E2E_CN_WEBDRIVER=1).
E2E_SKIP_BUILD Skip the tauri build step and reuse the existing binary.
E2E_APP_PATH Absolute path to a prebuilt app/binary to test (also implies skip-build).
E2E_SKIP Comma-separated plugin names to skip, e.g. E2E_SKIP=clipboard-manager,global-shortcut.
E2E_SKIP_WM Skip window-manager-dependent tests (the window-state size restore).
E2E_SPEC_RETRIES Retry count for flaky spec files (default 0).
E2E_CN_WEBDRIVER Use the CrabNebula Webdriver on Linux/Windows too (instead of the native driver).
E2E_NATIVE_DRIVER Path passed to tauri-driver --native-driver (e.g. a specific chromedriver).
CARGO_TARGET_DIR Override the target dir the app binary is looked up in.

Mobile only:

Variable Purpose
E2E_ANDROID_TARGET Rust target for the APK (aarch64, armv7, i686, x86_64); default: the connected device's ABI.
E2E_ANDROID_DEVICE adb serial of the device/emulator to use (appium:udid); default: the first connected one.
E2E_ANDROID_AVD Name of an AVD for Appium to boot (appium:avd) instead of using an already-running emulator.
E2E_CHROMEDRIVER chromedriver binary matching the device's WebView, instead of letting Appium download one.
E2E_IOS_TARGET Rust target for the simulator app (aarch64-sim or x86_64); default: the host architecture.
E2E_IOS_DEVICE Simulator UDID or name (as in xcrun simctl list); default: a booted iPhone, else the newest one.
E2E_PLATFORM Set by the mobile configs for the spec workers (android/ios) — see platform in the helpers.

Appium's own log is written to logs/wdio-appium.log in this package.

Adding tests for a new plugin API

  1. Add a spec. Create test/specs/<plugin>.spec.ts and use describePlugin('<plugin>', …) with the tauri() helper. It is picked up automatically by the test/specs/**/*.spec.ts glob. Minimal example:

    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, 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 — 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 (or desktop.json/mobile.json for platform-specific plugins). Each plugin documents its permissions under plugins/<plugin>/permissions/autogenerated/reference.md.

  3. Need a server? Add a route to the fixture server rather than hitting the network; its URL is exported as FIXTURE_SERVER_URL.

  4. Handle environment-sensitive cases. Use itWm (instead of it) for assertions that depend on a real window manager, and eventually() to poll for state that is applied asynchronously. Branch on platform from the helpers (never process.platform, which is the host running the emulator/simulator on mobile) for platform-specific behavior. Files go under scratchDir('<plugin>'), relative to BaseDirectory.AppData, which the example's fs scope allows.

  5. Gate what mobile does not have. The same specs run on Android and iOS. Wrap tests of desktop-only behavior — a command the mobile build does not expose, a mobile implementation that answers "Unsupported on this platform", or a permission only the desktop capability grants — in itDesktop, use itOn('android', …) / itOn('ios', …) for platform-specific APIs, and pass { desktopOnly: true } (or { mobileOnly: true }) to describePlugin for plugins the example does not register on the other side. Skipped tests show up as pending rather than silently disappearing.

Rules for tauri() page functions

The function you pass to tauri() runs inside the webview, serialized as a string:

  • It cannot close over anything from the spec module — pass every value it needs through the trailing tauri(fn, ...args) arguments.
  • It may only reference api (the window.__TAURI__ object), those args, and browser globals (window, document, setTimeout, Promise, …).
  • Its return value must be JSON-serializable — return plain objects/primitives, not class instances (call methods and return their results instead), and remember that undefined values are dropped.
  • Restore any app state you mutate (clipboard, registered shortcuts, window size, …); tests within a spec file share the same app instance.
  • For in-page waiting, wrap logic in a Promise with an explicit setTimeout rejection so a failure surfaces as a message rather than an opaque driver timeout.

Use tauriError(fn, ...args) to assert that a call rejects; it returns the rejection message.