/** * @file * * Evaluates a function inside a running Obsidian instance via a pluggable transport. */ import type { App, Editor, Modifier, TFile } from 'obsidian'; import type * as obsidian from 'obsidian'; import type { Promisable } from 'type-fest'; import type { ContextArguments, ContextId } from './context-id.cjs'; import type { ObsidianTransport } from './transport.cjs'; /** * Parameters for {@link CommonArguments.clickElement}. */ export interface ClickElementParams { /** * The mouse button to press. * * @default `'left'` */ readonly button?: MouseButton; /** * The element to click. The pointer is moved to its center. This is a live * renderer DOM node — the callback runs in-renderer, so no cross-process * serialization is needed (same as {@link TypeIntoEditorParams.editor}). */ readonly element: HTMLElement; /** * The modifier keys to hold, using Obsidian's {@link Modifier} names. `'Mod'` * resolves per-platform (Cmd on macOS, Ctrl elsewhere). * * @default `[]` */ readonly modifiers?: readonly Modifier[]; } /** * Parameters for {@link CommonArguments.clickMouse}. */ export interface ClickMouseParams { /** * The mouse button to press. * * @default `'left'` */ readonly button?: MouseButton; /** * The modifier keys to hold, using Obsidian's {@link Modifier} names. `'Mod'` * resolves per-platform (Cmd on macOS, Ctrl elsewhere). * * @default `[]` */ readonly modifiers?: readonly Modifier[]; /** * The x coordinate (web-contents DIP) to click at. */ readonly x: number; /** * The y coordinate (web-contents DIP) to click at. */ readonly y: number; } /** * Common arguments automatically provided to every {@link evalInObsidian} callback. */ export interface CommonArguments { /** * The Obsidian {@link App} instance. */ app: App; /** * The shared library bag injected into every callback — see {@link Lib}. * * Never empty: the harness pre-populates a **base** set of renderer-driving * helpers (trusted input — `typeIntoEditor` / `pressKey` / `moveMouse` / * `clickMouse` / `hoverElement` / `unhoverElement` / `clickElement` — plus * `waitUntil`, `createNote` and `openSettingsTab`), and provider packages * (chiefly `obsidian-dev-utils`) `Object.assign` their whole renderer-safe * library on top via {@link registerLibResolver}. A serialized closure reaches * every shared helper through `lib` — `lib.typeIntoEditor({ editor, text })`, * `lib.getFileOrNull({ app, … })` — instead of importing or hand-rolling them. */ lib: Lib; /** * The `obsidian` module, resolved at runtime inside the Obsidian process. */ obsidianModule: typeof obsidian; } /** * Parameters for {@link CommonArguments.createNote}. */ export interface CreateNoteParams { /** * The note's content. This exact string is what the created note is read back * against, so it is the definition of a successful write. */ readonly content: string; /** * The vault-relative path to create, extension included. */ readonly path: string; } /** * Parameters for {@link evalInObsidian}. */ export interface EvalInObsidianParams | undefined = undefined> { /** * The function to evaluate in the Obsidian context. */ readonly callback: (input: CommonArguments & ContextArguments & Input) => Promisable; /** * A {@link ContextId} linking this call to a persistent store on `window` * in the Obsidian process. The callback receives a typed `context` object * that survives across calls sharing the same ID. * * When omitted, `context` is a fresh empty object each call. */ readonly contextId?: TContextId; /** * Additional arguments to pass to the function. Values may include functions — * they are serialized via `toString()`. */ readonly input?: Input; /** * Skips pre-flight checks (vault registration, CLI availability). * Used internally by vault registration functions. * * @internal */ readonly shouldSkipPreflightChecks?: boolean; /** * Override the transport for this call. When omitted, uses the transport * configured via the context provider (set by the framework adapter's global setup). */ readonly transport?: ObsidianTransport; /** * The path to the Obsidian vault. Defaults to `process.cwd()`. */ readonly vaultPath?: string; } /** * A plain object with string keys. */ export type GenericObject = Record; /** * Parameters for {@link CommonArguments.hoverElement}. */ export interface HoverElementParams { /** * The element to hover. The pointer is moved to its center. This is a live * renderer DOM node — the callback runs in-renderer, so no cross-process * serialization is needed (same as {@link TypeIntoEditorParams.editor}). */ readonly element: HTMLElement; } /** * The shared library bag injected into every {@link evalInObsidian} callback as * {@link CommonArguments.lib}. * * Two layers compose into this one bag. The **base** — the harness-provided * renderer-driving helpers declared below (the trusted-input primitives and * {@link Lib.waitUntil}) — is always present. On top, provider packages register * a renderer-side resolver via {@link registerLibResolver} to `Object.assign` * their whole renderer-safe library at runtime, and augment this **augmentable** * interface (the `i18next` `CustomTypeOptions` idiom) via * `declare module 'obsidian-integration-testing'` to type it. Multiple providers * compose: their exports merge at runtime and their augmentations merge in the * type system (`interface Lib extends …`). * * @example * ```ts * declare module 'obsidian-integration-testing' { * interface Lib extends (typeof import('obsidian-dev-utils/__merged')) {} * } * ``` */ export interface Lib { /** * Clicks the center of an element using **trusted** pointer input — Electron's * `sendInputEvent` on desktop, a CDP touch tap in the WebView on mobile. * * The element-relative counterpart of {@link Lib.clickMouse}, mirroring the * {@link Lib.moveMouse} / {@link Lib.hoverElement} split. Use * {@link Lib.clickMouse} directly when the point to click is **not** the * element's center — the markdown editor's margin, for instance, lies inside * `cm.scrollDOM` but outside `.cm-sizer`, so no element's center lands on it. * * **Must be awaited.** On mobile the injection is a round-trip to the host — * the renderer cannot produce a trusted event itself — so a missing `await` * would let the assertion run before the click landed. * * @param params - The element to click, the button to press and any modifiers * to hold. * @returns A {@link Promise} that resolves once the click has been injected. */ clickElement: (this: void, params: ClickElementParams) => Promise; /** * Clicks at the given web-contents coordinates using **trusted** Electron * pointer input, so Chromium synthesizes a real `click` (or `contextmenu`, * for the right button) with `isTrusted === true`. * * This is what `element.dispatchEvent(new MouseEvent('click'))` cannot do: * Obsidian and CodeMirror gate on `isTrusted`, so a dispatched event silently * exercises nothing while the test still passes whatever weaker assertion it * makes. Obsidian 1.13.7's markdown viewport (margin) menu, for example, * opens from a `cm.scrollDOM` `contextmenu` listener guarded by * `e.isTrusted`, which a dispatched event never gets past. * * It is the low-level primitive: a single trusted `mouseMove` → `mouseDown` → * `mouseUp` at one point, with no waiting for any effect (callers poll their * own readiness signal). The leading move is what puts the pointer over the * hit-test target before the button goes down. Prefer * {@link Lib.clickElement} for element-relative clicks. * * A real context menu actually opens, so a suite driving a right click must * close it (or remove the leftover `.menu` element) before the next test. * * **On mobile** the button model does not survive the port: touch has no * buttons, so `'left'` (the default) is a **tap**, `'right'` is the * **long-press** that opens Obsidian Mobile's context menu, and `'middle'` * throws — there is no gesture for it, and inventing one would be worse than * saying so. The coordinates are CSS pixels in the WebView's own viewport, * which is what CDP takes, so `devicePixelRatio` never enters the picture. * * **Must be awaited** — see {@link Lib.clickElement}. * * @param params - The web-contents DIP coordinates to click at, the button to * press and any modifiers to hold. * @returns A {@link Promise} that resolves once the click has been injected. */ clickMouse: (this: void, params: ClickMouseParams) => Promise; /** * Creates a note and does not return until its content is verifiably on * disk, rewriting it if it is not. * * Use this instead of `app.vault.create` in any suite that may run on * Android. The emulator transport loses roughly **0.9 %** of `vault.create` * writes (measured: 7 lost in 800 creates): the file lands **0 bytes** on * disk while Obsidian's in-memory `TFile.stat` reports the full byte count, * and it does not heal on its own. A suite doing ~34 creates per run * therefore has a ~26 % chance of at least one lost write, and whichever test * loses that lottery fails on a `waitUntil` for content that was never * written — which is why it reads as an unrelated per-test flake rather than * one shared cause. * * Verification is by **reading the note back**, never by inspecting * `TFile.stat`: `stat` is exactly the field that lies here. A rewrite through * `vault.modify` with the same content lands correctly (also measured), so a * lost write costs a retry rather than a failure. A note whose content still * does not match after the bounded retries throws, naming the path and both * lengths, so a genuinely broken write fails loudly instead of spinning. * * Harmless everywhere else — on a transport that does not lose writes the * read-back matches first time and nothing is rewritten. * * @param params - The note path and content. * @returns A {@link Promise} resolving to the created file, once its content * is confirmed. */ createNote: (this: void, params: CreateNoteParams) => Promise; /** * Moves the mouse pointer to the center of an element using **trusted** * Electron pointer input, then polls until the element actually matches * `:hover`. * * Because the move is trusted (see {@link Lib.moveMouse}), the real `:hover` * state takes effect in the CSS engine — real theme `var()` values and real * compositing — instead of a hand-simulated cascade. It polls the live * `element.matches(':hover')` state (not a fixed delay), so it is robust under * shared-instance load. It targets the single shared window's **global** * pointer, so only one element is hovered at a time. * * **Desktop only — it throws on mobile**, deliberately: touch input has no * hover state, so there is nothing faithful to inject. A silent no-op would * leave a test asserting against a hover that never happened, which is the * exact false-confidence failure trusted input exists to prevent. Branch on * `Platform.isDesktopApp`, or drive the element with {@link Lib.clickElement}. * * @param params - The element to hover. * @returns A {@link Promise} that resolves once the element matches `:hover`. * @throws When the element never matches `:hover`. Resolving quietly there * would let a test read an element that is not hovered and pass on whatever * its base style happens to be. */ hoverElement: (this: void, params: HoverElementParams) => Promise; /** * Moves the mouse pointer to the given web-contents coordinates using a * **trusted** Electron pointer move. * * A trusted move (injected via Electron's `webContents.sendInputEvent`) * updates the real pointer state in the CSS engine, so `:hover` rules * genuinely apply — unlike `dispatchEvent(new MouseEvent('mouseover'))`, * which is untrusted and never sets `:hover`. It targets the single shared * window's **global** pointer, so only one element is hovered at a time. * * This is the low-level primitive: it performs a single move and does **not** * wait for any state to settle (callers poll their own readiness signal). * Prefer {@link Lib.hoverElement} / {@link Lib.unhoverElement} for * element-relative moves; use `moveMouse` directly when an element-relative * target does not fit (e.g. an element spanning the full viewport width). * * **Desktop only — it throws on mobile**, for the same reason as * {@link Lib.hoverElement}: there is no touch pointer to move. * * **Must be awaited** — see {@link Lib.clickElement}. * * @param params - The web-contents DIP coordinates to move to. * @returns A {@link Promise} that resolves once the move has been injected. */ moveMouse: (this: void, params: MoveMouseParams) => Promise; /** * Opens Obsidian's settings modal on a given tab, and does not return until * that tab has actually rendered. * * **This exists because `app.setting.open()` on its own does not work from a * test.** `app.setting.containerEl` is built at startup but is never in the * document, and `open()` does not attach it — so the modal builds into a * detached tree, `open()` returns without throwing, and a screenshot taken * afterwards shows the untouched document. That is what made the settings tab * look impossible to capture, and got recorded as such in two plugins. * * The fix is one step, and its **order is load-bearing**: the container is * appended to `document.body` **before** `open()`. Attaching afterwards is too * late — whatever the modal drew on open has already gone into the detached * container, so it ends up on screen showing the wrong thing. That failure * looks like success until the captured frame is examined, which is the trap * this helper exists to remove. * * Re-attaching is idempotent, so a tab closed with `app.setting.close()` can * simply be re-opened. * * @param params - The tab to open and how long to wait for it to render. * @returns A {@link Promise} resolving to the names of the setting rows the * tab rendered — both the proof it rendered and what a caller asserts on. A * tab that legitimately renders no `.setting-item-name` rows (Hotkeys, say) * resolves to an empty array. * @throws Error if no tab carries {@link OpenSettingsTabParams.tabId}, or if * the tab does not render within the timeout. */ openSettingsTab: (this: void, params: OpenSettingsTabParams) => Promise; /** * Presses a single key (optionally with modifiers) using **trusted** Electron * keyboard input, firing the full real key pipeline — * `keydown` → `keypress` → `beforeinput` → `input` → `keyup`. * * This is the key-press analog of {@link Lib.typeIntoEditor}: it injects a * trusted `keyDown` → `char` → `keyUp` sequence via Electron's * `webContents.sendInputEvent`, so it is delivered to the window's DOM-focused * element and flows through the real input pipeline — unlike * `dispatchEvent(new KeyboardEvent(...))`, which is untrusted (`isTrusted: * false`) and ignored by CodeMirror and most key handlers. Use it for special * keys (`'Enter'`, `'Escape'`, `'Tab'`, arrow keys) and modifier combinations * (`Shift+Enter`, `Ctrl+A`) that {@link Lib.typeIntoEditor} (which types * printable text) does not cover. * * This is the low-level primitive: it injects the key press and does **not** * poll for any effect (a key press has no universal observable outcome — * `Enter` edits the document, `Escape` closes a modal, `ArrowDown` moves the * selection). The caller focuses the intended target first, then awaits the * expected effect via {@link Lib.waitUntil}. It targets the single shared * window's **global** focus, so only the DOM-focused element receives the key. * * **On mobile** the same sequence is injected through the WebView's debugger * (`Input.dispatchKeyEvent`), which is equally trusted. Named keys (`'Enter'`, * `'Escape'`, `'Tab'`, `'Backspace'`, `'Delete'`, the arrows) and single * printable characters are supported; any other multi-character name throws * rather than pressing nothing. * * **Must be awaited** — see {@link Lib.clickElement}. * * @param params - The key to press and any modifiers to hold. * @returns A {@link Promise} that resolves once the key press has been * injected. */ pressKey: (this: void, params: PressKeyParams) => Promise; /** * Types text into a CodeMirror {@link Editor} using **trusted** Electron * keyboard input. * * Typing is pressing each character key in turn: this focuses the editor * (caret to end) and presses every code point of `text` via * {@link Lib.pressKey} — the same trusted `keyDown` → `char` → `keyUp` a real * user produces. Each keystroke is delivered to the window's DOM-focused * element and flows through CodeMirror's real input pipeline, so the typed * text reaches the document **only if the editor genuinely holds focus**. * This makes "the user typed into the editor" a faithful end-to-end check, * unlike `dispatchEvent(new KeyboardEvent(...))` (untrusted — ignored by * CodeMirror) or `execCommand('insertText')` (mutates the selection even * when the editor is not focused, masking focus bugs as false positives). * * After injecting the keystrokes it polls until the document reflects the * input, or a bounded timeout elapses (the expected outcome when the editor * is read-only and rejects the input, or when focus was stolen). * * @param params - The editor to type into and the text to type. * @returns A {@link Promise} that resolves once the keystrokes have settled. */ typeIntoEditor: (this: void, params: TypeIntoEditorParams) => Promise; /** * Moves the mouse pointer to a point just outside an element's bounding box * using a **trusted** Electron pointer move, then polls until the element no * longer matches `:hover`. * * The inverse of {@link Lib.hoverElement}. It targets the single shared * window's **global** pointer, so only one element is hovered at a time. When * an element spans the full viewport (no point outside its box is reachable), * use {@link Lib.moveMouse} directly to move the pointer to a known empty * coordinate instead. * * **Desktop only — it throws on mobile**, for the same reason as * {@link Lib.hoverElement}. * * @param params - The element to move the pointer away from. * @returns A {@link Promise} that resolves once the element no longer matches * `:hover`. * @throws When the element keeps matching `:hover`, which is what a * full-viewport element does. */ unhoverElement: (this: void, params: UnhoverElementParams) => Promise; /** * Polls a predicate until it becomes truthy, or rejects once a bounded * timeout elapses. * * Integration-test `evalInObsidian` callbacks routinely need to wait for an * asynchronous effect to settle (a view to open, a DOM node to appear, a * setting to apply). Because the callback is serialized via `toString()` and * cannot import modules, it can't reuse `obsidian-dev-utils`' * `retryWithTimeout` / `runWithTimeout`. This helper is the shared, injected * replacement for the per-closure poll loops consumers would otherwise * hand-roll. * * The `predicate` may be synchronous or asynchronous — it is awaited on every * poll. It is checked immediately, then re-checked every * `intervalInMilliseconds` until it returns truthy or `timeoutInMilliseconds` * elapses, at which point the returned {@link Promise} rejects (the error * includes `message` when provided). * * @param params - The predicate to poll plus optional timeout, interval, and * timeout message. * @returns A {@link Promise} that resolves once the predicate is truthy. */ waitUntil: (this: void, params: WaitUntilParams) => Promise; } /** * The mouse button a trusted click presses, in Electron's `sendInputEvent` * spelling. */ export type MouseButton = 'left' | 'middle' | 'right'; /** * Parameters for {@link CommonArguments.moveMouse}. */ export interface MoveMouseParams { /** * The x coordinate (web-contents DIP) to move the pointer to. */ readonly x: number; /** * The y coordinate (web-contents DIP) to move the pointer to. */ readonly y: number; } /** * Parameters for {@link Lib.openSettingsTab}. */ export interface OpenSettingsTabParams { /** * The id of the settings tab to open — a plugin id for a plugin's own tab, or * a core tab id (`'editor'`, `'hotkeys'`, …). * * Required, because opening the modal without naming a tab renders **nothing** * on a harness-owned instance: the modal restores the last tab the profile * used, and an isolated profile has never opened one, so `activeTab` stays * `null` and no row is drawn. An unknown id is rejected immediately, listing * the ids that do exist. */ readonly tabId: string; /** * The maximum time to wait for the modal to open and for the requested tab to * render its content. * * @default `5000` */ readonly timeoutInMilliseconds?: number; } /** * Parameters for {@link CommonArguments.pressKey}. */ export interface PressKeyParams { /** * The key to press, given as an Electron Accelerator key name — e.g. * `'Enter'`, `'Escape'`, `'Tab'`, `'Backspace'`, `'Delete'`, an arrow key * (`'Up'` / `'Down'` / `'Left'` / `'Right'`), or a printable character * (`'a'`, `'1'`). The produced character (when the key inserts text) is the * literal `key` value; case-correct text belongs to * {@link CommonArguments.typeIntoEditor}, not a key-press primitive. */ readonly key: string; /** * The modifier keys to hold while the key is pressed, using Obsidian's * {@link Modifier} names (the same values as an Obsidian `Hotkey`). `'Mod'` * resolves per-platform (Cmd on macOS, Ctrl elsewhere); each is mapped to * Electron's lowercase `sendInputEvent` modifier name. * * @default `[]` */ readonly modifiers?: readonly Modifier[]; } /** * Parameters for {@link CommonArguments.typeIntoEditor}. */ export interface TypeIntoEditorParams { /** * The editor to type into. It is focused (with the caret moved to the end of * the document) before the keystrokes are injected. */ readonly editor: Editor; /** * The text to type. Each code point is pressed via {@link CommonArguments.pressKey} * (a trusted `keyDown` → `char` → `keyUp`), exactly as a real user typing. */ readonly text: string; } /** * Parameters for {@link CommonArguments.unhoverElement}. */ export interface UnhoverElementParams { /** * The element to move the pointer away from. The pointer is moved to a point * just outside its bounding box. This is a live renderer DOM node — the * callback runs in-renderer, so no cross-process serialization is needed * (same as {@link TypeIntoEditorParams.editor}). */ readonly element: HTMLElement; } /** * Parameters for {@link CommonArguments.waitUntil}. */ export interface WaitUntilParams { /** * The polling interval between predicate checks. * * @default `50` */ readonly intervalInMilliseconds?: number; /** * An optional detail appended to the timeout error message, describing what * was being waited for. */ readonly message?: string; /** * The condition to wait for. Polled immediately, then on every interval until * it returns a truthy value. May be synchronous or asynchronous — it is * awaited on each poll. * * @returns Whether the awaited condition has been met. */ readonly predicate: (this: void) => boolean | Promise; /** * The maximum time to wait before rejecting. * * @default `5000` */ readonly timeoutInMilliseconds?: number; } /** * Evaluates a function inside the running Obsidian instance * via the active transport and returns the parsed result. * * The function receives an input object that includes `app`, `obsidianModule`, * `context`, and any additional `input` passed by the caller. * It is serialized via `toString()` and invoked as an IIFE. * The function must be self-contained — closures over local variables will not work. * Pass any needed values as `input` — they are JSON-serialized and deserialized on the Obsidian side. * * The result is `JSON.stringify`'d on the Obsidian side and parsed back. * * @param params - The parameters for the function to evaluate. * @returns A {@link Promise} that resolves to the return value of `callback`. */ export declare function evalInObsidian | undefined = undefined>(params: EvalInObsidianParams): Promise;