mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
tests(e2e): improve coverage
This commit is contained in:
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user