Writing tests
evalInObsidian is the whole API surface of a test. Everything else — vaults, transports, version pinning
— exists to put a real Obsidian on the other end of it.
How a callback actually runs
Section titled “How a callback actually runs”Your callback does not run in your test process. It is serialized with toString(), shipped to Obsidian,
and invoked there as an IIFE. Three consequences follow, and every one of them shows up as a confusing
failure the first time it bites:
- The callback must be self-contained. A closure over a local variable in your test file is not
serialized, so referencing one throws inside Obsidian. Pass values through
inputinstead. import/requireare not available inside it. Reach the Obsidian API through the injectedobsidianModuleandapp; reach your own helpers through thelibbag.- Arguments and the return value cross a JSON boundary. Strings, numbers, booleans, arrays and plain
objects survive;
TFile,Editor,Map,Set, DOM nodes and class instances do not. Return something serializable — a path, a length, atextContent— and keep the live object in a context.
Pass arguments
Section titled “Pass arguments”input is merged into the object the callback receives:
const result = await evalInObsidian({ input: { pluginId: 'my-plugin' }, callback: ({ app, pluginId }) => app.plugins.enabledPlugins.has(pluginId)});Functions in input are supported — they are serialized with toString() too, under the same
self-contained constraint:
const result = await evalInObsidian({ input: { transform(x: number): number { return x * 2; }, value: 5 }, callback: ({ transform, value }) => transform(value)});// result === 10Keep state between calls
Section titled “Keep state between calls”Obsidian objects live in the Obsidian process and cannot be returned. ContextId gives you a typed store
that persists across calls, so one call can create a TFile and the next can use it:
import type { TFile } from 'obsidian';import { afterEach, beforeEach, expect, it } from 'vitest';import { ContextId, evalInObsidian } from 'obsidian-integration-testing';
interface Context { file: TFile;}
const contextId = new ContextId<Context>();
beforeEach(async () => { await evalInObsidian({ contextId, callback: async ({ app, context }) => { context.file = await app.vault.create('test.md', '# Hello'); } });});
afterEach(async () => { await evalInObsidian({ contextId, callback: async ({ app, context: { file } }) => { await app.vault.delete(file); } }); await contextId.dispose();});
it('should read the file path', async () => { const path = await evalInObsidian({ contextId, callback: ({ context: { file } }) => file.path }); expect(path).toBe('test.md');});ContextId implements AsyncDisposable, so await using disposes it for you.
Wait for something to happen
Section titled “Wait for something to happen”Anything asynchronous inside Obsidian — a view opening, a DOM node appearing, a setting taking effect —
needs polling rather than a fixed sleep. The injected waitUntil is that poll; see
Simulating user input.
Reach internal APIs
Section titled “Reach internal APIs”Because the callback runs inside a real Obsidian, undocumented internals like app.plugins,
app.commands and app.title are all there. They are simply not declared in obsidian.d.ts, so
TypeScript refuses to compile a reference. From best to worst:
-
Install
obsidian-typings(recommended). It declares the full internal API, and everything compiles with no extra work:const title = await evalInObsidian({callback: ({ app }) => app.title}); -
Augment the module yourself, declaring only what you use:
declare module 'obsidian' {interface App {title: string;}} -
as any/@ts-expect-error— works, but switches off the type checking that would have caught a genuine mistake in the same expression:const title = await evalInObsidian({// @ts-expect-error -- accessing internal APIcallback: ({ app }) => app.title});
Wait longer than one eval may take
Section titled “Wait longer than one eval may take”A single closure cannot run longer than CDP’s ~30 s Runtime.evaluate cap, so an operation that takes
minutes — a whole plugin or vault bootstrap — cannot simply be awaited inside one callback.
pollInObsidian moves the waiting to the Node side: an optional start closure kicks the work off once,
then a short poll closure runs repeatedly — each its own well-under-30 s eval — until the Node-side
until predicate accepts a poll result or the timeout (default 120000 ms, polled every 500 ms)
elapses. It replaces the hand-rolled evalInObsidian + sleep loop, and contextId lets start stash
non-serializable state for poll to read.
Related
Section titled “Related”- The
libbag — inject your own helpers into callbacks. evalInObsidianAPI referenceContextIdAPI reference