Hi all,
Over the last few months I kept seeing the same three questions in plugin-dev chats:
- How do I test my plugin across several Obsidian versions?
- How do I let a coding agent see and drive the vault UI, instead of pasting screenshots back to it?
- How do I test keyboard input, context menus and drags for real, not through
dispatchEvent?
I maintain a library that answers all three, and I suspect few people know it exists, so here it is: obsidian-integration-testing (docs).
What it does:
- Your tests run inside a real Obsidian: the real
App, vault and workspace. The harness launches its own isolated instance in a temporary--user-data-dir, so your own Obsidian, its config and its vault list are never touched. - Any version. Pin
obsidianVersionto any public or catalyst build and the harness downloads it.runObsidianVersionMatrixruns your suites on the latest public and the latest catalyst build in one command. See Version matrix. - Desktop and Android. The same test code runs over CDP on desktop and over Appium on an Android emulator.
- Trusted input.
typeIntoEditor,pressKey,clickElement,hoverElementanddragMousego through Chromium’s own input pipeline, so the page seesisTrusted: true, as it does for a user. Examples below. - Agent-friendly.
npx obsidian-integration-testing --vault ./demo-vaultkeeps an isolated instance open and prints its CDP port; an agent’s scripts attach withconnectToCdp({ port })to read state, click, type and save screenshots. See Driving Obsidian from an agent. - Vitest and Jest global setups that build a temporary vault, install your built plugin and enable it.
A first test, using only Obsidian’s public API. The callback runs inside Obsidian; obsidianModule is the obsidian module and lib holds the harness helpers:
import { evalInObsidian } from 'obsidian-integration-testing';
import { getTemporaryVault } from 'obsidian-integration-testing/vitest-global-setup-plugin';
it('should type into a new note', async () => {
const text = await evalInObsidian({
async callback({ app, lib, obsidianModule }) {
const file = await app.vault.create('Typed.md', '');
const leaf = app.workspace.getLeaf(true);
await leaf.openFile(file);
if (!(leaf.view instanceof obsidianModule.MarkdownView)) {
throw new Error('Expected a Markdown view');
}
await lib.typeIntoEditor({ editor: leaf.view.editor, text: 'Hello, world' });
return leaf.view.editor.getValue();
},
vaultPath: getTemporaryVault().path
});
expect(text).toBe('Hello, world');
});
Trusted events
An event made with new KeyboardEvent(...) and dispatchEvent arrives with isTrusted: false. CodeMirror ignores such key events, so the text never changes, and some of Obsidian’s own handlers check isTrusted too: the editor’s margin context menu does nothing for a dispatched contextmenu. A test built on synthetic events can pass while the feature is broken, or fail while it works.
OIT’s helpers send real input instead (Electron’s sendInputEvent on desktop, the WebView’s debugger on Android), so the event goes through Chromium’s whole pipeline. This shows the difference on one key:
const flags = await evalInObsidian({
async callback({ app, lib, obsidianModule }) {
const view = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView);
if (!view) {
throw new Error('No active Markdown view');
}
view.editor.focus();
const trustedFlags: boolean[] = [];
view.containerEl.addEventListener('keydown', (evt) => trustedFlags.push(evt.isTrusted), { capture: true });
activeDocument.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'x' }));
await lib.pressKey({ key: 'x' });
await lib.waitUntil({ predicate: () => trustedFlags.length === 2 });
return trustedFlags;
},
vaultPath: getTemporaryVault().path
});
expect(flags).toEqual([false, true]); // only the pressKey one also typed the "x"
A right-click is a trusted mousedown/mouseup pair, so Chromium raises a real contextmenu, and a menu your plugin opens from it can be asserted:
const result = await evalInObsidian({
async callback({ lib, obsidianModule }) {
const target = activeDocument.body.createDiv({ text: 'Right-click me' });
target.setCssStyles({ height: '40px', left: '100px', position: 'fixed', top: '100px', width: '160px' });
let isTrusted: boolean | undefined;
target.addEventListener('contextmenu', (evt) => {
isTrusted = evt.isTrusted;
new obsidianModule.Menu().addItem((item) => item.setTitle('Do the thing')).showAtMouseEvent(evt);
});
await lib.clickElement({ button: 'right', element: target });
await lib.waitUntil({ predicate: () => activeDocument.querySelector('.menu') !== null });
const title = activeDocument.querySelector('.menu .menu-item-title')?.textContent;
await lib.pressKey({ key: 'Escape' });
target.remove();
return { isTrusted, title };
},
vaultPath: getTemporaryVault().path
});
expect(result).toEqual({ isTrusted: true, title: 'Do the thing' });
dragMouse({ from, to }) does the same for drags: a trusted press, a series of trusted moves with the button held (buttons === 1), then the release. That is what Obsidian’s own drag handling, such as the canvas, waits for before it treats the pointer as dragging.
On Android, clickElement becomes a tap and button: 'right' a long-press. Hover and drag have no touch equivalent there, so those helpers throw instead of silently doing nothing.
I use it for the integration tests of my own plugins, so it is actively maintained. Questions, bug reports and feature requests are welcome on the issue tracker.