npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

obsidian-e2e

v0.12.1

Published

Vitest-first end-to-end test utilities for Obsidian plugins.

Readme

obsidian-e2e

Vitest-first end-to-end test utilities for Obsidian plugins.

obsidian-e2e is a thin testing library around a live Obsidian vault and the globally installed obsidian CLI. It stays plugin-agnostic on purpose: you get generic fixtures for Obsidian, vault access, and per-test sandboxes, then opt into plugin-specific behavior through obsidian.plugin(id).

Install

pnpm add -D obsidian-e2e

Requirements:

  • Obsidian must be installed locally.
  • The obsidian CLI must already be available on PATH.
  • Your target vault must be open and reachable from the CLI.

Public Entry Points

  • obsidian-e2e
    • low-level client and shared types
    • resolveObsidianEnvOptions() / verifyVaultPath()
  • obsidian-e2e/vitest
    • createObsidianTest()
    • createPluginTest()
    • createPluginHarness() (suite-scoped)
    • resolveObsidianEnvOptions() / verifyVaultPath()
  • obsidian-e2e/matchers
    • optional expect matchers for vault and sandbox assertions
  • obsidian-e2e/runner
    • programmatic API for the instance runner (see below)
  • obsidian-e2e/capture
    • screenshot/recording primitives over CDP (see "Screenshots And Recordings")
  • obsidian-e2e bin
    • the obsidian-e2e <provision|start|stop|run|android|capture> command

Instance Runner (CLI)

The test helpers above assume Obsidian is already running against a vault the obsidian CLI can reach. The instance runner is the piece that gets you there: it stands up a worktree-isolated Obsidian instance - its own vault, its own private HOME, and its own app process - so parallel git worktrees never collide on one vault and a run never touches another plugin's instance or your day-to-day Obsidian.

Each consumer keeps a small obsidian-e2e.config.mjs at the worktree root and points four npm scripts at the bin. The runner adds zero runtime dependencies (Node stdlib only).

Why worktree isolation

  • Two checkouts of the same plugin (e.g. a feature branch and main) each get a distinct vault name and a distinct /tmp profile, so their instances never fight over one obsidian vault=dev.
  • The profile lives under a private HOME, so the CLI socket, keychain links, and obsidian.json registry are all scoped to that instance.
  • A version guard refuses to run against an Obsidian build below the plugin's minAppVersion, turning a false "missing API" failure into an explicit error.

Install and configure

pnpm add -D obsidian-e2e

Create obsidian-e2e.config.mjs at the worktree root. Only pluginId is required; every other field has the default shown here:

// obsidian-e2e.config.mjs
export default {
  pluginId: "your-plugin", // required; drives the plugin dir, community-plugins, reload id, ready probe
  vaultPrefix: "your-plugin", // default: pluginId. Vault name is `${vaultPrefix}-${worktree basename}`
  pluginArtifacts: ["manifest.json", "main.js"], // symlinked into the vault; add "styles.css" if you ship one
  defaultData: {}, // data.json seed on first provision
  buildCommand: "npm run build", // referenced in the "build first" error text
  defaultCommand: ["eval", "code=app.vault.getName()"], // the `run` subcommand default
  readyProbe: {
    // how the launcher confirms the plugin is live; the default is:
    kind: "eval",
    code: 'Boolean(app.plugins.plugins["your-plugin"])',
    match: "=> true",
  },
  envPrefix: undefined, // e.g. "YOURPLUGIN" to also emit legacy <PREFIX>_E2E_* aliases during migration
  profileRoot: "/tmp/your-plugin-obsidian-e2e", // private profile root
  appName: "Obsidian", // the .app name passed to `open -a`
  obsidianBin: "obsidian", // the obsidian CLI binary
};

Requirements are the same as the test helpers: Obsidian installed locally and the obsidian CLI on PATH.

Subcommands

Wire the bin into the same script names the AGENTS.md playbooks already use:

{
  "scripts": {
    "provision:e2e-vault": "obsidian-e2e provision",
    "start:e2e-obsidian": "obsidian-e2e start",
    "stop:e2e-obsidian": "obsidian-e2e stop",
    "obsidian:e2e": "obsidian-e2e run",
  },
}
  • provision - lay down the worktree-local vault (write .obsidian config, symlink the plugin artifacts, seed data.json). Pure filesystem; never launches Obsidian.
  • start - provision, prepare the private profile, guard the app version, then reuse-and-reload a warm instance or launch a fresh one and verify the plugin.
  • stop - terminate this worktree's instance (SIGTERM, then SIGKILL for stragglers) and remove its profile. Safe to run when nothing is up.
  • run - forward the command after the first non-option token (or after --) to the obsidian CLI. With no command, the config's defaultCommand runs. A warm instance already serving this vault is used as-is (no plugin reload, no ready probe), so plugin and UI state such as an open modal, or a plugin you disabled, survive between separate commands. With no warm instance, run brings one up like start. Pass --reload to redeploy a rebuilt plugin first. run dev:mobile on|off waits until the reloaded renderer reports the new mode, so the next command does not race the reload.
# Provision only, and load the vault env into the current shell:
eval "$(pnpm run provision:e2e-vault -- --print-env)"

# Bring an instance up and export its env for a Vitest run:
eval "$(pnpm run start:e2e-obsidian -- --print-env)"

# Forward a command to the running instance's obsidian CLI:
pnpm run obsidian:e2e -- eval "code=app.vault.getName()"

# Tear the instance down, and also reap any instance whose worktree is gone:
pnpm run stop:e2e-obsidian -- --prune

Shared flags: --vault --root --worktree --data --profile-root --obsidian-app --obsidian-bin --config (value) and --force --json --help (boolean). Per-subcommand extras: provision adds --print-env; start adds --print-env --no-launch --skip-version-guard; stop adds --dry-run --prune; run adds --reload --skip-version-guard and forwards everything after the first non-option token.

