Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ docs/site

# Obsidian E2E failure artifacts
.obsidian-e2e-artifacts

# Isolated per-worktree Obsidian E2E vaults (provisioned by scripts/obsidian-e2e-*)
.obsidian-e2e-vaults
59 changes: 53 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,66 @@ Use a dedicated development vault for manual or scripted Obsidian checks. Ensure
the vault's PodNotes plugin folder points at this checkout's generated plugin
artifacts before trusting runtime evidence.

Typical local loop:
If using the `obsidian` CLI, pass the vault selector consistently and prefer
scripted, repeatable checks for non-trivial flows. For bugs involving commands
or URIs, test both the user-facing path and the direct command/URI path when
possible.

### Shared dev vault (main checkout)
For work in the canonical `/Users/christian/Developer/PodNotes` checkout, use the
shared `dev` vault and target it explicitly with the `obsidian` CLI:

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

If using the `obsidian` CLI, pass the vault selector consistently and prefer
scripted, repeatable checks for non-trivial flows. For bugs involving commands
or URIs, test both the user-facing path and the direct command/URI path when
possible.
- Dev vault root: `/Users/christian/Developer/dev_vault/dev`.
- PodNotes plugin folder in the vault:
`/Users/christian/Developer/dev_vault/dev/.obsidian/plugins/podnotes`, whose
`main.js`/`manifest.json` symlinks point at the canonical checkout's artifacts.
- Only one checkout can own those symlinks at a time, so the shared `dev` vault
is for the main checkout. Worktrees must use the isolated wrapper below.

### Isolated worktree vault (parallel worktrees)
In a worktree (e.g. `/Users/christian/orca/workspaces/PodNotes/<slug>`), do **not**
race the shared `dev` vault — multiple worktree agents would clobber each other on
the plugin symlink, `data.json`, and `plugin:reload`. Use the isolated worktree
wrapper instead, which provisions a worktree-local vault under
`.obsidian-e2e-vaults/podnotes-<worktree>` (git-ignored), starts or reuses a
private-`HOME` Obsidian instance bound to that vault, disables Restricted Mode,
waits until PodNotes is live, and then runs your command with the right
`vault=<worktree vault>` and private `HOME` already applied:

```bash
npm run build # produce root main.js + manifest.json first
npm run obsidian:e2e -- eval code=app.vault.getName()
npm run obsidian:e2e -- eval code='Boolean(app.plugins.plugins.podnotes)'
npm run obsidian:e2e -- dev:errors
```

- The wrapper links the worktree's own `main.js`/`manifest.json` (PodNotes injects
its CSS into the bundle, so there is no `styles.css` to link) and seeds a clean
`DEFAULT_SETTINGS`-shaped `data.json` on first provision; it never touches
`/Users/christian/Developer/dev_vault/dev`.
- `npm run provision:e2e-vault` and `npm run start:e2e-obsidian` expose the
provision/launch steps individually; both accept `--help`.
- Use `npm run start:e2e-obsidian -- --print-env` only when you need to export
`PODNOTES_E2E_VAULT` / `PODNOTES_E2E_VAULT_PATH` / `PODNOTES_E2E_OBSIDIAN_HOME`
for a separate process. The `obsidian` CLI routes by `$HOME` (it talks to
`$HOME/.obsidian-cli.sock`), so to point the Vitest `tests/e2e` suite at the
isolated instance you must remap `HOME` as well as the vault name — exporting
`PODNOTES_E2E_VAULT` alone leaves the suite talking to the shared `dev` vault:

```bash
eval "$(npm run --silent start:e2e-obsidian -- --print-env)"
export HOME="$PODNOTES_E2E_OBSIDIAN_HOME" # required: re-point the CLI socket
PODNOTES_E2E_VAULT="$PODNOTES_E2E_VAULT" npm run test:e2e
Comment thread
chhoumann marked this conversation as resolved.
```

