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:
Lucas Fernandes Nogueira
2026-09-22 18:58:14 -03:00
committed by GitHub
parent 7f87091288
commit 684feb2510
47 changed files with 10292 additions and 107 deletions
+329
View File
@@ -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}`
}
+116
View File
@@ -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)
}
+71
View File
@@ -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' })
})
})
+33
View File
@@ -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('')
})
})
+380
View File
@@ -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/)
})
})
+149
View File
@@ -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)
})
})
+188
View File
@@ -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)
})
})
+120
View File
@@ -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())
})
})
+215
View File
@@ -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([])
})
}
})
+238
View File
@@ -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)
})
})
+261
View File
@@ -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)
})
})
+161
View File
@@ -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
View File
@@ -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 {}