Env contract

Under --print-env, stdout carries only export lines (so eval "$(...)" is safe) and every human message goes to stderr. The canonical names are always emitted; the legacy <PREFIX>_E2E_* aliases are emitted only when envPrefix is set, so a harness can migrate off its old names at its own pace.

| Variable | Emitted by | Meaning | | ------------------------------------------------------- | -------------------------- | ------------------------------------------------------ | | OBSIDIAN_E2E_VAULT | provision, start | the isolated vault name (harnesses default to "dev") | | OBSIDIAN_E2E_VAULT_PATH | provision, start | absolute path to the provisioned vault | | OBSIDIAN_E2E_OBSIDIAN_HOME | start | the private HOME for this instance | | OBSIDIAN_BIN | start | only when a non-default --obsidian-bin is set | | <PREFIX>_E2E_VAULT / _VAULT_PATH / _OBSIDIAN_HOME | both, when envPrefix set | legacy aliases during migration |

Android (emulator)

The android family runs the plugin inside the real Obsidian Android app on an emulator - the only way to exercise genuine mobile behavior (touch long-press drags, Platform.isMobile code paths, the mobile settings UI) rather than desktop mobile-emulation. The app is driven over its webview devtools socket (Obsidian mobile ships with webview debugging enabled), so the runner needs no obsidian CLI on the device.

One-time machine setup (the runner never does this for you):

# An AVD from a google_apis image - it must be adb-rootable, so NOT a
# Play-Store image. KVM access is required for x86_64 emulation.
sdkmanager "platform-tools" "emulator" "system-images;android-34;google_apis;x86_64"
avdmanager create avd -n obsidian-e2e -k "system-images;android-34;google_apis;x86_64"
# The official APK is published as a release asset on obsidianmd/obsidian-releases.

Then add the android block to obsidian-e2e.config.mjs:

export default {
  pluginId: "your-plugin",
  // ...desktop config...
  android: {
    avd: "obsidian-e2e", // required: the AVD name to boot/reuse
    apk: "apks/Obsidian.apk", // only needed until the app is installed on the AVD
    adbBin: "adb", // default
    emulatorBin: "emulator", // default
    cdpPort: 9222, // host port forwarded to the webview devtools socket
    bootTimeoutMs: 240_000,
  },
};
  • android start - boot (or reuse) the AVD, install the app if needed, create and select the vault, push the plugin artifacts and the data.json seed (first provision only), enable the plugin, and run the ready probe. Supports --print-env (emitting OBSIDIAN_E2E_ANDROID_SERIAL, _CDP_PORT, _VAULT, _VAULT_PATH) and --json.
  • android run [-- eval code=<js>] - bring the instance up, then evaluate the expression in the app over CDP and print => <value>. Only the eval spelling is supported (there is no obsidian CLI socket on Android); a command-kind readyProbe likewise falls back to the plugin-loaded check.
  • android stop - force-stop the app and shut the emulator down.

Vault provisioning writes no first-run UI automation: app-storage vaults are plain folders under the app's external files dir, and the opened vault is whatever webview-localStorage mobile-selected-vault names - the runner creates the folder over adb root and flips that key over CDP. Real touch gestures for tests go through adb shell input against the booted serial; sub-100ms timing needs CDP Input.dispatchTouchEvent.

Safety notes

  • Version guard. start and run refuse to launch (or to reuse a warm instance across) an Obsidian app-code version below the plugin's minAppVersion, and hard-fail reuse when Obsidian updated mid-session (the running renderer no longer matches). Bypass with --skip-version-guard. A plain run against a warm instance still applies this guard, read-only, before forwarding.
  • Secure /tmp handling. The profile root defaults under world-writable /tmp. Every path that creates, reads, or removes inside it refuses a symlinked, foreign-owned, or group/other-accessible directory and fails closed rather than following a planted link.
  • Reaper semantics. An instance is orphaned once its backing worktree is gone from disk (the signature of a worktree removed on merge). start and any run that brings an instance up reap orphans as a self-healing safety net, and stop --prune reaps them on demand; a running-but-leaked instance for a live worktree is never reaped.

Archive-hook cleanup (orca)

When a worktree is archived, stop its instance so nothing leaks. The command is best-effort (|| true) so archiving never fails on a stale instance:

# orca.yaml
hooks:
  archive:
    - node_modules/.bin/obsidian-e2e stop --worktree "$ORCA_WORKTREE_PATH" || true

Programmatic API

Everything the bin does is available from obsidian-e2e/runner for scripts that need to orchestrate instances directly - loadRunnerConfig, resolveProvisionOptions, provisionVault, resolveInstanceOptions, ensureObsidianInstance, stopInstance, reapOrphanedInstances, runObsidianE2ECli, and the supporting types.

Screenshots And Recordings (capture)

obsidian-e2e capture (and obsidian-e2e/capture programmatically) is a set of small, composable primitives for documentation screenshots and demo recordings of a live Obsidian window over the Chrome DevTools Protocol. It is deliberately not a scenario framework: drive the UI between calls with anything (agent-browser --cdp <port>, the obsidian CLI, capture type, your own CDP), and every capture command prints a JSON result whose sizes/durations were checked after writing.

