Simulating user input
The lib bag every callback receives provides helpers that inject trusted input at the Chromium level —
the kind of event only the browser or the OS normally produces. On desktop they go through Electron’s
webContents.sendInputEvent; on Android, through the WebView’s own debugger. See
On mobile for what changes there.
Why trusted input matters
Section titled “Why trusted input matters”The in-page alternatives quietly give false results:
dispatchEvent(new KeyboardEvent(...))andnew MouseEvent(...)are untrusted (isTrusted: false), so CodeMirror ignores the keystroke and:hovernever takes effect. Obsidian’s own listeners gate one.isTrustedtoo, so a dispatched click does not merely look different — it does nothing at all, while the test still passes whatever weaker assertion it makes.execCommand('insertText')mutates the selection even when the editor is not focused, which turns a real focus bug into a passing test.
The trusted helpers flow through the real input pipeline, so text lands only if the editor genuinely
holds focus, and :hover rules genuinely apply. The element and editor arguments are live renderer DOM
nodes — the callback already runs in the renderer, so nothing is serialized.
The helpers
Section titled “The helpers”| Helper | Purpose |
|---|---|
typeIntoEditor({ editor, text }) | Focuses editor (caret to end), types text as trusted key events, then polls until the document reflects it. |
pressKey({ key, modifiers }) | Presses key with optional modifiers as a trusted keyDown→char→keyUp on the DOM-focused element (fires keydown/keypress/beforeinput/input/keyup). Does not poll — pair with waitUntil. |
hoverElement({ element }) | Moves the pointer to element’s center, then polls until element.matches(':hover'). |
unhoverElement({ element }) | Moves the pointer just outside element’s bounding box, then polls until it no longer matches :hover. |
clickElement({ element, button, modifiers }) | Clicks element’s center with optional button / modifiers, so Chromium synthesizes a real click (or contextmenu). Does not poll — pair with waitUntil. |
moveMouse({ x, y }) | Low-level primitive: injects one trusted pointer move at the given web-contents DIP coordinates. Does not poll. Desktop only. |
clickMouse({ x, y, button, modifiers }) | Low-level primitive: one trusted mouseMove → mouseDown → mouseUp at the given web-contents DIP coordinates. Does not poll. |
// Type into the active editor — only succeeds if the editor truly holds focus.const typed = await evalInObsidian({ callback: async ({ app, lib: { typeIntoEditor }, obsidianModule }) => { const view = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView); const editor = view?.editor; if (!editor) { return null; }
await typeIntoEditor({ editor, text: 'Hello, world!' }); return editor.getValue(); }});Key presses use Obsidian’s Modifier names, where 'Mod' is Cmd on macOS and Ctrl everywhere else. A key
press has no universal effect, so pair it with waitUntil to await the outcome you expect:
await evalInObsidian({ callback: async ({ app, lib: { pressKey, waitUntil }, obsidianModule }) => { const editor = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView)?.editor; editor?.focus();
pressKey({ key: 'Enter', modifiers: ['Shift'] }); // synchronous; soft line break await waitUntil({ predicate: () => (editor?.getValue().includes('\n') ?? false) }); }});Hovering gives you the genuine hovered appearance — real theme var() values, real compositing:
await evalInObsidian({ callback: async ({ lib: { hoverElement, unhoverElement } }) => { const bar = document.querySelector<HTMLElement>('.minimized-modal-bar'); if (!bar) { return; }
await hoverElement({ element: bar }); // ...assert the hovered appearance... await unhoverElement({ element: bar }); }});Clicking splits the same way hovering does. clickElement({ element }) is the element-relative one — it
clicks the element’s center. clickMouse({ x, y }) is the coordinate primitive underneath it, for a
point that is no element’s center: the markdown editor’s margin, for instance, lies inside
cm.scrollDOM but outside .cm-sizer, so nothing you can query has a center on it. Both accept
button ('left', 'middle', 'right') and modifiers in Obsidian’s Modifier names — the same
mapping pressKey uses, so 'Mod' cannot mean two different things — and both are synchronous, so pair
them with waitUntil to await the outcome:
await evalInObsidian({ callback: async ({ app, lib: { clickElement, clickMouse, waitUntil }, obsidianModule }) => { const view = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView); const action = view?.containerEl.querySelector<HTMLElement>('.view-action'); const sizer = view?.editor.cm.scrollDOM.querySelector<HTMLElement>('.cm-sizer'); if (!view || !action || !sizer) { return; }
clickElement({ element: action }); // synchronous; a real trusted left click await waitUntil({ predicate: () => Boolean(document.querySelector('.menu')) }); document.querySelector('.menu')?.remove();
// The editor margin: inside `cm.scrollDOM`, outside `.cm-sizer` — reachable only by coordinates. const scrollRect = view.editor.cm.scrollDOM.getBoundingClientRect(); const sizerRect = sizer.getBoundingClientRect(); clickMouse({ button: 'right', x: (scrollRect.left + sizerRect.left) / 2, y: sizerRect.top + 20 // just below the top edge of the text }); await waitUntil({ message: 'the margin context menu did not open', predicate: () => Boolean(document.querySelector('.menu')) }); }});A right click opens a real context menu, and it stays open. A suite that drives one must close it —
menu.hide() from your own handler, or removing the leftover .menu element — before the next test, or
the menu still on screen swallows the click that test makes.
Wait for an async condition
Section titled “Wait for an async condition”waitUntil({ predicate }) polls until an asynchronous effect settles — a view opens, a DOM node appears,
a setting applies. Because a callback is serialized with toString() and cannot import modules, it
cannot reuse a poll helper from a library; waitUntil is the injected replacement for the loop you would
otherwise hand-roll in every closure.
The predicate may be synchronous or asynchronous (it is awaited on each poll). It is checked
immediately, then re-checked every intervalInMilliseconds until it returns truthy or
timeoutInMilliseconds elapses, at which point the returned promise rejects (the error includes
message when given).
| Option | Purpose | Default |
|---|---|---|
predicate | Condition to poll; sync or async, awaited each check. | — |
intervalInMilliseconds | Delay between polls. | 50 |
timeoutInMilliseconds | Max time to wait before rejecting. | 5000 |
message | Detail appended to the timeout error message. | — |
const value = await evalInObsidian({ callback: async ({ app, lib: { waitUntil }, obsidianModule }) => { await waitUntil({ message: 'no active Markdown view', predicate: () => Boolean(app.workspace.getActiveViewOfType(obsidianModule.MarkdownView)) }); return app.workspace.getActiveViewOfType(obsidianModule.MarkdownView)?.editor.getValue() ?? null; }});For waits longer than one eval may take, use
pollInObsidian
instead.
On mobile
Section titled “On mobile”These helpers work on Android as well, but not all of them, and not identically. Every one of them returns
a promise — await it. A missing await used to be harmless on desktop; on mobile the injection is a
round-trip to the test host, so without it your assertion runs before the input has landed.
| Helper | On Android |
|---|---|
clickElement / clickMouse (default or 'left') | A tap. |
clickMouse({ button: 'right' }) | A long-press — the gesture that opens Obsidian Mobile’s context menu. |
clickMouse({ button: 'middle' }) | Throws. Touch has no middle button. |
pressKey / typeIntoEditor | Work. Named keys (Enter, Escape, Tab, Backspace, Delete, arrows) and single printable characters; any other multi-character name throws. |
moveMouse / hoverElement / unhoverElement | Throw. Touch input has no hover state. |
The three that throw do so deliberately rather than doing nothing quietly: a hover that silently no-ops
would leave a test asserting against a state that never existed, which is precisely the false-confidence
problem trusted input exists to solve. Branch on Platform.isDesktopApp when a suite runs on both.
Coordinates on mobile are CSS pixels in the WebView’s own viewport — the same numbers
getBoundingClientRect() reports — so there is no device-pixel-ratio conversion to do.
A long-press on mobile is a real platform gesture, and it must land inside the viewport: aimed outside
it, the injection fails with Position out of bounds rather than pressing nothing quietly. So being laid
out is not enough — an element in a sidebar that is still sliding open already has its full width at a
negative left, and its centre point is off-screen. Wait for the element’s centre to be inside the
viewport before pressing it:
await lib.waitUntil({ predicate: () => { const rect = element.getBoundingClientRect(); return rect.left + rect.width / 2 >= 0 && rect.top + rect.height / 2 >= 0; }});await lib.clickElement({ button: 'right', element });Related
Section titled “Related”- The
libbag — where these helpers come from, and how to add your own. LibAPI reference