mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-09-24 21:40:48 +02:00
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Shared WebdriverIO configuration for the mobile suites (`wdio.android.conf.ts`
|
|
// and `wdio.ios.conf.ts`). Instead of tauri-driver, the example app is driven
|
|
// through Appium: the UiAutomator2 driver (Android; chromedriver attaches to the
|
|
// WebView) or the XCUITest driver (iOS simulator; WebKit remote inspector).
|
|
// Debug builds enable webview debugging on both platforms, which is what makes
|
|
// the `WEBVIEW_*` context — and `browser.executeAsync` inside it — available.
|
|
|
|
import path from 'node:path'
|
|
import fs from 'node:fs'
|
|
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
startFixtureServer,
|
|
FIXTURE_SERVER_PORT,
|
|
type FixtureServer
|
|
} from './test/helpers/server.js'
|
|
|
|
export type MobilePlatform = 'android' | 'ios'
|
|
|
|
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const repoRoot = path.resolve(dirname, '..', '..')
|
|
const appDir = path.join(repoRoot, 'examples', 'api')
|
|
const tauriDir = path.join(appDir, 'src-tauri')
|
|
|
|
/** `identifier` in examples/api's tauri.conf.json. */
|
|
const appId = 'com.tauri.api'
|
|
|
|
/** Where Appium looks for drivers; they are devDependencies of this package. */
|
|
process.env.APPIUM_HOME ??= dirname
|
|
|
|
let fixtureServer: FixtureServer | undefined
|
|
|
|
export function mobileConfig(platform: MobilePlatform): WebdriverIO.Config {
|
|
const ios = platform === 'ios' ? iosTarget() : undefined
|
|
|
|
// Path passed to the driver as `appium:app`.
|
|
const application =
|
|
process.env.E2E_APP_PATH
|
|
?? (ios
|
|
? path.join(
|
|
tauriDir,
|
|
'gen',
|
|
'apple',
|
|
'build',
|
|
ios.outputArch,
|
|
'Tauri API.app'
|
|
)
|
|
: path.join(
|
|
tauriDir,
|
|
'gen',
|
|
'android',
|
|
'app',
|
|
'build',
|
|
'outputs',
|
|
'apk',
|
|
'universal',
|
|
'debug',
|
|
'app-universal-debug.apk'
|
|
))
|
|
|
|
return {
|
|
specs: ['./test/specs/**/*.spec.ts'],
|
|
// One device/simulator, one app instance: the suite runs serially.
|
|
maxInstances: 1,
|
|
capabilities: [
|
|
platform === 'android'
|
|
? androidCapabilities(application)
|
|
: iosCapabilities(application)
|
|
],
|
|
services: [
|
|
[
|
|
'appium',
|
|
{
|
|
args: {
|
|
address: '127.0.0.1',
|
|
// Appium 3 requires insecure features to be scoped to a driver.
|
|
// chromedriver_autodownload lets the UiAutomator2 driver fetch a
|
|
// chromedriver matching the device's WebView (see E2E_CHROMEDRIVER).
|
|
allowInsecure: 'uiautomator2:chromedriver_autodownload'
|
|
},
|
|
// First-session setup (chromedriver download, WebDriverAgent build)
|
|
// can be slow, hence the generous timeout.
|
|
appiumStartTimeout: 120_000,
|
|
logPath: path.join(dirname, 'logs')
|
|
}
|
|
]
|
|
],
|
|
reporters: ['spec'],
|
|
framework: 'mocha',
|
|
mochaOpts: {
|
|
ui: 'bdd',
|
|
timeout: 120000
|
|
},
|
|
connectionRetryCount: 0,
|
|
// The first session boots the simulator/emulator, installs the app and, on
|
|
// iOS, compiles WebDriverAgent — well over the 120s default that the
|
|
// request to create the session would otherwise be cut at (the driver
|
|
// timeouts in the capabilities below are what actually bound it).
|
|
connectionRetryTimeout: 600_000,
|
|
specFileRetries: Number(process.env.E2E_SPEC_RETRIES ?? 0),
|
|
// Tells the specs (which run in worker processes) what the app runs on;
|
|
// `process.platform` there is the host. See `platform` in test/helpers.
|
|
runnerEnv: { E2E_PLATFORM: platform },
|
|
|
|
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.
|
|
const pluginsBuilt = fs.existsSync(
|
|
path.join(repoRoot, 'plugins', 'fs', 'dist-js', 'index.js')
|
|
)
|
|
if (!pluginsBuilt) {
|
|
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 Android Studio / Xcode projects are committed in this repository
|
|
// (`examples/api/src-tauri/gen`), so this only runs if they were wiped.
|
|
// The build installs the Rust target it needs itself, so init skips that.
|
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
const projectGenerated = fs.existsSync(
|
|
path.join(tauriDir, 'gen', ios ? 'apple' : 'android')
|
|
)
|
|
if (!projectGenerated) {
|
|
tauriCli([platform, 'init', '--ci', '--skip-targets-install'])
|
|
}
|
|
// `tauri ios build` exports the simulator app with `fs::rename`, which
|
|
// fails with "Directory not empty" when a previous build is still
|
|
// there, so clear it out first.
|
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
if (ios && fs.existsSync(application)) {
|
|
fs.rmSync(application, { recursive: true, force: true })
|
|
}
|
|
// A debug build, so wry turns on webview debugging (Android
|
|
// `setWebContentsDebuggingEnabled`, iOS `isInspectable`), which is what
|
|
// lets Appium reach the page. Only the target that the device/emulator
|
|
// actually runs is compiled.
|
|
//
|
|
// Unlike the desktop suite this passes no `tauri.e2e.conf.json`
|
|
// override: both things it turns on (the automation plugin and the
|
|
// updater endpoint) belong to desktop-only plugins.
|
|
tauriCli(
|
|
ios
|
|
? [
|
|
'ios',
|
|
'build',
|
|
'--debug',
|
|
'--target',
|
|
ios.name,
|
|
// Simulator builds are not code signed (see
|
|
// `additionalWebviewBundleIds` in `iosCapabilities`).
|
|
'--no-sign'
|
|
]
|
|
: [
|
|
'android',
|
|
'build',
|
|
'--debug',
|
|
'--apk',
|
|
'--target',
|
|
androidTarget()
|
|
],
|
|
ios ? {} : androidBuildEnv()
|
|
)
|
|
}
|
|
|
|
// 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 upload/download fixtures the `upload` specs hit. Lives in
|
|
// the launcher process so it outlives the per-spec worker processes.
|
|
// (The updater manifest it also serves is only used on desktop, where
|
|
// the updater plugin is registered.)
|
|
fixtureServer = await startFixtureServer()
|
|
},
|
|
|
|
// The session starts in the native (`NATIVE_APP`) context. Every spec goes
|
|
// through `window.__TAURI__`, so switch to the app's webview as soon as it
|
|
// is attachable and then block until the page has loaded (as the desktop
|
|
// config does).
|
|
before: async (_capabilities, _specs, browser: WebdriverIO.Browser) => {
|
|
if (platform === 'android') {
|
|
// The fixture server listens on the host's loopback, which on a device
|
|
// (or emulator) is the device's own. `adb reverse` forwards the same
|
|
// port from the device back to the host so `FIXTURE_SERVER_URL` works
|
|
// unchanged in the page. The iOS simulator shares the host's network
|
|
// stack, so it needs nothing. Re-run per spec file (it is idempotent)
|
|
// because Appium may only have booted the emulator with the first
|
|
// session.
|
|
adbReverse(FIXTURE_SERVER_PORT)
|
|
}
|
|
|
|
let webview: string | undefined
|
|
await browser.waitUntil(
|
|
async () => {
|
|
const contexts = (await browser.getAppiumContexts())
|
|
// Detailed objects are only returned with `appium:fullContextList`.
|
|
.map((context) =>
|
|
typeof context === 'string' ? context : context.id
|
|
)
|
|
// Android names the context after the package, and lists every
|
|
// debuggable WebView on the device (other apps included), so match
|
|
// ours exactly. iOS names it `WEBVIEW_<pid>.<n>` and only lists the
|
|
// app under test's webviews, so the first one is it.
|
|
webview =
|
|
contexts.find((name) => name === `WEBVIEW_${appId}`)
|
|
?? (platform === 'ios'
|
|
? contexts.find((name) => name.startsWith('WEBVIEW_'))
|
|
: undefined)
|
|
return webview !== undefined
|
|
},
|
|
{
|
|
// Covers a cold app start plus, on Android, the on-demand chromedriver
|
|
// download for the first session.
|
|
timeout: 120_000,
|
|
interval: 1000,
|
|
timeoutMsg:
|
|
'no WEBVIEW context appeared — is the app a debug build (webview debugging enabled)?'
|
|
}
|
|
)
|
|
await browser.switchAppiumContext(webview!)
|
|
// The specs run the page through `executeAsync`, and the XCUITest driver
|
|
// starts with a script timeout of 0 (every async script times out at
|
|
// once) rather than the 30s the other drivers default to.
|
|
await browser.setTimeout({ script: 30_000 })
|
|
|
|
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.'
|
|
}
|
|
)
|
|
},
|
|
|
|
onComplete: () => {
|
|
closeFixtureServer()
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeFixtureServer(): void {
|
|
fixtureServer?.close()
|
|
fixtureServer = undefined
|
|
}
|
|
|
|
for (const signal of [
|
|
'exit',
|
|
'SIGINT',
|
|
'SIGTERM',
|
|
'SIGHUP',
|
|
'SIGBREAK'
|
|
] as const) {
|
|
process.on(signal, () => {
|
|
try {
|
|
closeFixtureServer()
|
|
} finally {
|
|
process.exit()
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Runs `pnpm tauri <args>` in examples/api, failing loudly. */
|
|
function tauriCli(args: string[], env: NodeJS.ProcessEnv = {}): void {
|
|
const result = spawnSync('pnpm', ['tauri', ...args], {
|
|
cwd: appDir,
|
|
stdio: 'inherit',
|
|
shell: true,
|
|
env: { ...process.env, ...env }
|
|
})
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`\`pnpm tauri ${args.join(' ')}\` failed with status ${result.status}`
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- Android -----------------------------------------------------------------
|
|
|
|
/**
|
|
* Environment for the Android build.
|
|
*
|
|
* - No debug info: the suite needs a debug build (webview debugging follows
|
|
* `debug_assertions`), but with the sql and websocket plugins the
|
|
* debug info alone grows the APK past what a default emulator can install
|
|
* ("not enough space").
|
|
*/
|
|
function androidBuildEnv(): NodeJS.ProcessEnv {
|
|
return { CARGO_PROFILE_DEV_DEBUG: '0' }
|
|
}
|
|
|
|
/** `adb` from the Android SDK, else whatever is on `PATH`. */
|
|
function adb(args: string[]): SpawnSyncReturns<string> {
|
|
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
|
|
const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
|
|
return spawnSync(
|
|
binary,
|
|
[
|
|
...(process.env.E2E_ANDROID_DEVICE
|
|
? ['-s', process.env.E2E_ANDROID_DEVICE]
|
|
: []),
|
|
...args
|
|
],
|
|
{ encoding: 'utf8', timeout: 20_000 }
|
|
)
|
|
}
|
|
|
|
function adbReverse(port: number): void {
|
|
const result = adb(['reverse', `tcp:${port}`, `tcp:${port}`])
|
|
if (result.status !== 0) {
|
|
console.warn(
|
|
`\`adb reverse tcp:${port}\` failed — the upload specs will not reach the fixture server.\n${result.stderr ?? ''}`
|
|
)
|
|
}
|
|
}
|
|
|
|
function androidCapabilities(app: string): WebdriverIO.Capabilities {
|
|
return {
|
|
platformName: 'Android',
|
|
'appium:automationName': 'UiAutomator2',
|
|
'appium:app': app,
|
|
'appium:appPackage': appId,
|
|
'appium:appActivity': '.MainActivity',
|
|
// Every build has the same version code, and Appium otherwise skips
|
|
// installing an APK whose version is already on the device, so a rebuilt
|
|
// app would never replace the installed one.
|
|
'appium:enforceAppInstall': true,
|
|
// Which device/emulator to use; Appium picks the first connected one
|
|
// otherwise. E2E_ANDROID_AVD instead boots that AVD.
|
|
...(process.env.E2E_ANDROID_DEVICE
|
|
? { 'appium:udid': process.env.E2E_ANDROID_DEVICE }
|
|
: {}),
|
|
...(process.env.E2E_ANDROID_AVD
|
|
? { 'appium:avd': process.env.E2E_ANDROID_AVD }
|
|
: {}),
|
|
// chromedriver must match the WebView's Chrome version: either a specific
|
|
// binary, or one Appium downloads on demand.
|
|
...(process.env.E2E_CHROMEDRIVER
|
|
? { 'appium:chromedriverExecutable': process.env.E2E_CHROMEDRIVER }
|
|
: { 'appium:chromedriverAutodownload': true }),
|
|
// Grants the runtime permissions the manifest declares (POST_NOTIFICATIONS
|
|
// among them) so the notification specs do not stop on a system dialog.
|
|
'appium:autoGrantPermissions': true,
|
|
// Emulators in CI are slow; give the UiAutomator2 server and adb room.
|
|
'appium:uiautomator2ServerInstallTimeout': 120_000,
|
|
'appium:uiautomator2ServerLaunchTimeout': 120_000,
|
|
'appium:adbExecTimeout': 60_000,
|
|
'appium:newCommandTimeout': 300
|
|
}
|
|
}
|
|
|
|
const androidTargets: Record<string, string> = {
|
|
// `ro.product.cpu.abi` -> `tauri android build --target`
|
|
'arm64-v8a': 'aarch64',
|
|
'armeabi-v7a': 'armv7',
|
|
x86_64: 'x86_64',
|
|
x86: 'i686'
|
|
}
|
|
|
|
/**
|
|
* The Rust target to build the APK for: `E2E_ANDROID_TARGET`, else the ABI of
|
|
* the connected device/emulator (via adb), else the host's (emulators run the
|
|
* host architecture).
|
|
*/
|
|
function androidTarget(): string {
|
|
if (process.env.E2E_ANDROID_TARGET) {
|
|
return process.env.E2E_ANDROID_TARGET
|
|
}
|
|
const abi = adb(['shell', 'getprop', 'ro.product.cpu.abi'])
|
|
const detected =
|
|
abi.status === 0 ? androidTargets[abi.stdout.trim()] : undefined
|
|
return detected ?? (process.arch === 'arm64' ? 'aarch64' : 'x86_64')
|
|
}
|
|
|
|
// --- iOS ---------------------------------------------------------------------
|
|
|
|
function iosCapabilities(app: string): WebdriverIO.Capabilities {
|
|
return {
|
|
platformName: 'iOS',
|
|
'appium:automationName': 'XCUITest',
|
|
'appium:app': app,
|
|
'appium:bundleId': appId,
|
|
// The driver looks the app up in the simulator's Web Inspector listing by
|
|
// bundle identifier. The inspector identifies an app by the
|
|
// `application-identifier` entitlement Xcode embeds when it code signs a
|
|
// simulator build (`__TEXT,__entitlements`); `--no-sign` skips signing
|
|
// altogether (`CODE_SIGNING_ALLOWED=NO`), so the entitlement is missing and
|
|
// the inspector falls back to `process-<executable name>`. Match that too.
|
|
'appium:additionalWebviewBundleIds': [
|
|
`process-${path.basename(app, '.app')}`
|
|
],
|
|
'appium:udid': iosSimulator(),
|
|
// No Simulator.app window. Besides not needing one, the driver otherwise
|
|
// shuts a simulator that is booted without a visible UI down to relaunch it
|
|
// with one, on every session — and that shutdown regularly outlasts the
|
|
// driver's 15s limit on it.
|
|
'appium:isHeadless': true,
|
|
// WebDriverAgent is compiled on the first session, which takes minutes on
|
|
// a CI runner.
|
|
'appium:wdaLaunchTimeout': 240_000,
|
|
'appium:wdaStartupRetries': 3,
|
|
'appium:simulatorStartupTimeout': 240_000,
|
|
'appium:newCommandTimeout': 300
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Rust target to build the simulator app for (`E2E_IOS_TARGET`, else the
|
|
* host's architecture) and the directory the CLI exports it to.
|
|
*/
|
|
function iosTarget(): { name: string; outputArch: string } {
|
|
const name =
|
|
process.env.E2E_IOS_TARGET
|
|
?? (process.arch === 'arm64' ? 'aarch64-sim' : 'x86_64')
|
|
// `tauri ios build` writes to gen/apple/build/<arch>/ (cargo-mobile2's
|
|
// `arch` for the target).
|
|
const outputArch = name === 'aarch64-sim' ? 'arm64-sim' : name
|
|
return { name, outputArch }
|
|
}
|
|
|
|
interface SimctlDevice {
|
|
name: string
|
|
udid: string
|
|
state: string
|
|
isAvailable: boolean
|
|
deviceTypeIdentifier?: string
|
|
}
|
|
|
|
/**
|
|
* UDID of the simulator to run on: `E2E_IOS_DEVICE` (a UDID or device name),
|
|
* else an already-booted iPhone, else the iPhone on the newest installed
|
|
* runtime. Resolved through `simctl` so nothing has to be hardcoded per Xcode
|
|
* version.
|
|
*/
|
|
function iosSimulator(): string {
|
|
const requested = process.env.E2E_IOS_DEVICE
|
|
if (requested && /^[0-9A-F-]{36}$/i.test(requested)) {
|
|
return requested
|
|
}
|
|
const list = spawnSync(
|
|
'xcrun',
|
|
['simctl', 'list', 'devices', 'available', '--json'],
|
|
{ encoding: 'utf8' }
|
|
)
|
|
if (list.status !== 0) {
|
|
throw new Error(
|
|
`\`xcrun simctl list\` failed — is Xcode installed?\n${list.stderr}`
|
|
)
|
|
}
|
|
const { devices: runtimes } = JSON.parse(list.stdout) as {
|
|
devices: Record<string, SimctlDevice[]>
|
|
}
|
|
const iphones = Object.entries(runtimes)
|
|
// e.g. `com.apple.CoreSimulator.SimRuntime.iOS-18-2`
|
|
.filter(([runtime]) => runtime.includes('.iOS-'))
|
|
.sort(([a], [b]) => runtimeVersion(b) - runtimeVersion(a))
|
|
.flatMap(([, devices]) =>
|
|
devices.filter(
|
|
(device) =>
|
|
device.isAvailable
|
|
&& (device.deviceTypeIdentifier ?? device.name).includes('iPhone')
|
|
)
|
|
)
|
|
const device = requested
|
|
? iphones.find((candidate) => candidate.name === requested)
|
|
: (iphones.find((candidate) => candidate.state === 'Booted') ?? iphones[0])
|
|
if (!device) {
|
|
throw new Error(
|
|
requested
|
|
? `no available iPhone simulator named "${requested}" (E2E_IOS_DEVICE)`
|
|
: 'no available iPhone simulator found — install an iOS runtime in Xcode.'
|
|
)
|
|
}
|
|
return device.udid
|
|
}
|
|
|
|
function runtimeVersion(runtime: string): number {
|
|
const [major = '0', minor = '0'] = (runtime.split('.iOS-')[1] ?? '').split(
|
|
'-'
|
|
)
|
|
return Number(major) * 100 + Number(minor)
|
|
}
|