mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
* 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]
307 lines
9.8 KiB
TypeScript
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)
|