mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
feat(tests): add api e2e tests (#3617)
* feat(tests): add api e2e tests * lockfile * mobile * try linux fix [skip ci] * ios fix * fix test on windows * improve cache * fix pnpm audit [skip ci]
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { browser } from '@wdio/globals'
|
||||
import type * as TauriApi from '@tauri-apps/api'
|
||||
import type * as BarcodeScanner from '@tauri-apps/plugin-barcode-scanner'
|
||||
import type * as Biometric from '@tauri-apps/plugin-biometric'
|
||||
import type * as Cli from '@tauri-apps/plugin-cli'
|
||||
import type * as ClipboardManager from '@tauri-apps/plugin-clipboard-manager'
|
||||
import type * as Dialog from '@tauri-apps/plugin-dialog'
|
||||
import type * as Fs from '@tauri-apps/plugin-fs'
|
||||
import type * as Geolocation from '@tauri-apps/plugin-geolocation'
|
||||
import type * as GlobalShortcut from '@tauri-apps/plugin-global-shortcut'
|
||||
import type * as Haptics from '@tauri-apps/plugin-haptics'
|
||||
import type * as Http from '@tauri-apps/plugin-http'
|
||||
import type * as Log from '@tauri-apps/plugin-log'
|
||||
import type * as Nfc from '@tauri-apps/plugin-nfc'
|
||||
import type * as Notification from '@tauri-apps/plugin-notification'
|
||||
import type * as Opener from '@tauri-apps/plugin-opener'
|
||||
import type * as Os from '@tauri-apps/plugin-os'
|
||||
import type * as Process from '@tauri-apps/plugin-process'
|
||||
import type * as Shell from '@tauri-apps/plugin-shell'
|
||||
import type * as Store from '@tauri-apps/plugin-store'
|
||||
import type * as Updater from '@tauri-apps/plugin-updater'
|
||||
import type * as Upload from '@tauri-apps/plugin-upload'
|
||||
import type * as WindowState from '@tauri-apps/plugin-window-state'
|
||||
|
||||
/**
|
||||
* The plugin APIs the example registers on every platform, keyed by the name
|
||||
* each plugin's `api-iife.js` defines on `window.__TAURI__` (the package name
|
||||
* without the `@tauri-apps/plugin-` prefix, camel-cased).
|
||||
*/
|
||||
export interface CommonPluginApi {
|
||||
clipboardManager: typeof ClipboardManager
|
||||
dialog: typeof Dialog
|
||||
fs: typeof Fs
|
||||
http: typeof Http
|
||||
log: typeof Log
|
||||
notification: typeof Notification
|
||||
opener: typeof Opener
|
||||
os: typeof Os
|
||||
process: typeof Process
|
||||
shell: typeof Shell
|
||||
store: typeof Store
|
||||
upload: typeof Upload
|
||||
}
|
||||
|
||||
/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */
|
||||
export interface DesktopPluginApi {
|
||||
cli: typeof Cli
|
||||
globalShortcut: typeof GlobalShortcut
|
||||
updater: typeof Updater
|
||||
windowState: typeof WindowState
|
||||
}
|
||||
|
||||
/** The plugin APIs the example only registers on mobile (`#[cfg(mobile)]`). */
|
||||
export interface MobilePluginApi {
|
||||
barcodeScanner: typeof BarcodeScanner
|
||||
biometric: typeof Biometric
|
||||
geolocation: typeof Geolocation
|
||||
haptics: typeof Haptics
|
||||
nfc: typeof Nfc
|
||||
}
|
||||
|
||||
/**
|
||||
* Every plugin API the example can register. Only the platform-appropriate
|
||||
* half is actually on `window.__TAURI__` at runtime — see `describePlugin`'s
|
||||
* `desktopOnly`/`mobileOnly` options and `plugins.spec.ts`.
|
||||
*/
|
||||
export type PluginApi = CommonPluginApi & DesktopPluginApi & MobilePluginApi
|
||||
|
||||
/** The `@tauri-apps/api` surface plus every plugin, as exposed on `window.__TAURI__`. */
|
||||
export type Api = typeof TauriApi & PluginApi
|
||||
|
||||
/** OS the app under test runs on. */
|
||||
export type Platform = NodeJS.Platform | 'android' | 'ios'
|
||||
|
||||
/**
|
||||
* The platform of the app under test. The desktop suite drives an app on the
|
||||
* host, so it is `process.platform`; the mobile configs (`wdio.android.conf.ts`,
|
||||
* `wdio.ios.conf.ts`) drive an emulator/simulator and set `E2E_PLATFORM` for
|
||||
* the spec workers instead.
|
||||
*/
|
||||
export const platform: Platform =
|
||||
(process.env.E2E_PLATFORM as Platform | undefined) ?? process.platform
|
||||
|
||||
export const isMobile = platform === 'android' || platform === 'ios'
|
||||
|
||||
type PageOutcome<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: string; stack?: string }
|
||||
|
||||
/** Thrown when the function passed to {@link tauri} rejects inside the webview. */
|
||||
export class TauriPageError extends Error {
|
||||
pageStack?: string
|
||||
constructor(message: string, pageStack?: string) {
|
||||
super(message)
|
||||
this.name = 'TauriPageError'
|
||||
this.pageStack = pageStack
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` inside the app's webview with `window.__TAURI__` as its first argument
|
||||
* and resolves with its (JSON-serializable) return value.
|
||||
*
|
||||
* `fn` is serialized with `Function.prototype.toString`, so it **cannot close over
|
||||
* anything in the spec module** — every value it needs must be passed through `args`,
|
||||
* and it may only reference `api`, those args, and browser globals (`window`,
|
||||
* `document`, `setTimeout`, `Promise`, ...).
|
||||
*
|
||||
* @example
|
||||
* const platform = await tauri((api) => api.os.platform())
|
||||
* const sum = await tauri((api, a, b) => a + b, 2, 3)
|
||||
*/
|
||||
export async function tauri<R, A extends unknown[]>(
|
||||
fn: (api: Api, ...args: A) => R,
|
||||
...args: A
|
||||
): Promise<Awaited<R>> {
|
||||
// A string body (rather than passing `fn` directly) keeps this working across
|
||||
// both the classic and bidi WebDriver protocols and avoids any in-page eval of
|
||||
// our own — the driver injects this script itself, which is exempt from the
|
||||
// app's CSP. `executeAsync` is used because promise support in `execute` is not
|
||||
// uniform across the platform drivers tauri-driver and Appium proxy to.
|
||||
//
|
||||
// The outcome crosses the driver as a JSON string rather than an object so no
|
||||
// driver gets to interpret its shape: the Selenium atoms that Appium runs
|
||||
// scripts through on iOS turn any object with a numeric `length` property
|
||||
// into an array.
|
||||
const script = `
|
||||
var done = arguments[arguments.length - 1];
|
||||
var args = Array.prototype.slice.call(arguments, 0, arguments.length - 1);
|
||||
var fn = (${fn.toString()});
|
||||
Promise.resolve()
|
||||
.then(function () { return fn.apply(null, [window.__TAURI__].concat(args)); })
|
||||
.then(
|
||||
function (value) { return { ok: true, value: value === undefined ? null : value }; },
|
||||
function (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
};
|
||||
}
|
||||
)
|
||||
.then(function (outcome) {
|
||||
try {
|
||||
done(JSON.stringify(outcome));
|
||||
} catch (error) {
|
||||
done(JSON.stringify({ ok: false, error: 'result is not JSON-serializable: ' + error }));
|
||||
}
|
||||
});
|
||||
`
|
||||
const raw: unknown = await browser.executeAsync(script, ...args)
|
||||
const outcome = (
|
||||
typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
) as PageOutcome<Awaited<R>> | null
|
||||
if (!outcome || typeof outcome !== 'object' || !('ok' in outcome)) {
|
||||
throw new Error(
|
||||
`tauri() bridge returned an unexpected value: ${JSON.stringify(outcome)}`
|
||||
)
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
throw new TauriPageError(outcome.error, outcome.stack)
|
||||
}
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the page-side call rejects and returns the rejection message,
|
||||
* so specs can assert on it. Throws if the call unexpectedly resolves.
|
||||
*/
|
||||
export async function tauriError<A extends unknown[]>(
|
||||
fn: (api: Api, ...args: A) => unknown,
|
||||
...args: A
|
||||
): Promise<string> {
|
||||
try {
|
||||
await tauri(fn, ...args)
|
||||
} catch (error) {
|
||||
if (error instanceof TauriPageError) {
|
||||
return error.message
|
||||
}
|
||||
// Some platform drivers (notably the Linux WebKitWebDriver) surface a
|
||||
// page-side `invoke` rejection as a WebDriver-level error on the
|
||||
// `execute/async` command instead of letting the in-page bridge report it
|
||||
// as an `{ ok: false }` outcome. Fall back to that error's message so the
|
||||
// backend rejection is still assertable. This is safe for error-path specs:
|
||||
// they match the message against an expected pattern, so a genuine driver
|
||||
// failure (whose message won't match) still fails the test.
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the API call to reject, but it resolved')
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls `check` until it returns without throwing or `timeout` elapses.
|
||||
* Use for state that is applied asynchronously (window manager, file watcher, ...).
|
||||
*/
|
||||
export async function eventually<T>(
|
||||
check: () => T | Promise<T>,
|
||||
{
|
||||
timeout = 10_000,
|
||||
interval = 250
|
||||
}: { timeout?: number; interval?: number } = {}
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeout
|
||||
let lastError: unknown
|
||||
for (;;) {
|
||||
try {
|
||||
return await check()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(String(lastError))
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval))
|
||||
}
|
||||
}
|
||||
|
||||
const skippedModules = (process.env.E2E_SKIP ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export interface DescribePluginOptions {
|
||||
/**
|
||||
* The example only registers the plugin on desktop (`cli`, `global-shortcut`,
|
||||
* `updater`, `window-state`), so the whole suite is skipped on mobile.
|
||||
*/
|
||||
desktopOnly?: boolean
|
||||
/**
|
||||
* The example only registers the plugin on mobile (`barcode-scanner`,
|
||||
* `biometric`, `geolocation`, `haptics`, `nfc`), so the whole suite is
|
||||
* skipped on desktop.
|
||||
*/
|
||||
mobileOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* `describe` wrapper keyed by plugin name (the `@tauri-apps/plugin-*` suffix).
|
||||
* Any plugin listed in the comma-separated `E2E_SKIP` env var
|
||||
* (e.g. `E2E_SKIP=clipboard-manager,global-shortcut`) is skipped.
|
||||
*/
|
||||
export function describePlugin(plugin: string, fn: () => void): void
|
||||
export function describePlugin(
|
||||
plugin: string,
|
||||
options: DescribePluginOptions,
|
||||
fn: () => void
|
||||
): void
|
||||
export function describePlugin(
|
||||
plugin: string,
|
||||
optionsOrFn: DescribePluginOptions | (() => void),
|
||||
maybeFn?: () => void
|
||||
): void {
|
||||
const [options, fn] =
|
||||
typeof optionsOrFn === 'function'
|
||||
? [{} as DescribePluginOptions, optionsOrFn]
|
||||
: [optionsOrFn, maybeFn!]
|
||||
const title = `@tauri-apps/plugin-${plugin}`
|
||||
if (
|
||||
skippedModules.includes(plugin)
|
||||
|| (options.desktopOnly && isMobile)
|
||||
|| (options.mobileOnly && !isMobile)
|
||||
) {
|
||||
describe.skip(title, fn)
|
||||
} else {
|
||||
describe(title, fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `it` restricted to the given platform(s); skipped (as pending) elsewhere.
|
||||
* Use for behavior that only exists on one OS.
|
||||
*/
|
||||
export function itOn(
|
||||
platforms: Platform | Platform[],
|
||||
title: string,
|
||||
fn: () => void | Promise<void>
|
||||
): void {
|
||||
const list = Array.isArray(platforms) ? platforms : [platforms]
|
||||
if (list.includes(platform)) {
|
||||
it(title, fn)
|
||||
} else {
|
||||
it.skip(title, fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `it` for desktop-only behavior of a plugin that *is* registered on mobile:
|
||||
* a command the mobile build does not expose (`#[cfg(desktop)]`), a mobile
|
||||
* implementation that answers "Unsupported on this platform", or a permission
|
||||
* the example only grants in its desktop capability.
|
||||
*/
|
||||
export function itDesktop(title: string, fn: () => void | Promise<void>): void {
|
||||
if (isMobile) {
|
||||
it.skip(title, fn)
|
||||
} else {
|
||||
it(title, fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `it` for assertions that depend on a real window manager (window size and
|
||||
* position restore, ...). Skipped entirely when `E2E_SKIP_WM` is set (e.g.
|
||||
* bare headless CI), and on mobile, which has no window manager.
|
||||
*/
|
||||
export function itWm(title: string, fn: () => void | Promise<void>): void {
|
||||
if (process.env.E2E_SKIP_WM || isMobile) {
|
||||
it.skip(title, fn)
|
||||
} else {
|
||||
it(title, fn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scratch directory the fs-backed specs may freely write to. It is relative
|
||||
* to `BaseDirectory.AppData` (`$APPDATA`), which the example's fs scope allows
|
||||
* recursively; `name` keeps each spec file's files apart.
|
||||
*/
|
||||
export function scratchDir(name: string): string {
|
||||
return `e2e/${name}`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import http from 'node:http'
|
||||
|
||||
/**
|
||||
* Where the fixture server listens. The port is fixed because the updater
|
||||
* endpoint is baked into the app at build time (see `tauri.e2e.conf.json`).
|
||||
*/
|
||||
export const FIXTURE_SERVER_PORT = 3004
|
||||
export const FIXTURE_SERVER_URL = `http://127.0.0.1:${FIXTURE_SERVER_PORT}`
|
||||
|
||||
/** Version the `/updater` endpoint advertises by default. */
|
||||
export const UPDATER_FIXTURE_VERSION = '2.1.0'
|
||||
export const UPDATER_FIXTURE_NOTES = 'Test update from the e2e fixture server'
|
||||
/** `{{target}}` values with special behavior on the `/updater` endpoint. */
|
||||
export const UPDATER_TARGET_NO_UPDATE = 'e2e-no-update'
|
||||
export const UPDATER_TARGET_OLDER = 'e2e-older'
|
||||
export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0'
|
||||
|
||||
/** Body served by `GET /download`. */
|
||||
export const DOWNLOAD_FIXTURE_BODY =
|
||||
'hello from the plugins e2e fixture server\n'.repeat(64)
|
||||
|
||||
export interface FixtureServer {
|
||||
close(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny HTTP server the network-facing specs (updater, upload) talk to:
|
||||
*
|
||||
* - `GET /updater/{{target}}/{{arch}}/{{current_version}}` — an updater
|
||||
* manifest in the dynamic format. Advertises {@link UPDATER_FIXTURE_VERSION},
|
||||
* or {@link UPDATER_FIXTURE_OLDER_VERSION} when the target is
|
||||
* {@link UPDATER_TARGET_OLDER}, and replies `204 No Content` when it is
|
||||
* {@link UPDATER_TARGET_NO_UPDATE}.
|
||||
* - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`.
|
||||
* - `* /echo` — a JSON description of the request (`method`, `url`, `headers`
|
||||
* and the utf-8 `body`).
|
||||
*/
|
||||
export function startFixtureServer(): Promise<FixtureServer> {
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks)
|
||||
const url = new URL(req.url ?? '/', FIXTURE_SERVER_URL)
|
||||
const [, route, ...rest] = url.pathname.split('/')
|
||||
|
||||
if (route === 'updater' && req.method === 'GET') {
|
||||
const [target] = rest
|
||||
if (target === UPDATER_TARGET_NO_UPDATE) {
|
||||
res.writeHead(204).end()
|
||||
return
|
||||
}
|
||||
json(res, {
|
||||
version:
|
||||
target === UPDATER_TARGET_OLDER
|
||||
? UPDATER_FIXTURE_OLDER_VERSION
|
||||
: UPDATER_FIXTURE_VERSION,
|
||||
notes: UPDATER_FIXTURE_NOTES,
|
||||
pub_date: '2026-03-01T14:04:20Z',
|
||||
url: `${FIXTURE_SERVER_URL}/download`,
|
||||
signature: ''
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (route === 'download' && req.method === 'GET') {
|
||||
res
|
||||
.writeHead(200, {
|
||||
'content-type': 'text/plain',
|
||||
'content-length': Buffer.byteLength(DOWNLOAD_FIXTURE_BODY)
|
||||
})
|
||||
.end(DOWNLOAD_FIXTURE_BODY)
|
||||
return
|
||||
}
|
||||
|
||||
if (route === 'echo') {
|
||||
json(res, {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: body.toString('utf8')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404).end()
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve({
|
||||
close: () => {
|
||||
server.closeAllConnections()
|
||||
server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function json(res: http.ServerResponse, value: unknown) {
|
||||
const payload = JSON.stringify(value)
|
||||
res
|
||||
.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload)
|
||||
})
|
||||
.end(payload)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri } from '../helpers/index.js'
|
||||
|
||||
// The example app's own commands and events, which its "Communication" view
|
||||
// drives; they double as a check that the app under test is the right one.
|
||||
|
||||
describe('examples/api', () => {
|
||||
it('is built from the example config', async () => {
|
||||
const info = await tauri(async (api) => ({
|
||||
name: await api.app.getName(),
|
||||
version: await api.app.getVersion(),
|
||||
identifier: await api.app.getIdentifier(),
|
||||
label: api.window.getCurrentWindow().label
|
||||
}))
|
||||
expect(info).toEqual({
|
||||
name: 'Tauri API',
|
||||
version: '2.0.0',
|
||||
identifier: 'com.tauri.api',
|
||||
label: 'main'
|
||||
})
|
||||
})
|
||||
|
||||
it('perform_request returns the backend response', async () => {
|
||||
const response = await tauri((api) =>
|
||||
api.core.invoke('perform_request', {
|
||||
endpoint: 'dummy endpoint arg',
|
||||
body: { id: 5, name: 'test' }
|
||||
})
|
||||
)
|
||||
expect(response).toBe('message response')
|
||||
})
|
||||
|
||||
it('log_operation accepts an optional payload', async () => {
|
||||
await tauri(async (api) => {
|
||||
await api.core.invoke('log_operation', { event: 'tauri-click' })
|
||||
await api.core.invoke('log_operation', {
|
||||
event: 'tauri-click',
|
||||
payload: 'from e2e'
|
||||
})
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('js-event is answered with rust-event', async () => {
|
||||
const reply = await tauri(
|
||||
(api) =>
|
||||
new Promise<{ data: string }>((resolve, reject) => {
|
||||
const webview = api.webview.getCurrentWebview()
|
||||
webview
|
||||
.listen<{ data: string }>('rust-event', (event) =>
|
||||
resolve(event.payload)
|
||||
)
|
||||
.then((unlisten) => {
|
||||
webview
|
||||
.emit('js-event', 'this is the payload string')
|
||||
.catch(reject)
|
||||
setTimeout(() => {
|
||||
unlisten()
|
||||
reject(new Error('no rust-event reply received'))
|
||||
}, 5000)
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
)
|
||||
expect(reply).toEqual({ data: 'something else' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin } from '../helpers/index.js'
|
||||
|
||||
// The driver launches the app without arguments, so the matches reflect the
|
||||
// CLI definition in the example's `tauri.conf.json` with nothing set. The
|
||||
// example only registers the plugin on desktop, so the suite is skipped on
|
||||
// mobile (which has no command line to begin with).
|
||||
|
||||
describePlugin('cli', { desktopOnly: true }, () => {
|
||||
it('getMatches reports every defined argument as unset', async () => {
|
||||
const matches = await tauri((api) => api.cli.getMatches())
|
||||
expect(Object.keys(matches.args).sort()).toEqual([
|
||||
'config',
|
||||
'theme',
|
||||
'verbose'
|
||||
])
|
||||
// flags resolve to `false`, arguments taking a value to `null`
|
||||
expect(matches.args.verbose).toEqual({ value: false, occurrences: 0 })
|
||||
expect(matches.args.config).toEqual({ value: null, occurrences: 0 })
|
||||
expect(matches.args.theme).toEqual({ value: null, occurrences: 0 })
|
||||
})
|
||||
|
||||
it('getMatches reports no subcommand', async () => {
|
||||
const subcommand = await tauri(
|
||||
async (api) => (await api.cli.getMatches()).subcommand
|
||||
)
|
||||
expect(subcommand).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
itDesktop
|
||||
} from '../helpers/index.js'
|
||||
|
||||
// The mobile implementation only carries plain text: `write_html`, `write_image`
|
||||
// and `read_image` answer "Unsupported on this platform" there.
|
||||
describePlugin('clipboard-manager', () => {
|
||||
it('writeText and readText round-trip', async () => {
|
||||
const text = 'clipboard text from e2e — ✓'
|
||||
expect(
|
||||
await tauri(async (api, text) => {
|
||||
await api.clipboardManager.writeText(text)
|
||||
return api.clipboardManager.readText()
|
||||
}, text)
|
||||
).toBe(text)
|
||||
})
|
||||
|
||||
it('writeText replaces the previous contents', async () => {
|
||||
expect(
|
||||
await tauri(async (api) => {
|
||||
await api.clipboardManager.writeText('first')
|
||||
await api.clipboardManager.writeText('second')
|
||||
return api.clipboardManager.readText()
|
||||
})
|
||||
).toBe('second')
|
||||
})
|
||||
|
||||
itDesktop('writeHtml exposes the alt text as plain text', async () => {
|
||||
expect(
|
||||
await tauri(async (api) => {
|
||||
await api.clipboardManager.writeHtml(
|
||||
'<b>bold from e2e</b>',
|
||||
'bold from e2e (alt)'
|
||||
)
|
||||
return api.clipboardManager.readText()
|
||||
})
|
||||
).toBe('bold from e2e (alt)')
|
||||
})
|
||||
|
||||
itDesktop('writeImage and readImage round-trip pixels', async () => {
|
||||
// a 2x2 PNG: red, green / blue, white
|
||||
const png = [
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 2,
|
||||
0, 0, 0, 2, 8, 6, 0, 0, 0, 114, 182, 13, 36, 0, 0, 0, 18, 73, 68, 65, 84,
|
||||
120, 218, 99, 248, 207, 192, 240, 31, 12, 129, 52, 24, 0, 0, 73, 200, 9,
|
||||
247, 3, 217, 100, 241, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130
|
||||
]
|
||||
const rgba = [
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255
|
||||
]
|
||||
const result = await tauri(async (api, png) => {
|
||||
// encoded bytes are decoded by the plugin before hitting the clipboard
|
||||
await api.clipboardManager.writeImage(new Uint8Array(png))
|
||||
const read = await api.clipboardManager.readImage()
|
||||
const size = await read.size()
|
||||
const bytes = Array.from(await read.rgba())
|
||||
await read.close()
|
||||
return { size, bytes }
|
||||
}, png)
|
||||
expect(result.size).toEqual({ width: 2, height: 2 })
|
||||
expect(result.bytes).toEqual(rgba)
|
||||
})
|
||||
|
||||
itDesktop(
|
||||
'writeImage accepts an Image built with the core image API',
|
||||
async () => {
|
||||
// `window.__TAURI__.image.Image` and the class the plugin's global script
|
||||
// sees must be the same one for `transformImage`'s `instanceof` to hold.
|
||||
const rgba = [
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255
|
||||
]
|
||||
const result = await tauri(async (api, rgba) => {
|
||||
const image = await api.image.Image.new(rgba, 2, 2)
|
||||
await api.clipboardManager.writeImage(image)
|
||||
const read = await api.clipboardManager.readImage()
|
||||
const size = await read.size()
|
||||
const bytes = Array.from(await read.rgba())
|
||||
await image.close()
|
||||
await read.close()
|
||||
return { size, bytes }
|
||||
}, rgba)
|
||||
expect(result.size).toEqual({ width: 2, height: 2 })
|
||||
expect(result.bytes).toEqual(rgba)
|
||||
}
|
||||
)
|
||||
|
||||
itDesktop(
|
||||
'an Image read from the clipboard can be written back',
|
||||
async () => {
|
||||
const result = await tauri(async (api) => {
|
||||
const read = await api.clipboardManager.readImage()
|
||||
await api.clipboardManager.writeText('replaced by text')
|
||||
// `Image` instances are passed by resource id
|
||||
await api.clipboardManager.writeImage(read)
|
||||
const again = await api.clipboardManager.readImage()
|
||||
const size = await again.size()
|
||||
await read.close()
|
||||
await again.close()
|
||||
return size
|
||||
})
|
||||
expect(result).toEqual({ width: 2, height: 2 })
|
||||
}
|
||||
)
|
||||
|
||||
itDesktop('writeImage accepts a 1x1 image', async () => {
|
||||
// a 1x1 transparent PNG
|
||||
const png = [
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1,
|
||||
0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84,
|
||||
120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73,
|
||||
69, 78, 68, 174, 66, 96, 130
|
||||
]
|
||||
const size = await tauri(async (api, png) => {
|
||||
await api.clipboardManager.writeImage(new Uint8Array(png))
|
||||
const read = await api.clipboardManager.readImage()
|
||||
const size = await read.size()
|
||||
await read.close()
|
||||
return size
|
||||
}, png)
|
||||
expect(size).toEqual({ width: 1, height: 1 })
|
||||
})
|
||||
|
||||
it('readImage rejects when the clipboard holds text', async () => {
|
||||
const message = await tauriError(async (api) => {
|
||||
await api.clipboardManager.writeText('not an image')
|
||||
await api.clipboardManager.readImage()
|
||||
})
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('clear empties the clipboard', async () => {
|
||||
// reading an empty clipboard rejects on some platforms and yields an
|
||||
// empty string on others
|
||||
const text = await tauri(async (api) => {
|
||||
await api.clipboardManager.writeText('to be cleared')
|
||||
await api.clipboardManager.clear()
|
||||
try {
|
||||
return await api.clipboardManager.readText()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
expect(text).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
itDesktop,
|
||||
scratchDir
|
||||
} from '../helpers/index.js'
|
||||
|
||||
// Every path below is relative to `BaseDirectory.AppData`, which the example's
|
||||
// fs scope allows recursively (`fs:scope-appdata-recursive`).
|
||||
const dir = scratchDir('fs')
|
||||
|
||||
describePlugin('fs', () => {
|
||||
before(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
await api.fs.mkdir(dir, { baseDir, recursive: true })
|
||||
}, dir)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
}, dir)
|
||||
})
|
||||
|
||||
it('mkdir and exists report the scratch directory', async () => {
|
||||
const result = await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
return {
|
||||
dir: await api.fs.exists(dir, { baseDir }),
|
||||
missing: await api.fs.exists(`${dir}/does-not-exist`, { baseDir })
|
||||
}
|
||||
}, dir)
|
||||
expect(result).toEqual({ dir: true, missing: false })
|
||||
})
|
||||
|
||||
it('writeTextFile and readTextFile round-trip utf-8 text', async () => {
|
||||
const text = 'Hello from the e2e suite — olá, 世界! 🎉\nsecond line\n'
|
||||
const read = await tauri(
|
||||
async (api, path, text) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(path, text, { baseDir })
|
||||
return api.fs.readTextFile(path, { baseDir })
|
||||
},
|
||||
`${dir}/text.txt`,
|
||||
text
|
||||
)
|
||||
expect(read).toBe(text)
|
||||
})
|
||||
|
||||
it('writeTextFile appends when asked to', async () => {
|
||||
const read = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(path, 'first', { baseDir })
|
||||
await api.fs.writeTextFile(path, ' second', { baseDir, append: true })
|
||||
return api.fs.readTextFile(path, { baseDir })
|
||||
}, `${dir}/append.txt`)
|
||||
expect(read).toBe('first second')
|
||||
})
|
||||
|
||||
it('writeFile and readFile round-trip binary data', async () => {
|
||||
const bytes = [0, 1, 2, 3, 250, 251, 252, 253, 254, 255]
|
||||
const read = await tauri(
|
||||
async (api, path, bytes) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeFile(path, new Uint8Array(bytes), { baseDir })
|
||||
return Array.from(await api.fs.readFile(path, { baseDir }))
|
||||
},
|
||||
`${dir}/binary.bin`,
|
||||
bytes
|
||||
)
|
||||
expect(read).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('stat, lstat and size describe files and directories', async () => {
|
||||
const result = await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const file = `${dir}/stat.txt`
|
||||
await api.fs.writeTextFile(file, '0123456789', { baseDir })
|
||||
const fileStat = await api.fs.stat(file, { baseDir })
|
||||
const fileLstat = await api.fs.lstat(file, { baseDir })
|
||||
const dirStat = await api.fs.stat(dir, { baseDir })
|
||||
return {
|
||||
file: {
|
||||
isFile: fileStat.isFile,
|
||||
isDirectory: fileStat.isDirectory,
|
||||
isSymlink: fileStat.isSymlink,
|
||||
size: fileStat.size,
|
||||
hasMtime: fileStat.mtime instanceof Date
|
||||
},
|
||||
lstatSize: fileLstat.size,
|
||||
// `size` only takes absolute paths
|
||||
size: await api.fs.size(
|
||||
await api.path.join(await api.path.appDataDir(), file)
|
||||
),
|
||||
dir: { isFile: dirStat.isFile, isDirectory: dirStat.isDirectory }
|
||||
}
|
||||
}, dir)
|
||||
expect(result.file).toEqual({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
isSymlink: false,
|
||||
size: 10,
|
||||
hasMtime: true
|
||||
})
|
||||
expect(result.lstatSize).toBe(10)
|
||||
expect(result.size).toBe(10)
|
||||
expect(result.dir).toEqual({ isFile: false, isDirectory: true })
|
||||
})
|
||||
|
||||
it('copyFile, rename and readDir', async () => {
|
||||
const result = await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const sub = `${dir}/tree`
|
||||
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
|
||||
await api.fs.writeTextFile(`${sub}/a.txt`, 'a', { baseDir })
|
||||
await api.fs.copyFile(`${sub}/a.txt`, `${sub}/b.txt`, {
|
||||
fromPathBaseDir: baseDir,
|
||||
toPathBaseDir: baseDir
|
||||
})
|
||||
await api.fs.rename(`${sub}/b.txt`, `${sub}/c.txt`, {
|
||||
oldPathBaseDir: baseDir,
|
||||
newPathBaseDir: baseDir
|
||||
})
|
||||
const entries = await api.fs.readDir(sub, { baseDir })
|
||||
return {
|
||||
entries: entries
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
isFile: e.isFile,
|
||||
isDirectory: e.isDirectory
|
||||
}))
|
||||
.sort((x, y) => x.name.localeCompare(y.name)),
|
||||
copied: await api.fs.readTextFile(`${sub}/c.txt`, { baseDir }),
|
||||
renamedAway: await api.fs.exists(`${sub}/b.txt`, { baseDir })
|
||||
}
|
||||
}, dir)
|
||||
expect(result.entries).toEqual([
|
||||
{ name: 'a.txt', isFile: true, isDirectory: false },
|
||||
{ name: 'c.txt', isFile: true, isDirectory: false },
|
||||
{ name: 'nested', isFile: false, isDirectory: true }
|
||||
])
|
||||
expect(result.copied).toBe('a')
|
||||
expect(result.renamedAway).toBe(false)
|
||||
})
|
||||
|
||||
it('remove deletes files and (recursively) directories', async () => {
|
||||
const result = await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const sub = `${dir}/to-remove`
|
||||
await api.fs.mkdir(`${sub}/nested`, { baseDir, recursive: true })
|
||||
await api.fs.writeTextFile(`${sub}/nested/file.txt`, 'x', { baseDir })
|
||||
await api.fs.remove(`${sub}/nested/file.txt`, { baseDir })
|
||||
const fileGone = !(await api.fs.exists(`${sub}/nested/file.txt`, {
|
||||
baseDir
|
||||
}))
|
||||
await api.fs.writeTextFile(`${sub}/nested/other.txt`, 'x', { baseDir })
|
||||
let nonRecursiveError: string | null = null
|
||||
try {
|
||||
await api.fs.remove(sub, { baseDir })
|
||||
} catch (error) {
|
||||
nonRecursiveError = String(error)
|
||||
}
|
||||
await api.fs.remove(sub, { baseDir, recursive: true })
|
||||
return {
|
||||
fileGone,
|
||||
nonRecursiveError,
|
||||
dirGone: !(await api.fs.exists(sub, { baseDir }))
|
||||
}
|
||||
}, dir)
|
||||
expect(result.fileGone).toBe(true)
|
||||
// a non-empty directory cannot be removed without `recursive`
|
||||
expect(result.nonRecursiveError).not.toBeNull()
|
||||
expect(result.dirGone).toBe(true)
|
||||
})
|
||||
|
||||
it('truncate shortens a file', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(path, '0123456789', { baseDir })
|
||||
await api.fs.truncate(path, 4, { baseDir })
|
||||
const shortened = await api.fs.readTextFile(path, { baseDir })
|
||||
await api.fs.truncate(path, undefined, { baseDir })
|
||||
return {
|
||||
shortened,
|
||||
emptied: await api.fs.readTextFile(path, { baseDir })
|
||||
}
|
||||
}, `${dir}/truncate.txt`)
|
||||
expect(result).toEqual({ shortened: '0123', emptied: '' })
|
||||
})
|
||||
|
||||
it('readTextFileLines iterates a file line by line', async () => {
|
||||
const lines = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(path, 'one\ntwo\r\nthree', { baseDir })
|
||||
const result: string[] = []
|
||||
for await (const line of await api.fs.readTextFileLines(path, {
|
||||
baseDir
|
||||
})) {
|
||||
result.push(line)
|
||||
}
|
||||
return result
|
||||
}, `${dir}/lines.txt`)
|
||||
expect(lines).toEqual(['one', 'two', 'three'])
|
||||
})
|
||||
|
||||
it('FileHandle supports write, seek, read, stat and truncate', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
const created = await api.fs.create(path, { baseDir })
|
||||
const written = await created.write(encoder.encode('hello world'))
|
||||
const statAfterWrite = await created.stat()
|
||||
await created.close()
|
||||
|
||||
const file = await api.fs.open(path, { baseDir, read: true, write: true })
|
||||
// seek past "hello " and read the rest
|
||||
const position = await file.seek(6, api.fs.SeekMode.Start)
|
||||
const buffer = new Uint8Array(32)
|
||||
const read = await file.read(buffer)
|
||||
const rest = decoder.decode(buffer.subarray(0, read ?? 0))
|
||||
// at EOF, read reports null
|
||||
const atEof = await file.read(new Uint8Array(8))
|
||||
// relative and end-relative seeks
|
||||
const fromEnd = await file.seek(-5, api.fs.SeekMode.End)
|
||||
const relative = await file.seek(-1, api.fs.SeekMode.Current)
|
||||
await file.truncate(5)
|
||||
const statAfterTruncate = await file.stat()
|
||||
await file.close()
|
||||
|
||||
return {
|
||||
written,
|
||||
sizeAfterWrite: statAfterWrite.size,
|
||||
position,
|
||||
read,
|
||||
rest,
|
||||
atEof,
|
||||
fromEnd,
|
||||
relative,
|
||||
sizeAfterTruncate: statAfterTruncate.size,
|
||||
contents: await api.fs.readTextFile(path, { baseDir })
|
||||
}
|
||||
}, `${dir}/handle.txt`)
|
||||
expect(result).toEqual({
|
||||
written: 11,
|
||||
sizeAfterWrite: 11,
|
||||
position: 6,
|
||||
read: 5,
|
||||
rest: 'world',
|
||||
atEof: null,
|
||||
fromEnd: 6,
|
||||
relative: 5,
|
||||
sizeAfterTruncate: 5,
|
||||
contents: 'hello'
|
||||
})
|
||||
})
|
||||
|
||||
it('a closed FileHandle cannot be used again', async () => {
|
||||
const message = await tauriError(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const file = await api.fs.create(path, { baseDir })
|
||||
await file.close()
|
||||
await file.stat()
|
||||
}, `${dir}/closed.txt`)
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejects paths outside the configured scope', async () => {
|
||||
// `$HOME` itself is not in the example's fs scope (only the app dirs,
|
||||
// `$DOWNLOAD` and `$RESOURCE` are).
|
||||
const message = await tauriError(async (api) =>
|
||||
api.fs.readTextFile('e2e-forbidden.txt', {
|
||||
baseDir: api.fs.BaseDirectory.Home
|
||||
})
|
||||
)
|
||||
expect(message).toMatch(/forbidden path/)
|
||||
})
|
||||
|
||||
it('rejects paths escaping the scope through `..`', async () => {
|
||||
const message = await tauriError(async (api) =>
|
||||
api.fs.writeTextFile('../../e2e-escape.txt', 'nope', {
|
||||
baseDir: api.fs.BaseDirectory.AppData
|
||||
})
|
||||
)
|
||||
expect(message).toMatch(/cannot traverse directory|forbidden path/)
|
||||
})
|
||||
|
||||
// The watch specs are desktop-only: `fs:allow-watch` is granted in the
|
||||
// example's desktop capability only.
|
||||
itDesktop(
|
||||
'watchImmediate reports changes in a watched directory',
|
||||
async () => {
|
||||
const result = await tauri(async (api, watched) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.mkdir(watched, { baseDir, recursive: true })
|
||||
const events: { kind: string; paths: string[] }[] = []
|
||||
const unwatch = await api.fs.watchImmediate(
|
||||
watched,
|
||||
(event) => {
|
||||
events.push({
|
||||
kind:
|
||||
typeof event.type === 'string'
|
||||
? event.type
|
||||
: Object.keys(event.type)[0],
|
||||
paths: event.paths
|
||||
})
|
||||
},
|
||||
{ baseDir, recursive: true }
|
||||
)
|
||||
await api.fs.writeTextFile(`${watched}/touched.txt`, 'watched', {
|
||||
baseDir
|
||||
})
|
||||
// give the notifier a moment to deliver
|
||||
const deadline = Date.now() + 10_000
|
||||
while (
|
||||
!events.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
|
||||
) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`no watch event for touched.txt, got ${JSON.stringify(events)}`
|
||||
)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
unwatch()
|
||||
return events
|
||||
}, `${dir}/watched`)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.every((e) => typeof e.kind === 'string')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
itDesktop('watch debounces and unwatch stops delivery', async () => {
|
||||
const result = await tauri(async (api, watched) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.mkdir(watched, { baseDir, recursive: true })
|
||||
let count = 0
|
||||
const unwatch = await api.fs.watch(
|
||||
watched,
|
||||
() => {
|
||||
count++
|
||||
},
|
||||
{ baseDir, delayMs: 200 }
|
||||
)
|
||||
await api.fs.writeTextFile(`${watched}/debounced.txt`, 'a', {
|
||||
baseDir
|
||||
})
|
||||
const deadline = Date.now() + 10_000
|
||||
while (count === 0) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('no debounced watch event received')
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
const afterFirst = count
|
||||
unwatch()
|
||||
await api.fs.writeTextFile(`${watched}/after-unwatch.txt`, 'b', {
|
||||
baseDir
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return { afterFirst, afterUnwatch: count }
|
||||
}, `${dir}/debounced`)
|
||||
expect(result.afterFirst).toBeGreaterThan(0)
|
||||
expect(result.afterUnwatch).toBe(result.afterFirst)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
|
||||
|
||||
// Triggering a shortcut needs OS-level synthetic input the WebDriver session
|
||||
// cannot produce, so the specs cover the registry (register / isRegistered /
|
||||
// unregister / unregisterAll) and the error paths. The plugin is desktop-only,
|
||||
// so the whole suite is skipped on mobile.
|
||||
|
||||
describePlugin('global-shortcut', { desktopOnly: true }, () => {
|
||||
afterEach(async () => {
|
||||
await tauri((api) => api.globalShortcut.unregisterAll())
|
||||
})
|
||||
|
||||
it('register and unregister update isRegistered', async () => {
|
||||
const result = await tauri(async (api) => {
|
||||
const shortcut = 'CommandOrControl+Shift+F9'
|
||||
const before = await api.globalShortcut.isRegistered(shortcut)
|
||||
await api.globalShortcut.register(shortcut, () => {})
|
||||
const registered = await api.globalShortcut.isRegistered(shortcut)
|
||||
await api.globalShortcut.unregister(shortcut)
|
||||
const after = await api.globalShortcut.isRegistered(shortcut)
|
||||
return { before, registered, after }
|
||||
})
|
||||
expect(result).toEqual({ before: false, registered: true, after: false })
|
||||
})
|
||||
|
||||
it('register accepts a list of shortcuts and unregister a list too', async () => {
|
||||
const result = await tauri(async (api) => {
|
||||
const shortcuts = ['Alt+Shift+F7', 'Alt+Shift+F8']
|
||||
await api.globalShortcut.register(shortcuts, () => {})
|
||||
const registered = await Promise.all(
|
||||
shortcuts.map((s) => api.globalShortcut.isRegistered(s))
|
||||
)
|
||||
await api.globalShortcut.unregister(shortcuts)
|
||||
const after = await Promise.all(
|
||||
shortcuts.map((s) => api.globalShortcut.isRegistered(s))
|
||||
)
|
||||
return { registered, after }
|
||||
})
|
||||
expect(result.registered).toEqual([true, true])
|
||||
expect(result.after).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('unregisterAll clears every registration', async () => {
|
||||
const result = await tauri(async (api) => {
|
||||
await api.globalShortcut.register('Alt+Shift+F5', () => {})
|
||||
await api.globalShortcut.register('Alt+Shift+F6', () => {})
|
||||
await api.globalShortcut.unregisterAll()
|
||||
return [
|
||||
await api.globalShortcut.isRegistered('Alt+Shift+F5'),
|
||||
await api.globalShortcut.isRegistered('Alt+Shift+F6')
|
||||
]
|
||||
})
|
||||
expect(result).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('shortcut names are normalized when checking registrations', async () => {
|
||||
const result = await tauri(async (api) => {
|
||||
await api.globalShortcut.register('CmdOrCtrl+Alt+F10', () => {})
|
||||
return {
|
||||
aliased: await api.globalShortcut.isRegistered(
|
||||
'CommandOrControl+Alt+F10'
|
||||
),
|
||||
reordered: await api.globalShortcut.isRegistered('Alt+CmdOrCtrl+F10')
|
||||
}
|
||||
})
|
||||
expect(result).toEqual({ aliased: true, reordered: true })
|
||||
})
|
||||
|
||||
it('registering the same shortcut twice rejects', async () => {
|
||||
const message = await tauriError(async (api) => {
|
||||
await api.globalShortcut.register('Alt+Shift+F11', () => {})
|
||||
await api.globalShortcut.register('Alt+Shift+F11', () => {})
|
||||
})
|
||||
expect(message).toMatch(/already registered/i)
|
||||
})
|
||||
|
||||
it('rejects shortcuts that cannot be parsed', async () => {
|
||||
const message = await tauriError((api) =>
|
||||
api.globalShortcut.register('NotAKey+Nope', () => {})
|
||||
)
|
||||
expect(message).toMatch(/NotAKey/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
|
||||
|
||||
// The example spawns an echo server on this port: it replies with the request
|
||||
// body and the request headers, and sets a `session-token` cookie on requests
|
||||
// that do not carry one. It is also the only `http://` origin in the example's
|
||||
// http scope.
|
||||
const echoServer = 'http://localhost:3003'
|
||||
|
||||
describePlugin('http', () => {
|
||||
it('fetch performs a GET and exposes status, url and headers', async () => {
|
||||
const response = await tauri(async (api, url) => {
|
||||
const response = await api.http.fetch(url, {
|
||||
headers: { 'x-e2e-header': 'present' }
|
||||
})
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
url: response.url,
|
||||
// the echo server mirrors the request headers back
|
||||
echoedHeader: response.headers.get('x-e2e-header'),
|
||||
body: await response.text()
|
||||
}
|
||||
}, echoServer)
|
||||
expect(response.ok).toBe(true)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.url).toBe(`${echoServer}/`)
|
||||
expect(response.echoedHeader).toBe('present')
|
||||
expect(response.body).toBe('')
|
||||
})
|
||||
|
||||
it('fetch sends a JSON body and parses the echoed response', async () => {
|
||||
const payload = { message: 'hello from e2e', nested: { list: [1, 2, 3] } }
|
||||
const response = await tauri(
|
||||
async (api, url, payload) => {
|
||||
const response = await api.http.fetch(`${url}/json`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type'),
|
||||
body: (await response.json()) as unknown
|
||||
}
|
||||
},
|
||||
echoServer,
|
||||
payload
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.contentType).toBe('application/json')
|
||||
expect(response.body).toEqual(payload)
|
||||
})
|
||||
|
||||
it('fetch sends binary bodies and reads them back as bytes', async () => {
|
||||
const bytes = [0, 1, 2, 127, 128, 254, 255]
|
||||
const echoed = await tauri(
|
||||
async (api, url, bytes) => {
|
||||
const response = await api.http.fetch(`${url}/bytes`, {
|
||||
method: 'PUT',
|
||||
body: new Uint8Array(bytes)
|
||||
})
|
||||
return Array.from(new Uint8Array(await response.arrayBuffer()))
|
||||
},
|
||||
echoServer,
|
||||
bytes
|
||||
)
|
||||
expect(echoed).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('fetch sends multipart form data', async () => {
|
||||
const body = await tauri(async (api, url) => {
|
||||
const form = new FormData()
|
||||
form.append('foo', 'baz')
|
||||
form.append('bar', 'qux')
|
||||
const response = await api.http.fetch(`${url}/form`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
return {
|
||||
contentType: response.headers.get('content-type'),
|
||||
text: await response.text()
|
||||
}
|
||||
}, echoServer)
|
||||
expect(body.contentType).toMatch(/^multipart\/form-data; boundary=/)
|
||||
expect(body.text).toContain('name="foo"')
|
||||
expect(body.text).toContain('baz')
|
||||
expect(body.text).toContain('name="bar"')
|
||||
expect(body.text).toContain('qux')
|
||||
})
|
||||
|
||||
it('the cookie jar stores and replays cookies across requests', async () => {
|
||||
const result = await tauri(async (api, url) => {
|
||||
// The jar is persisted in the app data dir, so an earlier run (or the
|
||||
// requests above) may already hold the cookie: the first request then
|
||||
// replays it instead of being handed a new one.
|
||||
const first = await api.http.fetch(`${url}/cookies`)
|
||||
await first.text()
|
||||
// Either way the jar attaches it to the next request, which the echo
|
||||
// server mirrors back as a `cookie` header without setting a new one.
|
||||
const second = await api.http.fetch(`${url}/cookies`)
|
||||
await second.text()
|
||||
return {
|
||||
setCookie: first.headers.get('set-cookie'),
|
||||
replayedFirst: first.headers.get('cookie'),
|
||||
replayed: second.headers.get('cookie'),
|
||||
setAgain: second.headers.get('set-cookie')
|
||||
}
|
||||
}, echoServer)
|
||||
if (result.replayedFirst === null) {
|
||||
expect(result.setCookie).toMatch(/^session-token=test-value/)
|
||||
} else {
|
||||
expect(result.replayedFirst).toContain('session-token=test-value')
|
||||
}
|
||||
expect(result.replayed).toContain('session-token=test-value')
|
||||
expect(result.setAgain).toBeNull()
|
||||
})
|
||||
|
||||
it('fetch can be aborted', async () => {
|
||||
const message = await tauriError(async (api, url) => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await api.http.fetch(`${url}/aborted`, { signal: controller.signal })
|
||||
}, echoServer)
|
||||
expect(message).toMatch(/abort|cancel/i)
|
||||
})
|
||||
|
||||
it('rejects URLs outside the configured scope', async () => {
|
||||
const message = await tauriError((api) =>
|
||||
api.http.fetch('http://localhost:3999/not-in-scope')
|
||||
)
|
||||
expect(message).toMatch(/url not allowed on the configured scope/)
|
||||
})
|
||||
|
||||
it('network failures reject', async () => {
|
||||
// Every in-scope origin is reachable, so route the request through a proxy
|
||||
// nothing listens on to force a connection error.
|
||||
const message = await tauriError(
|
||||
(api, url) =>
|
||||
api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }),
|
||||
echoServer
|
||||
)
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin, isMobile } from '../helpers/index.js'
|
||||
|
||||
// The example registers the plugin with a `Webview` target, so records logged
|
||||
// from the page (and from Rust) are forwarded back to `attachLogger`.
|
||||
//
|
||||
// The plugin's default format differs per platform: `[date][time][target][level]
|
||||
// message` on desktop, and just `[target] message` on mobile, where the platform
|
||||
// logger (logcat / os_log) already stamps the time and level.
|
||||
|
||||
interface Record {
|
||||
level: number
|
||||
message: string
|
||||
}
|
||||
|
||||
type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace'
|
||||
|
||||
/** Logs `message` at `level` and resolves with the records `attachLogger` saw. */
|
||||
function logAndCollect(level: Level, message: string) {
|
||||
return tauri(
|
||||
(api, level, message) =>
|
||||
new Promise<Record[]>((resolve, reject) => {
|
||||
const records: Record[] = []
|
||||
api.log
|
||||
.attachLogger((record) => {
|
||||
if (record.message.includes(message)) {
|
||||
records.push(record)
|
||||
}
|
||||
})
|
||||
.then(async (detach) => {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
await api.log[level](message)
|
||||
setTimeout(() => {
|
||||
detach()
|
||||
resolve(records)
|
||||
}, 1000)
|
||||
})
|
||||
.catch(reject)
|
||||
}),
|
||||
level,
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
describePlugin('log', () => {
|
||||
it('attachLogger receives records logged from the webview', async () => {
|
||||
const records = await logAndCollect('info', 'info record from e2e')
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].level).toBe(3) // LogLevel.Info
|
||||
expect(records[0].message).toMatch(
|
||||
isMobile
|
||||
? /\[webview[^\]]*\] info record from e2e$/
|
||||
: /\[webview[^\]]*\]\[INFO\] info record from e2e$/
|
||||
)
|
||||
})
|
||||
|
||||
it('records carry the level they were logged at', async () => {
|
||||
const error = await logAndCollect('error', 'error record from e2e')
|
||||
const warn = await logAndCollect('warn', 'warn record from e2e')
|
||||
expect(error[0].level).toBe(5) // LogLevel.Error
|
||||
expect(warn[0].level).toBe(4) // LogLevel.Warn
|
||||
if (!isMobile) {
|
||||
// the level is only part of the formatted message on desktop
|
||||
expect(error[0].message).toContain('[ERROR]')
|
||||
expect(warn[0].message).toContain('[WARN]')
|
||||
}
|
||||
})
|
||||
|
||||
it('records below the configured level are dropped', async () => {
|
||||
// the example sets the level filter to Info
|
||||
const debug = await logAndCollect('debug', 'debug record from e2e')
|
||||
const trace = await logAndCollect('trace', 'trace record from e2e')
|
||||
expect(debug).toHaveLength(0)
|
||||
expect(trace).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('log options are accepted', async () => {
|
||||
const records = await tauri(
|
||||
(api, needle) =>
|
||||
new Promise<Record[]>((resolve, reject) => {
|
||||
const records: Record[] = []
|
||||
api.log
|
||||
.attachLogger((record) => {
|
||||
if (record.message.includes(needle)) records.push(record)
|
||||
})
|
||||
.then(async (detach) => {
|
||||
await api.log.info(needle, {
|
||||
file: 'e2e.spec.ts',
|
||||
line: 42,
|
||||
keyValues: { suite: 'plugins-e2e' }
|
||||
})
|
||||
setTimeout(() => {
|
||||
detach()
|
||||
resolve(records)
|
||||
}, 1000)
|
||||
})
|
||||
.catch(reject)
|
||||
}),
|
||||
'record with options from e2e'
|
||||
)
|
||||
expect(records).toHaveLength(1)
|
||||
expect(records[0].level).toBe(3)
|
||||
})
|
||||
|
||||
it('detaching the logger stops delivery', async () => {
|
||||
const count = await tauri(
|
||||
(api, needle) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
let count = 0
|
||||
api.log
|
||||
.attachLogger((record) => {
|
||||
if (record.message.includes(needle)) count++
|
||||
})
|
||||
.then(async (detach) => {
|
||||
await api.log.info(needle)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
detach()
|
||||
await api.log.info(needle)
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
resolve(count)
|
||||
})
|
||||
.catch(reject)
|
||||
}),
|
||||
'detached record from e2e'
|
||||
)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
it('attachConsole forwards records to the console', async () => {
|
||||
const forwarded = await tauri(
|
||||
(api, needle) =>
|
||||
new Promise<string[]>((resolve, reject) => {
|
||||
const seen: string[] = []
|
||||
// Info records are forwarded to `console.info`
|
||||
const original = console.info
|
||||
console.info = (...args: unknown[]) => {
|
||||
const text = args.map(String).join(' ')
|
||||
if (text.includes(needle)) seen.push(text)
|
||||
original.apply(console, args)
|
||||
}
|
||||
api.log
|
||||
.attachConsole()
|
||||
.then(async (detach) => {
|
||||
await api.log.info(needle)
|
||||
setTimeout(() => {
|
||||
detach()
|
||||
console.info = original
|
||||
resolve(seen)
|
||||
}, 1000)
|
||||
})
|
||||
.catch(reject)
|
||||
}),
|
||||
'console record from e2e'
|
||||
)
|
||||
expect(forwarded.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('records logged from Rust are forwarded to the webview too', async () => {
|
||||
// the example's `log_operation` command logs its arguments at Info
|
||||
const message = await tauri(
|
||||
(api, needle) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
api.log
|
||||
.attachLogger((record) => {
|
||||
if (record.message.includes(needle)) resolve(record.message)
|
||||
})
|
||||
.then(() =>
|
||||
api.core.invoke('log_operation', {
|
||||
event: 'tauri-click',
|
||||
payload: needle
|
||||
})
|
||||
)
|
||||
.catch(reject)
|
||||
setTimeout(() => reject(new Error('record not received')), 5000)
|
||||
}),
|
||||
'rust log from e2e'
|
||||
)
|
||||
expect(message).toContain(
|
||||
isMobile ? '[api_lib::cmd] tauri-click' : '[INFO] tauri-click'
|
||||
)
|
||||
expect(message).toContain('rust log from e2e')
|
||||
expect(message).not.toContain('[webview')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin, itDesktop } from '../helpers/index.js'
|
||||
|
||||
// Whether a notification actually shows up depends on the desktop session
|
||||
// (a notification daemon on Linux, the app's registration on Windows and
|
||||
// macOS), which the suite cannot observe. The specs cover the permission
|
||||
// model and that sending does not error out synchronously.
|
||||
//
|
||||
// The permission specs are desktop-only: mobile starts out ungranted and
|
||||
// `requestPermission` puts up a system dialog the session would then block on.
|
||||
|
||||
describePlugin('notification', () => {
|
||||
itDesktop('permission is granted on desktop', async () => {
|
||||
const result = await tauri(async (api) => ({
|
||||
granted: await api.notification.isPermissionGranted(),
|
||||
requested: await api.notification.requestPermission()
|
||||
}))
|
||||
expect(result).toEqual({ granted: true, requested: 'granted' })
|
||||
})
|
||||
|
||||
itDesktop('the plugin overrides window.Notification', async () => {
|
||||
const result = await tauri(async () => ({
|
||||
permission: window.Notification.permission,
|
||||
requested: await window.Notification.requestPermission()
|
||||
}))
|
||||
expect(result).toEqual({ permission: 'granted', requested: 'granted' })
|
||||
})
|
||||
|
||||
it('sendNotification accepts a title string and an options object', async () => {
|
||||
const result = await tauri((api) => {
|
||||
api.notification.sendNotification('notification from e2e')
|
||||
api.notification.sendNotification({
|
||||
title: 'notification from e2e',
|
||||
body: 'with a body',
|
||||
sound: 'default'
|
||||
})
|
||||
return true
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('new Notification() goes through the plugin', async () => {
|
||||
const result = await tauri(() => {
|
||||
const notification = new window.Notification(
|
||||
'window.Notification from e2e',
|
||||
{
|
||||
body: 'created through the DOM API'
|
||||
}
|
||||
)
|
||||
return typeof notification === 'object'
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauriError, describePlugin } from '../helpers/index.js'
|
||||
|
||||
// A successful open launches an external application (browser, file manager)
|
||||
// the suite cannot control or close, so only the scope enforcement is covered.
|
||||
// The example allows `mailto:`, `tel:`, `http(s)://` URLs (opener:default),
|
||||
// `https://` URLs specifically with `inAppBrowser`, and paths under `$APPDATA`.
|
||||
|
||||
describePlugin('opener', () => {
|
||||
it('openUrl rejects URL schemes outside the scope', async () => {
|
||||
const message = await tauriError((api) =>
|
||||
api.opener.openUrl('ftp://example.com/file')
|
||||
)
|
||||
expect(message).toMatch(
|
||||
/Not allowed to open url ftp:\/\/example\.com\/file/
|
||||
)
|
||||
})
|
||||
|
||||
it('openUrl rejects an app that is not in the scope for the URL', async () => {
|
||||
// `inAppBrowser` is only allowed for `https://` URLs
|
||||
const message = await tauriError((api) =>
|
||||
api.opener.openUrl('http://example.com', 'inAppBrowser')
|
||||
)
|
||||
expect(message).toMatch(/Not allowed to open url http:\/\/example\.com/)
|
||||
})
|
||||
|
||||
it('openPath rejects paths outside the scope', async () => {
|
||||
const message = await tauriError(async (api) =>
|
||||
api.opener.openPath(await api.path.join(await api.path.homeDir(), 'e2e'))
|
||||
)
|
||||
expect(message).toMatch(/Not allowed to open path/)
|
||||
})
|
||||
|
||||
it('revealItemInDir rejects paths that do not exist', async () => {
|
||||
const message = await tauriError(async (api) =>
|
||||
api.opener.revealItemInDir(
|
||||
await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e')
|
||||
)
|
||||
)
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import os from 'node:os'
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
describePlugin,
|
||||
itDesktop,
|
||||
platform,
|
||||
isMobile
|
||||
} from '../helpers/index.js'
|
||||
|
||||
// Everything the plugin reports is baked in at compile time, so it describes
|
||||
// the *app's* platform — the host for the desktop suite, but the emulator or
|
||||
// simulator for the mobile ones, which is why nothing here compares against
|
||||
// Node's view of the host unless the two are the same machine.
|
||||
|
||||
const nodePlatformToTauri: Record<string, string> = {
|
||||
linux: 'linux',
|
||||
win32: 'windows',
|
||||
darwin: 'macos',
|
||||
freebsd: 'freebsd',
|
||||
openbsd: 'openbsd',
|
||||
android: 'android',
|
||||
ios: 'ios'
|
||||
}
|
||||
|
||||
const nodeArchToTauri: Record<string, string> = {
|
||||
x64: 'x86_64',
|
||||
ia32: 'x86',
|
||||
arm64: 'aarch64',
|
||||
arm: 'arm',
|
||||
riscv64: 'riscv64',
|
||||
ppc64: 'powerpc64',
|
||||
s390x: 's390x'
|
||||
}
|
||||
|
||||
// `Arch` in the plugin's guest-js.
|
||||
const architectures = [
|
||||
'x86',
|
||||
'x86_64',
|
||||
'arm',
|
||||
'aarch64',
|
||||
'mips',
|
||||
'mips64',
|
||||
'powerpc',
|
||||
'powerpc64',
|
||||
'riscv64',
|
||||
's390x',
|
||||
'sparc64'
|
||||
]
|
||||
|
||||
describePlugin('os', () => {
|
||||
it('platform, type and family match the target', async () => {
|
||||
const info = await tauri((api) => ({
|
||||
platform: api.os.platform(),
|
||||
type: api.os.type(),
|
||||
family: api.os.family()
|
||||
}))
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
expect(info.platform).toBe(nodePlatformToTauri[platform])
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
expect(info.type).toBe(nodePlatformToTauri[platform])
|
||||
expect(info.family).toBe(platform === 'win32' ? 'windows' : 'unix')
|
||||
})
|
||||
|
||||
it('arch reports a known architecture', async () => {
|
||||
const arch = await tauri((api) => api.os.arch())
|
||||
if (isMobile) {
|
||||
// The device/simulator is not necessarily the host's architecture (see
|
||||
// `E2E_ANDROID_TARGET` / `E2E_IOS_TARGET`).
|
||||
expect(architectures).toContain(arch)
|
||||
} else {
|
||||
expect(arch).toBe(nodeArchToTauri[process.arch])
|
||||
}
|
||||
})
|
||||
|
||||
it('eol and exeExtension match the platform conventions', async () => {
|
||||
const info = await tauri((api) => ({
|
||||
eol: api.os.eol(),
|
||||
exeExtension: api.os.exeExtension()
|
||||
}))
|
||||
expect(info.eol).toBe(platform === 'win32' ? '\r\n' : '\n')
|
||||
expect(info.exeExtension).toBe(platform === 'win32' ? 'exe' : '')
|
||||
})
|
||||
|
||||
it('version reports a non-empty OS version', async () => {
|
||||
const version = await tauri((api) => api.os.version())
|
||||
expect(version.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('locale is null or a language tag', async () => {
|
||||
const locale = await tauri((api) => api.os.locale())
|
||||
// The plugin forwards the environment's POSIX locale as-is, so a host with
|
||||
// no locale configured (CI runners default to `LANG=C.UTF-8`) reports the
|
||||
// POSIX default instead of a language tag.
|
||||
if (locale !== null && locale !== 'C' && locale !== 'POSIX') {
|
||||
// e.g. `en-US`
|
||||
const [language, ...subtags] = locale.split(/[-_]/)
|
||||
expect(language).toMatch(/^[A-Za-z]{2,3}$/)
|
||||
for (const subtag of subtags) {
|
||||
expect(subtag).toMatch(/^[A-Za-z0-9]+$/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('hostname reports a name', async () => {
|
||||
const hostname = await tauri((api) => api.os.hostname())
|
||||
expect(hostname).not.toBeNull()
|
||||
expect(hostname!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
itDesktop('hostname matches the host', async () => {
|
||||
const hostname = await tauri((api) => api.os.hostname())
|
||||
// Windows can report the name in a different case than Node does.
|
||||
expect(hostname!.toLowerCase()).toBe(os.hostname().toLowerCase())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
isMobile,
|
||||
type CommonPluginApi,
|
||||
type DesktopPluginApi,
|
||||
type MobilePluginApi
|
||||
} from '../helpers/index.js'
|
||||
|
||||
/**
|
||||
* The members each plugin's `api-iife.js` is expected to define on
|
||||
* `window.__TAURI__.<plugin>`. This is the one place the suite covers the
|
||||
* plugins whose commands cannot be driven from a WebDriver session (dialog
|
||||
* blocks on native UI, process terminates the app, the mobile plugins need
|
||||
* hardware or native UI), and it catches a plugin whose global API script is
|
||||
* missing from its `build.rs`.
|
||||
*/
|
||||
type Surface<T> = { [P in keyof T]: (keyof T[P])[] }
|
||||
|
||||
/** Plugins the example registers on every platform. */
|
||||
const commonSurface: Surface<CommonPluginApi> = {
|
||||
clipboardManager: [
|
||||
'writeText',
|
||||
'readText',
|
||||
'writeHtml',
|
||||
'clear',
|
||||
'readImage',
|
||||
'writeImage'
|
||||
],
|
||||
dialog: ['open', 'save', 'message', 'ask', 'confirm'],
|
||||
fs: [
|
||||
'BaseDirectory',
|
||||
'FileHandle',
|
||||
'SeekMode',
|
||||
'create',
|
||||
'open',
|
||||
'copyFile',
|
||||
'mkdir',
|
||||
'readDir',
|
||||
'readFile',
|
||||
'readTextFile',
|
||||
'readTextFileLines',
|
||||
'remove',
|
||||
'rename',
|
||||
'stat',
|
||||
'lstat',
|
||||
'truncate',
|
||||
'writeFile',
|
||||
'writeTextFile',
|
||||
'exists',
|
||||
'watch',
|
||||
'watchImmediate',
|
||||
'size'
|
||||
],
|
||||
http: ['fetch'],
|
||||
log: [
|
||||
'LogLevel',
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'debug',
|
||||
'trace',
|
||||
'attachLogger',
|
||||
'attachConsole'
|
||||
],
|
||||
notification: [
|
||||
'isPermissionGranted',
|
||||
'requestPermission',
|
||||
'sendNotification',
|
||||
'registerActionTypes',
|
||||
'pending',
|
||||
'cancel',
|
||||
'cancelAll',
|
||||
'active',
|
||||
'removeActive',
|
||||
'removeAllActive',
|
||||
'createChannel',
|
||||
'removeChannel',
|
||||
'channels',
|
||||
'onNotificationReceived',
|
||||
'onAction'
|
||||
],
|
||||
opener: ['openUrl', 'openPath', 'revealItemInDir'],
|
||||
os: [
|
||||
'eol',
|
||||
'platform',
|
||||
'family',
|
||||
'version',
|
||||
'type',
|
||||
'arch',
|
||||
'locale',
|
||||
'exeExtension',
|
||||
'hostname'
|
||||
],
|
||||
process: ['exit', 'relaunch'],
|
||||
shell: ['Command', 'Child', 'EventEmitter', 'open'],
|
||||
store: ['load', 'getStore', 'LazyStore', 'Store'],
|
||||
upload: ['download', 'upload', 'HttpMethod']
|
||||
}
|
||||
|
||||
/** Plugins the example only registers on desktop. */
|
||||
const desktopSurface: Surface<DesktopPluginApi> = {
|
||||
cli: ['getMatches'],
|
||||
globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'],
|
||||
updater: ['check', 'Update'],
|
||||
windowState: [
|
||||
'StateFlags',
|
||||
'restoreState',
|
||||
'restoreStateCurrent',
|
||||
'saveWindowState',
|
||||
'filename'
|
||||
]
|
||||
}
|
||||
|
||||
/** Plugins the example only registers on mobile. */
|
||||
const mobileSurface: Surface<MobilePluginApi> = {
|
||||
barcodeScanner: [
|
||||
'Format',
|
||||
'scan',
|
||||
'cancel',
|
||||
'checkPermissions',
|
||||
'requestPermissions',
|
||||
'openAppSettings'
|
||||
],
|
||||
biometric: ['BiometryType', 'checkStatus', 'authenticate'],
|
||||
geolocation: [
|
||||
'watchPosition',
|
||||
'getCurrentPosition',
|
||||
'clearWatch',
|
||||
'checkPermissions',
|
||||
'requestPermissions'
|
||||
],
|
||||
// `ImpactFeedbackStyle` and `NotificationFeedbackType` are type aliases, so
|
||||
// they are not part of the runtime namespace.
|
||||
haptics: [
|
||||
'vibrate',
|
||||
'impactFeedback',
|
||||
'notificationFeedback',
|
||||
'selectionFeedback'
|
||||
],
|
||||
nfc: [
|
||||
'NFCTypeNameFormat',
|
||||
'TechKind',
|
||||
'RTD_TEXT',
|
||||
'RTD_URI',
|
||||
'record',
|
||||
'textRecord',
|
||||
'uriRecord',
|
||||
'scan',
|
||||
'write',
|
||||
'isAvailable'
|
||||
]
|
||||
}
|
||||
|
||||
const surface = {
|
||||
...commonSurface,
|
||||
...(isMobile ? mobileSurface : desktopSurface)
|
||||
} as Record<string, string[]>
|
||||
|
||||
/** The other platform's plugins, which must *not* be in this build. */
|
||||
const foreign = Object.keys(isMobile ? desktopSurface : mobileSurface)
|
||||
|
||||
describe('plugin globals', () => {
|
||||
it('every plugin this platform registers exposes its API on window.__TAURI__', async () => {
|
||||
const missing = await tauri(
|
||||
(api, plugins) =>
|
||||
plugins.filter(
|
||||
(plugin) =>
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
typeof (api as unknown as Record<string, unknown>)[plugin]
|
||||
!== 'object'
|
||||
),
|
||||
Object.keys(surface)
|
||||
)
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('the other platform’s plugins are not in the build', async () => {
|
||||
// Their Rust crates are target-gated in the example's Cargo.toml, so their
|
||||
// `global_api_script_path` is never injected either.
|
||||
const present = await tauri(
|
||||
(api, plugins) =>
|
||||
plugins.filter(
|
||||
(plugin) =>
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
(api as unknown as Record<string, unknown>)[plugin] !== undefined
|
||||
),
|
||||
foreign
|
||||
)
|
||||
expect(present).toEqual([])
|
||||
})
|
||||
|
||||
for (const [plugin, members] of Object.entries(surface)) {
|
||||
it(`${plugin} exposes its documented members`, async () => {
|
||||
const missing = await tauri(
|
||||
(api, plugin, members) => {
|
||||
const namespaces = api as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
const namespace = namespaces[plugin]
|
||||
return members.filter((member) => !(member in namespace))
|
||||
},
|
||||
plugin,
|
||||
members
|
||||
)
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
platform
|
||||
} from '../helpers/index.js'
|
||||
|
||||
// The example's shell scope allows `sh -c <script>` and `cmd /C <script>`.
|
||||
const shell =
|
||||
platform === 'win32'
|
||||
? { program: 'cmd', flag: '/C' }
|
||||
: { program: 'sh', flag: '-c' }
|
||||
|
||||
// Running a child process works on desktop and on Android (`/system/bin/sh`),
|
||||
// but iOS does not let an app spawn one at all. The scope specs below stay on
|
||||
// every platform: `prepare_cmd` rejects before anything is executed.
|
||||
const itSpawns = platform === 'ios' ? it.skip : it
|
||||
|
||||
/**
|
||||
* Puts a directory in the form the two sides of the working directory
|
||||
* assertion can be compared in: the shell may print it with a different path
|
||||
* style, and on Android `/data/user/<n>/<pkg>` (which `appDataDir` reports) is
|
||||
* a symlink to `/data/data/<pkg>` (which `pwd` resolves it to).
|
||||
*/
|
||||
function normalizeDir(dir: string): string {
|
||||
return dir
|
||||
.replace(/[\\/]+$/, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/data\/user\/\d+\//, '/data/data/')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
describePlugin('shell', () => {
|
||||
itSpawns('execute collects stdout, stderr and the exit code', async () => {
|
||||
const output = await tauri(
|
||||
async (api, program, flag, script) => {
|
||||
const result = await api.shell.Command.create(program, [
|
||||
flag,
|
||||
script
|
||||
]).execute()
|
||||
return {
|
||||
code: result.code,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout.trim(),
|
||||
stderr: result.stderr.trim()
|
||||
}
|
||||
},
|
||||
shell.program,
|
||||
shell.flag,
|
||||
platform === 'win32'
|
||||
? 'echo hello from e2e && echo warning 1>&2 && exit 3'
|
||||
: 'echo "hello from e2e"; echo "warning" >&2; exit 3'
|
||||
)
|
||||
expect(output.code).toBe(3)
|
||||
expect(output.signal).toBeNull()
|
||||
expect(output.stdout).toBe('hello from e2e')
|
||||
expect(output.stderr).toBe('warning')
|
||||
})
|
||||
|
||||
itSpawns(
|
||||
'execute passes environment variables and the working directory',
|
||||
async () => {
|
||||
const cwd = await tauri((api) => api.path.appDataDir())
|
||||
const output = await tauri(
|
||||
async (api, program, flag, script, cwd) => {
|
||||
const result = await api.shell.Command.create(
|
||||
program,
|
||||
[flag, script],
|
||||
{
|
||||
cwd,
|
||||
env: { E2E_VALUE: 'from-e2e' }
|
||||
}
|
||||
).execute()
|
||||
return { code: result.code, stdout: result.stdout.trim() }
|
||||
},
|
||||
shell.program,
|
||||
shell.flag,
|
||||
platform === 'win32'
|
||||
? 'echo %E2E_VALUE% && cd'
|
||||
: 'echo "$E2E_VALUE"; pwd',
|
||||
cwd
|
||||
)
|
||||
expect(output.code).toBe(0)
|
||||
// `cmd` echoes everything between `echo ` and `&&`, the space before the
|
||||
// separator included, so every line is trimmed and not just the ends of
|
||||
// the output as a whole.
|
||||
const [value, reportedCwd] = output.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
expect(value).toBe('from-e2e')
|
||||
expect(normalizeDir(reportedCwd ?? '')).toBe(normalizeDir(cwd))
|
||||
}
|
||||
)
|
||||
|
||||
itSpawns(
|
||||
'spawn streams stdout and stderr lines and reports close',
|
||||
async () => {
|
||||
const events = await tauri(
|
||||
(api, program, flag, script) =>
|
||||
new Promise<{
|
||||
stdout: string[]
|
||||
stderr: string[]
|
||||
close: { code: number | null; signal: number | null }
|
||||
pid: number
|
||||
}>((resolve, reject) => {
|
||||
const stdout: string[] = []
|
||||
const stderr: string[] = []
|
||||
let pid = 0
|
||||
const command = api.shell.Command.create(program, [flag, script])
|
||||
command.stdout.on('data', (line) => stdout.push(line.trim()))
|
||||
command.stderr.on('data', (line) => stderr.push(line.trim()))
|
||||
command.on('error', (error) => reject(new Error(error)))
|
||||
command.on('close', (payload) =>
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
close: { code: payload.code, signal: payload.signal },
|
||||
pid
|
||||
})
|
||||
)
|
||||
command
|
||||
.spawn()
|
||||
.then((child) => {
|
||||
pid = child.pid
|
||||
})
|
||||
.catch(reject)
|
||||
setTimeout(() => reject(new Error('command never closed')), 15000)
|
||||
}),
|
||||
shell.program,
|
||||
shell.flag,
|
||||
platform === 'win32'
|
||||
? 'echo one && echo two && echo err 1>&2'
|
||||
: 'echo one; echo two; echo err >&2'
|
||||
)
|
||||
expect(events.pid).toBeGreaterThan(0)
|
||||
expect(events.stdout).toEqual(['one', 'two'])
|
||||
expect(events.stderr).toEqual(['err'])
|
||||
expect(events.close.code).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
itSpawns('write sends to stdin', async () => {
|
||||
const output = await tauri(
|
||||
(api, program, flag, script) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
let out = ''
|
||||
const command = api.shell.Command.create(program, [flag, script])
|
||||
command.stdout.on('data', (line) => {
|
||||
out += line
|
||||
})
|
||||
command.on('error', (error) => reject(new Error(error)))
|
||||
command.on('close', () => resolve(out.trim()))
|
||||
command
|
||||
.spawn()
|
||||
.then((child) => child.write('ping from e2e\n'))
|
||||
.catch(reject)
|
||||
setTimeout(() => reject(new Error('command never closed')), 15000)
|
||||
}),
|
||||
shell.program,
|
||||
shell.flag,
|
||||
// `call` makes cmd expand `%line%` after `set /p` ran, rather than
|
||||
// when the line is parsed
|
||||
platform === 'win32'
|
||||
? 'set /p line= & call echo got: %line%'
|
||||
: 'read line; echo "got: $line"'
|
||||
)
|
||||
expect(output).toBe('got: ping from e2e')
|
||||
})
|
||||
|
||||
itSpawns('kill terminates a running child', async () => {
|
||||
const result = await tauri(
|
||||
(api, program, flag, script) =>
|
||||
new Promise<{ code: number | null; signal: number | null }>(
|
||||
(resolve, reject) => {
|
||||
const command = api.shell.Command.create(program, [flag, script])
|
||||
command.on('error', (error) => reject(new Error(error)))
|
||||
command.on('close', (payload) =>
|
||||
resolve({ code: payload.code, signal: payload.signal })
|
||||
)
|
||||
command
|
||||
.spawn()
|
||||
.then((child) =>
|
||||
// give the shell a moment to start before killing it
|
||||
new Promise((r) => setTimeout(r, 500)).then(() => child.kill())
|
||||
)
|
||||
.catch(reject)
|
||||
setTimeout(() => reject(new Error('child was not killed')), 15000)
|
||||
}
|
||||
),
|
||||
shell.program,
|
||||
shell.flag,
|
||||
// The script has to keep the shell itself busy rather than start another
|
||||
// process: `kill` only signals the direct child, and a surviving
|
||||
// grandchild holds the stdout/stderr pipes open, which withholds the
|
||||
// `close` event until it exits on its own. Both shells block on their
|
||||
// built-in stdin read, and the test never writes to stdin.
|
||||
platform === 'win32' ? 'set /p killme=' : 'read killme'
|
||||
)
|
||||
if (platform === 'win32') {
|
||||
// TerminateProcess sets an exit code of 1
|
||||
expect(result.code).not.toBe(0)
|
||||
} else {
|
||||
// killed by SIGKILL, so no exit code
|
||||
expect(result.code).toBeNull()
|
||||
expect(result.signal).toBe(9)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects programs that are not in the scope', async () => {
|
||||
const message = await tauriError((api) =>
|
||||
api.shell.Command.create('e2e-not-allowed').execute()
|
||||
)
|
||||
expect(message).toMatch(/program not allowed on the configured shell scope/)
|
||||
})
|
||||
|
||||
it('rejects arguments that do not match the scope', async () => {
|
||||
// the scope only allows `-c`/`/C` followed by a non-empty script
|
||||
const message = await tauriError(
|
||||
(api, program) =>
|
||||
api.shell.Command.create(program, ['--version']).execute(),
|
||||
shell.program
|
||||
)
|
||||
expect(message).toMatch(/not allowed|validator|scope/i)
|
||||
})
|
||||
|
||||
it('open rejects URLs outside the default scope', async () => {
|
||||
// `shell:default` only allows http(s), mailto and tel URLs
|
||||
const message = await tauriError((api) =>
|
||||
api.shell.open('ftp://example.com')
|
||||
)
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin, scratchDir } from '../helpers/index.js'
|
||||
|
||||
// Store paths are relative to `$APPDATA`, which is also inside the example's
|
||||
// fs scope, so the specs can inspect what the plugin persists.
|
||||
const dir = scratchDir('store')
|
||||
const storePath = `${dir}/e2e.json`
|
||||
|
||||
describePlugin('store', () => {
|
||||
before(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
await api.fs.mkdir(dir, { baseDir, recursive: true })
|
||||
}, dir)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
}, dir)
|
||||
})
|
||||
|
||||
it('set, get, has, keys, values, entries, length and delete', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
await store.set('string', 'value')
|
||||
await store.set('number', 42)
|
||||
await store.set('object', { nested: [1, 2, 3] })
|
||||
const snapshot = {
|
||||
string: await store.get<string>('string'),
|
||||
number: await store.get<number>('number'),
|
||||
object: await store.get<{ nested: number[] }>('object'),
|
||||
// `undefined` is normalized so the result survives serialization
|
||||
missing: (await store.get('missing')) ?? null,
|
||||
hasString: await store.has('string'),
|
||||
hasMissing: await store.has('missing'),
|
||||
keys: (await store.keys()).sort(),
|
||||
values: await store.values(),
|
||||
entries: (await store.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
||||
length: await store.length()
|
||||
}
|
||||
const deleted = await store.delete('number')
|
||||
const deletedAgain = await store.delete('number')
|
||||
const lengthAfterDelete = await store.length()
|
||||
await store.clear()
|
||||
const lengthAfterClear = await store.length()
|
||||
await store.close()
|
||||
return {
|
||||
...snapshot,
|
||||
deleted,
|
||||
deletedAgain,
|
||||
lengthAfterDelete,
|
||||
lengthAfterClear
|
||||
}
|
||||
}, storePath)
|
||||
|
||||
expect(result.string).toBe('value')
|
||||
expect(result.number).toBe(42)
|
||||
expect(result.object).toEqual({ nested: [1, 2, 3] })
|
||||
expect(result.missing).toBeNull()
|
||||
expect(result.hasString).toBe(true)
|
||||
expect(result.hasMissing).toBe(false)
|
||||
expect(result.keys).toEqual(['number', 'object', 'string'])
|
||||
expect(result.values).toHaveLength(3)
|
||||
expect(result.entries).toEqual([
|
||||
['number', 42],
|
||||
['object', { nested: [1, 2, 3] }],
|
||||
['string', 'value']
|
||||
])
|
||||
expect(result.length).toBe(3)
|
||||
expect(result.deleted).toBe(true)
|
||||
expect(result.deletedAgain).toBe(false)
|
||||
expect(result.lengthAfterDelete).toBe(2)
|
||||
expect(result.lengthAfterClear).toBe(0)
|
||||
})
|
||||
|
||||
it('save persists to disk and load reads it back', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
await store.set('persisted', { answer: 42 })
|
||||
await store.save()
|
||||
await store.close()
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
await api.fs.readTextFile(path, { baseDir })
|
||||
) as Record<string, unknown>
|
||||
|
||||
const reloaded = await api.store.load(path, { autoSave: false })
|
||||
const value = await reloaded.get<{ answer: number }>('persisted')
|
||||
await reloaded.close()
|
||||
return { onDisk, value }
|
||||
}, `${dir}/persisted.json`)
|
||||
expect(result.onDisk).toEqual({ persisted: { answer: 42 } })
|
||||
expect(result.value).toEqual({ answer: 42 })
|
||||
})
|
||||
|
||||
it('autoSave writes changes without an explicit save', async () => {
|
||||
const onDisk = await tauri(async (api, path) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const store = await api.store.load(path, { autoSave: 50 })
|
||||
await store.set('auto', true)
|
||||
// autoSave is debounced; wait for it to flush
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!(await api.fs.exists(path, { baseDir }))) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('store was never auto-saved')
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
const contents = JSON.parse(
|
||||
await api.fs.readTextFile(path, { baseDir })
|
||||
) as Record<string, unknown>
|
||||
await store.close()
|
||||
return contents
|
||||
}, `${dir}/autosave.json`)
|
||||
expect(onDisk).toEqual({ auto: true })
|
||||
})
|
||||
|
||||
it('defaults apply on load and reset restores them', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const store = await api.store.load(path, {
|
||||
autoSave: false,
|
||||
defaults: { theme: 'dark', volume: 5 }
|
||||
})
|
||||
const initial = {
|
||||
theme: await store.get('theme'),
|
||||
volume: await store.get('volume')
|
||||
}
|
||||
await store.set('theme', 'light')
|
||||
await store.set('extra', 1)
|
||||
await store.reset()
|
||||
const afterReset = {
|
||||
theme: await store.get('theme'),
|
||||
volume: await store.get('volume'),
|
||||
extra: (await store.get('extra')) ?? null,
|
||||
length: await store.length()
|
||||
}
|
||||
await store.close()
|
||||
return { initial, afterReset }
|
||||
}, `${dir}/defaults.json`)
|
||||
expect(result.initial).toEqual({ theme: 'dark', volume: 5 })
|
||||
expect(result.afterReset).toEqual({
|
||||
theme: 'dark',
|
||||
volume: 5,
|
||||
extra: null,
|
||||
length: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('reload merges the on-disk state, or replaces it with ignoreDefaults', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
await store.set('saved', 1)
|
||||
await store.save()
|
||||
await store.set('saved', 2)
|
||||
await store.set('unsaved', true)
|
||||
// a plain reload only re-applies what is on disk on top of the cache
|
||||
await store.reload()
|
||||
const merged = {
|
||||
saved: await store.get('saved'),
|
||||
unsaved: (await store.get('unsaved')) ?? null
|
||||
}
|
||||
// ignoreDefaults makes the store match the disk exactly
|
||||
await store.set('unsaved', true)
|
||||
await store.reload({ ignoreDefaults: true })
|
||||
const replaced = {
|
||||
saved: await store.get('saved'),
|
||||
unsaved: (await store.get('unsaved')) ?? null
|
||||
}
|
||||
await store.close()
|
||||
return { merged, replaced }
|
||||
}, `${dir}/reload.json`)
|
||||
expect(result.merged).toEqual({ saved: 1, unsaved: true })
|
||||
expect(result.replaced).toEqual({ saved: 1, unsaved: null })
|
||||
})
|
||||
|
||||
it('getStore returns the already-loaded instance, or null', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const before = await api.store.getStore(path)
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
await store.set('shared', 'yes')
|
||||
const existing = await api.store.getStore(path)
|
||||
const sharedValue = existing ? await existing.get<string>('shared') : null
|
||||
await store.close()
|
||||
const afterClose = await api.store.getStore(path)
|
||||
return { before, sharedValue, afterClose }
|
||||
}, `${dir}/get-store.json`)
|
||||
expect(result.before).toBeNull()
|
||||
expect(result.sharedValue).toBe('yes')
|
||||
expect(result.afterClose).toBeNull()
|
||||
})
|
||||
|
||||
it('LazyStore initializes on first use and errors after close', async () => {
|
||||
const result = await tauri(async (api, path) => {
|
||||
const store = new api.store.LazyStore(path, { autoSave: false })
|
||||
await store.set('lazy', 'loaded')
|
||||
const value = await store.get<string>('lazy')
|
||||
await store.close()
|
||||
let closedError: string | null = null
|
||||
try {
|
||||
await store.get('lazy')
|
||||
} catch (error) {
|
||||
closedError = String(error)
|
||||
}
|
||||
return { value, closedError }
|
||||
}, `${dir}/lazy.json`)
|
||||
expect(result.value).toBe('loaded')
|
||||
expect(result.closedError).not.toBeNull()
|
||||
})
|
||||
|
||||
it('change listeners fire for set and delete', async () => {
|
||||
const changes = await tauri(async (api, path) => {
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
const changes: { key: string; value: unknown }[] = []
|
||||
const unlisten = await store.onChange((key, value) => {
|
||||
// `undefined` does not survive JSON serialization
|
||||
changes.push({ key, value: value === undefined ? null : value })
|
||||
})
|
||||
await store.set('watched', 1)
|
||||
await store.delete('watched')
|
||||
// events are delivered asynchronously
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
unlisten()
|
||||
await store.close()
|
||||
return changes
|
||||
}, `${dir}/on-change.json`)
|
||||
expect(changes).toEqual([
|
||||
{ key: 'watched', value: 1 },
|
||||
{ key: 'watched', value: null }
|
||||
])
|
||||
})
|
||||
|
||||
it('onKeyChange only fires for the watched key', async () => {
|
||||
const values = await tauri(async (api, path) => {
|
||||
const store = await api.store.load(path, { autoSave: false })
|
||||
const values: unknown[] = []
|
||||
const unlisten = await store.onKeyChange('watched', (value) => {
|
||||
values.push(value === undefined ? null : value)
|
||||
})
|
||||
await store.set('other', 'ignored')
|
||||
await store.set('watched', 'a')
|
||||
await store.set('watched', 'b')
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
unlisten()
|
||||
await store.close()
|
||||
return values
|
||||
}, `${dir}/on-key-change.json`)
|
||||
expect(values).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin } from '../helpers/index.js'
|
||||
import {
|
||||
UPDATER_FIXTURE_VERSION,
|
||||
UPDATER_FIXTURE_NOTES,
|
||||
UPDATER_TARGET_NO_UPDATE,
|
||||
UPDATER_TARGET_OLDER
|
||||
} from '../helpers/server.js'
|
||||
|
||||
// The e2e build points the updater endpoint at the fixture server (see
|
||||
// `tauri.e2e.conf.json`), which answers based on the `{{target}}` placeholder.
|
||||
// Only `check` is exercised: installing would replace the binary under test.
|
||||
// The plugin is desktop-only, so the whole suite is skipped on mobile (the
|
||||
// mobile builds are not built with the override config either).
|
||||
|
||||
describePlugin('updater', { desktopOnly: true }, () => {
|
||||
it('check finds a newer release on the endpoint', async () => {
|
||||
const update = await tauri(async (api) => {
|
||||
const update = await api.updater.check()
|
||||
if (!update) return null
|
||||
const info = {
|
||||
available: update.available,
|
||||
currentVersion: update.currentVersion,
|
||||
version: update.version,
|
||||
body: update.body,
|
||||
date: update.date,
|
||||
rawVersion: (update.rawJson as { version?: string }).version
|
||||
}
|
||||
await update.close()
|
||||
return info
|
||||
})
|
||||
expect(update).not.toBeNull()
|
||||
expect(update!.available).toBe(true)
|
||||
expect(update!.currentVersion).toBe('2.0.0')
|
||||
expect(update!.version).toBe(UPDATER_FIXTURE_VERSION)
|
||||
expect(update!.body).toBe(UPDATER_FIXTURE_NOTES)
|
||||
expect(update!.date).toContain('2026-03-01')
|
||||
expect(update!.rawVersion).toBe(UPDATER_FIXTURE_VERSION)
|
||||
})
|
||||
|
||||
it('check resolves null when the endpoint has no update (204)', async () => {
|
||||
const update = await tauri(
|
||||
(api, target) => api.updater.check({ target }),
|
||||
UPDATER_TARGET_NO_UPDATE
|
||||
)
|
||||
expect(update).toBeNull()
|
||||
})
|
||||
|
||||
it('check ignores a release older than the current version', async () => {
|
||||
// Downgrades are a build-time decision (the plugin's `allowDowngrades`
|
||||
// config), not something `check` can be asked for, so the older manifest
|
||||
// can only be checked for the update being ignored.
|
||||
const update = await tauri(
|
||||
(api, target) => api.updater.check({ target }),
|
||||
UPDATER_TARGET_OLDER
|
||||
)
|
||||
expect(update).toBeNull()
|
||||
})
|
||||
|
||||
it('check forwards custom headers and honors the timeout option', async () => {
|
||||
// a successful check with extra headers and a generous timeout
|
||||
const version = await tauri(async (api) => {
|
||||
const update = await api.updater.check({
|
||||
headers: { 'x-e2e-updater': 'yes' },
|
||||
timeout: 30_000
|
||||
})
|
||||
const version = update?.version ?? null
|
||||
await update?.close()
|
||||
return version
|
||||
})
|
||||
expect(version).toBe(UPDATER_FIXTURE_VERSION)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
scratchDir
|
||||
} from '../helpers/index.js'
|
||||
import { FIXTURE_SERVER_URL, DOWNLOAD_FIXTURE_BODY } from '../helpers/server.js'
|
||||
|
||||
// The plugin only takes absolute paths; these are resolved against `$APPDATA`
|
||||
// inside the page, which is inside the example's fs scope so the specs can
|
||||
// prepare and inspect the files.
|
||||
const dir = scratchDir('upload')
|
||||
|
||||
interface Progress {
|
||||
progress: number
|
||||
progressTotal: number
|
||||
total: number
|
||||
transferSpeed: number
|
||||
}
|
||||
|
||||
describePlugin('upload', () => {
|
||||
before(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
await api.fs.mkdir(dir, { baseDir, recursive: true })
|
||||
}, dir)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await tauri(async (api, dir) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
if (await api.fs.exists(dir, { baseDir })) {
|
||||
await api.fs.remove(dir, { baseDir, recursive: true })
|
||||
}
|
||||
}, dir)
|
||||
})
|
||||
|
||||
it('download writes the response to disk and reports progress', async () => {
|
||||
const result = await tauri(
|
||||
async (api, url, relativePath) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
relativePath
|
||||
)
|
||||
const events: Progress[] = []
|
||||
await api.upload.download(
|
||||
url,
|
||||
path,
|
||||
(progress) => events.push(progress),
|
||||
new Map([['x-e2e-download', 'yes']])
|
||||
)
|
||||
return {
|
||||
contents: await api.fs.readTextFile(relativePath, { baseDir }),
|
||||
events
|
||||
}
|
||||
},
|
||||
`${FIXTURE_SERVER_URL}/download`,
|
||||
`${dir}/downloaded.txt`
|
||||
)
|
||||
expect(result.contents).toBe(DOWNLOAD_FIXTURE_BODY)
|
||||
expect(result.events.length).toBeGreaterThan(0)
|
||||
const last = result.events[result.events.length - 1]
|
||||
const expectedSize = Buffer.byteLength(DOWNLOAD_FIXTURE_BODY)
|
||||
// the fixture server sends a Content-Length, so the total is known
|
||||
expect(last.total).toBe(expectedSize)
|
||||
expect(last.progressTotal).toBe(expectedSize)
|
||||
expect(result.events.reduce((sum, event) => sum + event.progress, 0)).toBe(
|
||||
expectedSize
|
||||
)
|
||||
})
|
||||
|
||||
it('download rejects on a non-success status', async () => {
|
||||
const message = await tauriError(
|
||||
async (api, url, relativePath) =>
|
||||
api.upload.download(
|
||||
url,
|
||||
await api.path.join(await api.path.appDataDir(), relativePath)
|
||||
),
|
||||
`${FIXTURE_SERVER_URL}/does-not-exist`,
|
||||
`${dir}/missing.txt`
|
||||
)
|
||||
expect(message).toMatch(/404/)
|
||||
})
|
||||
|
||||
it('upload streams a file with the requested method and headers', async () => {
|
||||
const contents = 'upload me\n'.repeat(1000)
|
||||
const result = await tauri(
|
||||
async (api, url, relativePath, contents) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(relativePath, contents, { baseDir })
|
||||
const path = await api.path.join(
|
||||
await api.path.appDataDir(),
|
||||
relativePath
|
||||
)
|
||||
const events: Progress[] = []
|
||||
const response = await api.upload.upload(
|
||||
url,
|
||||
path,
|
||||
(progress) => events.push(progress),
|
||||
new Map([['x-e2e-upload', 'yes']]),
|
||||
api.upload.HttpMethod.Put
|
||||
)
|
||||
return { response: JSON.parse(response) as unknown, events }
|
||||
},
|
||||
`${FIXTURE_SERVER_URL}/echo`,
|
||||
`${dir}/to-upload.txt`,
|
||||
contents
|
||||
)
|
||||
const echoed = result.response as {
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: string
|
||||
}
|
||||
expect(echoed.method).toBe('PUT')
|
||||
expect(echoed.headers['x-e2e-upload']).toBe('yes')
|
||||
expect(echoed.body).toBe(contents)
|
||||
const size = Buffer.byteLength(contents)
|
||||
expect(result.events.length).toBeGreaterThan(0)
|
||||
expect(result.events[result.events.length - 1].total).toBe(size)
|
||||
expect(result.events[result.events.length - 1].progressTotal).toBe(size)
|
||||
})
|
||||
|
||||
it('upload defaults to POST', async () => {
|
||||
const method = await tauri(
|
||||
async (api, url, relativePath) => {
|
||||
const baseDir = api.fs.BaseDirectory.AppData
|
||||
await api.fs.writeTextFile(relativePath, 'post me', { baseDir })
|
||||
const response = await api.upload.upload(
|
||||
url,
|
||||
await api.path.join(await api.path.appDataDir(), relativePath)
|
||||
)
|
||||
return (JSON.parse(response) as { method: string }).method
|
||||
},
|
||||
`${FIXTURE_SERVER_URL}/echo`,
|
||||
`${dir}/to-post.txt`
|
||||
)
|
||||
expect(method).toBe('POST')
|
||||
})
|
||||
|
||||
it('upload rejects when the file does not exist', async () => {
|
||||
const message = await tauriError(
|
||||
async (api, url, relativePath) =>
|
||||
api.upload.upload(
|
||||
url,
|
||||
await api.path.join(await api.path.appDataDir(), relativePath)
|
||||
),
|
||||
`${FIXTURE_SERVER_URL}/echo`,
|
||||
`${dir}/does-not-exist.txt`
|
||||
)
|
||||
expect(message.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, eventually, describePlugin, itWm } from '../helpers/index.js'
|
||||
|
||||
// The plugin is desktop-only: a mobile window is the whole screen and has no
|
||||
// state to persist, so the whole suite is skipped there.
|
||||
describePlugin('window-state', { desktopOnly: true }, () => {
|
||||
it('filename reports the state file name', async () => {
|
||||
expect(await tauri((api) => api.windowState.filename())).toBe(
|
||||
'.window-state.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('saveWindowState and restoreState resolve', async () => {
|
||||
await tauri(async (api) => {
|
||||
await api.windowState.saveWindowState(api.windowState.StateFlags.ALL)
|
||||
await api.windowState.restoreState('main', api.windowState.StateFlags.ALL)
|
||||
await api.windowState.restoreStateCurrent()
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
it('StateFlags combine as a bit set', async () => {
|
||||
const flags = await tauri((api) => api.windowState.StateFlags)
|
||||
expect(flags.ALL).toBe(
|
||||
flags.SIZE
|
||||
| flags.POSITION
|
||||
| flags.MAXIMIZED
|
||||
| flags.VISIBLE
|
||||
| flags.DECORATIONS
|
||||
| flags.FULLSCREEN
|
||||
)
|
||||
})
|
||||
|
||||
itWm(
|
||||
'a new window is restored to the size it was last saved with',
|
||||
async () => {
|
||||
// The plugin applies the cached state of a label whenever a window with
|
||||
// that label is created, which is what persists sizes across sessions.
|
||||
const label = 'e2e-window-state'
|
||||
const scale = await tauri((api) =>
|
||||
api.window.getCurrentWindow().scaleFactor()
|
||||
)
|
||||
|
||||
const create = (width: number, height: number) =>
|
||||
tauri(
|
||||
(api, label, width, height) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const window = new api.webviewWindow.WebviewWindow(label, {
|
||||
width,
|
||||
height
|
||||
})
|
||||
window.once('tauri://created', () => resolve()).catch(reject)
|
||||
window
|
||||
.once('tauri://error', (event) =>
|
||||
reject(
|
||||
new Error(
|
||||
`window creation failed: ${String(event.payload)}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch(reject)
|
||||
setTimeout(
|
||||
() => reject(new Error('window creation timed out')),
|
||||
8000
|
||||
)
|
||||
}),
|
||||
label,
|
||||
width,
|
||||
height
|
||||
)
|
||||
const innerSize = () =>
|
||||
tauri(async (api, label) => {
|
||||
const window = await api.webviewWindow.WebviewWindow.getByLabel(label)
|
||||
if (!window) throw new Error(`window ${label} not found`)
|
||||
const size = await window.innerSize()
|
||||
return { width: size.width, height: size.height }
|
||||
}, label)
|
||||
const close = async () => {
|
||||
await tauri(async (api, label) => {
|
||||
const window = await api.webviewWindow.WebviewWindow.getByLabel(label)
|
||||
await window?.close()
|
||||
return null
|
||||
}, label)
|
||||
await eventually(async () => {
|
||||
const labels = await tauri(async (api) =>
|
||||
(await api.webviewWindow.getAllWebviewWindows()).map((w) => w.label)
|
||||
)
|
||||
if (labels.includes(label)) {
|
||||
throw new Error('window is still present after close')
|
||||
}
|
||||
})
|
||||
}
|
||||
const expectSize = (width: number, height: number) =>
|
||||
eventually(async () => {
|
||||
const size = await innerSize()
|
||||
const tolerance = Math.ceil(scale) * 8
|
||||
if (
|
||||
Math.abs(size.width - width) > tolerance
|
||||
|| Math.abs(size.height - height) > tolerance
|
||||
) {
|
||||
throw new Error(
|
||||
`size ${size.width}x${size.height} not near ${width}x${height}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// create the window at one size and save that state
|
||||
await create(500, 400)
|
||||
await expectSize(500 * scale, 400 * scale)
|
||||
const saved = await innerSize()
|
||||
await tauri((api) =>
|
||||
api.windowState.saveWindowState(api.windowState.StateFlags.SIZE)
|
||||
)
|
||||
await close()
|
||||
|
||||
// a new window with the same label asks for another size, and gets the
|
||||
// saved one back
|
||||
await create(700, 600)
|
||||
try {
|
||||
await expectSize(saved.width, saved.height)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// The examples/api app is built with `withGlobalTauri: true`, so the whole
|
||||
// `@tauri-apps/api` surface, plus every plugin's API (registered by its
|
||||
// `api-iife.js`), is available on `window.__TAURI__` inside the webview.
|
||||
// This mirrors that for the functions we serialize and run in the page.
|
||||
|
||||
import type { Api } from '../helpers/index.js'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__: Api
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user