Skip to content

ObsidianAndroidAppiumTransportOptions

Transport options for Android testing via Appium WebView injection.

Import:

import type { ObsidianAndroidAppiumTransportOptions } from 'obsidian-integration-testing';

Signature:

export interface ObsidianAndroidAppiumTransportOptions

Properties

PropertyTypeDescription
appId?stringApp package (Android) or bundle ID (iOS). Defaults to 'md.obsidian'
appiumStartTimeoutInMilliseconds?numberTimeout in milliseconds for the auto-started Appium server to become ready (its /status endpoint to respond).

Only applies when the harness auto-starts the Appium server (shouldAutoStartAppium); ignored when attaching to an already-running server. On a cold machine the npx appium server can take a while to finish booting, so raise this if startup times out.
appiumUrlstringThe Appium server URL (e.g. 'http://localhost:4723').
appStartTimeoutInMilliseconds?numberTimeout in milliseconds for Obsidian Mobile to get as far as globalThis.app existing after the vault is (re)opened.

The first of the two budgets the transport spends after location.reload(). It covers the app's own cold start — the WebView reloading and Obsidian's bundle coming up — which on a cold or contended guest can take minutes, and is not work layoutReadyTimeoutInMilliseconds should be sized for. Only once globalThis.app appears does that far tighter clock start.

Splitting the two is what stops a first run after a machine restart from failing by design: the old single 90s wall clock started at the reload, so a cold app start spent it before Obsidian had begun laying anything out.
avdNamestringThe Android AVD (Android Virtual Device) name.

The transport factory launches emulator -avd <avdName> as a background process and polls until the device appears. The emulator is killed on transport disposal.

Run emulator -list-avds to see available AVD names.
deviceId?stringThe device UDID to reuse (e.g. 'emulator-5554').

Populated automatically by the global setup after the Appium session is established. When present alongside sessionId, the transport factory skips emulator startup and attaches to the existing session.
deviceIdleTimeoutInMilliseconds?numberTimeout in milliseconds for waiting, after sys.boot_completed, for the emulator to become idle before the Appium session is established.

sys.boot_completed fires *before* the guest is actually idle: package optimization and system services keep churning, so establishing the session immediately makes every one of UiAutomator2's serialized adb round-trips contend with that work and inflates session establishment ~3x. The factory instead waits until the boot animation has stopped and the package manager is serving, proceeding early once idle or after this budget (best-effort — a timeout logs a warning and proceeds). Set 0 to skip the wait.

Applies to a **reused** device as well as a harness-started one. A device that is merely present in adb devices can still be mid-dex2oat, and skipping the gate there is what left a run polling a churning guest until layoutReadyTimeoutInMilliseconds ran out.
isAppiumConsoleVisible?booleanWhether the auto-started Appium server console window is shown.

When false (the default), the npx appium server process is spawned with its console window hidden (windowsHide) and its output discarded, so it neither steals focus nor writes to the invoking terminal. Ignored when attaching to an already-running Appium server (shouldAutoStartAppium false, or the server already reachable). Set true to see the server log window and its live output.
isEmulatorVisible?booleanWhether the auto-started Android emulator window is shown on screen.

When false (the default), the emulator is started with -no-window (headless), so it never steals focus. Ignored when reusing an already-running device (nothing is launched to hide). Set true to watch the emulator UI.
layoutReadyTimeoutInMilliseconds?numberTimeout in milliseconds for waiting for app.workspace.layoutReady, counted from the moment appStartTimeoutInMilliseconds is satisfied — not from the reload.

The second of the two budgets the transport spends after location.reload(). It covers only Obsidian's own work — opening the vault and loading every plugin — once the app itself is up, which is why it can stay tight: measured at ~1s, and ≤8.4s under 12-core + disk + memory stress.

