Demo Vault
Obsidian Dev Utils lets a plugin ship a demo vault — a curated Obsidian vault, committed to the plugin repo, that showcases the plugin — and lets users open it in one step. There are two cooperating parts:
- Release side — at release time, the demo vault is packaged (with the freshly built plugin installed into it) and attached to the GitHub release as an archive.
- Runtime side — an opt-in
Open demo vaultcommand downloads that archive and opens it as a vault in a new window.
Release side: archiving the demo vault
Section titled “Release side: archiving the demo vault”Put a curated vault at demo-vault/ in your plugin repo root (a normal vault, including its .obsidian/ config). When you release with updateVersion, the demo vault is archived automatically for Obsidian plugins:
- The freshly built plugin (from
dist/build/) is installed intodemo-vault/.obsidian/plugins/<plugin-id>/. - The bundled
demo-vault-helperbootstrap plugin (shipped insideobsidian-dev-utils) is injected intodemo-vault/.obsidian/plugins/demo-vault-helper/— see Thedemo-vault-helperbootstrap plugin. - The whole
demo-vault/folder is zipped todist/build/<plugin-id>-demo-vault.zip, under a single top-level<plugin-id>-demo-vault-<version>/folder — see Where the version lives. - The archive’s
.obsidian/app.jsongets the demo-vault settingsobsidian-dev-utilsowns merged into it. - The archived
README.md’s opening# headinggains the version, e.g.# My Plugin demo vault v1.2.3. Like theapp.jsonsettings above, this reaches the archive entry only — your committeddemo-vault/README.mdis a tracked file andupdateVersionarchives after it has already pushed, so an in-place write would leave an uncommitted change behind a published release. A vault that ships noREADME.md, or whose README opens on something other than a heading, is left alone. - Because the GitHub-release step uploads every file in
dist/build/, the archive is attached to the release automatically.
If the repo has no demo-vault/ folder, the step is silently skipped.
Where the version lives
Section titled “Where the version lives”The archive name carries the plugin id, so several plugins’ demo vaults never collide — and deliberately not the version. A release asset is already namespaced by its release tag (…/releases/download/<version>/…), so the version bought no disambiguation there, while making the asset name change on every single release. That is what breaks the Obsidian Community directory’s automated-review overrides: the Release contains extra unsupported files finding quotes the asset name in its message, and the message is part of the fingerprint the override matches on, so a settled finding came back on every release.
The version instead rides inside the archive, in the two places someone actually meets it:
- The vault sits under one
<plugin-id>-demo-vault-<version>/folder, so unzipping several releases by hand into one folder neither collides nor leaves you with anonymous directories. It is also the folder name Obsidian’s vault switcher shows when theOpen demo vaultcommand opens it, so both routes agree. - The
README.mdheading names it, so a vault that has been moved or renamed still says which release it demonstrates.
Nothing else depended on the version being in the name: the opener’s local archive cache and its extracted folder are both named from the version it resolved, never parsed out of the asset name.
One-time break for users on an older build. The name changed and the opener has no fallback to the old one, so a user still running a build of your plugin from before you bumped to this obsidian-dev-utils asks for the old asset name, does not find it on your new release, and gets No demo vault is available…. Updating the plugin fixes it. Nothing breaks in the other direction: the command only ever fetches the installed version (which is the code running it) or a later one, so a build carrying this change never asks for an old-named asset.
Opting out
Section titled “Opting out”Archiving is on by default. Pass --no-demo-vault to skip it for a release, or set shouldArchiveDemoVault: false in the updateVersion options.
The app.json settings obsidian-dev-utils owns
Section titled “The app.json settings obsidian-dev-utils owns”Four Obsidian settings have to be the same in every demo vault, so this package owns them rather than asking each vault to remember them:
| Setting | Value | Why |
|---|---|---|
defaultViewMode | "preview" | A note opens as a reader sees it, not as raw markup — the vault is documentation first and an editing surface second. |
livePreview | false | Same reason; both are needed, and both are Obsidian-only (GitHub never reads app.json). |
useMarkdownLinks | true | Decides what Obsidian writes when a link is created inside the vault. At the default, every new link is a [[wikilink]] — which GitHub renders as literal brackets leading nowhere. |
newLinkFormat | "relative" | Keeps a link resolving once the vault is extracted to an arbitrary folder. "absolute" would not. |
They are injected into the archived vault, and a demo vault must not commit any of them: a committed
copy is a second source of truth that nothing reconciles, and it is the copy that goes stale. The
demo-vault coverage suite fails a vault that commits one, and
archivePluginDemoVault refuses to archive it — so the settings have exactly one owner. Everything else in
app.json is the vault’s own and is carried into the archive untouched.
One consequence worth knowing: because the injection reaches only the archived copy, creating a link while authoring the vault in your own Obsidian still follows your settings, not these. The coverage suite’s no-wikilink check is what catches that before it ships.
These four settle the Obsidian reader and nothing else. GitHub never reads app.json; it renders every
note as HTML whatever the file says. What serves the GitHub reader is the markdown itself — Markdown links
rather than wikilinks, an # H1 and prose opener, no [Docs] line — so a vault is not GitHub-ready by
virtue of these shipping, and no setting under .obsidian/ can fix a GitHub-rendering problem.
Consumer setup
Section titled “Consumer setup”-
Create
demo-vault/with the curated notes and an.obsidian/config. The config directory must be the default.obsidian— the tooling resolves it by name. -
Commit
demo-vault/.obsidian/community-plugins.jsoncontaining both ids:["demo-vault-helper", "<plugin-id>"]. Listing only the helper is a silent failure: the helper installs, configures and enables CodeScript Toolkit only, so the vault would bootstrap and runstartup.tswhile the plugin the vault exists to demonstrate stays disabled. -
Do not commit the four
app.jsonsettings above — they are injected at release time. -
If
demo-vault/README.mdtells the reader which file to download, name<plugin-id>-demo-vault.zip— not a versioned one. Leave its# headingunversioned too; the version is added to the archived copy at release time, and a hand-typed one would go stale the moment you release again. -
Commit no plugin
data.jsonfor any plugin: the helper writes CodeScript Toolkit’s config at runtime, before enabling it. -
Gitignore the injected plugins and the workspace state that rewrites itself on every session:
demo-vault/.obsidian/plugins/*demo-vault/.obsidian/workspace*.jsondemo-vault/.obsidian/hotkeys.jsonIf your repo-root
.gitignoredropsnode_modules, re-include anything the vault vendors deliberately — for example!demo-vault/_assets/**/node_modules/**.
The demo-vault-helper bootstrap plugin
Section titled “The demo-vault-helper bootstrap plugin”A demo vault usually showcases the plugin through notes whose code-buttons run TypeScript via CodeScript Toolkit. So those buttons work with no manual setup, archivePluginDemoVault injects a tiny, plugin-agnostic bootstrap plugin — demo-vault-helper, owned, versioned, and bundled by obsidian-dev-utils — into every demo vault at release time. On layout-ready it installs CodeScript Toolkit from the community store (if missing), writes its settings, and enables it (writing the settings before a fresh enable, so it loads already configured with no reload). If CodeScript Toolkit is already enabled but the settings just changed, it is reloaded so it re-reads them; an ordinary re-open (settings unchanged) reloads nothing. CodeScript Toolkit then runs the vault’s startup.ts, and the helper raises the sandbox notice.
Among the settings it writes is a defaultCodeButtonConfig that turns CodeScript Toolkit’s source viewer on for every button, so a reader can see the code a button runs. Because it is written by the helper, every demo vault gets it without editing a single note.
Because obsidian-dev-utils owns and injects it, a demo vault commits nothing helper-related and never needs a manual “install CodeScript Toolkit” step; an obsidian-dev-utils bump propagates any fix to every demo vault. To adopt it, a demo vault commits only:
demo-vault/.obsidian/community-plugins.jsonlistingdemo-vault-helper(alongside your own<plugin-id>), so it auto-enables once injected.demo-vault/_assets/CodeScriptToolkit/startup.tsexportinginvoke(app)— where the vault opens its start note (e.g.00 Start) and does any plugin-specific setup.
No CodeScript Toolkit config (data.json) is committed — the helper writes it at runtime.
The settings it writes put CodeScript Toolkit’s module root at _assets/CodeScriptToolkit, its invocable scripts in Invocables, and its startup script at startup.ts. Three consequences for what the vault commits:
startup.tsmustexportaninvokefunction, not run at top level. CodeScript Toolkit requires the module and callsstartupScript.invoke(app), so a direct-execution script that leans on a top-level globalappthrowsTypeError: this.startupScript.invoke is not a functionthe moment the vault loads. An optionalexport async function cleanup(app) {}is called on unload. Useinvoketo open the start note and do live-safe setup (binding a demo hotkey, say); anything that has to be in place before the plugin loads is written by the helper, not here.- Put shared button code in
_assets/CodeScriptToolkit/demoSetup.tsso each note’scode-buttonstays a one-liner —require('/demoSetup.ts').installAndEnable(app, '<id>')— instead of a block copy-pasted across notes. Third-party prerequisites install throughinstallCommunityPlugin/enableCommunityPlugin; never commit someone else’s plugin binary. - Commit no
Invocables/placeholder. The helper creates the folder at runtime, so an empty directory or a.gitkeepin git is noise that has to be maintained.
Archiving without a release
Section titled “Archiving without a release”npm run build only bundles — it does not inject either plugin or write the zip. To prove the whole mechanism locally, call the archiver directly from the plugin root after a build:
node --input-type=module -e "import { archivePluginDemoVault } from 'obsidian-dev-utils/script-utils/demo-vault'; console.log(await archivePluginDemoVault());"Both plugins are injected (gitignored) and the zip is written, exactly as at release time.
archivePluginDemoVault
Section titled “archivePluginDemoVault”The archiving is exposed directly if you need to call it outside updateVersion:
import { archivePluginDemoVault } from 'obsidian-dev-utils/script-utils/demo-vault';
// Returns the path of the created zip, or `null` if there is no `demo-vault/` folder.// The plugin id and version are read from the repo's `manifest.json`.const zipPath = await archivePluginDemoVault();Runtime side: the Open demo vault command
Section titled “Runtime side: the Open demo vault command”The runtime side is opt-in. Register OpenDemoVaultCommandHandler directly from your plugin (for example in your CommandHandlerComponent’s command handlers) — no platform guard is needed, even in a plugin that also runs on mobile:
import { OpenDemoVaultCommandHandler } from 'obsidian-dev-utils/obsidian/command-handlers/open-demo-vault-command-handler';
new OpenDemoVaultCommandHandler({ app: this.app, pluginId: this.manifest.id, pluginNoticeComponent: this.pluginNoticeComponent, pluginVersion: this.manifest.version});The command is desktop only — it hides itself on mobile (its canExecute gates on Platform.isDesktopApp, so no mobile notice is ever shown), and the desktop-only machinery is loaded lazily (only when the command runs on desktop), so registering the handler is safe on every platform. When invoked the command:
- Resolves the plugin’s GitHub repository from Obsidian’s community registry (see
getCommunityPluginRepo). - Reads the latest release version. If the installed version is the latest (or newer), its demo vault opens directly; otherwise the user is offered a choice between the latest and the currently-installed version via a Select Option dialog.
- Downloads the chosen release’s
<plugin-id>-demo-vault.zip. Only the archive is cached (under the OS temp directory, keyed by plugin id and version); every open extracts a fresh copy into its own folder, so a previous session’s edits never leak into a new one. Extracted folders left over from earlier sessions are removed, best-effort, about a day after their last use. - Opens the archive’s own
<plugin-id>-demo-vault-<version>folder as a vault in a new window — the same name a hand-unzipped archive produces, and what the vault switcher then shows.
A progress notice is shown from the moment the command is invoked (Opening demo vault for …, then Downloading …, then Extracting …), because resolving the release and downloading the archive can take a while and a silent command invites a second click — which would produce a second extracted vault.
If the plugin is not in the community registry, or no archive exists for the chosen version, a notice is shown and nothing is opened.
What registering the command costs your bundle
Section titled “What registering the command costs your bundle”Registering OpenDemoVaultCommandHandler puts the opener into your plugin’s main.js, and with it a require('node:fs') and a window.electron.ipcRenderer.sendSync('vault-open', …) — extracting the archive writes files outside the vault, and opening a folder as a vault has no non-Electron API. The dynamic import() behind the command does not change that: an Obsidian plugin ships a single CJS main.js, esbuild has no code splitting to put a chunk behind, and import() defers evaluation rather than excluding the code.
So the Community directory’s automated review reports two Behavior warnings — Direct Filesystem Access and Electron IPC — on any listing whose plugin registers this command. Both are accurate: settle them with the review’s override, naming the demo-vault opener as the reason. If you would rather not carry them, do not register the handler; the archive is still attached to every release and users can download and open it by hand.
Extraction itself is dependency-free — extractZipArchive reads the archive with Node’s zlib, which esbuild already treats as external — so the feature costs a few KB rather than the ~34 KB an adm-zip on the runtime path used to add to every plugin built with this library.
The sandbox notice
Section titled “The sandbox notice”Because every open extracts a fresh copy, a user who writes their own notes in a demo vault will not find them in the next one. So once the vault is open, demo-vault-helper raises a notice — modelled on Obsidian’s own sandbox-vault notice, staying up until it is clicked — that names the plugin the vault demonstrates, gives the folder it was extracted to, says the folder is cleaned up automatically about a day after its last use, and explains that re-running the command creates a new copy.
The notice is raised by the helper, inside the demo vault, not by the Open demo vault command: that command runs in the vault the user started from, not the one being opened. Nothing is needed to opt in — every demo vault gets it through the injected helper.
getCommunityPluginRepo
Section titled “getCommunityPluginRepo”Resolves the owner/name GitHub repository of a community plugin from Obsidian’s public community-plugins.json registry (the plugin manifest itself carries no repository). The registry is fetched once and cached.
import { getCommunityPluginRepo } from 'obsidian-dev-utils/obsidian/community-plugins';
const repo = await getCommunityPluginRepo('my-plugin'); // e.g. 'owner/my-plugin', or `null` if not listedAuthoring the notes
Section titled “Authoring the notes”The demo vault is not a set of samples sitting beside the documentation — it is the documentation, read either in Obsidian or straight from GitHub by someone who has not installed the plugin. That second reader is what the following rules protect, and the coverage suite enforces them:
-
Every note opens with an
# H1, then 1-3 sentences of what it does and why you would want it — in behavior terms, not technical nouns. A note that goes from its title straight to a button teaches nothing to a reader who does not already know the feature. -
Links between notes are
[Text](<./NN Name.md>), never[[wikilinks]]. A wikilink renders as literal brackets on GitHub and leads nowhere. Angle brackets keep a name with spaces intact. Wikilinks shown inside a code fence or inline code are sample text and are fine. A note whose subject is the wikilink — an embed spelling the reader is being taught to type, a frontmatter property value that contrasts with a Markdown link, a fixture that must keep the wikilink a command is meant to leave alone, a link the reader clicks while it still resolves to nothing — says so itself, with the reason, in one of two forms modelled on ESLint’s.Per line or per region, as an HTML comment (invisible in Obsidian and on GitHub — the spelling already used for
markdownlint-disable-next-line):<!-- obsidian-dev-utils-disable-next-line demo-vault-validation/no-wikilinks -- Clicking a link to a note that does not exist yet IS the feature. -->2. Click this link: [[Projects/Fresh idea]].<!-- obsidian-dev-utils-disable demo-vault-validation/no-wikilinks -- The wikilink embed is the syntax this note teaches. -->![[basic.html|400]]<!-- obsidian-dev-utils-enable demo-vault-validation/no-wikilinks -->Per note, in its frontmatter — the only form that reaches a wikilink inside frontmatter, where a comment cannot go:
---obsidian-dev-utils:demo-vault-validation:allow-wikilinks: The `wikilink` property value is the contrast this note is built around.---Either exempts the note from the wikilink rule only; the rest still apply. A declaration with no reason, one covering no wikilink, a region never enabled again, or a misspelled directive all fail the suite — an exemption nobody can justify, that nothing needs any more, or that silently does nothing is worse than none.
-
No
[Docs](…)link line. The note is the docs; a line pointing elsewhere for the real explanation is the shape this convention exists to remove. -
00 Start.mdis a getting-started narrative, not a bare list — what the vault is, one concrete first success, then an index grouped under headings with a one-line description per entry. Every other note must be reachable from it. -
The first success spells out the mechanics, because a first-time reader has never seen CodeScript Toolkit: a code button renders as a captioned rectangle, clicking it runs the code, the result appears below it, and the
</>toggle beside it reveals the source. Nothing about a coloured rectangle says “button” to someone who does not already know — say it once, in the first example, instead of assuming it. -
.obsidian/app.jsoncommits none of the settingsobsidian-dev-utilsowns — the reading experience they configure is injected into the archive, so the vault does not carry a second copy of it.
Rules 1-3, the reachability half of rule 4, and rule 6 are machine-checked by the coverage suite below. Whether 00 Start.md actually reads as a narrative and rule 5 are convention only — no check can tell prose from filler.
Keeping the vault honest
Section titled “Keeping the vault honest”registerDemoVaultCoverageSuite (from obsidian-dev-utils/script-utils/demo-vault-coverage) registers a vitest suite that reads the plugin’s real surface from source and checks the vault against it — without launching Obsidian. Call it once from src/demo-vault.no-app.integration.test.ts:
import { registerDemoVaultCoverageSuite } from 'obsidian-dev-utils/script-utils/demo-vault-coverage';import { getRootFolder } from 'obsidian-dev-utils/script-utils/root';
registerDemoVaultCoverageSuite({ configInterfaces: [{ interfaceName: 'PluginSettings', sourcePath: 'src/plugin-settings.ts' }], interfaces: [{ interfaceName: 'CodeButtonContext', kind: 'methods', receiver: 'codeButtonContext', sourcePath: 'src/code-button-context.ts' }], nonTrivialGuard: { expectDemoNote: '00 Start.md', expectMember: 'console', interfaceName: 'CodeButtonContext', sourcePath: 'src/code-button-context.ts' }, rootFolder: getRootFolder() ?? process.cwd()});It asserts that every reflected member is demonstrated somewhere in the notes, that no note references a member that no longer exists (rename drift), that .obsidian/app.json commits none of the settings this package injects, and that the notes follow the authoring rules above.
The authoring checks are always on — a note that breaks the convention is broken for real readers, so there is no flag to turn them off. The optional authoring member only tunes them:
authoring: { // Notes outside the learning path, exempt from every authoring check. // Defaults to the vault's own README, which addresses someone browsing the repo. excludedNotes: ['README.md'], // The note every other note must be reachable from. Defaults to `00 Start.md`. startNote: '00 Start.md'}docs stays optional and is only for a plugin that still keeps a separate docs/ folder: it checks that every feature doc has a demo note linking to it. A plugin whose vault is its documentation has no use for it.
rootFolder is the only required member. A plugin with no settings and no public API to reflect — nothing to put in configInterfaces, interfaces or nonTrivialGuard — registers the suite with that alone:
registerDemoVaultCoverageSuite({ rootFolder: getRootFolder() ?? process.cwd() });It then runs every authoring check, plus a guard that the vault is not empty — with no notes to read, the authoring checks would pass by having nothing to look at. Omit the reflection specs, never the suite: the authoring rules apply to every vault, whatever the plugin exposes.
Clicking every button
Section titled “Clicking every button”The coverage suite reads the notes; it never runs them. A button whose require('/demoSetup.ts') path is wrong, or that calls an API which has since changed shape, fails at click time — lint:md reads the markdown, the coverage suite checks the conventions, and neither executes anything, so nothing in a normal gate run ever finds out. registerDemoVaultButtonSuite (from obsidian-dev-utils/script-utils/demo-vault-buttons) is the gate that does: it opens every note of the in-repo demo-vault/ in a real Obsidian, clicks each code-button, and fails with the note, the caption and CodeScript Toolkit’s own captured error for any button that reports an error or never reports at all.
It takes three pieces of wiring, and all three are needed — the suite alone, without the other two, collects nothing or passes vacuously.
The suite
Section titled “The suite”src/demo-vault-buttons.demo-vault.integration.test.ts is the whole per-repo cost:
import { registerDemoVaultButtonSuite } from 'obsidian-dev-utils/script-utils/demo-vault-buttons';
registerDemoVaultButtonSuite();It walks the vault including any group folders, so a vault whose walkthroughs are grouped into numbered
folders is gated like any other — Materials/, _assets/, and any folder whose name starts with . or
_ are skipped, since they hold fixtures and config rather than walkthroughs. Before 94.5.0 only the
vault root was read, which reported nothing at all for a fully-grouped vault and silently gated only the
ungrouped part of a mixed one. excludedNotes matches either a file name or a vault-relative path.
It registers one it per note that declares at least one button, and runs one evalInObsidian per button — a single closure is one CDP Runtime.evaluate, which the harness caps at 30 seconds, so a note with a dozen buttons batched into one closure would time out as a whole instead of naming the button that actually failed.
Every option has a default worth knowing before overriding it:
registerDemoVaultButtonSuite({ // How long a clicked button may take to report a result. Defaults to 15000. buttonResultTimeoutInMilliseconds: 15_000, // Notes to skip. Defaults to `['README.md']` — the repo-facing page GitHub renders, not a walkthrough. // `00 Start.md` is deliberately NOT excluded: landing notes carry buttons of their own. excludedNotes: ['README.md'], // The repo root holding `demo-vault/`. Defaults to the resolved repo root, falling back to `process.cwd()`. rootFolder: getRootFolder() ?? process.cwd(), // How long a note's preview and its buttons may take to mount. Defaults to 20000. settleTimeoutInMilliseconds: 20_000});The global setup
Section titled “The global setup”The suite opens a temporary copy of the vault, so something has to put the vault there first — including the CodeScript Toolkit binary, without which every ```code-button fence stays an inert code block. That half is buildDemoVaultPopulate from obsidian-integration-testing; this package deliberately does not duplicate it. Put it in scripts/demo-vault-global-setup.ts:
import type { PopulateFilesParams } from 'obsidian-integration-testing';
import { join } from 'node:path';import process from 'node:process';import { CODE_SCRIPT_TOOLKIT_PLUGIN_ID } from 'obsidian-dev-utils/script-utils/demo-vault-buttons';import { getRootFolder } from 'obsidian-dev-utils/script-utils/root';import { buildDemoVaultPopulate } from 'obsidian-integration-testing';import { createSetup } from 'obsidian-integration-testing/vitest-global-setup-plugin';
const CODE_SCRIPT_TOOLKIT_SETTINGS = { invocableScriptsFolder: 'Invocables', modulesRoot: '_assets/CodeScriptToolkit', shouldHandleProtocolUrls: true, startupScriptPath: 'startup.ts'};
function populate(): PopulateFilesParams { return buildDemoVaultPopulate({ demoVaultPath: join(getRootFolder() ?? process.cwd(), 'demo-vault'), injectPlugins: [{ data: CODE_SCRIPT_TOOLKIT_SETTINGS, pluginId: CODE_SCRIPT_TOOLKIT_PLUGIN_ID }] });}
const { setup, teardown } = createSetup({ enableCommunityPlugins: [CODE_SCRIPT_TOOLKIT_PLUGIN_ID], populate});
export { setup, teardown};CODE_SCRIPT_TOOLKIT_PLUGIN_ID is exported by the same module so no repo has to hard-code fix-require-modules — CodeScript Toolkit still carries its original manifest id, which no longer matches its name and cannot be changed now that it is published.
The binary is copied out of your own demo-vault/, where the in-vault demo-vault-helper installs it from the community registry the first time the vault is opened. That keeps the run hermetic — no network, nothing to rate-limit — at the price of one manual step per repo. Until it has been done, the setup fails with:
Community plugin "fix-require-modules" is not installed in the demo vault (…/main.js missing).Open demo-vault/ in Obsidian once so demo-vault-helper installs it, then re-run.Do exactly that, once; the installed copy is gitignored, so each machine that runs the suite does it for itself.
The vitest project
Section titled “The vitest project”The suite drives a real desktop Obsidian, like the desktop project, but against a populated copy of demo-vault/ rather than an empty vault — so it needs its own globalSetup, and its own file suffix so the desktop project does not collect it and open it against a vault with no notes:
export const config = defineObsidianPluginVitestConfig({ customProjects(context: ObsidianPluginVitestConfigContext): TestProjectConfiguration[] { return [ { test: { ...context.desktop, globalSetup: ['./scripts/demo-vault-global-setup.ts'], include: ['src/**/*.demo-vault.integration.test.ts'], name: 'integration-tests:demo-vault' } } ]; }});Then add the project to scripts/test-integration.ts alongside the standard ones. Both halves matter: a project that is never declared matches no filter, and vitest fails the run with No projects matched the filter "integration-tests:demo-vault" — which, in a script that awaits its projects in order, also stops every project after it from running at all. A suite file whose suffix matches no declared project’s include is collected by nothing and reports nothing, which looks exactly like passing.
Run it in isolation with npx vitest run --project integration-tests:demo-vault.
What reading view does to the assertions
Section titled “What reading view does to the assertions”Two quirks of Obsidian’s reading view shape the suite, and both explain assertions that otherwise look too weak:
- It renders lazily and unmounts sections far off-screen, so no single scroll position holds a whole note’s buttons: sitting at the top never mounts the last ones, and sitting at the bottom never mounts the first ones. The suite therefore walks the preview — a viewport at a time, wrapping back to the top — while it waits, and accumulates the captions it sees along the way. Until 94.4.1 it pinned the preview to the bottom instead, which silently reported every button that was not near the end of its note as never rendered (
status: 'timeout'with empty output). If you see that status with an empty output, the button did not fail — the suite failed to reach it. - A button that opens a note moves the active view out from under the suite. Every lookup reads the
active
MarkdownView, so before 94.4.2 the first button that navigated — an ordinary thing for a demo button to do — sent every later button in that note tostatus: 'timeout'with an empty output, which reads as a button that never rendered rather than one looked for in the wrong view. Since 94.4.2 the note is re-opened before each click, so buttons are independent of what the ones before them did to the workspace. One consequence worth designing for: a button that acts on “the current file” sees the walkthrough note, not whatever a previous button opened. - While it settles it can hold several elements per fence — the same button has been observed rendered twice, so a four-button note reports six buttons with the first two captions duplicated. Buttons are therefore addressed by caption, deduplicated, never by index: indexing would click one button twice and miss another entirely. For the same reason the count assertion is a lower bound (at least as many distinct buttons rendered as the source declares), which still catches the failure that matters — a fence that silently stayed a plain code block.
The vault and the rest of your tooling
Section titled “The vault and the rest of your tooling”The shared script runners have been demo-vault-aware since 87.0.2, so nothing here needs the shared scripts patched.
- markdownlint lints the vault like the rest of your documentation — do not add a scoped
demo-vault/.markdownlint-cli2.jsonc. Such a file used to be needed to switch offMD041(notes that opened with a[Docs]link instead of a heading) andMD052(notes navigated by wikilinks); since 92.0.0 the coverage suite forbids both outright, so the suppressions suppress nothing. Keep the file only where a note legitimately carries a wikilink outside a fence, only forMD052, and write the reason in it. The vault’s ownREADME.mdis exempt from the coverage suite but not from markdownlint, so it still needs an# H1. - cspell — add demo-specific words (plugin ids and the like) to the repo’s
cspell.jsonwords. - eslint,
tscand dprint should leavedemo-vault/_assetsalone: the demo scripts show unusual module andrequirestyles on purpose, and a formatter that “fixes” them breaks the lesson. Exclude the vault in eslint (ignores: ['demo-vault/**']), keep it out of thetsconfiginclude, and adddemo-vaulttoexcludesin the project’sdprint.json. The shared eslint config does not do this for you. - Link checking does cover the notes. A broken link in a demo note is a real broken link — fix it rather than excluding the vault.