| Command | What it does | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | launch --vault <dir> | Foreground capture instance: own HOME/profile, --remote-debugging-port (default 9333), real --force-device-scale-factor (default 2), xvfb-run on Linux without $DISPLAY. Separate from the runner's test instance. --print-env prints its exports. | | prepare | Waits until the window is capturable (reconnecting through reloads), sets the content size, theme, font and capture CSS, then verifies viewport, DPR, theme and that the font is really installed. | | screenshot <out.png> | Viewport, --selector, top-most --modal, or any --rect-js expression; --pad, --expand (lift modal max-height), --clean (blur, drop suggestion popovers, no spellcheck squiggles). Cropped by Chromium at the real DPR; fails if the target is off-screen or the PNG is the wrong size. | | type <text> | Paced typing scheduled inside the page (one CDP call), so recordings stay real-time. Waits for the selector/focus. | | record <out.webm\|mp4> -- <cmd...> | Records while <cmd> drives the UI. Capture and final encoding are separate, and the video length is checked against wall-clock time. Non-zero exit, timeout (--max-seconds) or a signal discards the take and returns the same status; temp files are always removed and an existing output is only replaced by a verified take. --cursor draws a pointer for CDP mouse events. | | gif, sheet, probe | Palette GIF (+ gifsicle -O3 --lossy when installed), a contact sheet for reviewing a take, and an ffprobe summary. |

Recording backends (--backend, default auto):

  • x11: ffmpeg x11grab of the window's region on the X display (Linux/Xvfb). Constant-rate, independent of CDP traffic, and the --cursor overlay is page content so it is captured (the real X pointer is hidden, since CDP input never moves it). auto only picks it on the private Xvfb display started by capture launch; elsewhere pass --backend x11 explicitly and keep other windows off the region. The window must stay on-screen, unobscured by other Obsidian windows, and fixed in size and position for the whole take, or the take is rejected.
  • screencast: CDP Page.startScreencast. Works anywhere (macOS, no X) and captures page pixels only, but tops out around 15-20 distinct fps at 2560x1600 and adds renderer load.

Measured in a 2 vCPU orb at 2560x1600 (frame content decoded from a timestamp barcode burned into the page): x11 at --fps 30 kept 27-31 distinct fps during palette typing, per-key agent-browser input, clicks and 100 concurrent agent-browser eval calls, with video length within 0.1 s of page time; screencast managed 15-17. Heavy scenes are bounded by the app: a graph view plus continuous scrolling rendered at 13 fps with no recorder, ~10 fps while recording with x11 --fps 30, and 12 fps with x11 --fps 10. Use --fps 10 (the default) for GIFs and heavy scenes and --fps 30 for smooth light UI. Drive typing with capture type (one in-page call per string) rather than one CDP call per key.

Other page windows (e.g. Settings, which may open as a popout in Obsidian 1.13+) are addressed with --window <title substring>.

Requirements: Obsidian desktop, ffmpeg, and on Linux xvfb/xauth; optional gifsicle and the fonts you ask for (Debian/Ubuntu: apt-get install xvfb xauth ffmpeg gifsicle fonts-inter). Node 22+ (global WebSocket).

# A vault to capture (any folder works; `provision` links the plugin build).
# Delete .obsidian/core-plugins.json if you need core plugins such as the palette.
obsidian-e2e provision --root /tmp/capture --vault Demo
eval "$(obsidian-e2e capture launch --vault /tmp/capture/Demo --print-env)"
amp orb service start obsidian-capture \
  --command "$PWD/node_modules/.bin/obsidian-e2e capture launch --vault /tmp/capture/Demo"   # or tmux/terminal
HOME=$OBSIDIAN_E2E_CAPTURE_HOME obsidian vault=Demo plugins:restrict off # enable community plugins

obsidian-e2e capture prepare --width 1280 --height 800 --scale 2 \
  --theme light --font Inter --hide-secret-warning
obsidian-e2e capture screenshot modal.png --modal --pad 16 --clean
obsidian-e2e capture record demo.webm --cursor -- ./drive-the-ui.sh
obsidian-e2e capture gif demo.webm demo.gif && obsidian-e2e capture sheet demo.webm review.png

Notes from real captures:

  • Use a named AGENT_BROWSER_SESSION with agent-browser --cdp <port>; agent-browser connect launches its own browser instead of attaching. Don't mix in agent-browser set viewport: prepare sizes the real window so hit testing and captures agree.
  • An app reload (including toggling desktop mobile emulation) drops injected CSS; re-run prepare. Recordings survive reloads.
  • Tall content: prepare --height 1900 plus screenshot --expand; screenshots refuse targets that do not fit the viewport rather than capturing a clipped image.
  • Desktop mobile-layout captures (app.emulateMobile(true), then prepare with a phone-sized window) show Obsidian's mobile layout inside the desktop app; they are not captures from real mobile hardware or the Android app.

Setup

tests/setup.ts

import { createObsidianTest } from "obsidian-e2e/vitest";
import "obsidian-e2e/matchers";

export const test = createObsidianTest({
  vault: "dev",
  bin: process.env.OBSIDIAN_BIN ?? "obsidian",
  sandboxRoot: "__obsidian_e2e__",
  timeoutMs: 5_000,
});

vite.config.ts

import { defineConfig } from "vite-plus";

export default defineConfig({
  test: {
    fileParallelism: false,
    maxWorkers: 1,
  },
});

Run Obsidian-backed tests serially. A live Obsidian app and shared vault are not safe to hit from multiple Vitest workers at once, so fileParallelism: false and maxWorkers: 1 should be treated as the default, not as an optimization.

Shared Vault Locking

If multiple worktrees or separate test runs all point at the same obsidian vault=dev vault, you can enable sharedVaultLock to serialize access across those runs:

import { createObsidianTest, createPluginTest } from "obsidian-e2e/vitest";

export const test = createObsidianTest({
  vault: "dev",
  sharedVaultLock: true,
});

export const pluginTest = createPluginTest({
  vault: "dev",
  pluginId: "quickadd",
  sharedVaultLock: {
    onBusy: "wait",
    timeoutMs: 60_000,
  },
});

sharedVaultLock is acquired once per worker before that worker starts using the shared vault. The authoritative state is a host-side lock directory keyed by the resolved vault path. That file-backed lock owns the lease, updates a heartbeat, and allows stale-lock takeover after the configured timeout window.

