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.
- package/README.md +26 -1092
- package/dist/lib/cjs/capture-obsidian-screenshot.cjs +60 -0
- package/dist/lib/cjs/capture-obsidian-screenshot.d.cts +53 -0
- package/dist/lib/cjs/capture-screenshot.cjs +82 -0
- package/dist/lib/cjs/capture-screenshot.d.cts +99 -0
- package/dist/lib/cjs/connect-to-cdp.cjs +7 -1
- package/dist/lib/cjs/connect-to-cdp.d.cts +13 -0
- package/dist/lib/cjs/fit-screenshot.cjs +124 -0
- package/dist/lib/cjs/fit-screenshot.d.cts +117 -0
- package/dist/lib/cjs/index.cjs +18 -1
- package/dist/lib/cjs/index.d.cts +6 -0
- package/dist/lib/cjs/library.cjs +1 -1
- package/dist/lib/cjs/obsidian-metadata.cjs +28 -1
- package/dist/lib/cjs/transport-appium.cjs +23 -1
- package/dist/lib/cjs/transport-appium.d.cts +16 -0
- package/dist/lib/cjs/transport-desktop-cdp.cjs +43 -1
- package/dist/lib/cjs/transport-desktop-cdp.d.cts +15 -0
- package/dist/lib/cjs/transport.cjs +1 -1
- package/dist/lib/cjs/transport.d.cts +15 -0
- package/dist/lib/cjs/type-guards.cjs +6 -1
- package/dist/lib/cjs/type-guards.d.cts +7 -0
- package/dist/lib/esm/capture-obsidian-screenshot.d.mts +53 -0
- package/dist/lib/esm/capture-obsidian-screenshot.mjs +29 -0
- package/dist/lib/esm/capture-screenshot.d.mts +99 -0
- package/dist/lib/esm/capture-screenshot.mjs +55 -0
- package/dist/lib/esm/connect-to-cdp.d.mts +13 -0
- package/dist/lib/esm/connect-to-cdp.mjs +7 -1
- package/dist/lib/esm/fit-screenshot.d.mts +117 -0
- package/dist/lib/esm/fit-screenshot.mjs +89 -0
- package/dist/lib/esm/index.d.mts +6 -0
- package/dist/lib/esm/index.mjs +19 -1
- package/dist/lib/esm/library.mjs +1 -1
- package/dist/lib/esm/obsidian-metadata.mjs +28 -1
- package/dist/lib/esm/transport-appium.d.mts +16 -0
- package/dist/lib/esm/transport-appium.mjs +26 -1
- package/dist/lib/esm/transport-desktop-cdp.d.mts +15 -0
- package/dist/lib/esm/transport-desktop-cdp.mjs +47 -1
- package/dist/lib/esm/transport.d.mts +15 -0
- package/dist/lib/esm/type-guards.d.mts +7 -0
- package/dist/lib/esm/type-guards.mjs +5 -1
- package/dist/obsidian-integration-testing-10.2.0.tgz +0 -0
- package/package.json +36 -14
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|