## Documentation
Docs live in `docs/docs/` and are configured by `docs/mkdocs.yml`. Update docs
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
"dev": "vite build --watch --mode development",
"build": "npm run typecheck && vite build",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,cts,mts}\" \"tests/e2e/**/*.ts\" --max-warnings=0",
"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",
"lint": "eslint \"src/**/*.{ts,cts,mts}\" \"tests/e2e/**/*.ts\" \"scripts/**/*.test.ts\" --max-warnings=0",
"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",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"semantic-release": "semantic-release",
"test": "npm run check:a11y && vitest",
"test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
"obsidian:e2e": "node scripts/obsidian-e2e-cli.mjs",
"provision:e2e-vault": "node scripts/provision-obsidian-e2e-vault.mjs",
"start:e2e-obsidian": "node scripts/start-obsidian-e2e-instance.mjs",
"check:a11y": "svelte-check --fail-on-warnings",
"docs:build": "mkdocs build -f docs/mkdocs.yml -d site",
"docs:deploy": "npm run docs:build && npx wrangler pages deploy docs/site --project-name podnotes --branch master"
Expand Down
177 changes: 177 additions & 0 deletions scripts/obsidian-e2e-cli.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import process from "node:process";
import { provisionVault } from "./provision-obsidian-e2e-vault.mjs";
import {
isInstanceReady,
launchObsidianInstance,
parseArgs as parseInstanceArgs,
prepareObsidianProfile,
reloadPodNotes,
resolveInstanceOptions,
trustVaultAndVerifyPodNotes,
waitForInstanceReady,
} from "./start-obsidian-e2e-instance.mjs";

const VALUE_OPTIONS = new Set([
"--vault",
"--root",
"--worktree",
"--data",
"--profile-root",
"--obsidian-app",
"--obsidian-bin",
]);
const BOOLEAN_OPTIONS = new Set(["--force"]);
const DEFAULT_COMMAND = ["eval", "code=app.vault.getName()"];

function printUsage() {
console.log(`Usage: node scripts/obsidian-e2e-cli.mjs [instance options] [--] <obsidian command...>

Examples:
npm run obsidian:e2e -- eval code=app.vault.getName()
npm run obsidian:e2e -- dev:errors
npm run obsidian:e2e -- --vault podnotes-my-worktree eval code='app.plugins.plugins.podnotes?.manifest?.version'

Instance options:
--vault <name> Vault/profile name. Defaults to podnotes-<worktree>.
--root <path> Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults.
--worktree <path> PodNotes worktree to link plugin files from. Defaults to cwd.
--data <path> Optional PodNotes data.json seed to copy on first provision.
--profile-root <path> Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e.
--obsidian-app <name> Obsidian app name for macOS open. Defaults to Obsidian.
--obsidian-bin <path> Obsidian CLI executable. Defaults to obsidian.
--force Recreate plugin symlinks if they already exist.
--help Show this help.
`);
}