For visibility inside the running app, the holder also publishes a best-effort marker into the Obsidian process. That marker is not authoritative. The filesystem lock is the source of truth, and the app marker is only there to help humans understand which run currently owns the vault.

For manual lifecycle setups, the same lock helpers are available directly from the main package, so obsidian-e2e/vitest is not required:

import {
  acquireVaultRunLock,
  clearVaultRunLockMarker,
  createObsidianClient,
  type ObsidianClient,
  type VaultRunLock,
} from "obsidian-e2e";

let obsidian: ObsidianClient;
let lock: VaultRunLock;

beforeAll(async () => {
  obsidian = createObsidianClient({ vault: "dev" });
  await obsidian.verify();

  lock = await acquireVaultRunLock({
    vaultName: "dev",
    vaultPath: await obsidian.vaultPath(),
  });

  await lock.publishMarker(obsidian);
});

afterAll(async () => {
  await clearVaultRunLockMarker(obsidian);
  await lock.release();
});

For lock diagnostics, both obsidian-e2e and obsidian-e2e/vitest export:

import { inspectVaultRunLock, readVaultRunLockMarker } from "obsidian-e2e";

const state = await inspectVaultRunLock({
  vaultPath: "/absolute/path/to/dev-vault",
});

const marker = await readVaultRunLockMarker(obsidian);

inspectVaultRunLock() reads the authoritative host-side lock state and returns the current metadata, lock directory, heartbeat age, and stale status. readVaultRunLockMarker() reads the best-effort marker from the running Obsidian app.

If you prefer manual beforeAll / afterAll lifecycle, you can import the lock helpers directly from obsidian-e2e. You do not need obsidian-e2e/vitest for that usage:

import { afterAll, beforeAll } from "vite-plus/test";
import {
  acquireVaultRunLock,
  clearVaultRunLockMarker,
  createObsidianClient,
  type VaultRunLock,
} from "obsidian-e2e";

const obsidian = createObsidianClient({ vault: "dev" });
let vaultLock: VaultRunLock | undefined;

beforeAll(async () => {
  await obsidian.verify();

  vaultLock = await acquireVaultRunLock({
    vaultName: obsidian.vaultName,
    vaultPath: await obsidian.vaultPath(),
  });

  await vaultLock.publishMarker(obsidian);
});

afterAll(async () => {
  await clearVaultRunLockMarker(obsidian);
  await vaultLock?.release();
});

Within one worker/process, reacquiring the same shared-vault lock is reentrant: the existing lease is reused instead of contending against itself. Across different processes or worktrees, contention still serializes access through the host-side lock.

The lock path is covered by a real multi-process smoke test: one process can hold the lease while another waits, and a second process can also take over after the original holder dies and its heartbeat goes stale.

The fixture layer is also covered the same way: separate createObsidianTest() runs can contend for the same sharedVaultLock, and the smoke path verifies that one run waits until the other releases or goes stale. That proves lock handoff across process boundaries, not safe parallel mutation inside one vault.

This mode prevents collisions between concurrent runs that share one live vault, but it does not create true parallel execution inside that vault. It serializes access. If your goal is real parallelism, use separate vaults rather than one shared vault: "dev" target.

Writing Tests

import { expect } from "vite-plus/test";
import { test } from "./setup";

test("reloads a plugin after patching its data file", async ({ obsidian, vault, sandbox }) => {
  const plugin = obsidian.plugin("my-plugin");

  await sandbox.writeNote({
    path: "tpl.md",
    frontmatter: {
      tags: ["template"],
    },
    body: "template body",
  });
  await vault.write("notes/source.md", "existing");

  await plugin.data<{ enabled: boolean }>().patch((draft) => {
    draft.enabled = true;
  });

  await plugin.reload();

  await expect(sandbox).toHaveFile("tpl.md");
  await expect(vault).toHaveFileContaining("notes/source.md", "existing");
});

Fixture summary:

  • obsidian
    • low-level access to app, command(id), commands(), exec, execText, execJson, waitFor, vaultPath, and plugin(id)
  • vault
    • reads and writes anywhere in the vault rooted at the active Obsidian vault
  • sandbox
    • a per-test disposable directory under sandboxRoot; automatically cleaned up after each test
    • exposes note helpers such as writeNote(), readNote(), and path()

Plugin data mutations are snapshotted on first write and restored automatically after each test. Sandbox files are also cleaned up automatically.

Note Helpers And Test Context

Use sandbox.writeNote() when the test cares about note structure rather than raw YAML formatting:

await sandbox.writeNote({
  path: "Inbox/Today.md",
  frontmatter: {
    mood: "focused",
    tags: ["daily"],
  },
  body: "# Today\n",
});

await expect(sandbox.readNote("Inbox/Today.md")).resolves.toMatchObject({
  body: "# Today\n",
  frontmatter: {
    mood: "focused",
    tags: ["daily"],
  },
});

await obsidian.metadata.waitForFrontmatter(sandbox.path("Inbox/Today.md"), (frontmatter) =>
  frontmatter.tags.includes("daily"),
);

readNote() is file-derived. Metadata-cache reads stay under obsidian.metadata.*, so tests can distinguish raw file content from “Obsidian has indexed this note”.

Outside Vitest fixtures, use the public lifecycle wrapper:

import { withVaultSandbox } from "obsidian-e2e";

await withVaultSandbox(
  {
    testName: "quickadd smoke",
    vault: "dev",
  },
  async (context) => {
    const plugin = await context.plugin("quickadd", {
      filter: "community",
      seedData: { enabled: true },
    });

    await context.sandbox.writeNote({
      path: "fixtures/template.md",
      body: "Hello from template",
    });

    await plugin.reload();
  },
);

Plugin Test Helper

If you are testing one plugin repeatedly, createPluginTest() gives you a first-class plugin fixture and optional seed helpers for vault files and plugin data:

import { createPluginTest } from "obsidian-e2e/vitest";

