obsidian-integration-testing 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +26 -1092
  2. package/dist/lib/cjs/capture-obsidian-screenshot.cjs +60 -0
  3. package/dist/lib/cjs/capture-obsidian-screenshot.d.cts +53 -0
  4. package/dist/lib/cjs/capture-screenshot.cjs +82 -0
  5. package/dist/lib/cjs/capture-screenshot.d.cts +99 -0
  6. package/dist/lib/cjs/connect-to-cdp.cjs +7 -1
  7. package/dist/lib/cjs/connect-to-cdp.d.cts +13 -0
  8. package/dist/lib/cjs/fit-screenshot.cjs +124 -0
  9. package/dist/lib/cjs/fit-screenshot.d.cts +117 -0
  10. package/dist/lib/cjs/index.cjs +18 -1
  11. package/dist/lib/cjs/index.d.cts +6 -0
  12. package/dist/lib/cjs/library.cjs +1 -1
  13. package/dist/lib/cjs/obsidian-metadata.cjs +28 -1
  14. package/dist/lib/cjs/transport-appium.cjs +23 -1
  15. package/dist/lib/cjs/transport-appium.d.cts +16 -0
  16. package/dist/lib/cjs/transport-desktop-cdp.cjs +43 -1
  17. package/dist/lib/cjs/transport-desktop-cdp.d.cts +15 -0
  18. package/dist/lib/cjs/transport.cjs +1 -1
  19. package/dist/lib/cjs/transport.d.cts +15 -0
  20. package/dist/lib/cjs/type-guards.cjs +6 -1
  21. package/dist/lib/cjs/type-guards.d.cts +7 -0
  22. package/dist/lib/esm/capture-obsidian-screenshot.d.mts +53 -0
  23. package/dist/lib/esm/capture-obsidian-screenshot.mjs +29 -0
  24. package/dist/lib/esm/capture-screenshot.d.mts +99 -0
  25. package/dist/lib/esm/capture-screenshot.mjs +55 -0
  26. package/dist/lib/esm/connect-to-cdp.d.mts +13 -0
  27. package/dist/lib/esm/connect-to-cdp.mjs +7 -1
  28. package/dist/lib/esm/fit-screenshot.d.mts +117 -0
  29. package/dist/lib/esm/fit-screenshot.mjs +89 -0
  30. package/dist/lib/esm/index.d.mts +6 -0
  31. package/dist/lib/esm/index.mjs +19 -1
  32. package/dist/lib/esm/library.mjs +1 -1
  33. package/dist/lib/esm/obsidian-metadata.mjs +28 -1
  34. package/dist/lib/esm/transport-appium.d.mts +16 -0
  35. package/dist/lib/esm/transport-appium.mjs +26 -1
  36. package/dist/lib/esm/transport-desktop-cdp.d.mts +15 -0
  37. package/dist/lib/esm/transport-desktop-cdp.mjs +47 -1
  38. package/dist/lib/esm/transport.d.mts +15 -0
  39. package/dist/lib/esm/type-guards.d.mts +7 -0
  40. package/dist/lib/esm/type-guards.mjs +5 -1
  41. package/dist/obsidian-integration-testing-10.2.0.tgz +0 -0
  42. package/package.json +36 -14
  43. package/dist/obsidian-integration-testing-10.0.0.tgz +0 -0
package/README.md CHANGED
@@ -8,12 +8,32 @@
8
8
 
