mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
// 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 Stronghold from '@tauri-apps/plugin-stronghold'
|
|
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
|
|
stronghold: typeof Stronghold
|
|
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<T> =
|
|
| { ok: true; value: T }
|
|
| { ok: false; error: string; stack?: string }
|
|
|
|
/** Thrown when the function passed to {@link tauri} rejects inside the webview. */
|
|
export class TauriPageError extends Error {
|
|
pageStack?: string
|
|
constructor(message: string, pageStack?: string) {
|
|
super(message)
|
|
this.name = 'TauriPageError'
|
|
this.pageStack = pageStack
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs `fn` inside the app's webview with `window.__TAURI__` as its first argument
|
|
* and resolves with its (JSON-serializable) return value.
|
|
*
|
|
* `fn` is serialized with `Function.prototype.toString`, so it **cannot close over
|
|
* anything in the spec module** — every value it needs must be passed through `args`,
|
|
* and it may only reference `api`, those args, and browser globals (`window`,
|
|
* `document`, `setTimeout`, `Promise`, ...).
|
|
*
|
|
* @example
|
|
* const platform = await tauri((api) => api.os.platform())
|
|
* const sum = await tauri((api, a, b) => a + b, 2, 3)
|
|
*/
|
|
export async function tauri<R, A extends unknown[]>(
|
|
fn: (api: Api, ...args: A) => R,
|
|
...args: A
|
|
): Promise<Awaited<R>> {
|
|
// A string body (rather than passing `fn` directly) keeps this working across
|
|
// both the classic and bidi WebDriver protocols and avoids any in-page eval of
|
|
// our own — the driver injects this script itself, which is exempt from the
|
|
// app's CSP. `executeAsync` is used because promise support in `execute` is not
|
|
// uniform across the platform drivers tauri-driver and Appium proxy to.
|
|
//
|
|
// The outcome crosses the driver as a JSON string rather than an object so no
|
|
// driver gets to interpret its shape: the Selenium atoms that Appium runs
|
|
// scripts through on iOS turn any object with a numeric `length` property
|
|
// into an array.
|
|
const script = `
|
|
var done = arguments[arguments.length - 1];
|
|
var args = Array.prototype.slice.call(arguments, 0, arguments.length - 1);
|
|
var fn = (${fn.toString()});
|
|
Promise.resolve()
|
|
.then(function () { return fn.apply(null, [window.__TAURI__].concat(args)); })
|
|
.then(
|
|
function (value) { return { ok: true, value: value === undefined ? null : value }; },
|
|
function (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined
|
|
};
|
|
}
|
|
)
|
|
.then(function (outcome) {
|
|
try {
|
|
done(JSON.stringify(outcome));
|
|
} catch (error) {
|
|
done(JSON.stringify({ ok: false, error: 'result is not JSON-serializable: ' + error }));
|
|
}
|
|
});
|
|
`
|
|
const raw: unknown = await browser.executeAsync(script, ...args)
|
|
const outcome = (
|
|
typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
) as PageOutcome<Awaited<R>> | null
|
|
if (!outcome || typeof outcome !== 'object' || !('ok' in outcome)) {
|
|
throw new Error(
|
|
`tauri() bridge returned an unexpected value: ${JSON.stringify(outcome)}`
|
|
)
|
|
}
|
|
if (!outcome.ok) {
|
|
throw new TauriPageError(outcome.error, outcome.stack)
|
|
}
|
|
return outcome.value
|
|
}
|
|
|
|
/**
|
|
* Asserts that the page-side call rejects and returns the rejection message,
|
|
* so specs can assert on it. Throws if the call unexpectedly resolves.
|
|
*/
|
|
export async function tauriError<A extends unknown[]>(
|
|
fn: (api: Api, ...args: A) => unknown,
|
|
...args: A
|
|
): Promise<string> {
|
|
try {
|
|
await tauri(fn, ...args)
|
|
} catch (error) {
|
|
if (error instanceof TauriPageError) {
|
|
return error.message
|
|
}
|
|
// Some platform drivers (notably the Linux WebKitWebDriver) surface a
|
|
// page-side `invoke` rejection as a WebDriver-level error on the
|
|
// `execute/async` command instead of letting the in-page bridge report it
|
|
// as an `{ ok: false }` outcome. Fall back to that error's message so the
|
|
// backend rejection is still assertable. This is safe for error-path specs:
|
|
// they match the message against an expected pattern, so a genuine driver
|
|
// failure (whose message won't match) still fails the test.
|
|
if (error instanceof Error) {
|
|
return error.message
|
|
}
|
|
throw error
|
|
}
|
|
throw new Error('expected the API call to reject, but it resolved')
|
|
}
|
|
|
|
/**
|
|
* Polls `check` until it returns without throwing or `timeout` elapses.
|
|
* Use for state that is applied asynchronously (window manager, file watcher, ...).
|
|
*/
|
|
export async function eventually<T>(
|
|
check: () => T | Promise<T>,
|
|
{
|
|
timeout = 10_000,
|
|
interval = 250
|
|
}: { timeout?: number; interval?: number } = {}
|
|
): Promise<T> {
|
|
const deadline = Date.now() + timeout
|
|
let lastError: unknown
|
|
for (;;) {
|
|
try {
|
|
return await check()
|
|
} catch (error) {
|
|
lastError = error
|
|
}
|
|
if (Date.now() > deadline) {
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(String(lastError))
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, interval))
|
|
}
|
|
}
|
|
|
|
const skippedModules = (process.env.E2E_SKIP ?? '')
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
|
|
export interface DescribePluginOptions {
|
|
/**
|
|
* The example only registers the plugin on desktop (`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>
|
|
): void {
|
|
const list = Array.isArray(platforms) ? platforms : [platforms]
|
|
if (list.includes(platform)) {
|
|
it(title, fn)
|
|
} else {
|
|
it.skip(title, fn)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `it` for desktop-only behavior of a plugin that *is* registered on mobile:
|
|
* a command the mobile build does not expose (`#[cfg(desktop)]`), a mobile
|
|
* implementation that answers "Unsupported on this platform", or a permission
|
|
* the example only grants in its desktop capability.
|
|
*/
|
|
export function itDesktop(title: string, fn: () => void | Promise<void>): void {
|
|
if (isMobile) {
|
|
it.skip(title, fn)
|
|
} else {
|
|
it(title, fn)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `it` for assertions that depend on a real window manager (window size and
|
|
* position restore, ...). Skipped entirely when `E2E_SKIP_WM` is set (e.g.
|
|
* bare headless CI), and on mobile, which has no window manager.
|
|
*/
|
|
export function itWm(title: string, fn: () => void | Promise<void>): void {
|
|
if (process.env.E2E_SKIP_WM || isMobile) {
|
|
it.skip(title, fn)
|
|
} else {
|
|
it(title, fn)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A scratch directory the fs-backed specs may freely write to. It is relative
|
|
* to `BaseDirectory.AppData` (`$APPDATA`), which the example's fs scope allows
|
|
* recursively; `name` keeps each spec file's files apart.
|
|
*/
|
|
export function scratchDir(name: string): string {
|
|
return `e2e/${name}`
|
|
}
|