export const test = createPluginTest({
  vault: "dev",
  pluginId: "quickadd",
  pluginFilter: "community",
  seedPluginData: { enabled: true },
  seedVault: {
    "fixtures/template.md": {
      note: {
        body: "template body",
        frontmatter: {
          tags: ["template"],
        },
      },
    },
    "fixtures/state.json": { json: { ready: true } },
  },
});

createPluginTest():

  • injects plugin alongside obsidian, vault, and sandbox
  • enables the target plugin for the test when needed and restores the prior enabled/disabled state afterward
  • seeds vault files before each test and restores the original files afterward
    • seedVault accepts raw strings, { json }, and { note } descriptors
  • seeds data.json through the normal plugin snapshot/restore path
  • supports the same opt-in failure artifact capture as createObsidianTest()

Example:

import { expect } from "vite-plus/test";
import { test } from "./setup";

test("runs against a seeded plugin fixture", async ({ plugin, vault }) => {
  await expect(plugin.data<{ enabled: boolean }>().read()).resolves.toEqual({
    enabled: true,
  });
  await expect(vault.read("fixtures/template.md")).resolves.toBe("template body");

  await plugin.reload();
});

Suite-Scoped Plugin Harness

createPluginTest() is per-test: it reloads the plugin and sandboxes the vault for every test(). Some suites instead want one reload and one sandbox for the whole file, restoring only data.json between tests. createPluginHarness() covers that shape while reusing the same lock, sandbox, artifact, and restore internals - so you never hand-roll beforeAll/afterEach/afterAll.

It returns a (testName) => () => { obsidian, plugin, sandbox } factory. Call it once per suite to register the lifecycle hooks; the returned getter yields the live context inside each test:

import { createPluginHarness, resolveObsidianEnvOptions } from "obsidian-e2e/vitest";
import { expect } from "vite-plus/test";

const usePodNotes = createPluginHarness({
  pluginId: "podnotes",
  // Canonical OBSIDIAN_E2E_* env, with a legacy per-plugin prefix fallback.
  ...resolveObsidianEnvOptions({ legacyPrefix: "PODNOTES" }),
  reload: { readyCommandId: "podnotes:podnotes-show-leaf", timeoutMs: 30_000 },
  // Extra readiness beyond plugin-loaded + ready command.
  waitUntilReady: (obsidian) =>
    obsidian.dev.evalJson<boolean>("Boolean(app.plugins.plugins.podnotes?.api)"),
  // Runs while the plugin is still enabled, before each data.json restore.
  beforeDataRestore: (obsidian) =>
    obsidian.dev.evalJsonAsync(
      "(async () => { await app.plugins.plugins.podnotes?.saveChain; })()",
    ),
  symlinkArtifacts: ["main.js", "manifest.json"],
  captureOnFailure: true,
});

const getContext = usePodNotes("podnotes-runtime");

test("keeps the API available after a data reset", async () => {
  const { obsidian, plugin, sandbox } = getContext();
  await sandbox.writeNote({ path: "note.md", body: "hi" });
  await expect(plugin.isEnabled()).resolves.toBe(true);
});

Lifecycle: beforeAll acquires the vault lock and marker, runs the optional symlink preflight, creates the sandbox, then reloads the plugin and waits for the ready command plus your waitUntilReady predicate. beforeEach resets diagnostics; afterEach runs beforeDataRestore, restores data.json, and re-readies the plugin. afterAll runs an ordered, error-aggregating teardown: plugin data restore, then sandbox removal, then in-app lock-marker clear, and only then the vault-lock release - so a waiting run never acquires the vault mid-cleanup. A failed marker clear fails the suite instead of being swallowed, since it means Obsidian retained stale ownership metadata.

resolveObsidianEnvOptions() maps the canonical OBSIDIAN_E2E_VAULT, OBSIDIAN_E2E_VAULT_PATH, and OBSIDIAN_E2E_OBSIDIAN_HOME env (with an optional legacyPrefix fallback such as PODNOTES_E2E_VAULT) into spreadable client options. The Obsidian home is injected into defaultExecOptions.env.HOME per-client, never mutating process.env. Pair the returned expectedVaultPath with verifyVaultPath() (or set it on the harness) to refuse running against the wrong vault.

Failure Artifacts

Both fixture families support opt-in artifact capture:

  • createObsidianTest({ artifactsDir, captureOnFailure })
  • createPluginTest({ artifactsDir, captureOnFailure, ... })

Example:

import { createObsidianTest, createPluginTest } from "obsidian-e2e/vitest";

export const test = createObsidianTest({
  vault: "dev",
  captureOnFailure: true,
});

export const pluginTest = createPluginTest({
  vault: "dev",
  pluginId: "quickadd",
  artifactsDir: ".artifacts",
  captureOnFailure: {
    screenshot: false,
  },
});

When captureOnFailure is enabled, failed tests write artifacts under .obsidian-e2e-artifacts by default, or under artifactsDir if you set one. Each failed test gets its own directory named from the test name plus a stable task-id suffix, for example:

.obsidian-e2e-artifacts/
  writes-useful-artifacts-abcdef12/

createObsidianTest() captures:

  • active-file.json
  • active-note.md
  • active-note-frontmatter.json
  • console-messages.json
  • dom.txt
  • editor.json
  • notices.json
  • runtime-errors.json
  • tabs.json
  • workspace.json
  • screenshot.png when screenshot capture succeeds

createPluginTest() adds:

  • <pluginId>-data.json

dom.txt is the whole document.body, one tag per line, so open modals (.modal-container) and notices (.notice-container) are included. Scripts and SVG paths are dropped, and input and textarea values (except passwords) are written into value attributes. notices.json lists the notices on screen (visible) and the notices raised since the diagnostics were last reset (raised).