9
9
  A set of helpers that simplify integration testing of [Obsidian](https://obsidian.md/) plugins against a running Obsidian instance.
10
10
 
11
+ Your tests run inside a **real Obsidian** — the real `App`, the real vault, the real workspace, internal APIs included. By default the harness launches and owns an isolated instance in a temporary `--user-data-dir`, so your own Obsidian (its config, vault registry, open window, auto-update) is never touched. The same test code runs on the desktop app over `CDP` and on Obsidian Mobile over Appium.
12
+
13
+ ## Documentation
14
+
15
+ Full documentation lives at **[mnaoumov.dev/obsidian-integration-testing](https://mnaoumov.dev/obsidian-integration-testing/)**.
16
+
17
+ - [Getting started](https://mnaoumov.dev/obsidian-integration-testing/guides/getting-started/) — install, wire up Vitest or Jest, first assertion.
18
+ - [Writing tests](https://mnaoumov.dev/obsidian-integration-testing/guides/writing-tests/) — how `evalInObsidian` executes a callback, arguments, results, `ContextId`, internal APIs.
19
+ - [Simulating user input](https://mnaoumov.dev/obsidian-integration-testing/guides/user-input/) — trusted keyboard and pointer events, and `waitUntil`.
20
+ - [The `lib` bag](https://mnaoumov.dev/obsidian-integration-testing/guides/lib/) — inject your own helpers into callbacks; `createNote`.
21
+ - [Vaults and fixtures](https://mnaoumov.dev/obsidian-integration-testing/guides/vaults/) — temporary vaults, pre-populating files, seeding a plugin's `demo-vault/`, non-plugin consumers.
22
+ - [Transport modes](https://mnaoumov.dev/obsidian-integration-testing/guides/transports/) — version pinning, window visibility, attaching to a running Obsidian, multi-platform runs.
23
+ - [Android testing](https://mnaoumov.dev/obsidian-integration-testing/guides/android/) — Appium setup, AVD provisioning, troubleshooting.
24
+ - [Version matrix](https://mnaoumov.dev/obsidian-integration-testing/guides/version-matrix/) — run the suites across the supported Obsidian range.
25
+ - [Leftover cleanup](https://mnaoumov.dev/obsidian-integration-testing/guides/leftover-cleanup/) — what a dead run leaks, and how the sweeps handle it.
26
+ - [Ad-hoc debugging](https://mnaoumov.dev/obsidian-integration-testing/guides/debugging/) — `connectToCdp` and the CLI.
27
+ - [API reference](https://mnaoumov.dev/obsidian-integration-testing/api/) — every exported function, class and type, generated from the TSDoc.
28
+
11
29
  ## Installation
12
30
 
13
31
  ```bash
14
32
  npm install --save-dev obsidian-integration-testing
15
33
  ```
16
34
 
35
+ You also need [Obsidian](https://obsidian.md/download) (the desktop app) installed, and [Node.js](https://nodejs.org/) 22+.
36
+
17
37
  ## Quick start
18
38
 
19
39
  The global setup expects your built plugin in `dist/dev` or `dist/build` (whichever has a newer `main.js`), with a `manifest.json` at the root of the chosen folder. The setup creates a temporary vault, copies the build into it, and enables the plugin.
@@ -26,25 +46,10 @@ import { defineConfig } from 'vitest/config';
26
46
 
27
47
  export default defineConfig({
28
48
  test: {
29
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
30
- },
31
- });
32
- ```
33
-
34
- To get Vitest module augmentations (`environmentOptions.obsidianTransport`, `inject('obsidianTransport')`, `inject('temporaryVaultPath')`), add a side-effect import in your test setup or config:
35
-
36
- ```ts
37
- import 'obsidian-integration-testing/vitest/typings';
38
- ```
39
-
40
- Or add it to `compilerOptions.types` in your `tsconfig.json`:
41
-
42
- ```json
43
- {
44
- "compilerOptions": {
45
- "types": ["obsidian-integration-testing/vitest/typings"]
49
+ fileParallelism: false,
50
+ globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin']
46
51
  }
47
- }
52
+ });
48
53
  ```
49
54
 
50
55
  ### Jest
@@ -53,382 +58,11 @@ Or add it to `compilerOptions.types` in your `tsconfig.json`:
53
58
  // jest.config.ts
54
59
  export default {
55
60
  globalSetup: 'obsidian-integration-testing/jest-global-setup-plugin',
56
- globalTeardown: 'obsidian-integration-testing/jest-global-teardown-plugin',
57
- };
58
- ```
59
-
60
- > [!NOTE]
61
- >
62
- > Jest requires the `globalSetup` and `globalTeardown` modules to be **separate** entry points, each with a **default-export** function — that is why setup and teardown are imported from two different subpaths.
63
-
64
- To configure transport options with Jest, populate `globalThis.__obsidianIntegrationTesting` before the global setup runs (e.g., in a setup file or via Jest `globals`):
65
-
66
- ```ts
67
- globalThis.__obsidianIntegrationTesting = {
68
- transportOptions: { type: 'obsidian-cdp' },
61
+ globalTeardown: 'obsidian-integration-testing/jest-global-teardown-plugin'
69
62
  };
70
63
  ```
71
64
 
72
- After setup, `globalThis.__obsidianIntegrationTesting.temporaryVaultPath` is available in test workers.
73
-
74
- By default this launches a **harness-owned, isolated `CDP` instance** (a temporary Obsidian that never touches your real config). See [Transport modes](#transport-modes) for version pinning, attaching to a running Obsidian, and mobile.
75
-
76
- ### Write integration tests
77
-
78
- Use `evalInObsidian()` to run code inside the Obsidian process. The `vaultPath` is optional — it defaults to `process.cwd()`:
79
-
80
- ```ts
81
- import { evalInObsidian } from 'obsidian-integration-testing';
82
-
83
- // Simple expression
84
- const sum = await evalInObsidian({
85
- input: { a: 2, b: 3 },
86
- callback: ({ a, b }) => a + b
87
- });
88
- // sum === 5
89
- ```
90
-
91
- ### Access the Obsidian API
92
-
93
- Every callback receives `app` (the Obsidian `App` instance) and `obsidianModule` (the full `obsidian` module):
94
-
95
- ```ts
96
- // Read the vault config directory
97
- const configDir = await evalInObsidian({
98
- callback: ({ app }) => app.vault.configDir
99
- });
100
-
101
- // Use the obsidian module
102
- const yaml = await evalInObsidian({
103
- callback: ({ obsidianModule }) => obsidianModule.stringifyYaml({ key: 'value' })
104
- });
105
-
106
- // Access internal APIs
107
- const title = await evalInObsidian({
108
- callback: ({ app }) => app.title
109
- });
110
- ```
111
-
112
- ### Simulate real user input (trusted keyboard & pointer)
113
-
114
- The callback's `lib` bag provides helpers that inject **trusted** input at the Chromium level
115
- (via Electron's `webContents.sendInputEvent`) — the kind of event only the browser/OS
116
- normally produces. This matters because the in-page alternatives give false results:
117
- `dispatchEvent(new KeyboardEvent(...))` / `new MouseEvent(...)` are untrusted
118
- (`isTrusted: false`), so CodeMirror ignores the keystroke and `:hover` never takes effect;
119
- `execCommand('insertText')` mutates the selection even when the editor is not focused,
120
- masking focus bugs as false passes. The trusted helpers flow through the real input
121
- pipeline, so text lands **only if the editor genuinely holds focus** and `:hover` rules
122
- genuinely apply.
123
-
124
- The element/editor arguments are live renderer DOM nodes — the callback runs in the Obsidian
125
- renderer, so no cross-process serialization is needed.
126
-
127
- | Helper | Purpose |
128
- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
- | `typeIntoEditor({ editor, text })` | Focuses `editor` (caret to end), types `text` as trusted key events, then polls until the document reflects it. |
130
- | `pressKey({ key, modifiers })` | Presses `key` with optional `modifiers` as a trusted `keyDown`→`char`→`keyUp` on the DOM-focused element (fires `keydown`/`keypress`/`beforeinput`/`input`/`keyup`). **Synchronous**; does **not** poll — pair with `waitUntil`. |
131
- | `hoverElement({ element })` | Moves the pointer to `element`'s center, then polls until `element.matches(':hover')`. |
132
- | `unhoverElement({ element })` | Moves the pointer just outside `element`'s bounding box, then polls until it no longer matches `:hover`. |
133
- | `moveMouse({ x, y })` | Low-level primitive: injects one trusted pointer move at the given web-contents DIP coordinates. **Synchronous**; does **not** poll. |
134
-
135
- ```ts
136
- // Type into the active editor — only succeeds if the editor truly holds focus.
137
- const typed = await evalInObsidian({
138
- callback: async ({ app, lib: { typeIntoEditor }, obsidianModule }) => {
139
- const view = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView);
140
- const editor = view?.editor;
141
- if (!editor) {
142
- return null;
143
- }
144
-
145
- await typeIntoEditor({ editor, text: 'Hello, world!' });
146
- return editor.getValue();
147
- }
148
- });
149
-
150
- // Press special keys / shortcuts (Obsidian `Modifier` names; `'Mod'` = Cmd on macOS, Ctrl elsewhere).
151
- // A key press has no universal effect, so pair it with `waitUntil` to await the outcome.
152
- await evalInObsidian({
153
- callback: async ({ app, lib: { pressKey, waitUntil }, obsidianModule }) => {
154
- const editor = app.workspace.getActiveViewOfType(obsidianModule.MarkdownView)?.editor;
155
- editor?.focus();
156
-
157
- pressKey({ key: 'Enter', modifiers: ['Shift'] }); // synchronous; soft line break
158
- await waitUntil({ predicate: () => (editor?.getValue().includes('\n') ?? false) });
159
- }
160
- });
161
-
162
- // Observe a genuine :hover state (real theme var() values, real compositing).
163
- await evalInObsidian({
164
- callback: async ({ lib: { hoverElement, unhoverElement } }) => {
165
- const bar = document.querySelector<HTMLElement>('.minimized-modal-bar');
166
- if (!bar) {
167
- return;
168
- }
169
-
170
- await hoverElement({ element: bar });
171
- // ...assert the hovered appearance...
172
- await unhoverElement({ element: bar });
173
- }
174
- });
175
- ```
176
-
177
- > **Serialize focus/pointer-dependent test files.** Trusted input targets the single shared
178
- > window's **global** focus and pointer, so test files that depend on either must not run in
179
- > parallel against the one shared Obsidian instance (they race for focus, and a
180
- > `detachLeavesOfType('markdown')` in one file wipes another's editor). Run your
181
- > obsidian-integration Vitest project serially — `fileParallelism: false` and `maxWorkers: 1`.
182
-
183
- ### Wait for an async condition (`waitUntil`)
184
-
185
- The `lib` bag also provides a `waitUntil({ predicate })` helper for polling until an
186
- asynchronous effect settles (a view opens, a DOM node appears, a setting applies). Because
187
- the callback is serialized via `toString()` and **cannot import modules**, it can't reuse a
188
- library poll helper — `waitUntil` is the shared, injected replacement for the loops you would
189
- otherwise hand-roll in every closure.
190
-
191
- The `predicate` may be synchronous or asynchronous (it is `await`ed on each poll). It is
192
- checked immediately, then re-checked every `intervalInMilliseconds` until it returns truthy or
193
- `timeoutInMilliseconds` elapses, at which point the returned promise **rejects** (the error
194
- includes `message` when given).
195
-
196
- | Option | Purpose | Default |
197
- | ------------------------ | ----------------------------------------------------- | ------- |
198
- | `predicate` | Condition to poll; sync or async, awaited each check. | — |
199
- | `intervalInMilliseconds` | Delay between polls. | `50` |
200
- | `timeoutInMilliseconds` | Max time to wait before rejecting. | `5000` |
201
- | `message` | Detail appended to the timeout error message. | — |
202
-
203
- ```ts
204
- // Wait until the plugin has opened a Markdown view, then read its editor.
205
- const value = await evalInObsidian({
206
- callback: async ({ app, lib: { waitUntil }, obsidianModule }) => {
207
- await waitUntil({
208
- message: 'no active Markdown view',
209
- predicate: () => Boolean(app.workspace.getActiveViewOfType(obsidianModule.MarkdownView))
210
- });
211
- return app.workspace.getActiveViewOfType(obsidianModule.MarkdownView)?.editor.getValue() ?? null;
212
- }
213
- });
214
- ```
215
-
216
- ### Create a note that is actually written (`createNote`)
217
-
218
- **Use `lib.createNote({ content, path })` instead of `app.vault.create` in any suite that may run
219
- on Android.** The emulator transport loses roughly **0.9 %** of `vault.create` writes — measured at
220
- 7 lost in 800 creates, over two runs of a 400-create stress harness.
221
-
222
- What a lost write looks like, captured at the moment of failure and before any repair:
223
-
224
- | Probe | Value |
225
- | ------------------------------------- | ------------------------- |
226
- | `adapter.stat(path).size` | `0` |
227
- | `adapter.stat(path).size` + 500 ms | `0` |
228
- | `vault.read(file).length` | `0` |
229
- | `vault.cachedRead(file).length` | `0` |
230
- | `file.stat.size` (Obsidian's `TFile`) | `38` — the full content |
231
-
232
- So the write is lost **below** Obsidian: `TFile.stat` says it wrote the content, the filesystem
233
- holds zero bytes, and it does not heal on its own. It is not a trash/recreate race — creating a
234
- fresh never-used path fails at the same rate as recreating a just-trashed one.
235
-
236
- The consequence for a suite is worse than the rate suggests: at ~34 creates per run there is a
237
- **~26 %** chance of at least one lost write, and whichever test loses that lottery is the one that
238
- fails — on a `waitUntil` for content that was never written. It moves between tests run to run,
239
- which is exactly why it reads as a per-test flake rather than one shared cause.
240
-
241
- `createNote` creates the note, **reads it back**, and rewrites it through `vault.modify` (which
242
- lands correctly) until the content matches, up to three repairs. Verification is by read-back and
243
- never by `TFile.stat` — `stat` is precisely the field that lies here. If the content still has not
244
- landed, it throws naming the path and both lengths, so a genuinely broken write fails loudly
245
- instead of being retried forever. On a transport that does not lose writes the first read matches
246
- and nothing is rewritten, so it is safe to use everywhere.
247
-
248
- ```ts
249
- const heading = await evalInObsidian({
250
- callback: async ({ app, lib: { createNote, waitUntil }, obsidianModule }) => {
251
- // Guaranteed on disk before the wait below can depend on it.
252
- const file = await createNote({ content: '# Title\n', path: 'note.md' });
253
- await app.workspace.getLeaf().openFile(file);
254
- await waitUntil({
255
- message: 'the rendered heading did not appear',
256
- predicate: () => Boolean(document.querySelector('.markdown-preview-view h1'))
257
- });
258
- return document.querySelector('.markdown-preview-view h1')?.textContent ?? null;
259
- }
260
- });
261
- ```
262
-
263
- Whether `modify` / `process` writes can be lost the same way is **not** measured — only `create`
264
- was stressed — so a content assertion that fails elsewhere may share this cause.
265
-
266
- ### Inject a shared library (`lib`)
267
-
268
- Because a callback is serialized via `toString()` and **cannot import modules**, it can't reuse
269
- your utility library directly. Every callback receives a `lib` argument — a single bag that
270
- **provider packages populate** with their whole (renderer-safe) library, so closures can call
271
- shared helpers instead of hand-rolling them. `lib` is `{}` until a provider registers a resolver.
272
-
273
- A provider registers a **renderer-side resolver** with `registerLibResolver` from its per-worker
274
- test setup (a `setupFiles` entry, the same place the context resolvers are registered). The
275
- resolver runs inside Obsidian and returns an object; every registered resolver's result is merged
276
- (`Object.assign`) into the one `lib` bag, so multiple providers compose. The resolver is
277
- serialized, so it must be self-contained — read a value a fixture plugin published on `window`:
278
-
279
- ```ts
280
- // provider's setup file (registered via setupFiles)
281
- import { registerLibResolver } from 'obsidian-integration-testing';
282
-
283
- registerLibResolver(() => window.__myLibraryModule__);
284
- ```
285
-
286
- Make `lib` type-safe by augmenting the `Lib` interface (multiple augmentations merge, mirroring
287
- the runtime merge):
288
-
289
- ```ts
290
- declare module 'obsidian-integration-testing' {
291
- interface Lib {
292
- getThing(id: string): Thing;
293
- }
294
- }
295
-
296
- const name = await evalInObsidian({
297
- callback: ({ lib: { getThing } }) => getThing('a').name
298
- });
299
- ```
300
-
301
- ### Pass complex arguments
302
-
303
- Arguments are JSON-serialized. You can even pass functions — they are serialized via `toString()`:
304
-
305
- ```ts
306
- const result = await evalInObsidian({
307
- input: {
308
- transform(x: number): number {
309
- return x * 2;
310
- },
311
- value: 5
312
- },
313
- callback: ({ transform, value }) => transform(value)
314
- });
315
- // result === 10
316
- ```
317
-
318
- ### Persist non-serializable values across calls
319
-
320
- Obsidian objects like `TFile` or `Editor` live in the Obsidian process and can't be returned to the test. Use `ContextId` to create a typed store that persists across calls:
321
-
322
- ```ts
323
- import type { TFile } from 'obsidian';
324
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
325
- import { ContextId, evalInObsidian } from 'obsidian-integration-testing';
326
-
327
- interface Context {
328
- file: TFile;
329
- }
330
-
331
- const contextId = new ContextId<Context>();
332
-
333
- beforeEach(async () => {
334
- await evalInObsidian({
335
- contextId,
336
- callback: async ({ app, context }) => {
337
- context.file = await app.vault.create('test.md', '# Hello');
338
- }
339
- });
340
- });
341
-
342
- afterEach(async () => {
343
- await evalInObsidian({
344
- contextId,
345
- callback: async ({ app, context: { file } }) => {
346
- await app.vault.delete(file);
347
- }
348
- });
349
- await contextId.dispose();
350
- });
351
-
352
- it('should read the file path', async () => {
353
- const path = await evalInObsidian({
354
- contextId,
355
- callback: ({ context: { file } }) => file.path
356
- });
357
- expect(path).toBe('test.md');
358
- });
359
- ```
360
-
361
- ### Create a temporary vault
362
-
363
- Use `TemporaryVault` to create a disposable vault pre-populated with files:
364
-
365
- ```ts
366
- import type { TFile } from 'obsidian';
367
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
368
- import { ContextId, evalInObsidian, TemporaryVault } from 'obsidian-integration-testing';
369
-
370
- interface Context {
371
- file: TFile;
372
- }
373
-
374
- const vault = new TemporaryVault();
375
-
376
- vault.populate({
377
- 'note.md': '# Hello',
378
- 'folder/nested.md': 'nested content',
379
- });
380
-
381
- const contextId = new ContextId<Context>();
382
-
383
- beforeAll(async () => {
384
- await vault.register();
385
-
386
- // Resolve the pre-populated file into a TFile and store it in the context
387
- await evalInObsidian({
388
- contextId,
389
- callback: async ({ app, context }) => {
390
- const file = app.vault.getFileByPath('note.md');
391
- if (!file) {
392
- throw new Error('File not found');
393
- }
394
- context.file = file;
395
- },
396
- vaultPath: vault.path
397
- });
398
- });
399
-
400
- afterAll(async () => {
401
- await contextId.dispose(vault.path);
402
- await vault.dispose();
403
- });
404
-
405
- it('should read a pre-populated file', async () => {
406
- const content = await evalInObsidian({
407
- callback: ({ app }) => app.vault.adapter.read('note.md'),
408
- vaultPath: vault.path
409
- });
410
- expect(content).toBe('# Hello');
411
- });
412
-
413
- it('should access the TFile from context', async () => {
414
- const path = await evalInObsidian({
415
- contextId,
416
- callback: ({ context: { file } }) => file.path,
417
- vaultPath: vault.path
418
- });
419
- expect(path).toBe('note.md');
420
- });
421
- ```
422
-
423
- Both `TemporaryVault` and `ContextId` implement `AsyncDisposable`, so you can use `await using` for automatic cleanup.
424
-
425
- Parent directories are created automatically. To create an empty folder, use a path ending with `/` and an empty string as content.
426
-
427
- ### Test your plugin
428
-
429
- Use `getTemporaryVault()` to get the temporary vault created by the global setup:
430
-
431
- **Vitest:**
65
+ ### Write a test
432
66
 
433
67
  ```ts
434
68
  import { describe, expect, it } from 'vitest';
@@ -446,710 +80,10 @@ describe('my-plugin', () => {
446
80
  });
447
81
  expect(isEnabled).toBe(true);
448
82
  });
449
-
450
- it('should create a file', async () => {
451
- await evalInObsidian({
452
- callback: async ({ app }) => {
453
- await app.vault.create('test.md', '# Hello');
454
- },
455
- vaultPath: vault.path
456
- });
457
-
458
- const content = await evalInObsidian({
459
- callback: ({ app }) => app.vault.adapter.read('test.md'),
460
- vaultPath: vault.path
461
- });
462
- expect(content).toBe('# Hello');
463
- });
464
- });
465
- ```
466
-
467
- **Jest:**
468
-
469
- ```ts
470
- import { evalInObsidian } from 'obsidian-integration-testing';
471
- import { getTemporaryVault } from 'obsidian-integration-testing/jest-global-setup-plugin';
472
-
473
- describe('my-plugin', () => {
474
- const vault = getTemporaryVault();
475
-
476
- it('should be enabled', async () => {
477
- const isEnabled = await evalInObsidian({
478
- input: { pluginId: 'my-plugin' },
479
- callback: ({ app, pluginId }) => app.plugins.enabledPlugins.has(pluginId),
480
- vaultPath: vault.path
481
- });
482
- expect(isEnabled).toBe(true);
483
- });
484
- });
485
- ```
486
-
487
- ### Pre-populate the vault before Obsidian opens
488
-
489
- For large fixtures, write files into the vault **before** Obsidian opens it, 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. The same `populate` map shape is used everywhere (`path` → file content; a path ending with `/` and empty content creates an empty folder; parent directories are created automatically).
490
-
491
- This capability reaches all three consumption paths.
492
-
493
- **Vitest** — create your own `globalSetup` module with `createSetup({ populate })` and point the config at it. `populate` is a thunk so large fixtures are built lazily, once, in the setup process:
494
-
495
- ```ts
496
- // integration-global-setup.ts
497
- import { createSetup } from 'obsidian-integration-testing/vitest-global-setup-plugin';
498
-
499
- export const { setup, teardown } = createSetup({
500
- populate: () => ({
501
- 'note.md': '# Hello',
502
- 'folder/nested.md': 'nested content'
503
- })
504
- });
505
- ```
506
-
507
- ```ts
508
- // vitest.config.ts
509
- import { defineConfig } from 'vitest/config';
510
-
511
- export default defineConfig({
512
- test: {
513
- globalSetup: ['./integration-global-setup.ts']
514
- }
515
- });
516
- ```
517
-
518
- **Jest** — same `createSetup({ populate })` factory, but Jest needs `globalSetup` and `globalTeardown` to be separate modules, each with a **default-export** function. Build the `createSetup` pair once in a shared module and re-export each half as a default:
519
-
520
- ```ts
521
- // integration-global-setup.ts — shared createSetup pair
522
- import { createSetup } from 'obsidian-integration-testing/jest-global-setup-plugin';
523
-
524
- export const { setup, teardown } = createSetup({
525
- populate: () => ({
526
- 'note.md': '# Hello',
527
- 'folder/nested.md': 'nested content'
528
- })
529
- });
530
-
531
- export default setup;
532
- ```
533
-
534
- ```ts
535
- // integration-global-teardown.ts
536
- import { teardown } from './integration-global-setup.ts';
537
-
538
- export default teardown;
539
- ```
540
-
541
- ```ts
542
- // jest.config.ts
543
- export default {
544
- globalSetup: '<rootDir>/integration-global-setup.ts',
545
- globalTeardown: '<rootDir>/integration-global-teardown.ts'
546
- };
547
- ```
548
-
549
- Both files share the same `createSetup` instance (via the common module), so `teardown` cleans up exactly what `setup` created.
550
-
551
- **Manual** — when wiring `TemporaryVault` yourself (without a framework global setup), call `vault.populate()` before `vault.register()`, as shown in [Create a temporary vault](#create-a-temporary-vault).
552
-
553
- ### Seed a plugin's `demo-vault/` and enable extra community plugins
554
-
555
- A plugin's committed `demo-vault/` often needs more than the plugin-under-test — e.g. **CodeScript Toolkit** (`fix-require-modules`) to run its `code-button` blocks, or the `demo-vault-helper` bootstrap. Two pieces make this a one-liner:
556
-
557
- - **`enableCommunityPlugins`** — a `createSetup` option 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). This replaces the hand-rolled `beforeAll` that turned off restricted mode and called `enablePlugin(...)` in every demo-vault test.
558
- - **`buildDemoVaultPopulate`** — reads the repo's `demo-vault/` tree, carries over selected `.obsidian/*` config (`app.json`, `appearance.json`, `core-plugins.json` by default), and seeds each injected plugin's binaries (+ optional `data.json`) — returning a `populate` map. It complements the release-time demo-vault archiving in `obsidian-dev-utils`.
559
-
560
- ```ts
561
- // integration-global-setup.ts
562
- import { buildDemoVaultPopulate } from 'obsidian-integration-testing';
563
- import { createSetup } from 'obsidian-integration-testing/vitest-global-setup-plugin';
564
- import { getRootFolder } from 'obsidian-dev-utils/script-utils/root';
565
- import { join } from 'node:path';
566
-
567
- const CST_ID = 'fix-require-modules';
568
-
569
- export const { setup, teardown } = createSetup({
570
- // Turn on the seeded extra plugins (the plugin-under-test is enabled automatically).
571
- enableCommunityPlugins: [CST_ID],
572
- populate: () =>
573
- buildDemoVaultPopulate({
574
- demoVaultPath: join(getRootFolder() ?? process.cwd(), 'demo-vault'),
575
- // CST binaries come from the demo vault's local (gitignored) install; `data` writes its data.json.
576
- injectPlugins: [{ pluginId: CST_ID, data: { modulesRoot: '_assets' } }]
577
- })
578
- });
579
- ```
580
-
581
- `buildDemoVaultPopulate` throws an actionable error if an injected plugin's `main.js`/`manifest.json` is missing from the demo vault — open `demo-vault/` in Obsidian once so `demo-vault-helper` installs it, then re-run. `enableCommunityPlugins` also composes with `installPlugin: false` (enable extras into an otherwise plugin-less vault).
582
-
583
- ### Non-plugin consumers
584
-
585
- If your project is **not** a plugin — a tool that only needs a registered, empty vault to `evalInObsidian` against (e.g. 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 skips reading `dist/manifest.json`, copying a plugin, writing `community-plugins.json`, and enabling a plugin. No wrapper module needed:
586
-
587
- ```ts
588
- // vitest.config.ts
589
- import { defineConfig } from 'vitest/config';
590
-
591
- export default defineConfig({
592
- test: {
593
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-no-plugin']
594
- }
595
83
  });
596
84
  ```
597
85
 
598
- ```ts
599
- // your.integration.test.ts — read the empty registered vault's path
600
- import { getTemporaryVault } from 'obsidian-integration-testing/vitest-global-setup-no-plugin';
601
- ```
602
-
603
- For Jest, use `obsidian-integration-testing/jest-global-setup-no-plugin` (`globalSetup`) + `obsidian-integration-testing/jest-global-teardown-no-plugin` (`globalTeardown`). If you also need to pre-populate the empty vault, build the pair yourself with `createSetup({ installPlugin: false, populate })` from the `-plugin` factory and re-export its `setup`/`teardown` (the same wrapper pattern shown above for `populate`).
604
-
605
- > [!WARNING]
606
- >
607
- > **Parallelism:**
608
- >
609
- > A test run shares a single Obsidian instance and one temporary vault. Running test files in parallel makes them race on that shared instance and vault, producing flaky failures. Disable file-level parallelism in your Vitest config:
610
- >
611
- > ```ts
612
- > // vitest.config.ts
613
- > export default defineConfig({
614
- > test: {
615
- > fileParallelism: false
616
- > }
617
- > });
618
- > ```
619
-
620
- &nbsp;
621
-
622
- > [!WARNING]
623
- >
624
- > **`evalInObsidian` limitations:**
625
- >
626
- > - The function is serialized via `toString()` and executed in a separate process. It must be **self-contained** — closures over local variables will not work.
627
- > - Pass any needed values via `input`. Arguments must be **JSON-serializable** (strings, numbers, booleans, arrays, plain objects). Functions in `input` are supported — they are serialized via `toString()` with the same self-contained constraint.
628
- > - The **return value** must also be JSON-serializable. You cannot return functions, class instances, `Map`, `Set`, DOM elements, or other non-serializable values.
629
- > - Imports (`import`/`require`) are not available inside the function. Use `obsidianModule` to access the `obsidian` API, and `app` to access the Obsidian `App` instance.
630
-
631
- ### Accessing internal APIs
632
-
633
- Since `evalInObsidian` runs inside a real Obsidian process, you have access to internal (undocumented) APIs like `app.plugins`, `app.commands`, `app.title`, etc. However, these are not declared in `obsidian.d.ts`, so TypeScript won't compile references to them. Here are the options to make it work, from best to worst:
634
-
635
- **1. Use `obsidian-typings`** (recommended) — install [`obsidian-typings`](https://www.npmjs.com/package/obsidian-typings) which declares the full internal API. Everything compiles with no extra work:
636
-
637
- ```ts
638
- // With obsidian-typings installed — no casts needed
639
- const title = await evalInObsidian({
640
- callback: ({ app }) => app.title
641
- });
642
- ```
643
-
644
- **2. Manual module augmentation** — declare only what you need:
645
-
646
- ```ts
647
- declare module 'obsidian' {
648
- interface App {
649
- title: string;
650
- }
651
- }
652
-
653
- const title = await evalInObsidian({
654
- callback: ({ app }) => app.title
655
- });
656
- ```
657
-
658
- **3. `as any` / `@ts-expect-error` / `@ts-ignore`** (not recommended) — suppresses all type checking and hides real errors:
659
-
660
- ```ts
661
- const title = await evalInObsidian({
662
- // @ts-expect-error -- accessing internal API
663
- callback: ({ app }) => app.title
664
- });
665
-
666
- // or
667
- const title2 = await evalInObsidian({
668
- callback: ({ app }) => (app as any).title
669
- });
670
- ```
671
-
672
- ### Transport modes
673
-
674
- The transport determines how the library communicates with Obsidian. Configure it via transport options in your test framework's config (see [Quick start](#quick-start)):
675
-
676
- | Type | Platform | Mechanism |
677
- | ------------------------- | -------- | ------------------------------------------- |
678
- | `obsidian-cdp` (default) | Desktop | Obsidian `Chrome DevTools Protocol` (`CDP`) |
679
- | `obsidian-android-appium` | Mobile | Obsidian Android Appium WebView injection |
680
-
681
- #### `CDP` transport (default)
682
-
683
- By default the library **launches and owns an isolated Obsidian instance** in a temporary `--user-data-dir` on a free `--remote-debugging-port`, and communicates with it over the Obsidian `Chrome DevTools Protocol` (`CDP`). The owned instance never touches your real Obsidian — your config, vault registry, running window, and auto-update are all left untouched — and it runs in parallel with your everyday Obsidian.
684
-
685
- **Setup:**
686
-
687
- 1. [Install Obsidian](https://obsidian.md/download) (the desktop app) so a shell is available to launch.
688
- 2. Ensure [`Node.js`](https://nodejs.org/) 22+ is installed (uses built-in `WebSocket` and `fetch` globals).
689
- 3. No transport configuration is required — the owned `CDP` instance is the default:
690
-
691
- ```ts
692
- // vitest.config.ts
693
- export default defineConfig({
694
- test: {
695
- fileParallelism: false,
696
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
697
- },
698
- });
699
- ```
700
-
701
- ##### Pinning an Obsidian version
702
-
703
- To run the tests against a specific Obsidian version, set `obsidianVersion` and/or `obsidianInstallerVersion`. Each accepts an explicit `'x.y.z'`, `'public-latest'`, or `'catalyst-latest'`. Downloaded asars and installer shells are cached under the system temp dir for reuse.
704
-
705
- ```ts
706
- environmentOptions: {
707
- obsidianTransport: {
708
- type: 'obsidian-cdp',
709
- // The Obsidian app version (asar). At or above the installed shell version
710
- // it is applied as a fast asar swap; an older version transparently
711
- // downloads the matching installer.
712
- obsidianVersion: '1.8.10',
713
- },
714
- }
715
- ```
716
-
717
- - **`obsidianVersion`** pins the app code (asar). When omitted, the owned instance runs the same version your installed Obsidian currently runs.
718
- - **`obsidianInstallerVersion`** pins the Electron shell (installer build), downloaded and extracted from the matching GitHub release (Windows installers require [7-Zip](https://www.7-zip.org/) on `PATH`). Public releases only — catalyst/beta builds have no public installer, so a catalyst version can only be pinned at the asar level.
719
-
720
- ##### Running against every supported version (`runObsidianVersionMatrix`)
721
-
722
- Obsidian support is a **range** — `[latest public, latest catalyst]` — and both ends are expected to work. The two ends periodically **coincide**: when public catches up to catalyst, `public-latest` and `catalyst-latest` provision the same build, so running the suites twice re-runs the same build and verifies nothing extra. Worse, a project that runs both still reports "green on public **and** catalyst" — a two-end claim it never actually verified.
723
-
724
- `runObsidianVersionMatrix` makes that decision once, in the harness:
725
-
726
- ```ts
727
- // scripts/test-integration-desktop.ts
728
- import { runObsidianVersionMatrix } from 'obsidian-integration-testing';
729
-
730
- await runObsidianVersionMatrix({
731
- // Defaults to ['public-latest', 'catalyst-latest'].
732
- // Accepts an array or a comma-separated string, so an env var passes straight through.
733
- versions: process.env.OBSIDIAN_VERSION,
734
- run: ({ version }) => {
735
- const result = spawnSync('npx', ['vitest', 'run', '--project=integration-tests:desktop'], {
736
- env: { ...process.env, OBSIDIAN_VERSION: version },
737
- shell: true,
738
- stdio: 'inherit',
739
- });
740
- if (result.status !== 0) {
741
- throw new Error(`Exit code ${String(result.status)}`);
742
- }
743
- },
744
- });
745
- ```
746
-
747
- Your test config keeps reading the version the same way it always did — the runner just decides how many times to invoke it:
748
-
749
- ```ts
750
- environmentOptions: {
751
- obsidianTransport: {
752
- type: 'obsidian-cdp',
753
- obsidianVersion: process.env.OBSIDIAN_VERSION ?? 'public-latest',
754
- },
755
- }
756
- ```
757
-
758
- - **De-duplication is keyed on the *resolved* version, never the specifier string.** `['1.13.4', 'catalyst-latest']` collapses to a single run when catalyst *is* `1.13.4`, exactly as `['public-latest', 'catalyst-latest']` does when the channels converge.
759
- - **The decision is always stated in the log**, so one run where you expected two is never ambiguous:
760
-
761
- ```text
762
- [version-matrix] public-latest -> 1.13.4
763
- [version-matrix] catalyst-latest -> 1.13.4
764
- [version-matrix] 2 requested specifiers resolve to 1 distinct version: 1.13.4 (public-latest, catalyst-latest). Running the suites once.
765
- [version-matrix] Run 1 of 1: 1.13.4 (public-latest, catalyst-latest)
766
- ```
767
-
768
- - **Every version runs before anything is reported.** A failing end never hides the other: the summary names which concrete versions failed and which passed, and the thrown `AggregateError` carries each underlying failure.
769
-
770
- ```text
771
- AggregateError: Obsidian version matrix failed on 1 of 2 versions: 1.12.7 (public-latest). Passed: 1.13.4 (catalyst-latest).
772
- ```
773
-
774
- - **Only this runner defaults to both ends.** `obsidianVersion` with no explicit pin still means "whatever your installed Obsidian runs", so `connectToCdp()`, the CLI, and any suite not using the runner are unaffected.
775
- - The runner never launches Obsidian itself — your `run` callback does — so it stays framework-agnostic and works for Vitest, Jest, and manual consumers alike.
776
-
777
- ##### Dead-boot fast-fail
778
-
779
- If you pin an app version that cannot run on the launched Electron shell — an `obsidianInstallerVersion` too old for the `obsidianVersion` — Obsidian loads a black screen: the renderer finishes loading but the app never bootstraps (empty `<body>`, no `window.app`). Rather than waiting out the full readiness timeout, the harness detects this terminal state and throws a **`RendererFailedToInitializeError`** as soon as it has held for a short grace window:
780
-
781
- ```ts
782
- import { RendererFailedToInitializeError } from 'obsidian-integration-testing';
783
-
784
- try {
785
- // ... register a vault against an incompatible version pair
786
- } catch (error) {
787
- if (error instanceof RendererFailedToInitializeError) {
788
- // The installer/Electron version is too old for this Obsidian app version.
789
- }
790
- }
791
- ```
792
-
793
- - **`deadBootGraceInMilliseconds`** (default `10000`) — how long the renderer must sit in the dead state (document `complete`, empty `<body>`, no `window.app`) before fast-failing. The grace clock starts when the renderer first reports `readyState: 'complete'`, so a slow-but-valid boot is never misjudged. Set `0` to disable the fast-fail and restore the plain wait-out-the-readiness-timeout behavior. Owned mode only (ignored in attach mode).
794
-
795
- ##### Window visibility
796
-
797
- By default the owned Obsidian window is shown. Integration setup explicitly hides its owned window so test runs do not steal focus. `isObsidianAppVisible` controls this:
798
-
799
- ```ts
800
- environmentOptions: {
801
- obsidianTransport: {
802
- type: 'obsidian-cdp',
803
- isObsidianAppVisible: false, // hide the window for this run
804
- },
805
- }
806
- ```
807
-
808
- - **`isObsidianAppVisible`** (default `true`) — when `false`, the harness launches the owned instance with keep-alive Chromium flags and moves its window **off-screen** once Electron's remote bridge is up. Off-screen (not minimized) keeps the renderer fully live, so `setTimeout`, `requestAnimationFrame`, `:hover`, and trusted keyboard/pointer input behave exactly as they would for a visible window — tests are unaffected. Integration setup sets this to `false`; set it explicitly to `false` in other automated runs that should not show a window. Ignored in attach mode — the harness never moves your own running Obsidian.
809
-
810
- > [!NOTE]
811
- >
812
- > There is a brief (~1–2 s) flash while the window appears and is then moved off-screen: Obsidian's own process shows and focuses the window at launch, which the harness cannot prevent from outside. The persistent focus theft is eliminated, not the initial flash.
813
-
814
- ##### Attaching to a running Obsidian
815
-
816
- To attach to an already-running Obsidian instead of owning one, launch Obsidian with `--remote-debugging-port=<port>` and set `port` to that same port (the version-pinning options do not apply in attach mode):
817
-
818
- ```powershell
819
- # Windows (PowerShell) — uses Obsidian from PATH (e.g. scoop), falling back to the installer location
820
- $obsidian = (Get-Command Obsidian.exe -ErrorAction SilentlyContinue).Source
821
- if (-not $obsidian) { $obsidian = "$env:LOCALAPPDATA\Programs\Obsidian\Obsidian.exe" }
822
- Start-Process $obsidian -ArgumentList '--remote-debugging-port=8315'
823
- ```
824
-
825
- ```ts
826
- environmentOptions: {
827
- obsidianTransport: {
828
- type: 'obsidian-cdp',
829
- port: 8315, // must match the --remote-debugging-port Obsidian was launched with
830
-
831
- // default values can be omitted
832
- host: 'localhost',
833
- commandTimeoutInMilliseconds: 30000,
834
- },
835
- }
836
- ```
837
-
838
- #### Obsidian Android Appium transport
839
-
840
- Runs tests against Obsidian Mobile on an Android emulator or real device via Appium WebView injection.
841
-
842
- **Setup:**
843
-
844
- 1. Install [Android Studio](https://developer.android.com/studio), which includes the Android SDK and `adb` command-line tools
845
-
846
- 2. Create an Android Virtual Device (AVD):
847
-
848
- - Open Android Studio → Device Manager → Create Virtual Device
849
- - Select a phone profile (e.g. Pixel 7) and a system image (e.g. API 34)
850
- - Give the AVD a name (e.g. `obsidian_test`) — this is the value you pass as `avdName`
851
- - **Provision it deliberately — Android Studio's defaults are not enough.** See
852
- [AVD provisioning](#avd-provisioning) below and set these before you start using the device.
853
- - You do **not** need to start the emulator manually — the test framework auto-starts it
854
-
855
- To list existing AVD names:
856
-
857
- ```bash
858
- emulator -list-avds
859
- ```
860
-
861
- #### AVD provisioning
862
-
863
- These are minimums, not suggestions. Following the steps above with Android Studio's defaults
864
- produces a device that fails — and it fails in ways that look like plugin bugs, so the cost of
865
- getting this wrong is paid in debugging, not in an obvious error.
866
-
867
- | Setting | Minimum | Android Studio's default | Why |
868
- | -------------------------- | ------- | ------------------------ | -------------------------------------------------------------------------- |
869
- | `disk.dataPartition.size` | `16G` | `6G` | This is the one that actually bites — see below. |
870
- | `hw.ramSize` | `4096` | `2048` | The WebView has to become ready inside a fixed budget. |
871
- | `vm.heapSize` | `512` | `256` | Obsidian is a large WebView app. |
872
- | `hw.cpu.ncore` | `4`+ | `4` | Raise it if the host has cores to spare; emulator startup is CPU-bound. |
873
-
874
- Edit them in Device Manager → *Edit* → *Show Advanced Settings*, or directly in the AVD's
875
- `config.ini` (`~/.android/avd/<name>.avd/config.ini`); a size change needs a wipe of user data.
876
-
877
- **Why disk is the setting that matters.** Every failed run leaks a `temp-vault-*` directory, and
878
- every leaked vault stays **registered** for Obsidian to enumerate at startup — inside the same
879
- WebView-readiness budget the run is already straining (see [Leftover cleanup](#leftover-cleanup)).
880
- A full `/data` then produces failures that look like anything but a full disk:
881
-
882
- - `/data` at 92 % with 103 leftover vaults: runs failed in global setup with `WEBVIEW_md.obsidian`
883
- timing out at the full 60 s. After a sweep the same context was found in **0.3 s**.
884
- - `/data` at 91 % with only **8** leftover vaults — the count alone is not the signal. The four
885
- disk-bound cases (the only ones creating folders and renaming files) timed out at webdriver's
886
- 30 s wall, and the same four passed **6/6 in isolation on the same device**.
887
-
888
- **Prefer a `google_apis` image over `google_apis_playstore`.** A Play-Store image consumes most of a
889
- default data partition on its own, and it blocks `adb root` (`adbd cannot run as root in production
890
- builds`) — so when `/data` does fill, you cannot inspect it to find out what is using the space.
891
- `pm trim-caches 5G` recovers on the order of tens of megabytes and is the only lever left without
892
- root. `google_apis` is smaller and does allow `adb root`; nothing in this harness needs the Play
893
- Store.
894
-
895
- **Health check — run this before blaming the plugin:**
896
-
897
- ```bash
898
- adb shell df -h /data
899
- adb shell ls -d /sdcard/Documents/temp-vault-* | wc -l
900
- ```
901
-
902
- And apply the isolation rule: **a suite that fails in the aggregate and passes alone is the device**,
903
- not the code.
904
-
905
- 3. Install [Obsidian](https://obsidian.md/download) on the emulator (via Play Store or APK sideload) and grant storage permission — either via the app's permission prompt or via `adb`:
906
-
907
- ```bash
908
- adb shell appops set md.obsidian MANAGE_EXTERNAL_STORAGE allow
909
- ```
910
-
911
- 4. (Optional) Install [Appium](https://appium.io/) and the [UiAutomator2 driver](https://github.com/appium/appium-uiautomator2-driver):
912
-
913
- ```bash
914
- npm install -g appium
915
- appium driver install uiautomator2
916
- ```
917
-
918
- > [!NOTE]
919
- >
920
- > This step is optional. You do not need to start the Appium server manually — the test framework auto-starts it if it is not already running, and by default it also **auto-installs** Appium (globally) and the UiAutomator2 driver when they are missing. Set `shouldAutoInstallAppiumDependencies: false` to manage the Appium toolchain yourself and skip the global install.
921
-
922
- 5. Configure vitest:
923
-
924
- ```ts
925
- // vitest.config.ts
926
- export default defineConfig({
927
- test: {
928
- fileParallelism: false,
929
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
930
- environmentOptions: {
931
- obsidianTransport: {
932
- type: 'obsidian-android-appium',
933
- appiumUrl: 'http://localhost:4723',
934
- avdName: 'obsidian_test',
935
- },
936
- },
937
- },
938
- });
939
- ```
940
-
941
- Besides the required `appiumUrl` and `avdName`, the transport accepts these optional knobs (all with sensible defaults):
942
-
943
- | Option | Purpose | Default |
944
- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------- |
945
- | `appId` | App package (Android) or bundle ID (iOS). | `'md.obsidian'` |
946
- | `appiumStartTimeoutInMilliseconds` | Max wait for the auto-started Appium server to become ready; only when the harness auto-starts it. | `180000` |
947
- | `deviceIdleTimeoutInMilliseconds` | Max wait after boot for a started emulator to go idle before the session; avoids inflated cold setup. 0 skips. | `60000` |
948
- | `isAppiumConsoleVisible` | Show the auto-started Appium server console window and live output. Hidden and quiet by default. | `false` |
949
- | `isEmulatorVisible` | Show the auto-started emulator window. Hidden (`-no-window`, headless) by default so it never steals focus. | `false` |
950
- | `layoutReadyTimeoutInMilliseconds` | Max wait for `app.workspace.layoutReady` after the vault (re)opens; raise on slow emulators. | `90000` |
951
- | `leftoverMaxAgeInMilliseconds` | Age gate for the **host** leftover sweep; the device sweep is unconditional. See [Leftover cleanup](#leftover-cleanup). | `7200000` |
952
- | `sessionConnectionRetryTimeoutInMilliseconds` | Max wait to establish the Appium session (UiAutomator2 install + app launch); the dominant startup cost. | `180000` |
953
- | `shouldAutoInstallAppiumDependencies` | Auto-install missing Appium + the UiAutomator2 driver before auto-starting the server (global `npm install -g`). | `true` |
954
- | `shouldAutoStartAppium` | Auto-start the Appium server when it is not already reachable. | `true` |
955
- | `shouldSweepLeftovers` | Remove the temp vaults / instance profiles earlier runs leaked. See [Leftover cleanup](#leftover-cleanup). | `true` |
956
- | `vaultBasePath` | Base device path where Obsidian stores vaults. | `'/sdcard/Documents/'` |
957
- | `webviewTimeoutInMilliseconds` | Max wait for the WebView context after the Appium session starts. | `60000` |
958
-
959
- > [!NOTE]
960
- >
961
- > Plugins with `isDesktopOnly: true` in `manifest.json` automatically reject Android tests.
962
-
963
- #### Troubleshooting: "Process system isn't responding"
964
-
965
- A resource-starved emulator can raise a **"Process system isn't responding"** ANR dialog during boot.
966
- If it appears before Appium attaches, nothing can dismiss it and the run fails intermittently. As soon
967
- as the device reports `sys.boot_completed`, the harness runs
968
- `adb shell settings put global hide_error_dialogs 1` so Android no longer draws crash/ANR dialogs.
969
- This narrows but cannot fully close the race — an ANR that fires between boot completing and that
970
- command still slips through. To eliminate it entirely, boot the AVD once, run the command yourself,
971
- save a snapshot, and always boot from that snapshot. Either way, an ANR signals the emulator is
972
- under-provisioned, so check it against [AVD provisioning](#avd-provisioning) and confirm hardware
973
- acceleration (`emulator -accel-check`).
974
-
975
- #### Troubleshooting: "Android AVD ... not found" / "Appium server ... exited during startup"
976
-
977
- The Android setup fails fast (rather than spinning out a timeout) when the toolchain cannot be brought
978
- up, naming what is missing:
979
-
980
- - **`Android AVD "<name>" not found. Available AVDs: ...`** — the `avdName` you passed does not exist.
981
- Run `emulator -list-avds`, then either point `avdName` at a listed AVD or create the one you want
982
- (Android Studio Device Manager, or `avdmanager create avd`). AVD creation is not automated — it needs a
983
- system-image download, license acceptance, and hardware/API-level choices.
984
- - **`Auto-started Appium server ... during startup` / `... did not become ready ...`** — the harness
985
- auto-started Appium (`npx --no-install appium`) but it exited or never responded; the message appends
986
- the captured server output. Usually a missing/broken toolchain: pass `isAppiumConsoleVisible: true` to
987
- watch the live server log, or manage Appium yourself (`shouldAutoStartAppium: false`, pointing
988
- `appiumUrl` at your own running server).
989
- - **`Appium was installed ... but is still not resolvable ...`** — the auto-install ran
990
- `npm install -g appium`, but the npm global bin dir is not on PATH (common with scoop/nvm-managed Node).
991
- Add it to PATH (see `npm config get prefix`), or set `shouldAutoInstallAppiumDependencies: false` and
992
- install Appium yourself.
993
-
994
- #### Troubleshooting: "Obsidian layout did not become ready"
995
-
996
- Registering a vault reloads the page, triggering a full Obsidian re-init (reopen the vault and
997
- reload every plugin — the heaviest startup step). On a cold-booted or under-provisioned emulator
998
- that can exceed the default `90000`ms budget and fail setup with
999
- `Obsidian layout did not become ready within 90000ms`. Run the health check in
1000
- [AVD provisioning](#avd-provisioning) first — a full `/data` presents exactly like this — then bring the
1001
- AVD up to the minimums there and, if still needed, raise the budget via
1002
- `layoutReadyTimeoutInMilliseconds` in the transport options. It is headroom, not a substitute for
1003
- adequate provisioning.
1004
-
1005
- ### Leftover cleanup
1006
-
1007
- A run that dies mid-flight cannot clean up after itself. On Android that is the normal case, not the
1008
- exception: teardown removes the vault through the WebView, and a dead WebView is exactly what most
1009
- failures are (`Vault cleanup error (non-fatal): no such window`). So every failure leaves a
1010
- `temp-vault-*` directory behind — and, worse, leaves it **registered**, which is work Obsidian has to
1011
- redo at every startup, inside the same WebView-readiness budget the run is already straining. Failures
1012
- therefore make the next failure likelier. One real emulator had accumulated **103 leftover vaults**.
1013
-
1014
- Every run now sweeps at **both ends**, and the start sweep is the one that matters, because it runs
1015
- before anything that can die:
1016
-
1017
- - **On the device (Android)** — before the Appium session launches Obsidian, every `temp-vault-*`
1018
- directory under `vaultBasePath` is removed over `adb`, and their stale entries are pruned from
1019
- Obsidian Mobile's `localStorage` vault registry when the run registers its own vault. Unregistering
1020
- a vault now removes its device directory over `adb` **whether or not the WebView answered**.
1021
- - **On the host** — leftover `temp-vault-*` staging directories and owned `userdata-*` instance
1022
- profiles in the system temp directory are removed.
1023
-
1024
- The two halves gate differently, on purpose:
1025
-
1026
- | Sweep | Gate | Why |
1027
- | ---------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
1028
- | **Device** | Unconditional | Android runs hold an exclusive lock, so no concurrent run can own a device vault — and an age gate would let a vault leaked minutes ago poison a rerun. |
1029
- | **Host** | Only older than `leftoverMaxAgeInMilliseconds` | Desktop runs are deliberately not serialized, and every project on the machine shares one temp directory, so a young directory may belong to a live run. |
1030
-
1031
- Both knobs are available on either transport:
1032
-
1033
- - **`shouldSweepLeftovers`** (default `true`) — set `false` to disable both sweeps entirely.
1034
- - **`leftoverMaxAgeInMilliseconds`** (default `7200000`, i.e. two hours) — the host age gate. Raise it
1035
- if a run of yours can outlive the default; `0` removes every host match regardless of age.
1036
-
1037
- Sweeping is best-effort throughout: a directory another process still holds is skipped, never thrown.
1038
-
1039
- **One directory at a time, and the result is measured.** The device sweep removes each vault with its own
1040
- `rm -rf` and then re-lists what is left, so the count it reports is what actually went away rather than
1041
- what it asked for. Both halves of that matter, and both come from the same measured failure:
1042
-
1043
- - An Android emulator can end up holding a directory whose name the FUSE layer cannot express — `rm -rf`,
1044
- `find -delete` and force-stopping Obsidian first all answer `Operation not permitted`, and it is
1045
- permanent. Removing the whole set in one command let that single entry decide the fate of every other:
1046
- one device was found carrying **26** leftover vaults that the sweep had been "removing" every run.
1047
- Per-directory, it costs one warning per run instead of the whole sweep.
1048
- - A removal that leaves the directory behind is otherwise invisible, because `rm -rf` runs with its exit
1049
- code ignored. A run that passed end to end was still leaking a vault apiece, silently. Anything that
1050
- survives is now named in the log, at both ends — the start-of-run sweep and the teardown — and is
1051
- retried by the next run's sweep.
1052
-
1053
- ### Running multiple platforms
1054
-
1055
- Use vitest projects to run the same tests on multiple platforms:
1056
-
1057
- ```ts
1058
- // vitest.config.ts
1059
- import { defineConfig } from 'vitest/config';
1060
-
1061
- export default defineConfig({
1062
- test: {
1063
- projects: [
1064
- {
1065
- test: {
1066
- name: 'integration-tests:desktop-cdp',
1067
- fileParallelism: false,
1068
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
1069
- include: ['src/**/*.integration.test.ts'],
1070
- exclude: ['src/**/*.android.integration.test.ts'],
1071
- // default transport, can be omitted
1072
- environmentOptions: {
1073
- obsidianTransport: { type: 'obsidian-cdp' },
1074
- },
1075
- },
1076
- },
1077
- {
1078
- test: {
1079
- name: 'integration-tests:android-appium',
1080
- fileParallelism: false,
1081
- globalSetup: ['obsidian-integration-testing/vitest-global-setup-plugin'],
1082
- include: ['src/**/*.android.integration.test.ts'],
1083
- environmentOptions: {
1084
- obsidianTransport: {
1085
- type: 'obsidian-android-appium',
1086
- appiumUrl: 'http://localhost:4723',
1087
- avdName: 'obsidian_test',
1088
- },
1089
- },
1090
- },
1091
- },
1092
- ],
1093
- },
1094
- });
1095
- ```
1096
-
1097
- Run specific platforms:
1098
-
1099
- ```bash
1100
- # All tests
1101
- npx vitest run
1102
-
1103
- # Desktop CDP only
1104
- npx vitest run --project integration-tests:desktop-cdp
1105
-
1106
- # Android only (requires Appium + emulator running)
1107
- npx vitest run --project integration-tests:android-appium
1108
-
1109
- # All platforms
1110
- npx vitest run --project integration-tests:*
1111
- ```
1112
-
1113
- ## Ad-hoc debugging (`connectToCdp` and the CLI)
1114
-
1115
- Outside of a test framework, `connectToCdp()` launches (or attaches to) a `CDP` Obsidian instance, opens a vault, bootstraps the runtime helpers, and returns a disposable connection — handy for reproducing behavior in a real Obsidian from a throwaway script or the REPL.
1116
-
1117
- ```ts
1118
- import { connectToCdp } from 'obsidian-integration-testing';
1119
-
1120
- // Owns an isolated instance + an empty temp vault (both cleaned up on dispose).
1121
- await using conn = await connectToCdp();
1122
-
1123
- console.log(conn.port, conn.cdpUrl); // the free CDP port the instance was launched on
1124
-
1125
- // Raw expression → normalized string result:
1126
- await conn.invoke('app.vault.getName()');
1127
-
1128
- // Rich, typed path — `callback` runs in the Obsidian renderer with { app, obsidianModule, typeIntoEditor, context }:
1129
- await conn.evalInObsidian({ callback: ({ app }) => app.workspace.getActiveFile()?.path ?? null });
1130
- ```
1131
-
1132
- `connectToCdp` accepts the same version knobs as the transport (`obsidianVersion`, `obsidianInstallerVersion`, `host`, `commandTimeoutInMilliseconds`, both defaulting to your installed Obsidian), plus:
1133
-
1134
- - **`vault`** — path to an existing vault to open. When omitted, an empty temporary vault is created.
1135
- - **`isObsidianAppVisible`** — whether the window is shown (default `true`). Set `false` to launch it off-screen.
1136
- - **`port`** — attach to an already-running Obsidian on this `CDP` port instead of owning an instance (as in [Attaching to a running Obsidian](#attaching-to-a-running-obsidian)).
1137
- - **`deadBootGraceInMilliseconds`** (default `10000`) — fast-fail with a `RendererFailedToInitializeError` when a pinned version pair produces a [dead boot](#dead-boot-fast-fail); `0` disables it.
1138
- - **`shouldRemoveVaultOnDispose`** — whether `dispose()` removes the vault directory. Defaults to `true` for an implicit temp vault and `false` when a `vault` path is given, so a **real vault is never auto-deleted**. Set it explicitly to override.
1139
-
1140
- > [!WARNING]
1141
- >
1142
- > Opening a **real** vault in the owned instance may write to that vault's `.obsidian` config (normal Obsidian behavior). The vault directory itself is never deleted unless `shouldRemoveVaultOnDispose` is `true`.
1143
-
1144
- ### CLI
1145
-
1146
- The package ships an `obsidian-integration-testing` bin that wraps `connectToCdp`, prints the chosen port/URL, and stays alive until `Ctrl+C` — useful when an external tool (raw `CDP` `ws`, DevTools) needs to attach to a printed port:
1147
-
1148
- ```bash
1149
- npx obsidian-integration-testing --vault F:/path/to/vault --obsidian-version 1.8.10
1150
- ```
1151
-
1152
- Flags mirror the options above: `--vault`, `--obsidian-version`, `--obsidian-installer-version`, `--port`, `--host`, `--command-timeout`, and `--no-remove-vault` (keep the temp vault on exit).
86
+ The callback is serialized and executed inside Obsidian, so it must be self-contained, and both its arguments and its return value must be JSON-serializable. See [Writing tests](https://mnaoumov.dev/obsidian-integration-testing/guides/writing-tests/) for the full rules, and [Getting started](https://mnaoumov.dev/obsidian-integration-testing/guides/getting-started/) for the Jest equivalents and the transport options.
1153
87
 
1154
88
  ## Support
1155
89