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