Capture timing: Vitest runs afterEach hooks, fixture teardown, and onTestFinished callbacks before onTestFailed. createPluginHarness() captures as soon as the test body fails, before any of those run, so the artifacts show the modal or notice that caused the failure and the plugin data before the harness restores it (with sequence.hooks: "parallel", alongside your afterEach hooks instead). createObsidianTest() and createPluginTest() capture during fixture teardown, after your afterEach hooks but before onTestFinished callbacks.

Artifact collection is best-effort. If a specific capture fails, the test still fails for its original reason and the framework writes a neighboring *.error.txt file instead. Screenshot capture is the most environment-sensitive part of the set: desktop permissions, display availability, or Obsidian state can prevent screenshot.png from being produced, in which case you should expect screenshot.error.txt instead.

For a hand-rolled Vitest lifecycle, register the same early capture from beforeEach:

import { registerFailureArtifacts } from "obsidian-e2e/vitest";

beforeEach((ctx) => {
  registerFailureArtifacts(ctx, obsidian, { captureOnFailure: true }, obsidian.plugin("quickadd"));
});

If you are not using Vitest, the same artifact capture path is available directly from the main package:

import { captureFailureArtifacts, createObsidianClient } from "obsidian-e2e";

const obsidian = createObsidianClient({ vault: "dev" });

await captureFailureArtifacts(
  {
    id: "quickadd_case_1234abcd",
    name: "captures quickadd diagnostics",
  },
  obsidian,
  {
    captureOnFailure: true,
    plugin: obsidian.plugin("quickadd"),
  },
);

Maintainer CI And Releases

This repo now ships with a hardened CI and release flow built around Vite+ workflow setup, Changesets release orchestration, and npm trusted publishing through GitHub OIDC.

At a high level:

  • CI installs the toolchain with setup-vp, then runs vp check, vp test, and vp pack.
  • When CI fails after artifact capture is enabled in tests, it uploads .obsidian-e2e-artifacts so maintainers can inspect the same failure snapshots produced locally.
  • Releases go through Changesets PRs. Merge the version PR that Changesets opens, then let the release workflow publish to npm.

Maintainer setup notes:

  • Configure npm trusted publishing for this package and repository so the GitHub release workflow can publish without a long-lived npm token.
  • Grant the publish job id-token: write so GitHub can mint the OIDC token npm expects, and keep the release workflow permissions aligned with the write actions it needs, such as contents: write and pull-requests: write for Changesets automation.
  • If you protect publishing behind a GitHub environment, attach that environment to the release job and allow the workflow to use it.

Matchers

Import obsidian-e2e/matchers once in your test setup to register:

  • toHaveActiveFile(path)
  • toHaveCommand(commandId)
  • toHaveEditorTextContaining(needle)
  • toHaveFile(path)
  • toHaveFileContaining(path, needle)
  • toHaveFrontmatter(path, expected)
  • toHaveJsonFile(path)
  • toHaveNote(path, { frontmatter?, body?, bodyIncludes? })
  • toHaveOpenTab(title, viewType?)
  • toHavePluginData(expected)
  • toHaveWorkspaceNode(label)

Example:

import { expect } from "vite-plus/test";
import { test } from "./setup";

test("writes valid JSON into the sandbox", async ({ sandbox }) => {
  await sandbox.writeNote({
    path: "Today.md",
    frontmatter: {
      mood: "focused",
    },
    body: "# Today\n",
  });

  await expect(sandbox).toHaveNote("Today.md", {
    bodyIncludes: "Today",
    frontmatter: {
      mood: "focused",
    },
  });
  await expect(sandbox).toHaveFrontmatter("Today.md", {
    mood: "focused",
  });
});

test("asserts active Obsidian state", async ({ obsidian, plugin }) => {
  await expect(obsidian).toHaveCommand("quickadd:run-choice");
  await expect(obsidian).toHaveActiveFile("Inbox/Today.md");
  await expect(obsidian).toHaveEditorTextContaining("Today");
  await expect(obsidian).toHaveOpenTab("Today", "markdown");
  await expect(obsidian).toHaveWorkspaceNode("main");
  await expect(plugin).toHavePluginData({ enabled: true });
});

Low-Level Client

If you need to work below the fixture layer:

import { createObsidianClient, createVaultApi, parseNoteDocument } from "obsidian-e2e";

const obsidian = createObsidianClient({
  vault: "dev",
  bin: "obsidian",
  defaultExecOptions: {
    allowNonZeroExit: true,
  },
});
const vault = createVaultApi({ obsidian });

await obsidian.verify();
await vault.write("Inbox/Today.md", "# Today\n", { waitForContent: true });
await expect(await obsidian.metadata.frontmatter("Inbox/Today.md")).toBeNull();
await obsidian.plugin("my-plugin").reload({
  waitUntilReady: true,
  readyOptions: {
    commandId: "my-plugin:refresh",
  },
});
parseNoteDocument(await vault.read("Inbox/Today.md"));

App And Commands

The client now exposes app-level helpers and command helpers that map directly to the real obsidian CLI:

  • obsidian.app.version()
  • obsidian.app.reload()
  • obsidian.app.restart()
  • obsidian.app.waitUntilReady()
  • obsidian.commands({ filter? })
  • obsidian.command(id).exists()
  • obsidian.command(id).run()
  • obsidian.dev.dom({ ... })
  • obsidian.dev.eval(code)
  • obsidian.dev.evalJson(code)
  • obsidian.dev.evalJsonAsync(code)
  • obsidian.dev.evalRaw(code)
  • obsidian.dev.diagnostics()
  • obsidian.dev.resetDiagnostics()
  • obsidian.metadata.fileCache(path)
  • obsidian.metadata.frontmatter(path)
  • obsidian.metadata.waitForFileCache(path, predicate?)
  • obsidian.metadata.waitForFrontmatter(path, predicate?)
  • obsidian.metadata.waitForMetadata(path, predicate?)
  • obsidian.dev.screenshot(path)
  • obsidian.tabs()
  • obsidian.workspace()
  • obsidian.open({ file? | path?, newTab? })
  • obsidian.openTab({ file?, group?, view? })
  • obsidian.sleep(ms)
  • obsidian.waitForActiveFile(path)
  • obsidian.waitForConsoleMessage(predicate)
  • obsidian.waitForNotice(predicate)
  • obsidian.waitForRuntimeError(predicate)

