tests(e2e): improve coverage

This commit is contained in:
Lucas Nogueira
2026-09-22 21:54:21 -03:00
parent 684feb2510
commit c8c373b48b
50 changed files with 1893 additions and 183 deletions
+17 -3
View File
@@ -4,10 +4,12 @@
import { browser } from '@wdio/globals'
import type * as TauriApi from '@tauri-apps/api'
import type * as Autostart from '@tauri-apps/plugin-autostart'
import type * as BarcodeScanner from '@tauri-apps/plugin-barcode-scanner'
import type * as Biometric from '@tauri-apps/plugin-biometric'
import type * as Cli from '@tauri-apps/plugin-cli'
import type * as ClipboardManager from '@tauri-apps/plugin-clipboard-manager'
import type * as DeepLink from '@tauri-apps/plugin-deep-link'
import type * as Dialog from '@tauri-apps/plugin-dialog'
import type * as Fs from '@tauri-apps/plugin-fs'
import type * as Geolocation from '@tauri-apps/plugin-geolocation'
@@ -19,20 +21,26 @@ import type * as Nfc from '@tauri-apps/plugin-nfc'
import type * as Notification from '@tauri-apps/plugin-notification'
import type * as Opener from '@tauri-apps/plugin-opener'
import type * as Os from '@tauri-apps/plugin-os'
import type * as Positioner from '@tauri-apps/plugin-positioner'
import type * as Process from '@tauri-apps/plugin-process'
import type * as Shell from '@tauri-apps/plugin-shell'
import type * as Sql from '@tauri-apps/plugin-sql'
import type * as Store from '@tauri-apps/plugin-store'
import type * as Stronghold from '@tauri-apps/plugin-stronghold'
import type * as Updater from '@tauri-apps/plugin-updater'
import type * as Upload from '@tauri-apps/plugin-upload'
import type * as WebSocket from '@tauri-apps/plugin-websocket'
import type * as WindowState from '@tauri-apps/plugin-window-state'
/**
* The plugin APIs the example registers on every platform, keyed by the name
* each plugin's `api-iife.js` defines on `window.__TAURI__` (the package name
* without the `@tauri-apps/plugin-` prefix, camel-cased).
* without the `@tauri-apps/plugin-` prefix, camel-cased). `sql` and `websocket`
* only have a default export, so their global is that class itself.
*/
export interface CommonPluginApi {
clipboardManager: typeof ClipboardManager
deepLink: typeof DeepLink
dialog: typeof Dialog
fs: typeof Fs
http: typeof Http
@@ -42,14 +50,19 @@ export interface CommonPluginApi {
os: typeof Os
process: typeof Process
shell: typeof Shell
sql: typeof Sql.default
store: typeof Store
stronghold: typeof Stronghold
upload: typeof Upload
websocket: typeof WebSocket.default
}
/** The plugin APIs the example only registers on desktop (`#[cfg(desktop)]`). */
export interface DesktopPluginApi {
autostart: typeof Autostart
cli: typeof Cli
globalShortcut: typeof GlobalShortcut
positioner: typeof Positioner
updater: typeof Updater
windowState: typeof WindowState
}
@@ -231,8 +244,9 @@ const skippedModules = (process.env.E2E_SKIP ?? '')
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.
* The example only registers the plugin on desktop (`autostart`, `cli`,
* `global-shortcut`, `positioner`, `updater`, `window-state`), so the whole
* suite is skipped on mobile.
*/
desktopOnly?: boolean
/**
+39
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import http from 'node:http'
import { WebSocketServer } from 'ws'
/**
* Where the fixture server listens. The port is fixed because the updater
@@ -23,6 +24,13 @@ export const UPDATER_FIXTURE_OLDER_VERSION = '1.0.0'
export const DOWNLOAD_FIXTURE_BODY =
'hello from the plugins e2e fixture server\n'.repeat(64)
/** WebSocket endpoint of the fixture server (the `ws://` twin of {@link FIXTURE_SERVER_URL}). */
export const WEBSOCKET_FIXTURE_URL = `ws://127.0.0.1:${FIXTURE_SERVER_PORT}/ws`
/** Text message that makes the `/ws` endpoint close the connection with {@link WEBSOCKET_CLOSE_CODE}. */
export const WEBSOCKET_CLOSE_REQUEST = 'close-me'
export const WEBSOCKET_CLOSE_CODE = 4000
export const WEBSOCKET_CLOSE_REASON = 'closed by the fixture server'
export interface FixtureServer {
close(): void
}
@@ -38,6 +46,10 @@ export interface FixtureServer {
* - `GET /download` — {@link DOWNLOAD_FIXTURE_BODY} with a `Content-Length`.
* - `* /echo` — a JSON description of the request (`method`, `url`, `headers`
* and the utf-8 `body`).
* - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent
* back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the
* connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server
* first sends the upgrade request's headers as a JSON text message.
*/
export function startFixtureServer(): Promise<FixtureServer> {
const server = http.createServer((req, res) => {
@@ -91,12 +103,39 @@ export function startFixtureServer(): Promise<FixtureServer> {
})
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
const { pathname } = new URL(req.url ?? '/', FIXTURE_SERVER_URL)
if (pathname !== '/ws' && pathname !== '/ws/headers') {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (pathname === '/ws/headers') {
ws.send(JSON.stringify(req.headers))
}
ws.on('message', (data, isBinary) => {
if (
!isBinary
&& Buffer.isBuffer(data)
&& data.toString('utf8') === WEBSOCKET_CLOSE_REQUEST
) {
ws.close(WEBSOCKET_CLOSE_CODE, WEBSOCKET_CLOSE_REASON)
} else {
ws.send(data, { binary: isBinary })
}
})
})
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(FIXTURE_SERVER_PORT, '127.0.0.1', () => {
server.off('error', reject)
resolve({
close: () => {
for (const client of wss.clients) client.terminate()
wss.close()
server.closeAllConnections()
server.close()
}
@@ -0,0 +1,49 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin } from '../helpers/index.js'
// Enabling autostart registers the app with the host for real (a Launch Agent
// on macOS, an XDG autostart entry on Linux, a `Run` registry value on
// Windows), so the spec puts back whatever state it found.
//
// `disable` is only called while autostart is enabled: on Windows it fails
// when there is no `Run` value to delete.
describePlugin('autostart', { desktopOnly: true }, () => {
let initiallyEnabled = false
before(async () => {
initiallyEnabled = await tauri((api) => api.autostart.isEnabled())
})
after(async () => {
await tauri(async (api, enabled) => {
if ((await api.autostart.isEnabled()) === enabled) return
await (enabled ? api.autostart.enable() : api.autostart.disable())
}, initiallyEnabled)
})
it('enable and disable toggle isEnabled', async () => {
const states = await tauri(async (api) => {
await api.autostart.enable()
const enabled = await api.autostart.isEnabled()
await api.autostart.disable()
return { enabled, disabled: await api.autostart.isEnabled() }
})
expect(states).toEqual({ enabled: true, disabled: false })
})
it('enabling twice keeps it enabled', async () => {
const states = await tauri(async (api) => {
await api.autostart.enable()
await api.autostart.enable()
const enabled = await api.autostart.isEnabled()
await api.autostart.disable()
return { enabled, disabled: await api.autostart.isEnabled() }
})
expect(states).toEqual({ enabled: true, disabled: false })
})
})
@@ -7,7 +7,8 @@ import {
tauri,
tauriError,
describePlugin,
itDesktop
itDesktop,
isMobile
} from '../helpers/index.js'
// The mobile implementation only carries plain text: `write_html`, `write_image`
@@ -133,7 +134,11 @@ describePlugin('clipboard-manager', () => {
await api.clipboardManager.writeText('not an image')
await api.clipboardManager.readImage()
})
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(
isMobile
? /Unsupported on this platform/
: /not available in the requested format/
)
})
it('clear empties the clipboard', async () => {
@@ -0,0 +1,99 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import {
tauri,
tauriError,
describePlugin,
itOn,
platform
} from '../helpers/index.js'
// The suite launches the app without a URL, so there is no current deep link,
// and it cannot open one through the OS either. `onOpenUrl` is exercised by
// emitting the event the plugin itself emits when the app is opened with one.
//
// Registering a scheme at runtime is only implemented on Linux (a `.desktop`
// handler plus `xdg-mime`) and Windows (the registry); macOS and mobile read
// the schemes from the bundle and report the runtime APIs as unsupported.
/** A scheme no real app handles, so registering it cannot clobber anything. */
const scheme = 'tauri-plugins-e2e'
describePlugin('deep-link', () => {
it('getCurrent is null when the app was not opened with a URL', async () => {
expect(await tauri((api) => api.deepLink.getCurrent())).toBeNull()
})
it('onOpenUrl delivers the opened URLs until unlistened', async () => {
const received = await tauri(
async (api, urls) => {
const received: string[][] = []
const unlisten = await api.deepLink.onOpenUrl((urls) =>
received.push(urls)
)
await api.event.emit('deep-link://new-url', urls)
await new Promise((resolve) => setTimeout(resolve, 500))
unlisten()
await api.event.emit('deep-link://new-url', ['ignored://'])
await new Promise((resolve) => setTimeout(resolve, 500))
return received
},
[`${scheme}://open/path?query=1`]
)
expect(received).toEqual([[`${scheme}://open/path?query=1`]])
})
itOn(
['linux', 'win32'],
'register makes the app the scheme handler',
async () => {
const result = await tauri(async (api, scheme) => {
await api.deepLink.register(scheme)
const registered = await api.deepLink.isRegistered(scheme)
await api.deepLink.unregister(scheme)
return {
registered,
afterUnregister: await api.deepLink.isRegistered(scheme)
}
}, scheme)
expect(result.registered).toBe(true)
// On Linux `xdg-mime` falls back to the desktop database, which still lists
// the handler after its `mimeapps.list` default is removed.
if (platform === 'win32') {
expect(result.afterUnregister).toBe(false)
}
}
)
itOn('win32', 'isRegistered is false for an unknown scheme', async () => {
expect(
await tauri(
(api, scheme) => api.deepLink.isRegistered(scheme),
`${scheme}-unknown`
)
).toBe(false)
})
itOn(
['darwin', 'android', 'ios'],
'runtime registration is unsupported',
async () => {
for (const command of [
'register',
'unregister',
'isRegistered'
] as const) {
const error = await tauriError(
// eslint-disable-next-line security/detect-object-injection
(api, command, scheme) => api.deepLink[command](scheme),
command,
scheme
)
expect(error).toMatch(/unsupported platform/i)
}
}
)
})
+4 -3
View File
@@ -276,7 +276,7 @@ describePlugin('fs', () => {
await file.close()
await file.stat()
}, `${dir}/closed.txt`)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/resource id \d+ is invalid/)
})
it('rejects paths outside the configured scope', async () => {
@@ -339,8 +339,9 @@ describePlugin('fs', () => {
unwatch()
return events
}, `${dir}/watched`)
expect(result.length).toBeGreaterThan(0)
expect(result.every((e) => typeof e.kind === 'string')).toBe(true)
expect(
result.some((e) => e.paths.some((p) => p.endsWith('touched.txt')))
).toBe(true)
}
)
@@ -3,7 +3,12 @@
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin } from '../helpers/index.js'
import {
tauri,
tauriError,
describePlugin,
platform
} from '../helpers/index.js'
// Triggering a shortcut needs OS-level synthetic input the WebDriver session
// cannot produce, so the specs cover the registry (register / isRegistered /
@@ -74,9 +79,19 @@ describePlugin('global-shortcut', { desktopOnly: 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', () => {})
try {
await api.globalShortcut.register('Alt+Shift+F11', () => {})
} finally {
await api.globalShortcut.unregister('Alt+Shift+F11')
}
})
expect(message).toMatch(/already registered/i)
// macOS does not check for duplicates itself: the OS rejects the second
// `RegisterEventHotKey` call and the error is the generic one
expect(message).toMatch(
platform === 'darwin'
? /RegisterEventHotKey failed for F11/
: /already registered/i
)
})
it('rejects shortcuts that cannot be parsed', async () => {
+1 -1
View File
@@ -144,6 +144,6 @@ describePlugin('http', () => {
api.http.fetch(url, { proxy: { all: 'http://127.0.0.1:1' } }),
echoServer
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/error sending request/)
})
})
+105 -22
View File
@@ -3,15 +3,25 @@
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, describePlugin, itDesktop } from '../helpers/index.js'
import {
tauri,
tauriError,
describePlugin,
itDesktop,
itOn
} from '../helpers/index.js'
// Whether a notification actually shows up depends on the desktop session
// (a notification daemon on Linux, the app's registration on Windows and
// macOS), which the suite cannot observe. The specs cover the permission
// model and that sending does not error out synchronously.
// macOS), which the suite cannot observe, and sending is fire-and-forget, so
// only the permission model is covered on desktop.
//
// The permission specs are desktop-only: mobile starts out ungranted and
// `requestPermission` puts up a system dialog the session would then block on.
//
// Scheduling, action types, channels, the pending/active lists and the
// listeners are only implemented on mobile. None of them need the permission,
// so they are covered there on an app that has not been granted it.
describePlugin('notification', () => {
itDesktop('permission is granted on desktop', async () => {
@@ -30,29 +40,102 @@ describePlugin('notification', () => {
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'
itOn(
['android', 'ios'],
'action types register, and cancel/remove leave nothing pending or active',
async () => {
const result = await tauri(async (api) => {
await api.notification.registerActionTypes([
{
id: 'e2e-actions',
actions: [
{ id: 'reply', title: 'Reply', input: true },
{ id: 'dismiss', title: 'Dismiss', destructive: true }
]
}
])
await api.notification.cancel([424242])
await api.notification.cancelAll()
await api.notification.removeActive([{ id: 424242 }])
await api.notification.removeAllActive()
return {
pending: await api.notification.pending(),
active: await api.notification.active()
}
})
return true
expect(result).toEqual({ pending: [], active: [] })
}
)
itOn('android', 'channels can be created, listed and removed', async () => {
const result = await tauri(async (api) => {
const { Importance, Visibility } = api.notification
await api.notification.createChannel({
id: 'e2e-channel',
name: 'e2e channel',
description: 'created by the e2e suite',
importance: Importance.High,
visibility: Visibility.Public,
vibration: true
})
const created = (await api.notification.channels()).find(
(channel) => channel.id === 'e2e-channel'
)
await api.notification.removeChannel('e2e-channel')
const removed = (await api.notification.channels()).some(
(channel) => channel.id === 'e2e-channel'
)
return {
created: created && {
name: created.name,
description: created.description,
importance: created.importance,
vibration: created.vibration
},
removed
}
})
expect(result).toEqual({
created: {
name: 'e2e channel',
description: 'created by the e2e suite',
importance: 4, // Importance.High
vibration: true
},
removed: false
})
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'
}
itOn('ios', 'channels are not implemented', async () => {
for (const call of ['create', 'remove', 'list'] as const) {
const error = await tauriError(
(api, call) =>
call === 'create'
? api.notification.createChannel({ id: 'e2e', name: 'e2e' })
: call === 'remove'
? api.notification.removeChannel('e2e')
: api.notification.channels(),
call
)
return typeof notification === 'object'
})
expect(result).toBe(true)
expect(error).toMatch(/not implemented/)
}
})
itOn(
['android', 'ios'],
'onNotificationReceived and onAction listeners register and unregister',
async () => {
const result = await tauri(async (api) => {
const received = await api.notification.onNotificationReceived(() => {})
const action = await api.notification.onAction(() => {})
await received.unregister()
await action.unregister()
return { received: received.event, action: action.event }
})
expect(result).toEqual({
received: 'notification',
action: 'actionPerformed'
})
}
)
})
+1 -1
View File
@@ -41,6 +41,6 @@ describePlugin('opener', () => {
await api.path.join(await api.path.appDataDir(), 'does-not-exist-e2e')
)
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/os error 2/)
})
})
-11
View File
@@ -86,11 +86,6 @@ describePlugin('os', () => {
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
@@ -106,12 +101,6 @@ describePlugin('os', () => {
}
})
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.
+26 -7
View File
@@ -31,6 +31,13 @@ const commonSurface: Surface<CommonPluginApi> = {
'readImage',
'writeImage'
],
deepLink: [
'getCurrent',
'register',
'unregister',
'isRegistered',
'onOpenUrl'
],
dialog: ['open', 'save', 'message', 'ask', 'confirm'],
fs: [
'BaseDirectory',
@@ -98,14 +105,26 @@ const commonSurface: Surface<CommonPluginApi> = {
],
process: ['exit', 'relaunch'],
shell: ['Command', 'Child', 'EventEmitter', 'open'],
// the `Database` class itself
sql: ['load', 'get'],
store: ['load', 'getStore', 'LazyStore', 'Store'],
upload: ['download', 'upload', 'HttpMethod']
stronghold: ['Location', 'Client', 'Store', 'Vault', 'Stronghold'],
upload: ['download', 'upload', 'HttpMethod'],
// the `WebSocket` class itself
websocket: ['connect']
}
/** Plugins the example only registers on desktop. */
const desktopSurface: Surface<DesktopPluginApi> = {
autostart: ['enable', 'disable', 'isEnabled'],
cli: ['getMatches'],
globalShortcut: ['register', 'unregister', 'unregisterAll', 'isRegistered'],
positioner: [
'Position',
'moveWindow',
'moveWindowConstrained',
'handleIconState'
],
updater: ['check', 'Update'],
windowState: [
'StateFlags',
@@ -168,12 +187,12 @@ 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'
),
plugins.filter((plugin) => {
// eslint-disable-next-line security/detect-object-injection
const global = (api as unknown as Record<string, unknown>)[plugin]
// a module namespace, or the class a default-export-only module (sql, websocket) defines
return typeof global !== 'object' && typeof global !== 'function'
}),
Object.keys(surface)
)
expect(missing).toEqual([])
@@ -0,0 +1,137 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { expect } from '@wdio/globals'
import { tauri, tauriError, describePlugin, itWm } from '../helpers/index.js'
// Positions are computed from the window's current monitor and outer size, so
// the specs compute the expected physical position the same way and compare
// once the window manager has applied the move. The tray positions need the
// tray icon's rect, which normally comes from a click on the icon; the spec
// hands one in through `handleIconState`, as a JS tray event handler would.
// The moved window is not put back: each spec file gets a fresh app.
interface Geometry {
monitor: { x: number; y: number; width: number; height: number }
window: { width: number; height: number }
}
/** Moves the main window and reports where it ended up along with the geometry the position is derived from. */
function moveAndMeasure(position: string, constrained = false) {
return tauri(
async (api, position, constrained) => {
const win = api.window.getCurrentWindow()
const { Position } = api.positioner
const value = Position[position as keyof typeof Position]
await (constrained
? api.positioner.moveWindowConstrained(value)
: api.positioner.moveWindow(value))
await new Promise((resolve) => setTimeout(resolve, 500))
const monitor = await api.window.currentMonitor()
const size = await win.outerSize()
const outer = await win.outerPosition()
return {
monitor: {
x: monitor!.position.x,
y: monitor!.position.y,
width: monitor!.size.width,
height: monitor!.size.height
},
window: { width: size.width, height: size.height },
position: { x: outer.x, y: outer.y }
}
},
position,
constrained
)
}
function center({ monitor, window }: Geometry) {
return {
x: monitor.x + Math.trunc(monitor.width / 2) - Math.trunc(window.width / 2),
y:
monitor.y + Math.trunc(monitor.height / 2) - Math.trunc(window.height / 2)
}
}
describePlugin('positioner', { desktopOnly: true }, () => {
// must run before any tray rect is handed in
it('tray positions are rejected until the tray icon reported its rect', async () => {
const error = await tauriError((api) =>
api.positioner.moveWindow(api.positioner.Position.TrayCenter)
)
expect(error).toMatch(/Tray position not set/)
})
it('the Position enum matches the plugin', async () => {
const names = await tauri((api) =>
Object.keys(api.positioner.Position).filter((key) => isNaN(Number(key)))
)
expect(names).toEqual([
'TopLeft',
'TopRight',
'BottomLeft',
'BottomRight',
'TopCenter',
'BottomCenter',
'LeftCenter',
'RightCenter',
'Center',
'TrayLeft',
'TrayBottomLeft',
'TrayRight',
'TrayBottomRight',
'TrayCenter',
'TrayBottomCenter'
])
})
itWm('moveWindow centers the window on its monitor', async () => {
const result = await moveAndMeasure('Center')
expect(result.position).toEqual(center(result))
})
itWm(
'moveWindowConstrained behaves like moveWindow for screen positions',
async () => {
const result = await moveAndMeasure('Center', true)
expect(result.position).toEqual(center(result))
}
)
itWm('tray positions are relative to the reported tray rect', async () => {
// a tray "icon" in the middle of the window's monitor, far from any edge,
// so neither the OS-specific flips nor the constraint kick in
const tray = await tauri(async (api) => {
const monitor = await api.window.currentMonitor()
const rect = {
x: monitor!.position.x + Math.trunc(monitor!.size.width / 2),
y: monitor!.position.y + Math.trunc(monitor!.size.height / 2),
width: 20,
height: 20
}
await api.positioner.handleIconState({
type: 'Click',
id: 'e2e',
position: new api.dpi.PhysicalPosition(rect.x, rect.y),
rect: {
position: new api.dpi.PhysicalPosition(rect.x, rect.y),
size: new api.dpi.PhysicalSize(rect.width, rect.height)
},
button: 'Left',
buttonState: 'Down'
})
return rect
})
const bottomLeft = await moveAndMeasure('TrayBottomLeft')
expect(bottomLeft.position).toEqual({ x: tray.x, y: tray.y })
const bottomCenter = await moveAndMeasure('TrayBottomCenter', true)
expect(bottomCenter.position).toEqual({
x: tray.x + tray.width / 2 - Math.trunc(bottomCenter.window.width / 2),
y: tray.y
})
})
})
+1 -1
View File
@@ -233,6 +233,6 @@ describePlugin('shell', () => {
const message = await tauriError((api) =>
api.shell.open('ftp://example.com')
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/failed regex validation/)
})
})
+168
View File
@@ -0,0 +1,168 @@
// 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'
// SQLite databases live in the app config directory. The spec works on its
// own database, recreating its table before each test, and only reads the
// example's `sqlite:api.db` to check the migration the app registers for it.
const db = 'sqlite:e2e-sql.db'
interface Row {
id: number
name: string
score: number | null
ratio: number | null
}
describePlugin('sql', () => {
beforeEach(async () => {
await tauri(async (api, db) => {
const database = await api.sql.load(db)
await database.execute('DROP TABLE IF EXISTS e2e')
await database.execute(
'CREATE TABLE e2e (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score INTEGER, ratio REAL)'
)
}, db)
})
it('load resolves with a database for the given path', async () => {
const path = await tauri(
async (api, db) => (await api.sql.load(db)).path,
db
)
expect(path).toBe(db)
})
it('migrations registered by the app run when their database is loaded', async () => {
const result = await tauri(async (api) => {
const database = await api.sql.load('sqlite:api.db')
return {
tables: await database.select<{ name: string }[]>(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'todos'"
),
migrations: await database.select<
{ version: number; description: string; success: number }[]
>('SELECT version, description, success FROM _sqlx_migrations')
}
})
expect(result.tables).toEqual([{ name: 'todos' }])
// `success` is declared BOOLEAN but stored, and read back, as an integer
expect(result.migrations).toEqual([
{ version: 1, description: 'create_todos_table', success: 1 }
])
})
it('execute reports affected rows and the last insert id', async () => {
const results = await tauri(async (api, db) => {
const database = api.sql.get(db)
const first = await database.execute(
'INSERT INTO e2e (name, score) VALUES ($1, $2)',
['first', 1]
)
const second = await database.execute(
'INSERT INTO e2e (name, score) VALUES ($1, $2)',
['second', 2]
)
const update = await database.execute('UPDATE e2e SET score = score + 10')
return { first, second, update }
}, db)
expect(results.first).toEqual({ rowsAffected: 1, lastInsertId: 1 })
expect(results.second).toEqual({ rowsAffected: 1, lastInsertId: 2 })
expect(results.update.rowsAffected).toBe(2)
})
it('select returns rows as objects, with bound values and column types', async () => {
const rows = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute(
'INSERT INTO e2e (name, score, ratio) VALUES ($1, $2, $3), ($4, $5, $6)',
['alpha', 42, 0.5, 'beta', null, null]
)
return {
all: await database.select<Row[]>('SELECT * FROM e2e ORDER BY id'),
filtered: await database.select<Row[]>(
'SELECT name FROM e2e WHERE score = $1',
[42]
),
none: await database.select<Row[]>(
'SELECT * FROM e2e WHERE name = $1',
['missing']
)
}
}, db)
expect(rows.all).toEqual([
{ id: 1, name: 'alpha', score: 42, ratio: 0.5 },
{ id: 2, name: 'beta', score: null, ratio: null }
])
expect(rows.filtered).toEqual([{ name: 'alpha' }])
expect(rows.none).toEqual([])
})
it('column order follows the query', async () => {
const columns = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute("INSERT INTO e2e (name, score) VALUES ('x', 1)")
const [row] = await database.select<Record<string, unknown>[]>(
'SELECT score, name, id FROM e2e'
)
return Object.keys(row)
}, db)
expect(columns).toEqual(['score', 'name', 'id'])
})
it('invalid statements reject', async () => {
const error = await tauriError(
(api, db) => api.sql.get(db).execute('INSERT INTO missing VALUES (1)'),
db
)
expect(error).toMatch(/no such table: missing/)
const constraint = await tauriError(
(api, db) =>
api.sql.get(db).execute('INSERT INTO e2e (name) VALUES ($1)', [null]),
db
)
expect(constraint).toMatch(/NOT NULL constraint failed/)
})
it('a database that was never loaded is rejected', async () => {
const error = await tauriError((api) =>
api.sql.get('sqlite:never-loaded.db').select('SELECT 1')
)
expect(error).toMatch(/database sqlite:never-loaded\.db not loaded/)
})
it('close shuts the pool down until the database is loaded again', async () => {
const result = await tauri(async (api, db) => {
const database = api.sql.get(db)
await database.execute("INSERT INTO e2e (name) VALUES ('persisted')")
const closed = await database.close(db)
let error = ''
try {
await database.select('SELECT * FROM e2e')
} catch (e) {
error = String(e)
}
const reloaded = await api.sql.load(db)
return {
closed,
error,
rows: await reloaded.select<{ name: string }[]>('SELECT name FROM e2e')
}
}, db)
expect(result.closed).toBe(true)
expect(result.error).toMatch(/closed/i)
// the data was written to disk, not just to the closed pool
expect(result.rows).toEqual([{ name: 'persisted' }])
})
it('closing a database that was never loaded is rejected', async () => {
const error = await tauriError((api) =>
api.sql.get('sqlite:never-loaded.db').close('sqlite:never-loaded.db')
)
expect(error).toMatch(/not loaded/)
})
})
@@ -0,0 +1,248 @@
// 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'
// The example derives the snapshot key from the password with argon2. Each
// test works on its own snapshot file under the spec's scratch directory,
// which the fs plugin cleans up; the snapshot path is resolved inside the page.
const dir = scratchDir('stronghold')
const password = 'e2e-password'
const clientName = 'e2e-client'
/** An arbitrary, valid BIP39 mnemonic, so derived keys are deterministic. */
const mnemonic =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
describePlugin('stronghold', () => {
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('store records round-trip and persist in the snapshot', async () => {
const result = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'store.stronghold'
)
const value = Array.from(new TextEncoder().encode('top secret'))
const stronghold = await api.stronghold.Stronghold.load(path, password)
const client = await stronghold.createClient(clientName)
await client.getStore().insert('key', value)
const sameSession = await client.getStore().get('key')
await stronghold.save()
await stronghold.unload()
const reopened = await api.stronghold.Stronghold.load(path, password)
const store = (await reopened.loadClient(clientName)).getStore()
const persisted = await store.get('key')
const missing = await store.get('missing')
await reopened.unload()
return {
sameSession: sameSession && Array.from(sameSession),
persisted: persisted && new TextDecoder().decode(persisted),
missing,
fileExists: await api.fs.exists(path)
}
},
dir,
password,
clientName
)
expect(result.sameSession).toEqual(
Array.from(new TextEncoder().encode('top secret'))
)
expect(result.persisted).toBe('top secret')
expect(result.missing).toBeNull()
expect(result.fileExists).toBe(true)
})
it('store.remove returns the removed value', async () => {
const result = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'remove.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
const store = (await stronghold.createClient(clientName)).getStore()
await store.insert('key', [1, 2, 3])
const removed = await store.remove('key')
const after = await store.get('key')
await stronghold.unload()
return { removed: removed && Array.from(removed), after }
},
dir,
password,
clientName
)
expect(result).toEqual({ removed: [1, 2, 3], after: null })
})
it('a snapshot cannot be opened with the wrong password', async () => {
const path = await tauri(
async (api, dir, password, clientName) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'password.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
await stronghold.createClient(clientName)
await stronghold.unload()
return path
},
dir,
password,
clientName
)
const error = await tauriError(
(api, path) => api.stronghold.Stronghold.load(path, 'wrong-password'),
path
)
expect(error).toMatch(/failed to decode\/decrypt/)
})
it('loading a client that was never created is rejected', async () => {
const error = await tauriError(
async (api, dir, password) => {
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'no-client.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
try {
await stronghold.loadClient('never-created')
} finally {
await stronghold.unload()
}
},
dir,
password
)
expect(error).toMatch(/error loading client data/)
})
it('vault procedures derive keys and sign without exposing secrets', async () => {
const result = await tauri(
async (api, dir, password, clientName, mnemonic) => {
const { Location } = api.stronghold
const path = await api.path.join(
await api.path.appDataDir(),
dir,
'vault.stronghold'
)
const stronghold = await api.stronghold.Stronghold.load(path, password)
const vault = (await stronghold.createClient(clientName)).getVault(
'vault'
)
// the same mnemonic recovered twice derives the same key
const seedA = Location.generic('vault', 'seed-a')
const seedB = Location.generic('vault', 'seed-b')
await vault.recoverBIP39(mnemonic, seedA)
await vault.recoverBIP39(mnemonic, seedB)
// Ed25519 SLIP-10 only derives hardened indices (the high bit set).
// (No named helper: the transpiler would wrap it in a `__name` call
// that does not exist in the page.)
const chain = [44, 4218, 0, 0, 0].map((i) => (i | 0x80000000) >>> 0)
const otherChain = [44, 4218, 0, 0, 1].map(
(i) => (i | 0x80000000) >>> 0
)
const keyA = Location.generic('vault', 'key-a')
const keyB = Location.generic('vault', 'key-b')
const keyOther = Location.generic('vault', 'key-other')
await vault.deriveSLIP10(chain, 'Seed', seedA, keyA)
await vault.deriveSLIP10(chain, 'Seed', seedB, keyB)
await vault.deriveSLIP10(otherChain, 'Seed', seedA, keyOther)
const publicA = Array.from(await vault.getEd25519PublicKey(keyA))
const publicB = Array.from(await vault.getEd25519PublicKey(keyB))
const publicOther = Array.from(
await vault.getEd25519PublicKey(keyOther)
)
const signature = Array.from(await vault.signEd25519(keyA, 'message'))
const signatureAgain = Array.from(
await vault.signEd25519(keyA, 'message')
)
// a random seed and a raw secret can be stored and removed
const random = Location.generic('vault', 'random')
await vault.generateSLIP10Seed(random)
await vault.insert('raw', [9, 9, 9])
await vault.remove(Location.generic('vault', 'raw'))
// a generated mnemonic recovers the seed generateBIP39 stored
const generatedSeed = Location.generic('vault', 'generated')
const mnemonicBytes = await vault.generateBIP39(generatedSeed)
const recoveredSeed = Location.generic('vault', 'recovered')
await vault.recoverBIP39(
new TextDecoder().decode(mnemonicBytes),
recoveredSeed
)
const generatedKey = Location.generic('vault', 'generated-key')
const recoveredKey = Location.generic('vault', 'recovered-key')
await vault.deriveSLIP10(chain, 'Seed', generatedSeed, generatedKey)
await vault.deriveSLIP10(chain, 'Seed', recoveredSeed, recoveredKey)
const generatedPublic = Array.from(
await vault.getEd25519PublicKey(generatedKey)
)
const recoveredPublic = Array.from(
await vault.getEd25519PublicKey(recoveredKey)
)
await stronghold.unload()
return {
publicA,
publicB,
publicOther,
signature,
signatureAgain,
generatedPublic,
recoveredPublic
}
},
dir,
password,
clientName,
mnemonic
)
expect(result.publicA).toHaveLength(32)
expect(result.publicB).toEqual(result.publicA)
expect(result.publicOther).not.toEqual(result.publicA)
// Ed25519 signatures are 64 bytes and deterministic
expect(result.signature).toHaveLength(64)
expect(result.signatureAgain).toEqual(result.signature)
expect(result.recoveredPublic).toEqual(result.generatedPublic)
expect(result.generatedPublic).not.toEqual(result.publicA)
})
})
+1 -1
View File
@@ -156,6 +156,6 @@ describePlugin('upload', () => {
`${FIXTURE_SERVER_URL}/echo`,
`${dir}/does-not-exist.txt`
)
expect(message.length).toBeGreaterThan(0)
expect(message).toMatch(/os error 2/)
})
})
@@ -0,0 +1,185 @@
// 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'
import {
WEBSOCKET_FIXTURE_URL,
WEBSOCKET_CLOSE_REQUEST,
WEBSOCKET_CLOSE_CODE,
WEBSOCKET_CLOSE_REASON
} from '../helpers/server.js'
// Every test talks to the fixture server's `/ws` echo endpoint (through
// `adb reverse` on Android). Messages reach the page through the plugin's
// channel, so each page function collects them until it has what it expects,
// with its own timeout so a missing message fails with a readable error.
interface Message {
type: string
data: unknown
}
describePlugin('websocket', () => {
it('echoes text and binary messages', async () => {
const messages = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
const received: Message[] = []
await new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error('echo not received')), 5000)
ws.addListener((message) => {
received.push(message)
if (received.length === 3) resolve()
})
ws.send('hello')
.then(() => ws.send([1, 2, 3]))
.then(() => ws.send({ type: 'Text', data: 'explicit' }))
.catch(reject)
})
await ws.disconnect()
return received
}, WEBSOCKET_FIXTURE_URL)
expect(messages).toEqual([
{ type: 'Text', data: 'hello' },
{ type: 'Binary', data: [1, 2, 3] },
{ type: 'Text', data: 'explicit' }
])
})
it('a ping is answered with a pong carrying its payload', async () => {
const message = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
const pong = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('pong not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Pong') resolve(message)
})
ws.send({ type: 'Ping', data: [7, 8, 9] }).catch(reject)
})
await ws.disconnect()
return pong
}, WEBSOCKET_FIXTURE_URL)
expect(message).toEqual({ type: 'Pong', data: [7, 8, 9] })
})
it('sends the configured headers with the upgrade request', async () => {
const headers = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url, {
headers: { 'x-e2e-header': 'from the plugin' }
})
const message = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('headers not received')), 5000)
ws.addListener(resolve)
})
await ws.disconnect()
return JSON.parse(message.data as string) as Record<string, string>
}, `${WEBSOCKET_FIXTURE_URL}/headers`)
expect(headers['x-e2e-header']).toBe('from the plugin')
})
it('a listener stops receiving once removed', async () => {
const counts = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
let removed = 0
let kept = 0
const remove = ws.addListener(() => removed++)
await new Promise<void>((resolve, reject) => {
setTimeout(() => reject(new Error('echoes not received')), 5000)
ws.addListener(() => {
kept++
if (kept === 1) {
remove()
ws.send('second').catch(reject)
} else {
resolve()
}
})
ws.send('first').catch(reject)
})
await ws.disconnect()
return { removed, kept }
}, WEBSOCKET_FIXTURE_URL)
expect(counts).toEqual({ removed: 1, kept: 2 })
})
it('a close from the server is delivered and ends the connection', async () => {
const result = await tauri(
async (api, url, closeRequest) => {
const ws = await api.websocket.connect(url)
const close = await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('close not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Close') resolve(message)
})
ws.send(closeRequest).catch(reject)
})
let sendError = ''
try {
await ws.send('after close')
} catch (error) {
sendError = String(error)
}
return { close, sendError }
},
WEBSOCKET_FIXTURE_URL,
WEBSOCKET_CLOSE_REQUEST
)
expect(result.close).toEqual({
type: 'Close',
data: { code: WEBSOCKET_CLOSE_CODE, reason: WEBSOCKET_CLOSE_REASON }
})
expect(result.sendError).toMatch(/connection not found/)
})
it('disconnect sends a normal close frame', async () => {
const close = await tauri(async (api, url) => {
const ws = await api.websocket.connect(url)
// the server acknowledges the close with the same frame
return await new Promise<Message>((resolve, reject) => {
setTimeout(() => reject(new Error('close not received')), 5000)
ws.addListener((message) => {
if (message.type === 'Close') resolve(message)
})
ws.disconnect().catch(reject)
})
}, WEBSOCKET_FIXTURE_URL)
expect(close).toEqual({
type: 'Close',
data: { code: 1000, reason: 'Disconnected by client' }
})
})
it('connecting to a closed port is rejected', async () => {
// port 1 (tcpmux) is never listening on the loopback interface
const error = await tauriError((api) =>
api.websocket.connect('ws://127.0.0.1:1/')
)
expect(error).toMatch(/refused|connect/i)
})
it('invalid URLs and header names are rejected', async () => {
const url = await tauriError((api) => api.websocket.connect('not a url'))
expect(url).toMatch(/invalid uri/i)
const header = await tauriError(
(api, url) =>
api.websocket.connect(url, { headers: [['bad header', 'value']] }),
WEBSOCKET_FIXTURE_URL
)
// Chromium (Android) already rejects the name in the `Headers` constructor;
// WebKit passes it through and the plugin rejects it
expect(header).toMatch(/invalid (header )?name/i)
})
it('sending an unsupported message type throws', async () => {
const error = await tauriError(async (api, url) => {
const ws = await api.websocket.connect(url)
try {
await ws.send(42 as unknown as string)
} finally {
await ws.disconnect()
}
}, WEBSOCKET_FIXTURE_URL)
expect(error).toMatch(/invalid `message` type/)
})
})