export function parseArgs(argv) {
const instanceArgs = [];
const commandArgs = [];

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
const next = argv[index + 1];
if (
index === 0 &&
(next === "--help" ||
BOOLEAN_OPTIONS.has(next) ||
VALUE_OPTIONS.has(next))
) {
continue;
}
commandArgs.push(...argv.slice(index + 1));
break;
}
if (arg === "--help") {
return { help: true, instanceArgs, commandArgs };
}
if (BOOLEAN_OPTIONS.has(arg)) {
instanceArgs.push(arg);
continue;
}
if (VALUE_OPTIONS.has(arg)) {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${arg} requires a value.`);
}
instanceArgs.push(arg, value);
index += 1;
continue;
}

commandArgs.push(...argv.slice(index));
break;
}

return {
help: false,
instanceArgs,
commandArgs: commandArgs.length > 0 ? commandArgs : [...DEFAULT_COMMAND],
};
}

export function obsidianEnv(options) {
return {
...process.env,
HOME: options.obsidianHome,
};
}

export function obsidianCommandArgs(options, commandArgs) {
return [`vault=${options.vaultName}`, ...commandArgs];
}

export async function ensureObsidianInstance(options) {
const provisionResult = await provisionVault(options);
const profileResult = await prepareObsidianProfile(options);
options.userDataPath = profileResult.userDataPath;

const reused = await isInstanceReady(options);
if (reused) {
// A reused instance still holds the bundle it loaded earlier — possibly a
// broken pre-rebuild one. Reload BEFORE verifying so the rebuilt main.js is
// loaded first; otherwise a failed old bundle would make the readiness
// check below time out before the reload ever runs.
await reloadPodNotes(options);
} else {
// A freshly launched instance loads the current bundle on its own.
await launchObsidianInstance(options);
await waitForInstanceReady(options);
}

await trustVaultAndVerifyPodNotes(options);
Comment thread
chhoumann marked this conversation as resolved.

return {
...provisionResult,
...profileResult,
obsidianHome: options.obsidianHome,
};
}

function spawnObsidian(options, commandArgs) {
return new Promise((resolve) => {
const child = spawn(
options.obsidianBin,
obsidianCommandArgs(options, commandArgs),
{
env: obsidianEnv(options),
stdio: "inherit",
},
);
child.on("close", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
resolve(code ?? 1);
});
child.on("error", (error) => {
console.error(error instanceof Error ? error.message : error);
resolve(1);
});
});
}

async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help) {
printUsage();
return;
}

const options = resolveInstanceOptions(
parseInstanceArgs(parsed.instanceArgs),
);
await ensureObsidianInstance(options);
process.exitCode = await spawnObsidian(options, parsed.commandArgs);
}

if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
78 changes: 78 additions & 0 deletions scripts/obsidian-e2e-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
obsidianCommandArgs,
obsidianEnv,
parseArgs,
} from "./obsidian-e2e-cli.mjs";

describe("obsidian-e2e-cli", () => {
it("defaults to an eval of the vault name when no Obsidian command is provided", () => {
const parsed = parseArgs([]);

expect(parsed.instanceArgs).toEqual([]);
expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]);
});

it("splits instance options from the Obsidian command", () => {
const parsed = parseArgs([
"--vault",
"podnotes-worktree-a",
"--profile-root",
"profiles",
"dev:errors",
]);

expect(parsed.instanceArgs).toEqual([
"--vault",
"podnotes-worktree-a",
"--profile-root",
"profiles",
]);
expect(parsed.commandArgs).toEqual(["dev:errors"]);
});

it("uses -- to pass option-like Obsidian command arguments", () => {
const parsed = parseArgs([
"--vault",
"podnotes-worktree-a",
"--",
"eval",
"--some-obsidian-flag",
]);

expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]);
expect(parsed.commandArgs).toEqual(["eval", "--some-obsidian-flag"]);
});

it("accepts the leading separator produced by npm run before wrapper options", () => {
const parsed = parseArgs([
"--",
"--vault",
"podnotes-worktree-a",
"eval",
"code=app.vault.getName()",
]);

expect(parsed.instanceArgs).toEqual(["--vault", "podnotes-worktree-a"]);
expect(parsed.commandArgs).toEqual(["eval", "code=app.vault.getName()"]);
});

it("prefixes commands with the resolved isolated vault", () => {
expect(
obsidianCommandArgs({ vaultName: "podnotes-worktree-a" }, [
"eval",
"code=app.vault.getName()",
]),
).toEqual([
"vault=podnotes-worktree-a",
"eval",
"code=app.vault.getName()",
]);
});

it("runs Obsidian CLI commands with the isolated HOME", () => {
expect(obsidianEnv({ obsidianHome: "/tmp/podnotes/home" })).toMatchObject({
HOME: "/tmp/podnotes/home",
});
});
});
Loading
Loading