Example:

import { expect } from "vite-plus/test";
import { test } from "./setup";

test("reloads the app and runs a plugin command when it becomes available", async ({
  obsidian,
}) => {
  await obsidian.app.waitUntilReady();

  const commandId = "quickadd:run-choice";

  if (await obsidian.command(commandId).exists()) {
    await obsidian.command(commandId).run();
  }

  await obsidian.app.reload();

  await expect(obsidian.commands({ filter: "quickadd:" })).resolves.toContain(commandId);
});

obsidian.app.restart() waits for the app to come back by default. Pass { waitUntilReady: false } if you need to manage readiness explicitly.

Vault, Metadata, And Plugin Wait Helpers

The higher-level vault and plugin handles now expose the most common polling patterns directly, so tests do not need to hand-roll waitFor() loops around content reads, command discovery, or plugin data migration:

test("waits for generated content and plugin state", async ({ obsidian, sandbox, vault }) => {
  const plugin = obsidian.plugin("quickadd");

  await vault.write("queue.md", "pending", {
    waitForContent: true,
  });

  await vault.waitForContent("queue.md", (content) => content.includes("pending"));
  await sandbox.writeNote({
    path: "Inbox/Today.md",
    frontmatter: {
      tags: ["daily"],
    },
    body: "# Today\n",
  });
  await obsidian.metadata.waitForFrontmatter(sandbox.path("Inbox/Today.md"), (frontmatter) =>
    frontmatter.tags.includes("daily"),
  );

  await plugin.updateDataAndReload<{ migrations: Record<string, boolean> }>((draft) => {
    draft.migrations.quickadd_v2 = true;
  });

  await plugin.waitForData<{ migrations: Record<string, boolean> }>(
    (data) => data.migrations.quickadd_v2 === true,
  );
});

If you just need time to pass without inventing a fake polling condition, use await obsidian.sleep(ms).

Workspace and tab readers return parsed structures, so you can inspect layout state without writing custom parsers in every test:

test("opens a note into a new tab and finds it in the workspace", async ({ obsidian }) => {
  await obsidian.open({
    newTab: true,
    path: "Inbox/Today.md",
  });

  const tabs = await obsidian.tabs();
  const workspace = await obsidian.workspace();

  expect(tabs.some((tab) => tab.title === "Today")).toBe(true);
  expect(workspace.some((node) => node.label === "main")).toBe(true);
});

For deeper UI inspection, the dev namespace exposes the desktop developer commands:

test("inspects live UI state", async ({ obsidian }) => {
  const titles = await obsidian.dev.dom({
    all: true,
    selector: ".workspace-tab-header-inner-title",
    text: true,
  });

  expect(titles).toContain("Today");

  await obsidian.dev.screenshot("artifacts/today.png");
});

obsidian.dev.eval() remains the low-level escape hatch and preserves the raw CLI parsing behavior. Use obsidian.dev.evalJson() when you want JSON-safe typed results and remote error details, obsidian.dev.evalJsonAsync() when the evaluated code is an async body whose resolved value you need, and obsidian.dev.evalRaw() when you intentionally need the unstructured CLI output. The evalJson/evalJsonAsync envelope is wrapped in per-call sentinel markers, so plugin console output emitted on the eval channel while the code runs (e.g. QuickAdd: ... notices) cannot corrupt the parsed result - no window-global polling workaround is needed for logging-heavy operations. dev.dom() and dev.screenshot() remain the safer wrappers around the built-in developer CLI commands. Screenshot behavior depends on the active desktop environment, so start by validating it locally before relying on it in automation.

How evalJsonAsync awaits long operations

