/** * @file * * Appium transport — evaluates expressions inside Obsidian Mobile via WebView * JavaScript injection. Manages vault lifecycle via localStorage and file push. * * Configured via `environmentOptions.obsidianTransport` in vitest config: * * ```typescript * // vitest.config.ts * environmentOptions: { * obsidianTransport: { * type: 'obsidian-android-appium', * appiumUrl: 'http://localhost:4723', * avdName: 'obsidian_test', * }, * } * ``` * * For BrowserStack, set `appiumUrl` to the BrowserStack hub URL * — the transport itself is hub-agnostic. * * ## How vault registration works on mobile * * Obsidian Mobile stores its vault registry in the WebView's `localStorage`: * * - `mobile-external-vaults` — JSON array of registered vault paths * - `mobile-selected-vault` — the currently active vault path * - `enable-plugin-` — `"true"` to enable the plugin system for the vault * * To register a vault programmatically (without UI interaction): * * 1. Push vault files to the device (e.g. `/sdcard/Documents//.obsidian/app.json`) * 2. Switch to `WEBVIEW_md.obsidian` context * 3. Set the localStorage entries * 4. Call `location.reload()` — Obsidian re-reads localStorage and opens the vault * * This avoids the onboarding flow entirely. */ import type { Browser } from 'webdriverio'; import type { CaptureScreenshotParams } from './capture-screenshot.cjs'; import type { ConsoleCaptureHandle, ObsidianTransport, TransportEvalOptions } from './transport.cjs'; /** * Session connection info returned by {@link AppiumTransport.getSessionInfo}, * used to reattach from another process. */ export interface AppiumSessionInfo { /** The device UDID (e.g. `'emulator-5554'`). */ deviceId: string; /** The Appium/WebDriver session ID. */ sessionId: string; } /** * Configuration for the Appium transport. */ export interface AppiumTransportConfig { /** * App package (Android) or bundle ID (iOS). * Defaults to `'md.obsidian'`. */ appId?: string; /** * Timeout in milliseconds for Obsidian Mobile to get as far as `globalThis.app` * existing after the vault is (re)opened, before the tighter * {@link layoutReadyTimeoutInMilliseconds} clock starts. * * @default `180000` */ appStartTimeoutInMilliseconds?: number; /** * The Appium browser/driver instance. * Created by the consumer via e.g. WebDriverIO's `remote()`. */ browser: Browser; /** * The device UDID (e.g. `'emulator-5554'`). * Used for `adb` commands when pushing files to the device. */ deviceId: string; /** * Whether this transport owns the Appium session and should delete it on * {@link AppiumTransport.dispose}. * * `true` for sessions created via `remote()` (global setup). * `false` for sessions reattached via `attach()` (test workers). * * @default `true` */ isSessionOwner?: boolean; /** * Timeout in milliseconds for waiting for `app.workspace.layoutReady`, counted * from the moment {@link appStartTimeoutInMilliseconds} is satisfied — not from * the reload, so a cold app start is not charged against it. * * @default `90000` */ layoutReadyTimeoutInMilliseconds?: number; /** * Target platform. Determines WebView context naming and device file paths. */ platform: 'android' | 'ios'; /** * The per-script cap, in milliseconds, this session was given as its W3C * `timeouts.script` capability. * * Carried only so a script timeout can be reported with the number that * produced it; the capability itself is set when the session is created. * * @default `30000` */ scriptTimeoutInMilliseconds?: number; /** * Whether registering a vault also prunes the **other** `temp-vault-*` * registrations earlier runs left in Obsidian Mobile's `localStorage`. * * The device-side directories are swept before the app launches (see the * factory); this removes their now-dangling registry entries, which are what * Obsidian actually enumerates at startup. Unregistering this run's own vault * is unaffected — that always happens. * * @default `true` */ shouldSweepLeftovers?: boolean; /** * Base path on the device where Obsidian stores vaults. * * Defaults: * - Android: `/sdcard/Documents/` * - iOS: `@md.obsidian:documents/` */ vaultBasePath?: string; /** * Timeout in milliseconds for waiting for the WebView context to become available. * * @default `60000` */ webviewTimeoutInMilliseconds?: number; } /** * Base path on an Android device where Obsidian Mobile stores its vaults. * * Exported because the transport factory sweeps leftover vaults from it before * a transport exists (see the repo's L31). */ export declare const DEFAULT_ANDROID_VAULT_BASE_PATH = "/sdcard/Documents/"; /** * Transport that communicates with Obsidian Mobile via Appium WebView JS injection. * * Evaluates expressions by switching to the `WEBVIEW_md.obsidian` context and * calling `execute()`. Manages vaults by writing to the WebView's `localStorage` * (which Obsidian uses as its vault registry on mobile) and pushing files to the device. */ export declare class AppiumTransport implements ObsidianTransport { /** * Indicates whether this transport is for a mobile platform. Always `true` for this transport. */ readonly isMobile = true; private readonly appId; private readonly appStartTimeoutInMilliseconds; private readonly browser; private readonly deviceId; /** * The `process.on('exit')` handler that tears the channel down when nothing else will. */ private disposeInputChannelOnExit; /** * The host half of the trusted-input channel: a CDP connection to the WebView, independent of the * Appium session, opened lazily on first evaluate and reused (see **L39**). */ private inputChannel; /** * Set once the trusted-input channel is known not to be openable here (iOS, a remote hub, no local * `adb`), so the attempt is not repeated on every evaluate. */ private isInputChannelUnavailable; /** * Tracks whether the driver is currently switched to the WebView context. * * Set to `true` after a successful `switchContext(WEBVIEW_md.obsidian)`. * Reset to `false` when the context is known to be invalidated * (e.g. after `location.reload()` in {@link registerVault}). * * When `true`, {@link ensureWebViewContext} skips the expensive * `getContexts()` call (which runs `adb shell cat /proc/net/unix` and * can time out on slow emulators). */ private isInWebViewContext; /** * Whether the previous eval ended in a cap overrun, so the next one is owed the recovery grace. * * The abandoned closure is still running in the guest, and Appium serializes commands per session, so * the next command queues behind whatever is left of it. See {@link AppiumTransport.evaluateWithinCap}. */ private isRecoveringFromCapOverrun; private readonly isSessionOwner; private readonly layoutReadyTimeoutInMilliseconds; private readonly platform; private readonly scriptTimeoutInMilliseconds; private readonly shouldSweepLeftovers; private readonly vaultBasePath; private readonly webviewTimeoutInMilliseconds; /** * Creates a new Appium transport. * * @param config - Appium transport configuration. */ constructor(config: AppiumTransportConfig); /** * Begins a console capture by stamping the device log with a unique marker. * * Uses `adb shell log` to write a marker into `logcat` at capture start so a * later {@link AppiumTransport.readConsoleCaptureSince} can slice out * everything the WebView logged afterwards — without the invasive `logcat -c` * buffer clear. * * @returns A handle carrying the unique marker. */ beginConsoleCapture(): Promise; /** * Captures a PNG screenshot of the device's screen. * * The image is always the device's native framebuffer — there is no mobile * equivalent of the desktop viewport override, so * {@link CaptureScreenshotParams.widthInPixels} / * {@link CaptureScreenshotParams.heightInPixels} are ignored. Size a mobile * capture by choosing an AVD whose screen geometry already matches what the * image has to be. * * @param _params - Capture parameters. Ignored on mobile: `cwd` does not select a window (vault targeting is via localStorage), and the size cannot be overridden. * @returns The raw PNG bytes. * @throws Error if the driver returns data that is not a PNG. */ captureScreenshot(_params: CaptureScreenshotParams): Promise; /** * Ends the Appium session. * * Only deletes the session if this transport owns it. Transports created * via `attach()` (test workers reusing the global setup's session) skip * deletion so the session remains available for the owning process. */ dispose(): Promise; /** * Synchronous teardown for `process.on('exit')` handlers. * * Only the trusted-input channel needs it: the Appium session is the owning process's to end, and an * abrupt exit is exactly when the async {@link AppiumTransport.dispose} never runs — which would strand * the adb port forward on the device. */ disposeSync(): void; /** * Evaluates a JavaScript expression inside Obsidian Mobile's WebView. * * Switches to the `WEBVIEW_md.obsidian` context, executes the expression, * and returns the result string. * * @param expression - The JavaScript expression to evaluate. * @param _options - Evaluation options (cwd is not used on mobile — vault targeting is via localStorage). * @returns The normalized result string. */ evaluate(expression: string, _options: TransportEvalOptions): Promise; /** * Returns the session connection info needed to reattach to this session * from another process (e.g. a test worker). * * @returns The session ID and device ID. */ getSessionInfo(): AppiumSessionInfo; /** * Verifies that the Obsidian app is running and the WebView is available. * * @param _vaultPath - Not used on mobile. */ preflightCheck(_vaultPath: string): Promise; /** * Pushes vault files to the device via compressed `adb push`. * * Creates a tar.gz archive of the local vault directory, pushes it to the * device as a single file, and extracts it in-place. This avoids the * webdriver `RangeError` on large base64 payloads and is significantly * faster than per-file `browser.pushFile()` calls. * * @param vaultPath - The vault path (used as the vault directory name on device). * @param _files - Map of relative file paths to content buffers (unused — adb pushes the directory directly). */ pushFiles(vaultPath: string, _files: Record): Promise; /** * Dumps `adb logcat` and returns the WebView console/error output logged * since the marker from {@link AppiumTransport.beginConsoleCapture}. * * Filters to the Chromium/WebView tags that carry JS `console.*` output and * uncaught errors, and caps the result length. Bounded, post-hoc, * failure-path-only — never a live monitor. * * @param handle - The capture handle (or `undefined` when capture never started). * @returns The captured console/error text, or `undefined` if nothing relevant was logged. */ readConsoleCaptureSince(handle: ConsoleCaptureHandle | undefined): Promise; /** * Registers a vault on mobile by pushing files and configuring localStorage. * * The registration flow: * 1. Push a minimal `.obsidian/app.json` to the device so Obsidian recognizes the vault * 2. Switch to the WebView context * 3. Add the vault to localStorage (`mobile-external-vaults`, `mobile-selected-vault`, * `enable-plugin-`) * 4. Trigger `location.reload()` so Obsidian re-reads localStorage and opens the vault * 5. Wait for `globalThis.app` to exist (the app's cold start), then — on a * separate, tighter budget — for `app.workspace.layoutReady` * * Existing vault registrations in localStorage are preserved (append, not * overwrite) — except the **harness's own** `temp-vault-*` registrations from * earlier runs, which are pruned when {@link AppiumTransportConfig.shouldSweepLeftovers} * is on. Their directories are already gone (the factory sweeps them before * the app launches), and it is this registry — not the filesystem — that * Obsidian enumerates at startup, so leaving them is what makes each failed * run slow the next one down. * * @param vaultPath - The absolute path to the vault on the host machine. */ registerVault(vaultPath: string): Promise; /** * Unregisters a vault on mobile by removing it from localStorage. * * Preserves other vault registrations. If the unregistered vault was selected, * switches to the first remaining vault (or clears the selection). * * The vault's **files are then removed from the device over `adb`, whether or * not the `localStorage` step succeeded** — deliberately, because the * `localStorage` step goes through the WebView and a dead WebView is exactly * what most Android failures are. Routing the removal through the app was what * made every failed run leak its vault (`Vault cleanup error (non-fatal): no * such window`), so the filesystem-level removal must not depend on it. * * @param vaultPath - The absolute path to the vault on the host machine. */ unregisterVault(vaultPath: string): Promise; /** * Closes the trusted-input channel, if one is open. */ private disposeInputChannel; /** * Opens the trusted-input channel if it is not already open, and re-opens it if the WebView went away. * * Installs a `Runtime.addBinding` function on the page and services every call to it for the life of the * connection. The binding is exposed on *every* execution context of the page, so it survives the * `location.reload()` that {@link AppiumTransport.registerVault} performs; only a full app restart, which * destroys the WebView target, needs the reconnect below. */ private ensureInputChannel; /** * Switches the driver to the `WEBVIEW_md.obsidian` context. * * If the context was already verified (cached via {@link isInWebViewContext}), * returns immediately without calling `getContexts()` — which runs * `adb shell cat /proc/net/unix` and can time out on slow emulators. * * Polls until the context becomes available (the app may still be loading). * Uses the `WEBVIEW_md.obsidian` context specifically to avoid connecting * to Chrome or other WebViews on the device. */ private ensureWebViewContext; /** * Runs one Execute Script under the per-eval cap, raising {@link EvalCapExceededError} if it outlasts it. * * The cap covers the script ALONE — `ensureWebViewContext` and `ensureInputChannel` run before it, * because a `switchContext` legitimately costs ~17s (**L19**) and charging that to a test's own budget * would report the harness's setup as the test's overrun. * * The abandoned request is deliberately left in flight rather than cancelled. Nothing can cancel it: * `Runtime.terminateExecution` over the transport's own CDP channel was measured releasing nothing, * and a guest-side abort hook could not even be installed in time. What the same runs did show is that * the session usually keeps serving — a vault read-back was answered 528ms after one such abandonment, * with the session free — so bounding the wait costs a test its closure, not the rest of its suite. * * @param script - The script to run, already wrapped in its `return (...)`. * @returns The script's result. */ private evaluateWithinCap; /** * Converts a host-side vault path to the device-side path. * * @param vaultPath - Absolute path on the host machine. * @returns The device-side vault path. */ private getDeviceVaultPath; /** * Services one input request from the renderer: inject, then resolve the renderer's promise. * * Every failure path still resolves that promise — with an error message the renderer re-throws — because * a request that is silently dropped would hang the closure until the whole run times out, and report * nothing about why. * * @param connection - The channel the request arrived on. * @param payload - The raw JSON payload the binding was called with. */ private handleInputRequest; /** * Probes the WebView once for how far Obsidian's startup has got. * * A probe that throws is reported as `undefined` rather than propagated: the * page is mid-`location.reload()` for most of the app-start phase, and a * WebView that cannot answer yet is the *expected* reading there, not an error. * * @returns What the probe saw, or `undefined` when it could not run. */ private probeStartup; /** * Pushes the minimal `.obsidian/app.json` vault marker via `adb push`. * * `browser.pushFile` (WebDriver base64) is an order of magnitude slower on a * cold or loaded emulator — measured at 9–21s per call for this 2-byte marker, * versus sub-second over `adb`. This mirrors {@link pushFiles}, which switched * to `adb` for the same reason. `mkdir -p` guarantees the parent directory. * * @param deviceVaultPath - The device-side vault directory path. */ private pushObsidianMarker; /** * Removes a vault directory from the device over `adb`, and CHECKS that it is * actually gone. * * Best-effort: a failure is logged, not thrown, so a teardown problem never * masks the test result. The next run's start-of-run sweep picks up whatever * this could not remove. * * The check is the point. `rm -rf` runs with `shouldIgnoreExitCode`, so an * unremoved directory used to leave no trace at all — which is how runs that * passed end to end were still found to be leaking a vault apiece, invisibly, * until someone counted the directories on the device by hand. A leak is now * named in the log at the moment it happens. * * @param deviceVaultPath - The device-side vault directory path. */ private removeDeviceVaultDirectory; /** * Drops the `process.on('exit')` handler that would otherwise close an already-closed channel, and would * stack a listener per reconnect. */ private unregisterInputChannelExitHandler; /** * Polls until `globalThis.app` exists in the WebView — the app has finished * its cold start and Obsidian's own boot has begun. * * This is the **first** of the two budgets spent after `location.reload()`, * and the generous one. Everything it pays for is outside Obsidian's control: * the WebView reloading, and a guest that may still be optimizing packages. * Keeping it separate is the point of the split — the old single wall clock * started here and left the tight layout budget paying for a cold start. */ private waitForAppStarted; /** * Polls until `app.workspace.layoutReady` is `true` in the WebView. * * The **second** budget, and the tight one: by the time it starts, the app is * already up, so all it covers is Obsidian opening the vault and loading * plugins — ~1s in practice, ≤8.4s under heavy host stress (**L19**). */ private waitForLayoutReady; /** * Polls the WebView until a startup phase's milestone is satisfied, tracking * the evidence that tells a slow Obsidian from a contended guest. * * Each poll costs exactly one `browser.execute` round-trip — deliberately not * an {@link ensureWebViewContext} call, whose `getContexts()` runs * `adb shell cat /proc/net/unix` and was measured at ~17s per **L19**. The * round-trip is timed, and the slowest one is reported on timeout: a phase * that ran out after a handful of probes each taking tens of seconds was * starved by the guest, not by Obsidian. * * @param params - The phase, its budget, and the predicate that ends it. */ private waitForStartupMilestone; }