Files
tauri-plugins-workspace/packages/api-e2e/wdio.conf.ts
T
Lucas Fernandes Nogueira 684feb2510 feat(tests): add api e2e tests (#3617)
* feat(tests): add api e2e tests

* lockfile

* mobile

* try linux fix [skip ci]

* ios fix

* fix test on windows

* improve cache

* fix pnpm audit [skip ci]
2026-09-22 18:58:14 -03:00

307 lines
9.8 KiB
TypeScript

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import path from 'node:path'
import fs from 'node:fs'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { waitTauriDriverReady } from '@crabnebula/tauri-driver'
import {
startFixtureServer,
type FixtureServer
} from './test/helpers/server.js'
const dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(dirname, '..', '..')
const appDir = path.join(repoRoot, 'examples', 'api')
const targetDir = process.env.CARGO_TARGET_DIR ?? path.join(repoRoot, 'target')
// macOS has no native WebDriver for WKWebView, so the CrabNebula Webdriver
// (backed by tauri-plugin-automation + the test-runner-backend) is required there.
// It can be opted into on the other platforms via E2E_CN_WEBDRIVER for parity.
const useCrabNebulaWebdriver =
process.platform === 'darwin' || !!process.env.E2E_CN_WEBDRIVER
// Path passed to the driver as `tauri:options.application`.
const application =
process.env.E2E_APP_PATH
?? (process.platform === 'darwin'
? path.join(targetDir, 'debug', 'bundle', 'macos', 'Tauri API.app')
: process.platform === 'win32'
? path.join(targetDir, 'debug', 'api.exe')
: path.join(targetDir, 'debug', 'api'))
let tauriDriver: ChildProcess | undefined
let killedTauriDriver = false
let testRunnerBackend: ChildProcess | undefined
let killedTestRunnerBackend = false
let fixtureServer: FixtureServer | undefined
export const config: WebdriverIO.Config = {
hostname: '127.0.0.1',
port: 4444,
specs: ['./test/specs/**/*.spec.ts'],
// The driver spawns a single app instance and speaks WebDriver to it, so the
// suite must run serially.
maxInstances: 1,
capabilities: [
{
maxInstances: 1,
// `tauri:options` is understood by tauri-driver, not by the WebdriverIO types.
'tauri:options': { application }
} as unknown as WebdriverIO.Capabilities
],
reporters: ['spec'],
framework: 'mocha',
mochaOpts: {
ui: 'bdd',
timeout: 120000
},
connectionRetryCount: 0,
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
onPrepare: async () => {
// The example (and the specs' type-checking) resolve the plugins' JS
// packages from their `dist-js` build output. Fail early with a clear
// message instead of a confusing vite/tsc error.
if (
!fs.existsSync(
path.join(repoRoot, 'plugins', 'fs', 'dist-js', 'index.js')
)
) {
throw new Error(
'the plugins are not built — run `pnpm build` at the repo root before the e2e suite.'
)
}
if (!process.env.E2E_SKIP_BUILD && !process.env.E2E_APP_PATH) {
// The override config enables the example's off-by-default `automation`
// feature (which registers tauri-plugin-automation) and points the updater
// at the fixture server, for this (debug, test-only) build.
// Passed as an appDir-relative path to sidestep shell quoting.
const overrideConfig = path.relative(
appDir,
path.join(dirname, 'tauri.e2e.conf.json')
)
const buildArgs = [
'tauri',
'build',
'--debug',
'--config',
overrideConfig,
...(process.platform === 'darwin'
? ['--bundles', 'app'] // the .app bundle is needed for tauri:options
: ['--no-bundle'])
]
const build = spawnSync('pnpm', buildArgs, {
cwd: appDir,
stdio: 'inherit',
shell: true
})
if (build.status !== 0) {
throw new Error(
`\`pnpm ${buildArgs.join(' ')}\` failed with status ${build.status}`
)
}
}
// eslint-disable-next-line security/detect-non-literal-fs-filename
if (!fs.existsSync(application)) {
throw new Error(
`app not found at ${application} — build it (unset E2E_SKIP_BUILD) or point E2E_APP_PATH at an existing build.`
)
}
// Serves the updater manifest and the upload/download fixtures the
// network-facing specs hit. Lives in the launcher process so it outlives
// the per-spec worker processes.
fixtureServer = await startFixtureServer()
if (useCrabNebulaWebdriver) {
if (!process.env.CN_API_KEY) {
throw new Error(
'CN_API_KEY is required for the CrabNebula Webdriver (mandatory on macOS, or when E2E_CN_WEBDRIVER=1).'
)
}
testRunnerBackend = spawn('pnpm', ['exec', 'test-runner-backend'], {
cwd: dirname,
stdio: 'inherit',
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm ->
// test-runner-backend) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
testRunnerBackend.on('error', (error) => {
console.error('test-runner-backend error:', error)
process.exit(1)
})
testRunnerBackend.on('exit', (code) => {
if (!killedTestRunnerBackend) {
console.error('test-runner-backend exited with code:', code)
process.exit(1)
}
})
const { waitTestRunnerBackendReady } =
await import('@crabnebula/test-runner-backend')
await waitTestRunnerBackendReady()
process.env.REMOTE_WEBDRIVER_URL = 'http://127.0.0.1:3000'
}
},
// A fresh tauri-driver (and therefore a fresh app instance) per spec file,
// so each plugin's suite runs in isolation.
beforeSession: async () => {
const args = ['exec', 'tauri-driver']
if (process.env.E2E_NATIVE_DRIVER) {
args.push('--native-driver', process.env.E2E_NATIVE_DRIVER)
}
// Reset before each (re)spawn so the `exit` handler below still treats an
// unexpected driver crash as fatal on retried spec files.
killedTauriDriver = false
tauriDriver = spawn('pnpm', args, {
cwd: dirname,
stdio: [null, process.stdout, process.stderr],
shell: true,
// Lead a new process group so the whole tree (sh -> pnpm -> tauri-driver
// -> native webdriver) can be torn down together. See killProcessTree.
detached: process.platform !== 'win32'
})
tauriDriver.on('error', (error) => {
console.error('tauri-driver error:', error)
process.exit(1)
})
tauriDriver.on('exit', (code) => {
if (!killedTauriDriver) {
console.error('tauri-driver exited with code:', code)
process.exit(1)
}
})
await waitTauriDriverReady()
},
// The session is created as soon as the app's window exists, which can be
// before the webview has navigated to the app's page: WebView2 on a cold start
// (the first launches on a fresh Windows runner) still shows `about:blank`
// for a few seconds, so the first spec's scripts ran in a page without
// `window.__TAURI__`. Every spec goes through that global, so block until
// it exists.
before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => {
await browser.waitUntil(
async () => {
try {
const ready: unknown = await browser.executeAsync(
'var done = arguments[arguments.length - 1]; done(typeof window.__TAURI__ !== "undefined");'
)
return ready === true
} catch {
// A command issued mid-navigation can fail on a stale execution
// context; that just means "not ready yet".
return false
}
},
{
timeout: 30_000,
interval: 250,
timeoutMsg:
'window.__TAURI__ never became available — the app did not load its page.'
}
)
},
// Awaited so the driver (and its port) is fully gone before the next spec's
// beforeSession spawns a new one on the same port.
afterSession: async () => {
await closeTauriDriver()
},
onComplete: () => {
closeAll()
}
}
/**
* Kills a shell-spawned child and everything it started. `child.kill()` only
* signals the `sh`/`cmd` wrapper, orphaning the real process (which keeps
* holding the WebDriver port and poisons every later spec), so we take down the
* whole process group/tree instead.
*/
function killProcessTree(
child: ChildProcess | undefined,
signal: NodeJS.Signals = 'SIGTERM'
): void {
const pid = child?.pid
if (!pid || child?.exitCode !== null) return
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
stdio: 'ignore'
})
} else {
try {
// Negative pid targets the whole process group (see `detached` above).
process.kill(-pid, signal)
} catch {
try {
child.kill(signal)
} catch {
// already gone
}
}
}
}
async function closeTauriDriver(): Promise<void> {
killedTauriDriver = true
const driver = tauriDriver
tauriDriver = undefined
if (!driver || driver.exitCode !== null) return
const exited = new Promise<void>((resolve) =>
driver.once('exit', () => resolve())
)
killProcessTree(driver)
// Give it a moment to release the port, then escalate to SIGKILL.
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
exited,
new Promise<void>((resolve) => {
timer = setTimeout(() => {
killProcessTree(driver, 'SIGKILL')
resolve()
}, 5000)
})
])
if (timer) clearTimeout(timer)
}
function closeAll() {
killedTauriDriver = true
killProcessTree(tauriDriver)
tauriDriver = undefined
killedTestRunnerBackend = true
killProcessTree(testRunnerBackend)
testRunnerBackend = undefined
fixtureServer?.close()
fixtureServer = undefined
}
function onShutdown(fn: () => void) {
const cleanup = () => {
try {
fn()
} finally {
process.exit()
}
}
for (const signal of [
'exit',
'SIGINT',
'SIGTERM',
'SIGHUP',
'SIGBREAK'
] as const) {
process.on(signal, cleanup)
}
}
onShutdown(closeAll)