A single obsidian eval CLI command holds one socket request open for the whole lifetime of an awaited promise, and its reply is the only carrier of the result: a timeout kill or a renderer reload mid-eval loses the value forever even when the operation itself completed (#21). evalJsonAsync therefore never holds a long command open. It sends a short kickoff command exactly once - the command starts the operation and records its eventual { ok, value } envelope under a per-operation nonce inside the app - then retrieves the envelope with short idempotent poll reads. A lost reply is recovered by simply reading again, and because the kickoff is never resent, the evaluated code cannot run twice.

timeoutMs (per call or via defaultExecOptions) is the overall deadline for the awaited result; each internal CLI command runs on its own short budget. Successful results resolve the value and rethrow in-app failures as DevEvalError, exactly as before. When the result cannot be produced, the call throws DevEvalAsyncError whose reason names the precise transport state instead of a generic timeout:

  • ambiguous-delivery: the kickoff command failed or its reply was lost, and no in-app record of the operation appeared before the deadline; it may or may not have started. The kickoff is never resent, preserving exactly-once execution; the underlying CLI failure is attached as causeError.
  • context-reset: the operation was confirmed running and then the eval context was wiped (e.g. an app or vault reload); the result was discarded with the previous context.
  • still-pending: the awaited promise has not settled within the deadline; the operation may still complete in-app afterwards.

Hand-rolled fire-and-poll loops (a window global plus repeated short evalJson reads) are obsolete: evalJsonAsync is that pattern, managed by the package.

How exec() recovers lost CLI replies

Every CLI command's reply crosses Obsidian's main-to-renderer bridge exactly once, and that bridge loses or delays messages under suite load - a command can complete in-app in milliseconds while its reply never reaches the CLI client, which then hangs until the transport kills it (#25). exec() therefore wraps commands in a dispatch protocol: a one-time shim around the in-app CLI dispatcher records each command under a per-call nonce before running it and stores the reply when the handler settles. A lost reply is recovered by idempotent poll reads. Recovery never re-sends a command without positive proof it did not run: the shim dedups repeated nonces, refuses payloads pinned to a previous shim generation (an app reload), and a reply that cannot be positively identified counts as unknown fate rather than as proof of non-execution. quickadd:run-style non-idempotent commands stay exactly-once. The command's handler sees its exact original argv, and the recovered ExecResult is byte-identical to the direct CLI output (including exit code 0 for Error: ... replies).

Commands that settle inside a short in-app grace stay one CLI roundtrip. Slow commands receive {state:'pending'}, and polls carry the result without a routine CLI kill. Each dispatch attempt has an approximately 2.5s process budget, which covers the grace and bridge slack. Polling starts immediately, then waits 100ms and doubles the delay after each inconclusive poll up to 1s. On the recoverable path, timeoutMs remains the overall deadline for the command's result. Everything built on exec() - dev.eval, dev.evalJson, command(id).run(), plugin(id).reload(), and the evalJsonAsync internals - gains the same recovery automatically.

The shim and its registry live on the vault's main window. Obsidian's CLI server targets that window, and the runner sets settingsPopoutWindow: false.

Defaults: recoverable on the built-in transport, direct on a custom transport (a test fake cannot serve the protocol - note this also applies to transports that merely wrap the built-in one for logging; opt back in with recoverable: true). reload, restart, plugins:restrict, and dev:mobile always use the direct path because their success destroys the renderer context the recovery registry lives in, as does command with the context-destroying built-in ids (app:reload, app:quit); plugin commands whose handlers reload the app cannot be enumerated, so route those through recoverable: false yourself. Override per call or via defaultExecOptions with recoverable: true | false.

When the result cannot be produced, exec() throws ObsidianCommandDispatchError whose reason names the precise transport state. When a lost reply (a CLI transport timeout) drove the failure, that ObsidianCommandTimeoutError is attached as causeError; a clean still-pending timeout - every acknowledgement succeeded and the handler simply has not settled - carries no cause.

  • ambiguous-delivery: no dispatch attempt was acknowledged and no in-app record appeared; the command may or may not have started, and cannot have run twice.
  • context-reset: the renderer context was reset while an attempt was in flight; whether the command executed before the reset is unknowable.
  • still-pending: the command is confirmed running in-app but has not settled within the deadline.
  • undelivered: the dispatch shim could not be installed; the command was never dispatched and did not run, so retrying is safe.

Layer Boundaries

  • vault stays filesystem-only. If the behavior depends on Obsidian parsing or workspace state, it does not belong there.
  • sandbox.readNote() parses file content only. It does not imply that Obsidian has indexed the note.
  • obsidian.metadata.* reads metadata-cache state, which is the right layer for frontmatter synchronization and race-sensitive tests.
  • obsidian.dev.eval() is the escape hatch. Prefer the higher-level metadata, sandbox, wait, plugin, and matcher helpers first, and use obsidian.dev.evalJson() when you need structured JSON-safe results.

Migration Notes

  • Keep using obsidian.dev.eval() for the raw escape hatch semantics.
  • Use obsidian.dev.evalJson() when you want JSON-safe typed results and DevEvalError stack details.
  • Use obsidian.metadata.* for metadata-cache synchronization, including notes created under sandbox.path(...).
  • Prefer sandbox.writeNote() over hand-built YAML strings when the test is describing note content rather than string formatting.
  • Prefer plugin.updateDataAndReload() or plugin.withPatchedData() over open- coded patch/reload/restore sequences.

Regression Matrix

  • Metadata waits cover delayed file-cache population and frontmatter synchronization after note writes.
  • Failure artifacts capture active note content, parsed frontmatter, recent console/notices/runtime errors, and workspace snapshots.
  • Lifecycle cleanup restores tracked plugin data before disabling plugins, removes sandbox content and clears the in-app lock marker before releasing the vault lock, and surfaces marker-clear failures instead of swallowing them.

End-To-End Workflow

Putting it together, a realistic plugin test usually looks like this:

import { expect } from "vite-plus/test";
import { createPluginTest } from "obsidian-e2e/vitest";
import "obsidian-e2e/matchers";

const test = createPluginTest({
  vault: "dev",
  pluginId: "quickadd",
  pluginFilter: "community",
  seedPluginData: {
    macros: [],
  },
  seedVault: {
    "fixtures/template.md": "Hello from template",
    "Inbox/Today.md": "# Today\n",
  },
});

test("runs a seeded workflow end to end", async ({ obsidian, plugin, vault }) => {
  await expect(obsidian).toHaveCommand("quickadd:run-choice");
  await expect(plugin).toHavePluginData({
    macros: [],
  });

  if (await obsidian.command("quickadd:run-choice").exists()) {
    await obsidian.command("quickadd:run-choice").run();
  }

  await obsidian.open({
    path: "Inbox/Today.md",
  });

  await expect(obsidian).toHaveActiveFile("Inbox/Today.md");
  await expect(vault).toHaveFile("fixtures/template.md");

  const headers = await obsidian.dev.dom({
    all: true,
    selector: ".workspace-tab-header-inner-title",
    text: true,
  });

  expect(headers).toContain("Today");
});

That pattern keeps tests readable:

  • use createPluginTest() when one plugin is the main subject under test
  • seed only the files and plugin data needed for that case
  • prefer Obsidian-aware matchers over ad hoc CLI parsing
  • drop to obsidian.dev.* only when filesystem and command assertions are not enough

Notes

  • This package is a testing library, not a custom runner.
  • It is designed for real Obsidian-backed integration and e2e flows, not for mocked unit tests.
  • Headless CI for desktop Obsidian is environment-specific; start by getting tests reliable locally before automating them.