Obsidian-integration-testing: run your plugin's tests in a real Obsidian, on any version, desktop or Android

Hi all,

Over the last few months I kept seeing the same three questions in plugin-dev chats:

  1. How do I test my plugin across several Obsidian versions?
  2. How do I let a coding agent see and drive the vault UI, instead of pasting screenshots back to it?
  3. 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 obsidianVersion to any public or catalyst build and the harness downloads it. runObsidianVersionMatrix runs 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, hoverElement and dragMouse go through Chromium’s own input pipeline, so the page sees isTrusted: true, as it does for a user. Examples below.
  • Agent-friendly. npx obsidian-integration-testing --vault ./demo-vault keeps an isolated instance open and prints its CDP port; an agent’s scripts attach with connectToCdp({ 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.

4 Likes

Thank you! That’s a great library. I tried it in my plugin, and it works well. This is exactly what I wanted. I gave it a star on GitHub :upside_down_face:

1 Like

The version matrix and the Android leg are the two things my own Playwright setup can’t do, so I’m going to try this for those.

One question about typing. I read typeIntoEditor, and it sends pressKey for each character. For my plugin (Snipsy, a text expander) the hardest bug so far came from IME composition: Gboard on Android wraps each word in compositionstart/compositionend, and fcitx/ibus on Linux Wayland can send every character as its own composition. Tests that press keys pass, and expansion still fails for those users.

In my harness I drive that path over CDP:

await cdp.send("Input.imeSetComposition", { text: "t", selectionStart: 1, selectionEnd: 1 });
await cdp.send("Input.insertText", { text: "t" }); // commits, fires compositionend

Is a helper like composeIntoEditor in scope for the library? And on Android, does trusted input go through the WebView debugger too, so Gboard’s composition path is never exercised?

1 Like

The helper composeIntoEditor is certainly in the scope of the library. But better to move such discussions to the project’s GitHub.

Seems useful, I will give it a try. Thanks!

1 Like