Blowing it therefore means Obsidian genuinely stalled, **or** that each probe round-trip is being inflated by a busy guest. The timeout error names which, by reporting the probe count and slowest round-trip alongside the furthest startup milestone reached: a handful of probes each taking tens of seconds is a contended guest, and the knob for that is deviceIdleTimeoutInMilliseconds, not this one.
leftoverMaxAgeInMilliseconds?numberHow old a leftover **host** directory must be before shouldSweepLeftovers removes it.

Governs the host-side sweep only (the temp-vault-* staging directories this run's machine accumulates in its temp dir). The **device** sweep is unconditional: Android runs hold the exclusive android setup lock, so no concurrent run can own a device vault, and an age gate there would let a vault leaked minutes ago survive into the next run — which is the failure loop the sweep exists to break. Set 0 to remove every host match too.
networkReadyTimeoutInMilliseconds?numberTimeout in milliseconds for waiting, after the device-idle gate, for the emulator to report a **validated default network** before the Appium session is established.

A device can be idle, fully packaged and answering adb while still having no route: the two deviceIdleTimeoutInMilliseconds signals are satisfied well before the default network is created and validated, which lands ~80s into guest uptime. That gap is dangerous rather than merely slow — a test that reaches the network there runs to completion against a silently **empty result**, which no assertion inside the suite can tell apart from a genuinely empty response.

The factory polls dumpsys connectivity until an active default network reports validation, proceeding early once it does or after this budget (best-effort — a timeout logs a warning naming the missing network and proceeds). Set 0 to skip the wait, e.g. for a deliberately offline AVD or a suite that never touches the network.

Applies to a **reused** device as well as a harness-started one, for the same reason the idle gate does.
pluginEnableRetryCount?numberNumber of extra attempts to enable the plugin and verify it loaded, on top of the first attempt.

On a freshly cold-booted emulator the plugin subsystem can still be settling when the harness enables the plugin, so the enable lands in the enabled set but the load races and fails ("<id>" is in the enabled set but not loaded). Device-idle and layoutReady are already awaited, so this is the narrow residual race: the harness retries the enable + load-verification this many times with exponential backoff (see pluginEnableRetryDelayInMilliseconds), forcing a genuine reload each attempt. A captured plugin load error is treated as a deterministic bug and is **not** retried. Set 0 to disable retry (a single attempt).
pluginEnableRetryDelayInMilliseconds?numberBase delay in milliseconds between plugin-enable attempts (see pluginEnableRetryCount).

The delay grows exponentially per retry: the first retry waits this long, the second twice as long, the third four times, and so on — giving a still-settling cold guest progressively more time to become ready.
scriptTimeoutInMilliseconds?numberTimeout in milliseconds for a single script executed inside Obsidian — the per-evalInObsidian cap on this transport.

It is enforced by the transport itself, on the Node side, and is also sent as the W3C timeouts.script capability — but the capability is decoration. Measured on a live emulator: it is accepted and reads back as 30000 in the WebView context, and nothing ever acts on it. Over-cap closures ran past a 60s ceiling without WebDriver raising script timeout once, in both the sleeping and the spinning shape, with and without an explicit setTimeouts.

What happens past roughly half a minute is worse than a late answer: the closure completes in the guest on schedule and its Execute Script response never reaches the client, so the call simply never returns. This timeout is what turns that silence into an EvalCapExceededError naming the closure as the cause.

Raising it is almost never the right answer. A closure that needs to wait longer than this should not be waiting inside Obsidian at all — use pollInObsidian, which keeps each closure short and does the waiting from Node. The knob exists so the cap is explicit and matches ObsidianCdpTransportOptions.commandTimeoutInMilliseconds on desktop.

The default is DEFAULT_EVAL_CAP_IN_MILLISECONDS, exported from the package root — import it rather than restating the number, so a closure sized against the cap follows it if it ever moves.
sessionConnectionRetryTimeoutInMilliseconds?numberTimeout in milliseconds for establishing the Appium session (WebDriverIO remote() — UiAutomator2 server install + app launch).

This is the largest and most load-sensitive step of the Android setup: on a cold or contended emulator it dominates startup and can take a few minutes. Raise it if session establishment times out under load.
sessionId?stringAn existing Appium session ID to reattach to.

Populated automatically by the global setup and provided to test workers via the framework's context mechanism (e.g. Vitest provide/inject). When present, the transport factory uses WebDriverIO's attach() instead of creating a new session, avoiding duplicate Appium/ADB connections.
shouldAutoInstallAppiumDependencies?booleanWhether to automatically install missing Appium dependencies (the uiautomator2 driver, and Appium itself) before auto-starting the server.

When true (the default) and the harness is about to auto-start the Appium server (shouldAutoStartAppium), the factory first ensures Appium is installed (globally, via npm install -g appium) and that the uiautomator2 driver is installed (appium driver install uiautomator2), installing whichever is missing. Ignored when attaching to an already-running server (nothing is auto-started, so nothing is installed) or when shouldAutoStartAppium is false. Set it to false to manage the Appium toolchain yourself and skip the machine-mutating global install.
shouldAutoStartAppium?booleanWhether to automatically start the Appium server if it is not reachable.

When true (the default), the transport factory spawns npx appium as a background process when the preflight check fails, and kills it on transport disposal.
shouldFailOnStaleBuild?booleanWhether a dist/ older than the sources it was built from fails the run.

When true (the default) the global setup compares main.js in the dist folder it is about to install against the newest file under src/ (tests excluded), plus manifest.json and styles.css, and throws StaleBuildError when the build is older. The check runs before the emulator is booted, so a stale build costs a stat rather than a session.

Set it to false for a project that deliberately tests a shipped artifact. Doing so downgrades the failure to a warning — it never goes silent, because a suite that PASSES against a stale build proves nothing and says so to nobody. For a single run, OBSIDIAN_TEST_ALLOW_STALE_BUILD=1 does the same without editing a tracked file, and takes precedence over this option.
shouldReuseEmulatorSnapshot?booleanWhether the auto-started emulator may resume — and refresh — the AVD's saved boot snapshot.

When false (the default) the emulator is started with -no-snapshot-load -no-snapshot-save, so every run cold-boots a guest whose state nothing carried over. That is the hermetic default a test harness owes its callers, and it is also the safe one: a snapshot the harness loads but never writes rots unnoticed, and the failure it eventually produces — a device that serves adb, accepts a session, then drops offline about half a minute later — is indistinguishable from a code regression. Measured on one AVD back to back, a resumed snapshot killed the guest ~90s in *every* time, while the same AVD cold-booted in 50s and stayed up.

Set it to true on a **persistent runner** whose AVD is not shared with anything else: the emulator then both loads and saves default_boot, so the snapshot each run resumes is one a previous run wrote, and the ~112s cold boot is largely bought back. The trade is test isolation — a guest that carries state between runs — so it is opt-in per project, never a default. Ignored when reusing an already-running device (nothing is launched).
shouldSweepLeftovers?booleanWhether the harness removes the temporary directories earlier runs leaked.

When true (the default), each run sweeps at start **and** at end: on the device, every temp-vault-* directory under vaultBasePath (plus the stale vault registrations in Obsidian Mobile's localStorage); on the host, the leftover temp-vault-* and owned userdata-* directories older than leftoverMaxAgeInMilliseconds.

A run that dies cannot clean up after itself — its teardown goes through the WebView, and a dead WebView is what most Android failures are — so the residue accumulates and slows the next run's startup enumeration until it misses the WebView-readiness budget. The start sweep is the half that breaks that loop, because it runs before anything that can die.
type'obsidian-android-appium'Discriminant for the transport type.
vaultBasePath?stringBase path on the device where Obsidian stores vaults.

Defaults: - Android: /sdcard/Documents/ - iOS: @md.obsidian:documents/
webviewTimeoutInMilliseconds?numberTimeout in milliseconds for waiting for the WebView context to become available.

On slow emulators, the ChromeDriver proxy that handles WebView commands may not be ready immediately after the Appium session starts. This timeout controls how long to poll before giving up.

Links to this page: