Skip to content

Commit 5328994

Browse files
committed
feat(devx): isolated per-worktree Obsidian E2E vault wrapper
Port QuickAdd's per-worktree Obsidian vault isolation to PodNotes so parallel worktree agents stop clobbering the shared dev vault on the plugin symlink, data.json, and plugin:reload. - scripts/provision-obsidian-e2e-vault.mjs: provision .obsidian-e2e-vaults/ podnotes-<worktree>, symlink manifest.json + main.js from the worktree build (CSS is injected, so no styles.css), seed a DEFAULT_SETTINGS-shaped data.json. - scripts/start-obsidian-e2e-instance.mjs: start/reuse a private-HOME Obsidian instance bound to the worktree vault; disable Restricted Mode; verify podnotes loaded via obsidian eval. - scripts/obsidian-e2e-cli.mjs: obsidian:e2e wrapper (provision + start + run a command with vault=<worktree vault> and private HOME applied). - npm scripts obsidian:e2e / provision:e2e-vault / start:e2e-obsidian; ignore .obsidian-e2e-vaults; AGENTS.md documents the shared dev vault (main checkout) and isolated worktree workflows (incl. the HOME remap for tests/e2e). - Ported unit tests (provision, start, CLI parser) wired into vitest.config.ts; lint + format:check (biome) widened to cover scripts/. Hardening from self-review: - Dropped --register-via entirely: it ran the obsidian CLI against the real user HOME, the one path that could touch ~/Library obsidian.json / the shared vault. The private-HOME instance model makes it unnecessary; removing it also drops a dual same-named export trap and the readiness-marker duplication. - Drift guard: a test now asserts the seeded data.json equals src/constants.ts DEFAULT_SETTINGS (JSON-serialized), so a new setting can't silently skew. Verified end-to-end in this worktree: obsidian:e2e targets the worktree-local vault (app.vault.getName() => podnotes-devx-worktree-vault-isolation, Boolean(app.plugins.plugins.podnotes) => true, manifest 2.16.0); the shared dev vault and the real ~/Library obsidian.json were never touched. Gates green: lint, format:check, typecheck, build, test (271).
1 parent 12c503a commit 5328994

10 files changed

Lines changed: 1391 additions & 9 deletions

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,6 @@ docs/site
2828

2929
# Obsidian E2E failure artifacts
3030
.obsidian-e2e-artifacts
31+
32+
# Isolated per-worktree Obsidian E2E vaults (provisioned by scripts/obsidian-e2e-*)
33+
.obsidian-e2e-vaults

‎AGENTS.md‎

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,19 +75,66 @@ Use a dedicated development vault for manual or scripted Obsidian checks. Ensure
7575
the vault's PodNotes plugin folder points at this checkout's generated plugin
7676
artifacts before trusting runtime evidence.
7777

78-
Typical local loop:
78+
If using the `obsidian` CLI, pass the vault selector consistently and prefer
79+
scripted, repeatable checks for non-trivial flows. For bugs involving commands
80+
or URIs, test both the user-facing path and the direct command/URI path when
81+
possible.
82+
83+
### Shared dev vault (main checkout)
84+
For work in the canonical `/Users/christian/Developer/PodNotes` checkout, use the
85+
shared `dev` vault and target it explicitly with the `obsidian` CLI:
7986

8087
```bash
8188
npm run dev
82-
# reload or re-enable PodNotes in the development vault
89+
# reload or re-enable PodNotes in the dev vault, e.g.:
90+
obsidian vault=dev plugin:reload id=podnotes
8391
# trigger the relevant command, UI flow, or obsidian://podnotes URI
92+
obsidian vault=dev eval code='app.plugins.plugins.podnotes?.manifest?.version'
8493
# inspect console/errors and plugin state
8594
```
8695

87-
If using the `obsidian` CLI, pass the vault selector consistently and prefer
88-
scripted, repeatable checks for non-trivial flows. For bugs involving commands
89-
or URIs, test both the user-facing path and the direct command/URI path when
90-
possible.
96+
- Dev vault root: `/Users/christian/Developer/dev_vault/dev`.
97+
- PodNotes plugin folder in the vault:
98+
`/Users/christian/Developer/dev_vault/dev/.obsidian/plugins/podnotes`, whose
99+
`main.js`/`manifest.json` symlinks point at the canonical checkout's artifacts.
100+
- Only one checkout can own those symlinks at a time, so the shared `dev` vault
101+
is for the main checkout. Worktrees must use the isolated wrapper below.
102+
103+
### Isolated worktree vault (parallel worktrees)
104+
In a worktree (e.g. `/Users/christian/orca/workspaces/PodNotes/<slug>`), do **not**
105+
race the shared `dev` vault — multiple worktree agents would clobber each other on
106+
the plugin symlink, `data.json`, and `plugin:reload`. Use the isolated worktree
107+
wrapper instead, which provisions a worktree-local vault under
108+
`.obsidian-e2e-vaults/podnotes-<worktree>` (git-ignored), starts or reuses a
109+
private-`HOME` Obsidian instance bound to that vault, disables Restricted Mode,
110+
waits until PodNotes is live, and then runs your command with the right
111+
`vault=<worktree vault>` and private `HOME` already applied:
112+
113+
```bash
114+
npm run build # produce root main.js + manifest.json first
115+
npm run obsidian:e2e -- eval code=app.vault.getName()
116+
npm run obsidian:e2e -- eval code='Boolean(app.plugins.plugins.podnotes)'
117+
npm run obsidian:e2e -- dev:errors
118+
```
119+
120+
- The wrapper links the worktree's own `main.js`/`manifest.json` (PodNotes injects
121+
its CSS into the bundle, so there is no `styles.css` to link) and seeds a clean
122+
`DEFAULT_SETTINGS`-shaped `data.json` on first provision; it never touches
123+
`/Users/christian/Developer/dev_vault/dev`.
124+
- `npm run provision:e2e-vault` and `npm run start:e2e-obsidian` expose the
125+
provision/launch steps individually; both accept `--help`.
126+
- Use `npm run start:e2e-obsidian -- --print-env` only when you need to export
127+
`PODNOTES_E2E_VAULT` / `PODNOTES_E2E_VAULT_PATH` / `PODNOTES_E2E_OBSIDIAN_HOME`
128+
for a separate process. The `obsidian` CLI routes by `$HOME` (it talks to
129+
`$HOME/.obsidian-cli.sock`), so to point the Vitest `tests/e2e` suite at the
130+
isolated instance you must remap `HOME` as well as the vault name — exporting
131+
`PODNOTES_E2E_VAULT` alone leaves the suite talking to the shared `dev` vault:
132+
133+
```bash
134+
eval "$(npm run --silent start:e2e-obsidian -- --print-env)"
135+
export HOME="$PODNOTES_E2E_OBSIDIAN_HOME" # required: re-point the CLI socket
136+
PODNOTES_E2E_VAULT="$PODNOTES_E2E_VAULT" npm run test:e2e
137+
```
91138

92139
## Documentation
93140
Docs live in `docs/docs/` and are configured by `docs/mkdocs.yml`. Update docs

‎package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
"dev": "vite build --watch --mode development",
88
"build": "npm run typecheck && vite build",
99
"typecheck": "tsc --noEmit",
10-
"lint": "eslint \"src/**/*.{ts,cts,mts}\" \"tests/e2e/**/*.ts\" --max-warnings=0",
11-
"format:check": "biome check package.json manifest.json tsconfig.json eslint.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts tests/e2e",
10+
"lint": "eslint \"src/**/*.{ts,cts,mts}\" \"tests/e2e/**/*.ts\" \"scripts/**/*.test.ts\" --max-warnings=0",
11+
"format:check": "biome check package.json manifest.json tsconfig.json eslint.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts tests/e2e scripts",
1212
"version": "node version-bump.mjs && git add manifest.json versions.json",
1313
"semantic-release": "semantic-release",
1414
"test": "npm run check:a11y && vitest",
1515
"test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
16+
"obsidian:e2e": "node scripts/obsidian-e2e-cli.mjs",
17+
"provision:e2e-vault": "node scripts/provision-obsidian-e2e-vault.mjs",
18+
"start:e2e-obsidian": "node scripts/start-obsidian-e2e-instance.mjs",
1619
"check:a11y": "svelte-check --fail-on-warnings",
1720
"docs:build": "mkdocs build -f docs/mkdocs.yml -d site",
1821
"docs:deploy": "npm run docs:build && npx wrangler pages deploy docs/site --project-name podnotes --branch master"

‎scripts/obsidian-e2e-cli.mjs‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/usr/bin/env node
2+
import { execFile, spawn } from "node:child_process";
3+
import path from "node:path";
4+
import process from "node:process";
5+
import { promisify } from "node:util";
6+
import { provisionVault } from "./provision-obsidian-e2e-vault.mjs";
7+
import {
8+
launchObsidianInstance,
9+
parseArgs as parseInstanceArgs,
10+
prepareObsidianProfile,
11+
resolveInstanceOptions,
12+
trustVaultAndVerifyPodNotes,
13+
waitForInstanceReady,
14+
} from "./start-obsidian-e2e-instance.mjs";
15+
16+
const execFileAsync = promisify(execFile);
17+
const VALUE_OPTIONS = new Set([
18+
"--vault",
19+
"--root",
20+
"--worktree",
21+
"--data",
22+
"--profile-root",
23+
"--obsidian-app",
24+
"--obsidian-bin",
25+
]);
26+
const BOOLEAN_OPTIONS = new Set(["--force"]);
27+
const DEFAULT_COMMAND = ["eval", "code=app.vault.getName()"];
28+
29+
function printUsage() {
30+
console.log(`Usage: node scripts/obsidian-e2e-cli.mjs [instance options] [--] <obsidian command...>
31+
32+
Examples:
33+
npm run obsidian:e2e -- eval code=app.vault.getName()
34+
npm run obsidian:e2e -- dev:errors
35+
npm run obsidian:e2e -- --vault podnotes-my-worktree eval code='app.plugins.plugins.podnotes?.manifest?.version'
36+
37+
Instance options:
38+
--vault <name> Vault/profile name. Defaults to podnotes-<worktree>.
39+
--root <path> Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults.
40+
--worktree <path> PodNotes worktree to link plugin files from. Defaults to cwd.
41+
--data <path> Optional PodNotes data.json seed to copy on first provision.
42+
--profile-root <path> Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e.
43+
--obsidian-app <name> Obsidian app name for macOS open. Defaults to Obsidian.
44+
--obsidian-bin <path> Obsidian CLI executable. Defaults to obsidian.
45+
--force Recreate plugin symlinks if they already exist.
46+
--help Show this help.
47+
`);
48+
}
49+
50+
export function parseArgs(argv) {
51+
const instanceArgs = [];
52+
const commandArgs = [];
53+
54+
for (let index = 0; index < argv.length; index += 1) {
55+
const arg = argv[index];
56+
if (arg === "--") {
57+
const next = argv[index + 1];
58+
if (
59+
index === 0 &&
60+
(next === "--help" ||
61+
BOOLEAN_OPTIONS.has(next) ||
62+
VALUE_OPTIONS.has(next))
63+
) {
64+
continue;
65+
}
66+
commandArgs.push(...argv.slice(index + 1));
67+
break;
68+
}
69+
if (arg === "--help") {
70+
return { help: true, instanceArgs, commandArgs };
71+
}
72+
if (BOOLEAN_OPTIONS.has(arg)) {
73+
instanceArgs.push(arg);
74+
continue;
75+
}
76+
if (VALUE_OPTIONS.has(arg)) {
77+
const value = argv[index + 1];
78+
if (!value || value.startsWith("--")) {
79+
throw new Error(`${arg} requires a value.`);
80+
}
81+
instanceArgs.push(arg, value);
82+
index += 1;
83+
continue;
84+
}
85+
86+
commandArgs.push(...argv.slice(index));
87+
break;
88+
}
89+
90+
return {
91+
help: false,
92+
instanceArgs,
93+
commandArgs: commandArgs.length > 0 ? commandArgs : [...DEFAULT_COMMAND],
94+
};
95+
}
96+
97+
export function obsidianEnv(options) {
98+
return {
99+
...process.env,
100+
HOME: options.obsidianHome,
101+
};
102+
}
103+
104+
export function obsidianCommandArgs(options, commandArgs) {
105+
return [`vault=${options.vaultName}`, ...commandArgs];
106+
}
107+
108+
async function isInstanceReady(options) {
109+
try {
110+
const { stdout } = await execFileAsync(
111+
options.obsidianBin,
112+
obsidianCommandArgs(options, ["vault", "info=path"]),
113+
{
114+
env: obsidianEnv(options),
115+
timeout: 5_000,
116+
},
117+
);
118+
return path.resolve(stdout.trim()) === path.resolve(options.vaultPath);
119+
} catch {
120+
return false;
121+
}
122+
}
123+
124+
export async function ensureObsidianInstance(options) {
125+
const provisionResult = await provisionVault(options);
126+
const profileResult = await prepareObsidianProfile(options);
127+
options.userDataPath = profileResult.userDataPath;
128+
129+
if (!(await isInstanceReady(options))) {
130+
await launchObsidianInstance(options);
131+
await waitForInstanceReady(options);
132+
}
133+
134+
await trustVaultAndVerifyPodNotes(options);
135+
136+
return {
137+
...provisionResult,
138+
...profileResult,
139+
obsidianHome: options.obsidianHome,
140+
};
141+
}
142+
143+
function spawnObsidian(options, commandArgs) {
144+
return new Promise((resolve) => {
145+
const child = spawn(
146+
options.obsidianBin,
147+
obsidianCommandArgs(options, commandArgs),
148+
{
149+
env: obsidianEnv(options),
150+
stdio: "inherit",
151+
},
152+
);
153+
child.on("close", (code, signal) => {
154+
if (signal) {
155+
process.kill(process.pid, signal);
156+
return;
157+
}
158+
resolve(code ?? 1);
159+
});
160+
child.on("error", (error) => {
161+
console.error(error instanceof Error ? error.message : error);
162+
resolve(1);
163+
});
164+
});
165+
}
166+
167+
async function main() {
168+
const parsed = parseArgs(process.argv.slice(2));
169+
if (parsed.help) {
170+
printUsage();
171+
return;
172+
}
173+
174+
const options = resolveInstanceOptions(
175+
parseInstanceArgs(parsed.instanceArgs),
176+
);
177+
await ensureObsidianInstance(options);
178+
process.exitCode = await spawnObsidian(options, parsed.commandArgs);
179+
}
180+
181+
if (import.meta.url === `file://${process.argv[1]}`) {
182+
main().catch((error) => {
183+
console.error(error instanceof Error ? error.message : error);
184+
process.exitCode = 1;
185+
});
186+
}

‎scripts/obsidian-e2e-cli.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
obsidianCommandArgs,
4+
obsidianEnv,
5+
parseArgs,
6+
} from "./obsidian-e2e-cli.mjs";
7+
8+
describe("obsidian-e2e-cli", () => {
9+
it("defaults to an eval of the vault name when no Obsidian command is provided", () => {
10+
const parsed = parseArgs([]);
11+
12+
expect(parsed.instanceArgs).toEqual([]);
13+
expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]);
14+
});
15+
16+
it("splits instance options from the Obsidian command", () => {
17+
const parsed = parseArgs([
18+
"--vault",
19+
"podnotes-worktree-a",
20+
"--profile-root",
21+
"profiles",
22+
"dev:errors",
23+
]);
24+
25+
expect(parsed.instanceArgs).toEqual([
26+
"--vault",
27+
"podnotes-worktree-a",
28+
"--profile-root",
29+
"profiles",
30+
]);
31+
expect(parsed.commandArgs).toEqual(["dev:errors"]);
32+
});
33+
34+
it("uses -- to pass option-like Obsidian command arguments", () => {
35+
const parsed = parseArgs([
36+
"--vault",
37+
"podnotes-worktree-a",
38+
"--",
39+
"eval",
40+
"--some-obsidian-flag",
41+
]);
42+
43+
expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]);
44+
expect(parsed.commandArgs).toEqual(["eval", "--some-obsidian-flag"]);
45+
});
46+
47+
it("accepts the leading separator produced by npm run before wrapper options", () => {
48+
const parsed = parseArgs([
49+
"--",
50+
"--vault",
51+
"podnotes-worktree-a",
52+
"eval",
53+
"code=app.vault.getName()",
54+
]);
55+
56+
expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]);
57+
expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]);
58+
});
59+
60+
it("prefixes commands with the resolved isolated vault", () => {
61+
expect(
62+
obsidianCommandArgs({ vaultName: "podnotes-worktree-a" }, [
63+
"eval",
64+
"code=app.vault.getName()",
65+
]),
66+
).toEqual([
67+
"vault=podnotes-worktree-a",
68+
"eval",
69+
"code=app.vault.getName()",
70+
]);
71+
});
72+
73+
it("runs Obsidian CLI commands with the isolated HOME", () => {
74+
expect(obsidianEnv({ obsidianHome: "/tmp/podnotes/home" })).toMatchObject({
75+
HOME: "/tmp/podnotes/home",
76+
});
77+
});
78+
});

0 commit comments

Comments
 (0)