Vaults and fixtures
Every run works against a temporary vault. The global setup creates one for you; TemporaryVault lets you
create more, and populate puts fixture files in either.
The same populate map shape is used everywhere: path → file content, a path ending with / and empty
content creates an empty folder, and parent directories are created automatically.
A disposable vault of your own
Section titled “A disposable vault of your own”import type { TFile } from 'obsidian';import { afterAll, beforeAll, expect, it } from 'vitest';import { ContextId, evalInObsidian, TemporaryVault } from 'obsidian-integration-testing';
interface Context { file: TFile;}
const vault = new TemporaryVault();
vault.populate({ 'note.md': '# Hello', 'folder/nested.md': 'nested content'});
const contextId = new ContextId<Context>();
beforeAll(async () => { await vault.register();
// Resolve the pre-populated file into a TFile and store it in the context await evalInObsidian({ contextId, callback: async ({ app, context }) => { const file = app.vault.getFileByPath('note.md'); if (!file) { throw new Error('File not found'); } context.file = file; }, vaultPath: vault.path });});
afterAll(async () => { await contextId.dispose(vault.path); await vault.dispose();});
it('should read a pre-populated file', async () => { const content = await evalInObsidian({ callback: ({ app }) => app.vault.adapter.read('note.md'), vaultPath: vault.path }); expect(content).toBe('# Hello');});Both TemporaryVault and ContextId implement AsyncDisposable, so await using handles cleanup.
dispose() deletes only a directory the handle created
Section titled “dispose() deletes only a directory the handle created”dispose() always unregisters the vault from Obsidian, but it removes the directory only when that
handle is the one that created it — a new TemporaryVault() with no path, as above. A handle built over a
path you supplied unregisters and leaves the files where they are.
That is what makes the handle getTemporaryVault() returns safe: it wraps the vault the global setup
provisioned for the whole run, so an afterAll(() => vault.dispose()) that looks symmetric would otherwise
delete the directory out from under the open window and every test file that had not run yet.
Pass shouldRemoveDirectoryOnDispose to override the default in either direction:
// Delete a directory this handle did not create.const vault = new TemporaryVault(myScratchPath, { shouldRemoveDirectoryOnDispose: true });Pre-populate before Obsidian opens
Section titled “Pre-populate before Obsidian opens”For large fixtures, write the files before Obsidian opens the vault, so its startup scan indexes them in a single pass. Writing thousands of notes after open and forcing a re-scan is far slower and less reliable.
Vitest
Section titled “Vitest”Create your own globalSetup module with createSetup({ populate }) and point the config at it.
populate is a thunk, so a large fixture is built lazily, once, in the setup process:
import { createSetup } from 'obsidian-integration-testing/vitest-global-setup-plugin';
export const { setup, teardown } = createSetup({ populate: () => ({ 'note.md': '# Hello', 'folder/nested.md': 'nested content' })});import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['./integration-global-setup.ts'] }});The same createSetup({ populate }) factory, but Jest needs globalSetup and globalTeardown to be
separate modules, each with a default-export function. Build the pair once in a shared module and
re-export each half as a default:
// integration-global-setup.ts — shared createSetup pairimport { createSetup } from 'obsidian-integration-testing/jest-global-setup-plugin';
export const { setup, teardown } = createSetup({ populate: () => ({ 'note.md': '# Hello', 'folder/nested.md': 'nested content' })});
export default setup;import { teardown } from './integration-global-setup.ts';
export default teardown;export default { globalSetup: '<rootDir>/integration-global-setup.ts', globalTeardown: '<rootDir>/integration-global-teardown.ts'};Both files share the same createSetup instance through the common module, so teardown cleans up
exactly what setup created.
Manual
Section titled “Manual”When wiring TemporaryVault yourself, without a framework global setup, call vault.populate() before
vault.register(), as shown above.
Seed a plugin’s demo-vault/
Section titled “Seed a plugin’s demo-vault/”A plugin’s committed demo-vault/ often needs more than the plugin under test — CodeScript Toolkit
(fix-require-modules) to run its code-button blocks, say, or the demo-vault-helper bootstrap. Two
pieces make that a one-liner:
enableCommunityPlugins— acreateSetupoption listing community-plugin ids to enable in addition to the plugin under test, after it is enabled. Each id’s built files must already be in the vault (seed them below). It replaces the hand-rolledbeforeAllthat turned off restricted mode and calledenablePlugin(...)in every demo-vault test.buildDemoVaultPopulate— reads the repo’sdemo-vault/tree, carries over selected.obsidian/*config (app.json,appearance.json,core-plugins.jsonby default), and seeds each injected plugin’s binaries (plus an optionaldata.json), returning apopulatemap.
import { join } from 'node:path';import { buildDemoVaultPopulate } from 'obsidian-integration-testing';import { createSetup } from 'obsidian-integration-testing/vitest-global-setup-plugin';
const CST_ID = 'fix-require-modules';
export const { setup, teardown } = createSetup({ // Turn on the seeded extra plugins (the plugin under test is enabled automatically). enableCommunityPlugins: [CST_ID], populate: () => buildDemoVaultPopulate({ demoVaultPath: join(process.cwd(), 'demo-vault'), // CST binaries come from the demo vault's local (gitignored) install; `data` writes its data.json. injectPlugins: [{ pluginId: CST_ID, data: { modulesRoot: '_assets' } }] })});enableCommunityPlugins also composes with installPlugin: false, enabling extras into an otherwise
plugin-less vault.
Install the injected plugins headlessly
Section titled “Install the injected plugins headlessly”An injected plugin’s built files are not in git — .obsidian/plugins/* is gitignored — so a fresh
clone, a new machine, or CI has nothing to seed and buildDemoVaultPopulate throws. Since a release
preflight runs the integration tests, that is enough to block cutting a release. Two headless remedies,
both of which download the plugin’s published GitHub release assets into
demo-vault/.obsidian/plugins/<id>/ — the same folder Obsidian itself would have produced, so the shipped
*-demo-vault.zip — which unzips into a single *-demo-vault-<version> folder — is unaffected:
-
buildDemoVaultPopulateAsync— the self-healing drop-in. It installs whatever is missing and then builds the very same map, so the setup above needs one identifier changed and anawait-able thunk (both the Vitest and Jest adapters accept apopulatethunk that returns a promise):import { buildDemoVaultPopulateAsync } from 'obsidian-integration-testing';export const { setup, teardown } = createSetup({enableCommunityPlugins: [CST_ID],populate: () =>buildDemoVaultPopulateAsync({demoVaultPath: join(process.cwd(), 'demo-vault'),injectPlugins: [{ pluginId: CST_ID, data: { modulesRoot: '_assets' } }]})}); -
bootstrap-demo-vault— the one-off CLI, for repairing a checkout without touching the setup:Terminal window npx obsidian-integration-testing bootstrap-demo-vaultnpx obsidian-integration-testing bootstrap-demo-vault --plugin fix-require-modules --version 13.6.11With no
--pluginit installs every id already present under.obsidian/plugins/.--forcere-downloads plugins that are already installed;--repo owner/nameand--version <tag>apply to a single--plugin.
Each id resolves to its GitHub repository through Obsidian’s own community plugin registry — the same
id → repo table the in-app community browser installs from — so nothing is hardcoded. Pass repo on
the injected plugin to skip that lookup (or to bootstrap a plugin that is not listed there), and version
to pin a release tag instead of taking the latest.
An injected plugin that names an explicit sourceDirectory is deliberately excluded: that points at a
local build output, not somewhere to download a release into. buildDemoVaultPopulate stays synchronous
and keeps throwing — fetch has no synchronous form — but its message now names both remedies above.
Non-plugin consumers
Section titled “Non-plugin consumers”If your project is not a plugin — a tool that only needs a registered, empty vault to evalInObsidian
against, such as a typings crawler — point globalSetup at the -no-plugin entry point instead of
-plugin. It still launches one owned, off-screen Obsidian instance and publishes its endpoint to workers
so each worker attaches to it, but it skips reading dist/manifest.json, copying a plugin, writing
community-plugins.json, and enabling a plugin. No wrapper module is needed:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { globalSetup: ['obsidian-integration-testing/vitest-global-setup-no-plugin'] }});// your.integration.test.ts — read the empty registered vault's pathimport { getTemporaryVault } from 'obsidian-integration-testing/vitest-global-setup-no-plugin';For Jest, use obsidian-integration-testing/jest-global-setup-no-plugin (globalSetup) plus
obsidian-integration-testing/jest-global-teardown-no-plugin (globalTeardown). If you also need to
pre-populate that empty vault, build the pair yourself with createSetup({ installPlugin: false, populate })
from the -plugin factory and re-export its setup / teardown, following the wrapper pattern above.
Related
Section titled “Related”TemporaryVaultAPI referencebuildDemoVaultPopulateAPI reference- Leftover cleanup — what happens to vaults a dead run left behind.