From 4e68f661e383fbab494dc8f3bc5c04263ad5bde7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 10 Jul 2026 21:22:18 +0200 Subject: [PATCH 1/3] chore(e2e): migrate to shared obsidian-e2e instance runner Replace the four vendored scripts/*-obsidian-e2e-*.mjs orchestration copies (their unit tests, and the two helper files obsidian-e2e-errors.mjs / obsidian-e2e-types.d.ts that existed only to serve them) with the shared `obsidian-e2e` instance-runner bin. The four npm script names are unchanged; they now point at `obsidian-e2e `, configured by a new `obsidian-e2e.config.mjs` at the repo root. - Add obsidian-e2e.config.mjs: pluginId, two plugin artifacts (manifest.json + main.js only - PodNotes injects its CSS into main.js, so there is no styles.css to symlink), the schema-v2 DEFAULT_SETTINGS-shaped data.json seed, envPrefix "PODNOTES" so legacy PODNOTES_E2E_* aliases keep flowing, and the PodNotes eval ready probe. defaultCommand/readyProbe match the runner defaults; the probe is written out explicitly for documentation. - Port the data-seed drift test to scripts/e2e-config.test.ts (asserts the config's defaultData serialize-equals { schemaVersion: 2, ...DEFAULT_SETTINGS } from src/constants.ts). The deleted script unit tests drop out of the vitest `scripts/**` include cleanly. - tests/e2e/harness.ts: read canonical OBSIDIAN_E2E_VAULT first, legacy PODNOTES_E2E_VAULT alias as fallback. OBSIDIAN_BIN is unchanged. - orca.yaml archive hook: `npm exec obsidian-e2e -- stop --worktree ...` (npm's `npm exec -- ` forwards --worktree to the bin), keeping the `command -v node` guard and `|| true`. - package.json: add obsidian-e2e.config.mjs to the oxfmt format file list. - AGENTS.md + .gitignore: document the runner bin, config file, and canonical env names + legacy aliases. The dependency is a local file: link for validating the runner before it ships; npm normalized it to `file:../obsidian-e2e` (pnpm consumers keep the absolute path). This MUST switch to the published obsidian-e2e ^0.7.0 before this PR merges (the runner package is currently unpublished at 0.6.0). --- .gitignore | 2 +- AGENTS.md | 23 +- obsidian-e2e.config.mjs | 126 +++++ orca.yaml | 2 +- package-lock.json | 61 +- package.json | 12 +- scripts/e2e-config.test.ts | 18 + scripts/obsidian-e2e-cli.mjs | 193 ------- scripts/obsidian-e2e-cli.test.ts | 70 --- scripts/obsidian-e2e-errors.mjs | 40 -- scripts/obsidian-e2e-types.d.ts | 130 ----- scripts/provision-obsidian-e2e-vault.mjs | 416 -------------- scripts/provision-obsidian-e2e-vault.test.ts | 184 ------ scripts/start-obsidian-e2e-instance.mjs | 552 ------------------ scripts/start-obsidian-e2e-instance.test.ts | 134 ----- scripts/stop-obsidian-e2e-instance.mjs | 557 ------------------- scripts/stop-obsidian-e2e-instance.test.ts | 482 ---------------- tests/e2e/harness.ts | 4 +- 18 files changed, 216 insertions(+), 2790 deletions(-) create mode 100644 obsidian-e2e.config.mjs create mode 100644 scripts/e2e-config.test.ts delete mode 100644 scripts/obsidian-e2e-cli.mjs delete mode 100644 scripts/obsidian-e2e-cli.test.ts delete mode 100644 scripts/obsidian-e2e-errors.mjs delete mode 100644 scripts/obsidian-e2e-types.d.ts delete mode 100644 scripts/provision-obsidian-e2e-vault.mjs delete mode 100644 scripts/provision-obsidian-e2e-vault.test.ts delete mode 100644 scripts/start-obsidian-e2e-instance.mjs delete mode 100644 scripts/start-obsidian-e2e-instance.test.ts delete mode 100644 scripts/stop-obsidian-e2e-instance.mjs delete mode 100644 scripts/stop-obsidian-e2e-instance.test.ts diff --git a/.gitignore b/.gitignore index 755c9a1d..151b96d8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,5 @@ docs/site # Obsidian E2E failure artifacts .obsidian-e2e-artifacts -# Isolated per-worktree Obsidian E2E vaults (provisioned by scripts/obsidian-e2e-*) +# Isolated per-worktree Obsidian E2E vaults (provisioned by the obsidian-e2e runner) .obsidian-e2e-vaults diff --git a/AGENTS.md b/AGENTS.md index 432b83c9..aad1e052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,10 @@ npm run obsidian:e2e -- eval code='Boolean(app.plugins.plugins.podnotes)' npm run obsidian:e2e -- dev:errors ``` +- The four `provision:e2e-vault` / `start:e2e-obsidian` / `stop:e2e-obsidian` / + `obsidian:e2e` scripts run on the shared `obsidian-e2e` instance-runner bin, + configured by `obsidian-e2e.config.mjs` at the repo root (plugin id, the two + symlinked artifacts, the `data.json` seed, and the PodNotes ready probe). - 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 @@ -124,17 +128,20 @@ npm run obsidian:e2e -- dev:errors - `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: + the vault env for a separate process. `--print-env` emits export-only lines on + stdout (so `eval "$(...)"` is safe): the canonical `OBSIDIAN_E2E_VAULT` / + `OBSIDIAN_E2E_VAULT_PATH` / `OBSIDIAN_E2E_OBSIDIAN_HOME` names and, during the + migration, legacy `PODNOTES_E2E_*` aliases; `tests/e2e/harness.ts` reads the + canonical name first, then the alias. 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 the vault alone leaves the suite talking to the shared `dev` vault: ```bash - npm run build # required: provisioning links main.js + npm run build # required: provisioning links main.js 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 + export HOME="$OBSIDIAN_E2E_OBSIDIAN_HOME" # required: re-point the CLI socket + OBSIDIAN_E2E_VAULT="$OBSIDIAN_E2E_VAULT" npm run test:e2e ``` Build first so the instance loads the current bundle (provisioning also needs diff --git a/obsidian-e2e.config.mjs b/obsidian-e2e.config.mjs new file mode 100644 index 00000000..15d962be --- /dev/null +++ b/obsidian-e2e.config.mjs @@ -0,0 +1,126 @@ +// Consumer config for the shared obsidian-e2e instance runner. The four +// `provision:e2e-vault` / `start:e2e-obsidian` / `stop:e2e-obsidian` / +// `obsidian:e2e` scripts point at the `obsidian-e2e` bin, which reads this file +// from the worktree root. See the runner's README ("Instance Runner (CLI)") for +// the full schema. +// +// `defaultData` seeds a freshly provisioned vault's data.json. It mirrors +// DEFAULT_SETTINGS in src/constants.ts (plus the `schemaVersion: 2` persistence +// marker PodNotes writes alongside its settings) so a new vault loads with clean +// PodNotes state and the current on-disk schema. `currentEpisode` is omitted: +// DEFAULT_SETTINGS sets it to `undefined`, which JSON cannot represent and +// PodNotes treats as absent. Keep this in sync with constants.ts - +// scripts/e2e-config.test.ts fails if it drifts from DEFAULT_SETTINGS. +export default { + pluginId: "podnotes", + // PodNotes injects its CSS into main.js (svelte compilerOptions css: + // "injected"), so there is no styles.css artifact - only the manifest and the + // compiled bundle are symlinked into the vault. + pluginArtifacts: ["manifest.json", "main.js"], + defaultData: { + schemaVersion: 2, + savedFeeds: {}, + podNotes: {}, + defaultPlaybackRate: 1, + defaultVolume: 1, + hidePlayedEpisodes: false, + episodeListLimit: 10, + playedEpisodes: {}, + favorites: { + icon: "lucide-star", + name: "Favorites", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: [], + }, + queue: { + icon: "list-ordered", + name: "Queue", + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + episodes: [], + }, + autoQueue: true, + playlists: {}, + skipBackwardLength: 15, + skipForwardLength: 15, + timestamp: { + template: "- {{time}} ", + offset: 0, + }, + note: { + path: "PodNotes/{{podcast}}/{{title}}.md", + template: + "---\n" + + "type: podcastEpisode\n" + + 'podcast: "{{podcastlink}}"\n' + + "date: {{date:YYYY-MM-DD}}\n" + + "tags:\n" + + " - podcastEpisode\n" + + "status:\n" + + "rating:\n" + + "favorite: false\n" + + "---\n" + + "# {{title}}\n\n" + + "![]({{artwork}})\n\n" + + "[Resume in PodNotes]({{episodelink}})\n\n" + + "{{url}}\n\n" + + "{{description}}\n", + }, + feedNote: { + path: "PodNotes/Podcasts/{{podcast}}.md", + template: + "---\n" + + "type: podcast\n" + + 'podcast: "{{podcast}}"\n' + + 'image: "{{artwork}}"\n' + + 'url: "{{url}}"\n' + + 'feedUrl: "{{feedurl}}"\n' + + "tags:\n" + + " - podcast\n" + + "---\n" + + "# {{title}}\n" + + "{{author}}\n\n" + + "![]({{artwork}})\n\n" + + "{{description}}\n", + }, + download: { + path: "PodNotes/{{podcast}}/{{title}}", + }, + downloadedEpisodes: {}, + localFiles: { + icon: "folder", + name: "Local Files", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: [], + }, + openAISecretId: "", + deepgramSecretId: "", + transcript: { + path: "transcripts/{{podcast}}/{{title}}.md", + template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", + diarization: { + enabled: false, + provider: "openai", + speakerTemplate: "**{{speaker}}:** ", + }, + }, + feedCache: { + enabled: true, + ttlHours: 6, + }, + }, + buildCommand: "npm run build", + // Emit legacy PODNOTES_E2E_* env aliases alongside the canonical OBSIDIAN_E2E_* + // names while the harness and AGENTS.md playbooks migrate off them. + envPrefix: "PODNOTES", + // Confirm the PodNotes plugin instance is live in the target vault. The launcher + // waits for stdout to contain the match string; the code intentionally omits the + // literal "=> true" so an echoed command can't be mistaken for a positive result. + readyProbe: { + kind: "eval", + code: `Boolean(app.plugins.plugins["podnotes"])`, + match: "=> true", + }, +}; diff --git a/orca.yaml b/orca.yaml index 2c1cc7ac..de0a14b7 100644 --- a/orca.yaml +++ b/orca.yaml @@ -25,4 +25,4 @@ # of erroring. scripts: archive: | - command -v node >/dev/null 2>&1 && node scripts/stop-obsidian-e2e-instance.mjs --worktree "$ORCA_WORKTREE_PATH" || true + command -v node >/dev/null 2>&1 && npm exec obsidian-e2e -- stop --worktree "$ORCA_WORKTREE_PATH" || true diff --git a/package-lock.json b/package-lock.json index 85fdc581..614389aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "jsdom": "^29.1.1", "obsidian": "1.13.1", - "obsidian-e2e": "0.6.0", + "obsidian-e2e": "file:../obsidian-e2e", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", @@ -38,6 +38,32 @@ "node": "^22.14.0" } }, + "../obsidian-e2e": { + "version": "0.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + }, + "bin": { + "obsidian-e2e": "dist/runner/bin.mjs" + }, + "devDependencies": { + "@changesets/cli": "^2.30.0", + "@types/node": "^25.3.5", + "@typescript/native-preview": "7.0.0-dev.20260309.1", + "bumpp": "^10.4.1", + "typescript": "^5.9.3", + "vite-plus": "latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite-plus": "^0.1.11" + } + }, "node_modules/@actions/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", @@ -2089,6 +2115,7 @@ "integrity": "sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2100,6 +2127,7 @@ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2847,6 +2875,7 @@ "integrity": "sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2919,6 +2948,7 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT", + "optional": true, "peer": true }, "node_modules/@poppinss/colors": { @@ -4493,6 +4523,7 @@ "integrity": "sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -4559,6 +4590,7 @@ "integrity": "sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@oxc-project/runtime": "=0.133.0", @@ -6371,6 +6403,7 @@ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT", + "optional": true, "peer": true }, "node_modules/es-object-atoms": { @@ -9539,6 +9572,7 @@ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "engines": { "node": ">=10" @@ -11722,20 +11756,8 @@ } }, "node_modules/obsidian-e2e": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/obsidian-e2e/-/obsidian-e2e-0.6.0.tgz", - "integrity": "sha512-mbyij8q5tCXFRGZj5RFy7fS7F9ZIJ9bxNa9sBCsl7aoMsp3jWBW4vUjfN1Q2ZDMagDH7ideKYkMeCOb1NbrPRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "yaml": "^2.8.2" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite-plus": "^0.1.11" - } + "resolved": "../obsidian-e2e", + "link": true }, "node_modules/obug": { "version": "2.1.1", @@ -11921,6 +11943,7 @@ "integrity": "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" @@ -12209,6 +12232,7 @@ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", "dev": true, "license": "ISC", + "optional": true, "peer": true, "dependencies": { "pngjs": "^7.0.0" @@ -12300,6 +12324,7 @@ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "engines": { "node": ">=14.19.0" @@ -13441,6 +13466,7 @@ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -14320,6 +14346,7 @@ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "engines": { "node": ">=6" @@ -15069,6 +15096,7 @@ "integrity": "sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@oxc-project/types": "=0.133.0", @@ -15788,6 +15816,7 @@ "integrity": "sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "@oxc-project/runtime": "=0.133.0", @@ -15888,6 +15917,7 @@ "integrity": "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "dependencies": { "tinypool": "2.1.0" @@ -15941,6 +15971,7 @@ "integrity": "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "bin": { "oxlint": "bin/oxlint" diff --git a/package.json b/package.json index 248a898c..9511c936 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "release:plan": "node scripts/release-plan.mjs", "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", - "stop:e2e-obsidian": "node scripts/stop-obsidian-e2e-instance.mjs", + "obsidian:e2e": "obsidian-e2e run", + "provision:e2e-vault": "obsidian-e2e provision", + "start:e2e-obsidian": "obsidian-e2e start", + "stop:e2e-obsidian": "obsidian-e2e stop", "check:a11y": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && svelte-check --tsconfig ./tsconfig.test.json --fail-on-warnings", "docs:build": "mkdocs build -f docs/mkdocs.yml -d site", "docs:deploy": "npm run docs:build && wrangler pages deploy docs/site --project-name podnotes --branch master", - "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json svelte.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts wrangler.jsonc orca.yaml docs/mkdocs.yml" + "format": "oxfmt src tests scripts .github package.json manifest.json versions.json tsconfig.base.json tsconfig.json tsconfig.test.json .oxlintrc.json .oxfmtrc.json svelte.config.mjs vite.config.ts vitest.config.ts vitest.e2e.config.ts vitest.setup.ts obsidian-e2e.config.mjs wrangler.jsonc orca.yaml docs/mkdocs.yml" }, "dependencies": { "fuse.js": "^7.4.2", @@ -41,7 +41,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "jsdom": "^29.1.1", "obsidian": "1.13.1", - "obsidian-e2e": "0.6.0", + "obsidian-e2e": "file:../obsidian-e2e", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", diff --git a/scripts/e2e-config.test.ts b/scripts/e2e-config.test.ts new file mode 100644 index 00000000..bc637032 --- /dev/null +++ b/scripts/e2e-config.test.ts @@ -0,0 +1,18 @@ +import { DEFAULT_SETTINGS } from "src/constants"; +import { describe, expect, it } from "vitest"; +import config from "../obsidian-e2e.config.mjs"; + +describe("obsidian-e2e.config.mjs", () => { + it("seeds a defaultData that mirrors the real DEFAULT_SETTINGS", () => { + // JSON cannot represent currentEpisode: undefined, so compare the serialized + // forms - this is exactly what lands in a freshly provisioned vault's + // data.json. It fails if a setting is added to src/constants.ts without + // updating the runner config's defaultData seed. The `schemaVersion: 2` + // persistence marker PodNotes writes alongside its settings is seeded too, so + // a provisioned vault opens on the current on-disk schema. + expect(JSON.parse(JSON.stringify(config.defaultData))).toEqual({ + schemaVersion: 2, + ...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), + }); + }); +}); diff --git a/scripts/obsidian-e2e-cli.mjs b/scripts/obsidian-e2e-cli.mjs deleted file mode 100644 index d1d26ed1..00000000 --- a/scripts/obsidian-e2e-cli.mjs +++ /dev/null @@ -1,193 +0,0 @@ -#!/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, - reapStaleInstances, - reloadPodNotes, - resolveInstanceOptions, - trustVaultAndVerifyPodNotes, - waitForInstanceReady, -} from "./start-obsidian-e2e-instance.mjs"; - -/** @typedef {import("./obsidian-e2e-types").InstanceOptions} InstanceOptions */ -/** @typedef {import("./obsidian-e2e-types").WrapperArgs} WrapperArgs */ - -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] [--] - -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 Vault/profile name. Defaults to podnotes-. - --root Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults. - --worktree PodNotes worktree to link plugin files from. Defaults to cwd. - --data Optional PodNotes data.json seed to copy on first provision. - --profile-root Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e. - --obsidian-app Obsidian app name for macOS open. Defaults to Obsidian. - --obsidian-bin Obsidian CLI executable. Defaults to obsidian. - --force Recreate plugin symlinks if they already exist. - --help Show this help. -`); -} - -/** - * @param {readonly string[]} argv - * @returns {WrapperArgs} - */ -export function parseArgs(argv) { - /** @type {string[]} */ - const instanceArgs = []; - /** @type {string[]} */ - 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], - }; -} - -/** - * @param {Pick} options - * @returns {NodeJS.ProcessEnv} - */ -export function obsidianEnv(options) { - return { - ...process.env, - HOME: options.obsidianHome, - }; -} - -/** - * @param {Pick} options - * @param {readonly string[]} commandArgs - */ -export function obsidianCommandArgs(options, commandArgs) { - return [`vault=${options.vaultName}`, ...commandArgs]; -} - -/** @param {InstanceOptions} options */ -export async function ensureObsidianInstance(options) { - const provisionResult = await provisionVault(options); - const profileResult = await prepareObsidianProfile(options); - - 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); - - return { - ...provisionResult, - ...profileResult, - obsidianHome: options.obsidianHome, - }; -} - -/** - * @param {InstanceOptions} options - * @param {readonly string[]} commandArgs - * @returns {Promise} - */ -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 reapStaleInstances(options); - 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; - }); -} diff --git a/scripts/obsidian-e2e-cli.test.ts b/scripts/obsidian-e2e-cli.test.ts deleted file mode 100644 index 4ac917cd..00000000 --- a/scripts/obsidian-e2e-cli.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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", - }); - }); -}); diff --git a/scripts/obsidian-e2e-errors.mjs b/scripts/obsidian-e2e-errors.mjs deleted file mode 100644 index eac27711..00000000 --- a/scripts/obsidian-e2e-errors.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @param {unknown} error - * @returns {error is { code: unknown }} - */ -function hasErrorCode(error) { - return typeof error === "object" && error !== null && "code" in error; -} - -/** - * @param {unknown} error - * @param {string} code - */ -export function errorHasCode(error, code) { - return hasErrorCode(error) && error.code === code; -} - -/** - * @param {unknown} value - * @returns {value is Record} - */ -export function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Extract the useful output attached to a rejected child-process command. - * - * @param {unknown} error - */ -export function commandErrorMessage(error) { - if (typeof error === "object" && error !== null) { - if ("stderr" in error && typeof error.stderr === "string" && error.stderr.trim()) { - return error.stderr.trim(); - } - if ("stdout" in error && typeof error.stdout === "string" && error.stdout.trim()) { - return error.stdout.trim(); - } - } - return error instanceof Error ? error.message : String(error); -} diff --git a/scripts/obsidian-e2e-types.d.ts b/scripts/obsidian-e2e-types.d.ts deleted file mode 100644 index 88b41501..00000000 --- a/scripts/obsidian-e2e-types.d.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Dirent } from "node:fs"; - -export interface ProvisionRawOptions { - data?: string; - force?: boolean; - help?: boolean; - json?: boolean; - printEnv?: boolean; - root?: string; - vault?: string; - worktree?: string; -} - -export interface ProvisionOptions { - dataPath: string | undefined; - force: boolean; - json: boolean; - printEnv: boolean; - rootPath: string; - vaultName: string; - vaultPath: string; - worktreePath: string; -} - -export interface ProvisionResult { - pluginPath: string; - vaultName: string; - vaultPath: string; - worktreePath: string; -} - -export interface InstanceRawOptions extends ProvisionRawOptions { - launch?: boolean; - obsidianApp?: string; - obsidianBin?: string; - profileRoot?: string; -} - -export interface InstanceOptions extends ProvisionOptions { - instanceId: string; - instancePath: string; - launch: boolean; - obsidianApp: string; - obsidianBin: string; - obsidianHome: string; - profileRoot: string; - userDataPath: string; -} - -export interface ProfileResult { - obsidianJsonPath: string; - userDataPath: string; - vaultId: string; -} - -export interface LaunchResult { - pid: null; - pidPath: null; -} - -export interface InstanceShellResult { - obsidianBin?: string; - obsidianHome: string; - vaultName: string; - vaultPath: string; -} - -export interface WrapperArgs { - commandArgs: string[]; - help: boolean; - instanceArgs: string[]; -} - -export interface ProcessInfo { - command: string; - pid: number; - ppid: number; -} - -export interface CollectInstanceOptions { - selfPid?: number; -} - -export type KillFunction = (pid: number, signal: NodeJS.Signals | 0) => void; -export type RunPsFunction = () => Promise; -export type RemoveDirFunction = (dir: string) => Promise; -export type ReadFileFunction = (file: string) => Promise; -export type ExistsFunction = (target: string) => Promise; - -export interface StopOptions { - dryRun?: boolean; - graceMs?: number; - kill?: KillFunction; - pollMs?: number; - profileRoot?: string; - removeDir?: RemoveDirFunction; - runPs?: RunPsFunction; - selfPid?: number; -} - -export interface StopResult { - instancePath: string; - killed: number[]; - pids: number[]; - removed: boolean; - terminated: number[]; -} - -export interface InstanceReadDependencies { - exists?: ExistsFunction; - readFile?: ReadFileFunction; -} - -export interface ReapOptions extends StopOptions, InstanceReadDependencies { - exceptInstancePath?: string; - log?: (message: string) => void; - profileRoot?: string; - readdir?: (dir: string) => Promise; -} - -export interface ReapResult { - reaped: string[]; - scanned: number; -} - -export interface StopRawOptions extends InstanceRawOptions { - dryRun: boolean; - json: boolean; - prune: boolean; -} diff --git a/scripts/provision-obsidian-e2e-vault.mjs b/scripts/provision-obsidian-e2e-vault.mjs deleted file mode 100644 index 4a0dc5fb..00000000 --- a/scripts/provision-obsidian-e2e-vault.mjs +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env node -import fs from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { errorHasCode } from "./obsidian-e2e-errors.mjs"; - -/** @typedef {import("./obsidian-e2e-types").ProvisionOptions} ProvisionOptions */ -/** @typedef {import("./obsidian-e2e-types").ProvisionRawOptions} ProvisionRawOptions */ -/** @typedef {import("./obsidian-e2e-types").ProvisionResult} ProvisionResult */ - -// PodNotes bundles its CSS into main.js (svelte compilerOptions css: "injected"), -// so there is no styles.css artifact to symlink — only the manifest and bundle. -const REQUIRED_PLUGIN_FILES = ["manifest.json", "main.js"]; -const DEFAULT_ROOT = ".obsidian-e2e-vaults"; -const DEFAULT_VAULT_PREFIX = "podnotes"; -const PLUGIN_ID = "podnotes"; - -// Expression evaluated via `obsidian eval` to confirm the PodNotes plugin -// instance is live in the target vault (the launcher waits for stdout to -// contain "=> true"). The expression intentionally does not contain the literal -// "=> true" so an echoed command can't be mistaken for a positive result. -export const PODNOTES_READY_EVAL = `Boolean(app.plugins.plugins[${JSON.stringify(PLUGIN_ID)}])`; - -// A valid, empty PodNotes schema-v1 document. Mirrors DEFAULT_SETTINGS plus the -// persistence marker so a freshly provisioned vault loads with clean state -// instead of QuickAdd's { choices, migrations } shape. Keep in sync with -// constants.ts. (currentEpisode is intentionally omitted - DEFAULT_SETTINGS sets it to -// undefined, which JSON cannot represent and PodNotes treats as absent.) -export const DEFAULT_PODNOTES_DATA = { - schemaVersion: 2, - savedFeeds: {}, - podNotes: {}, - defaultPlaybackRate: 1, - defaultVolume: 1, - hidePlayedEpisodes: false, - episodeListLimit: 10, - playedEpisodes: {}, - favorites: { - icon: "lucide-star", - name: "Favorites", - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - episodes: [], - }, - queue: { - icon: "list-ordered", - name: "Queue", - shouldEpisodeRemoveAfterPlay: true, - shouldRepeat: false, - episodes: [], - }, - autoQueue: true, - playlists: {}, - skipBackwardLength: 15, - skipForwardLength: 15, - timestamp: { - template: "- {{time}} ", - offset: 0, - }, - note: { - path: "PodNotes/{{podcast}}/{{title}}.md", - template: - "---\n" + - "type: podcastEpisode\n" + - 'podcast: "{{podcastlink}}"\n' + - "date: {{date:YYYY-MM-DD}}\n" + - "tags:\n" + - " - podcastEpisode\n" + - "status:\n" + - "rating:\n" + - "favorite: false\n" + - "---\n" + - "# {{title}}\n\n" + - "![]({{artwork}})\n\n" + - "[Resume in PodNotes]({{episodelink}})\n\n" + - "{{url}}\n\n" + - "{{description}}\n", - }, - feedNote: { - path: "PodNotes/Podcasts/{{podcast}}.md", - template: - "---\n" + - "type: podcast\n" + - 'podcast: "{{podcast}}"\n' + - 'image: "{{artwork}}"\n' + - 'url: "{{url}}"\n' + - 'feedUrl: "{{feedurl}}"\n' + - "tags:\n" + - " - podcast\n" + - "---\n" + - "# {{title}}\n" + - "{{author}}\n\n" + - "![]({{artwork}})\n\n" + - "{{description}}\n", - }, - download: { - path: "PodNotes/{{podcast}}/{{title}}", - }, - downloadedEpisodes: {}, - localFiles: { - icon: "folder", - name: "Local Files", - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - episodes: [], - }, - openAISecretId: "", - deepgramSecretId: "", - transcript: { - path: "transcripts/{{podcast}}/{{title}}.md", - template: "# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}", - diarization: { - enabled: false, - provider: "openai", - speakerTemplate: "**{{speaker}}:** ", - }, - }, - feedCache: { - enabled: true, - ttlHours: 6, - }, -}; - -/** @param {string} value */ -function slugify(value) { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80) || "worktree" - ); -} - -function printUsage() { - console.log(`Usage: node scripts/provision-obsidian-e2e-vault.mjs [options] - -Options: - --vault Vault name to provision. Defaults to podnotes-. - --root Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults. - --worktree PodNotes worktree to link plugin files from. Defaults to cwd. - --data Optional PodNotes data.json seed to copy on first provision. - --force Recreate plugin symlinks if they already exist. - --print-env Print PODNOTES_E2E_VAULT exports after provisioning. - --json Print a machine-readable summary after provisioning. - --help Show this help. -`); -} - -/** - * @param {string[]} argv - * @returns {ProvisionRawOptions} - */ -export function parseArgs(argv) { - /** @type {ProvisionRawOptions} */ - const options = { - force: false, - json: false, - printEnv: false, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case "--": - break; - case "--force": - options.force = true; - break; - case "--json": - options.json = true; - break; - case "--print-env": - options.printEnv = true; - break; - case "--help": - options.help = true; - break; - case "--vault": - case "--root": - case "--worktree": - case "--data": { - const value = argv[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`${arg} requires a value.`); - } - options[toOptionKey(arg)] = value; - index += 1; - break; - } - default: - throw new Error(`Unknown option: ${arg}`); - } - } - - return options; -} - -/** - * @param {"--vault" | "--root" | "--worktree" | "--data"} arg - * @returns {"vault" | "root" | "worktree" | "data"} - */ -function toOptionKey(arg) { - switch (arg) { - case "--vault": - return "vault"; - case "--root": - return "root"; - case "--worktree": - return "worktree"; - case "--data": - return "data"; - } -} - -/** - * @param {ProvisionRawOptions} rawOptions - * @param {string} [cwd] - * @returns {ProvisionOptions} - */ -export function resolveProvisionOptions(rawOptions, cwd = process.cwd()) { - const worktreePath = path.resolve(cwd, rawOptions.worktree ?? "."); - const vaultName = - rawOptions.vault ?? `${DEFAULT_VAULT_PREFIX}-${slugify(path.basename(worktreePath))}`; - // Default the vault root to the *worktree* (not cwd) so the provisioned vault - // always lives inside the checkout whose plugin it links. Anchoring to cwd - // would put `--worktree /other/checkout` vaults under the caller's directory, - // where parallel worktrees would share a root — breaking isolation. An - // explicit --root still resolves against cwd. - const rootPath = rawOptions.root - ? path.resolve(cwd, rawOptions.root) - : path.join(worktreePath, DEFAULT_ROOT); - const vaultPath = path.resolve(rootPath, vaultName); - const dataPath = rawOptions.data ? path.resolve(cwd, rawOptions.data) : undefined; - - return { - dataPath, - force: rawOptions.force ?? false, - json: rawOptions.json ?? false, - printEnv: rawOptions.printEnv ?? false, - rootPath, - vaultName, - vaultPath, - worktreePath, - }; -} - -/** @param {string} filePath */ -async function pathExists(filePath) { - try { - await fs.lstat(filePath); - return true; - } catch (error) { - if (errorHasCode(error, "ENOENT")) return false; - throw error; - } -} - -/** @param {string} worktreePath */ -async function assertRequiredPluginFiles(worktreePath) { - const missing = []; - for (const fileName of REQUIRED_PLUGIN_FILES) { - const filePath = path.join(worktreePath, fileName); - if (!(await pathExists(filePath))) missing.push(fileName); - } - - if (missing.length > 0) { - throw new Error( - [ - `Cannot provision PodNotes in ${worktreePath}; missing ${missing.join(", ")}.`, - "Run npm run build in that worktree before provisioning.", - ].join(" "), - ); - } -} - -/** - * @param {string} filePath - * @param {unknown} value - */ -async function writeJson(filePath, value) { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(`${filePath}.tmp`, `${JSON.stringify(value, null, "\t")}\n`); - await fs.rename(`${filePath}.tmp`, filePath); -} - -/** - * @param {string} filePath - * @param {unknown} value - */ -async function writeJsonIfMissing(filePath, value) { - if (await pathExists(filePath)) return; - await writeJson(filePath, value); -} - -/** - * @param {string} sourcePath - * @param {string} destinationPath - * @param {boolean} force - */ -async function linkPluginFile(sourcePath, destinationPath, force) { - const existing = await pathExists(destinationPath); - if (existing && force) { - await fs.unlink(destinationPath); - } else if (existing) { - const stat = await fs.lstat(destinationPath); - if (!stat.isSymbolicLink()) { - throw new Error( - `${destinationPath} exists and is not a symlink. Use --force after reviewing it.`, - ); - } - - const currentTarget = await fs.readlink(destinationPath); - if (path.resolve(path.dirname(destinationPath), currentTarget) === sourcePath) { - return; - } - - throw new Error(`${destinationPath} points at ${currentTarget}. Use --force to relink it.`); - } - - await fs.symlink(sourcePath, destinationPath); -} - -/** - * @param {ProvisionOptions} options - * @returns {Promise} - */ -export async function provisionVault(options) { - await assertRequiredPluginFiles(options.worktreePath); - - const obsidianPath = path.join(options.vaultPath, ".obsidian"); - const pluginPath = path.join(obsidianPath, "plugins", PLUGIN_ID); - - await fs.mkdir(pluginPath, { recursive: true }); - await writeJsonIfMissing(path.join(obsidianPath, "app.json"), {}); - await writeJsonIfMissing(path.join(obsidianPath, "appearance.json"), {}); - await writeJsonIfMissing(path.join(obsidianPath, "core-plugins.json"), []); - await writeJson(path.join(obsidianPath, "community-plugins.json"), [PLUGIN_ID]); - await writeJsonIfMissing(path.join(obsidianPath, "workspace.json"), { - main: { id: "podnotes-e2e", type: "split", children: [] }, - left: { id: "podnotes-e2e-left", type: "split", children: [] }, - right: { id: "podnotes-e2e-right", type: "split", children: [] }, - }); - - for (const fileName of REQUIRED_PLUGIN_FILES) { - await linkPluginFile( - path.join(options.worktreePath, fileName), - path.join(pluginPath, fileName), - options.force, - ); - } - - const pluginDataPath = path.join(pluginPath, "data.json"); - if (options.dataPath && !(await pathExists(pluginDataPath))) { - await fs.copyFile(options.dataPath, pluginDataPath); - } else { - await writeJsonIfMissing(pluginDataPath, DEFAULT_PODNOTES_DATA); - } - - return { - pluginPath, - vaultName: options.vaultName, - vaultPath: options.vaultPath, - worktreePath: options.worktreePath, - }; -} - -/** @param {Pick} result */ -export function toShellExports(result) { - return [ - `export PODNOTES_E2E_VAULT=${shellQuote(result.vaultName)}`, - `export PODNOTES_E2E_VAULT_PATH=${shellQuote(result.vaultPath)}`, - ].join("\n"); -} - -/** @param {unknown} value */ -function shellQuote(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} - -async function main() { - const rawOptions = parseArgs(process.argv.slice(2)); - if (rawOptions.help) { - printUsage(); - return; - } - - const options = resolveProvisionOptions(rawOptions); - const result = await provisionVault(options); - - if (options.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - // With --print-env, stdout must contain only the `export ...` lines so - // `eval "$(... --print-env)"` works; route the human summary to stderr. - const status = options.printEnv ? console.error : console.log; - status(`Provisioned Obsidian E2E vault ${result.vaultName}`); - status(`Vault path: ${result.vaultPath}`); - status(`PodNotes plugin: ${result.pluginPath}`); - // Provisioning only lays down vault files; it does not launch Obsidian, - // disable Restricted Mode, or confirm the plugin loads. Use - // `npm run start:e2e-obsidian` / `npm run obsidian:e2e` for that. - status("Plugin not yet verified — start an instance to trust & load it."); - } - - if (options.printEnv) { - console.log(toShellExports(result)); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); -} diff --git a/scripts/provision-obsidian-e2e-vault.test.ts b/scripts/provision-obsidian-e2e-vault.test.ts deleted file mode 100644 index 40889e2e..00000000 --- a/scripts/provision-obsidian-e2e-vault.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { DEFAULT_SETTINGS } from "src/constants"; -import { afterEach, describe, expect, it } from "vitest"; -import { - DEFAULT_PODNOTES_DATA, - parseArgs, - provisionVault, - resolveProvisionOptions, - toShellExports, -} from "./provision-obsidian-e2e-vault.mjs"; - -const tempRoots: string[] = []; - -async function makeTempDir(name: string) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), `${name}-`)); - tempRoots.push(dir); - return dir; -} - -// PodNotes ships only manifest.json + main.js (CSS is injected into the bundle), -// so a seeded worktree never has a styles.css to link. -async function seedWorktree(dir: string, label: string) { - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "manifest.json"), JSON.stringify({ id: "podnotes" })); - await fs.writeFile(path.join(dir, "main.js"), `console.log(${JSON.stringify(label)});\n`); -} - -async function readLinkedTarget(filePath: string) { - return path.resolve(path.dirname(filePath), await fs.readlink(filePath)); -} - -afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - -describe("provision-obsidian-e2e-vault", () => { - it("parses vault and root options", () => { - const options = resolveProvisionOptions( - parseArgs(["--vault", "podnotes-a", "--root", "vaults"]), - "/tmp/podnotes-repo", - ); - - expect(options.vaultName).toBe("podnotes-a"); - expect(options.rootPath).toBe("/tmp/podnotes-repo/vaults"); - expect(options.vaultPath).toBe("/tmp/podnotes-repo/vaults/podnotes-a"); - }); - - it("seeds schema v2 data that mirrors the real DEFAULT_SETTINGS", () => { - // JSON cannot represent currentEpisode: undefined, so compare the - // serialized forms - this is exactly what lands in the vault's data.json - // and it fails if a new setting is added to src/constants.ts without - // updating DEFAULT_PODNOTES_DATA. - expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual({ - schemaVersion: 2, - ...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), - }); - }); - - it("defaults the vault name to podnotes-", () => { - const options = resolveProvisionOptions( - parseArgs([]), - "/tmp/repos/devx-worktree-vault-isolation", - ); - - expect(options.vaultName).toBe("podnotes-devx-worktree-vault-isolation"); - }); - - it("anchors the default vault root to the worktree, not cwd", () => { - // --worktree elsewhere without --root must keep the vault inside that - // checkout (worktree-local isolation), not the caller's cwd. - const options = resolveProvisionOptions( - parseArgs(["--worktree", "/tmp/other/checkout"]), - "/tmp/caller-cwd", - ); - - expect(options.rootPath).toBe("/tmp/other/checkout/.obsidian-e2e-vaults"); - expect(options.vaultPath).toBe( - "/tmp/other/checkout/.obsidian-e2e-vaults/podnotes-checkout", - ); - }); - - it("creates an Obsidian vault with PodNotes symlinked from a worktree", async () => { - const root = await makeTempDir("podnotes-e2e-root"); - const worktree = await makeTempDir("podnotes-worktree-a"); - await seedWorktree(worktree, "a"); - - const options = resolveProvisionOptions({ - root, - vault: "podnotes-a", - worktree, - }); - - const result = await provisionVault(options); - const pluginPath = path.join(result.vaultPath, ".obsidian", "plugins", "podnotes"); - - await expect( - fs.readFile(path.join(result.vaultPath, ".obsidian", "community-plugins.json"), "utf8"), - ).resolves.toBe('[\n\t"podnotes"\n]\n'); - await expect(readLinkedTarget(path.join(pluginPath, "main.js"))).resolves.toBe( - path.join(worktree, "main.js"), - ); - await expect(readLinkedTarget(path.join(pluginPath, "manifest.json"))).resolves.toBe( - path.join(worktree, "manifest.json"), - ); - // CSS is injected into main.js, so no styles.css link is created. - await expect(fs.lstat(path.join(pluginPath, "styles.css"))).rejects.toMatchObject({ - code: "ENOENT", - }); - const seededData = JSON.parse( - await fs.readFile(path.join(pluginPath, "data.json"), "utf8"), - ); - expect(seededData).toEqual(DEFAULT_PODNOTES_DATA); - expect(toShellExports(result)).toContain("PODNOTES_E2E_VAULT='podnotes-a'"); - expect(toShellExports(result)).toContain(`PODNOTES_E2E_VAULT_PATH='${result.vaultPath}'`); - }); - - it("keeps separately provisioned worktrees isolated", async () => { - const root = await makeTempDir("podnotes-e2e-root"); - const worktreeA = await makeTempDir("podnotes-worktree-a"); - const worktreeB = await makeTempDir("podnotes-worktree-b"); - await seedWorktree(worktreeA, "a"); - await seedWorktree(worktreeB, "b"); - - const resultA = await provisionVault( - resolveProvisionOptions({ - root, - vault: "podnotes-a", - worktree: worktreeA, - }), - ); - const resultB = await provisionVault( - resolveProvisionOptions({ - root, - vault: "podnotes-b", - worktree: worktreeB, - }), - ); - - const mainA = path.join(resultA.vaultPath, ".obsidian", "plugins", "podnotes", "main.js"); - const mainB = path.join(resultB.vaultPath, ".obsidian", "plugins", "podnotes", "main.js"); - - await expect(readLinkedTarget(mainA)).resolves.toBe(path.join(worktreeA, "main.js")); - await expect(readLinkedTarget(mainB)).resolves.toBe(path.join(worktreeB, "main.js")); - expect(resultA.vaultPath).not.toBe(resultB.vaultPath); - }); - - it("does not overwrite existing plugin data", async () => { - const root = await makeTempDir("podnotes-e2e-root"); - const worktree = await makeTempDir("podnotes-worktree"); - const seedData = path.join(await makeTempDir("podnotes-seed"), "data.json"); - await seedWorktree(worktree, "a"); - await fs.writeFile(seedData, '{"podNotes":{"seed":true}}\n'); - - const options = resolveProvisionOptions({ - data: seedData, - root, - vault: "podnotes-data", - worktree, - }); - - const result = await provisionVault(options); - const dataPath = path.join(result.pluginPath, "data.json"); - // First provision with --data copies the seed verbatim (not DEFAULT_PODNOTES_DATA). - await expect(fs.readFile(dataPath, "utf8")).resolves.toBe('{"podNotes":{"seed":true}}\n'); - - await fs.writeFile(dataPath, '{"podNotes":{"kept":true}}\n'); - await provisionVault(options); - - await expect(fs.readFile(dataPath, "utf8")).resolves.toBe('{"podNotes":{"kept":true}}\n'); - }); - - it("fails fast when the worktree has no built plugin artifacts", async () => { - const root = await makeTempDir("podnotes-e2e-root"); - const worktree = await makeTempDir("podnotes-worktree-empty"); - - await expect( - provisionVault(resolveProvisionOptions({ root, vault: "podnotes-empty", worktree })), - ).rejects.toThrow(/missing manifest\.json, main\.js/); - }); -}); diff --git a/scripts/start-obsidian-e2e-instance.mjs b/scripts/start-obsidian-e2e-instance.mjs deleted file mode 100644 index a1135827..00000000 --- a/scripts/start-obsidian-e2e-instance.mjs +++ /dev/null @@ -1,552 +0,0 @@ -#!/usr/bin/env node -import { execFile } from "node:child_process"; -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { promisify } from "node:util"; -import { commandErrorMessage, errorHasCode } from "./obsidian-e2e-errors.mjs"; -import { - PODNOTES_READY_EVAL, - provisionVault, - resolveProvisionOptions, - toShellExports, -} from "./provision-obsidian-e2e-vault.mjs"; - -/** @typedef {import("./obsidian-e2e-types").InstanceOptions} InstanceOptions */ -/** @typedef {import("./obsidian-e2e-types").InstanceRawOptions} InstanceRawOptions */ -/** @typedef {import("./obsidian-e2e-types").InstanceShellResult} InstanceShellResult */ -/** @typedef {import("./obsidian-e2e-types").LaunchResult} LaunchResult */ -/** @typedef {import("./obsidian-e2e-types").ProfileResult} ProfileResult */ - -const execFileAsync = promisify(execFile); -const DEFAULT_PROFILE_ROOT = "/tmp/podnotes-obsidian-e2e"; -// Sidecar written at the instance root recording which worktree the instance -// belongs to. The teardown reaper reads it to reap an instance only once its -// worktree is gone (a removed/merged worktree) — the reliable leak signal. -export const INSTANCE_MARKER_FILE = "podnotes-e2e-instance.json"; -const DEFAULT_OBSIDIAN_APP = "Obsidian"; -const DEFAULT_OBSIDIAN_BIN = "obsidian"; -const READY_TIMEOUT_MS = 30_000; -const READY_INTERVAL_MS = 500; -const PODNOTES_READY_MARKER = "=> true"; - -function printUsage() { - console.log(`Usage: node scripts/start-obsidian-e2e-instance.mjs [options] - -Options: - --vault Vault/profile name. Defaults to podnotes-. - --root Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults. - --worktree PodNotes worktree to link plugin files from. Defaults to cwd. - --data Optional PodNotes data.json seed to copy on first provision. - --profile-root Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e. - --obsidian-app Obsidian app name for macOS open. Defaults to Obsidian. - --obsidian-bin Obsidian CLI executable. Defaults to obsidian. - --force Recreate plugin symlinks if they already exist. - --no-launch Prepare the profile and vault without launching Obsidian. - --print-env Print exports for running e2e tests against this instance. - --json Print a machine-readable summary. - --help Show this help. -`); -} - -/** - * @param {readonly string[]} argv - * @returns {InstanceRawOptions} - */ -export function parseArgs(argv) { - /** @type {InstanceRawOptions} */ - const options = { - force: false, - json: false, - launch: true, - printEnv: false, - }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case "--": - break; - case "--force": - options.force = true; - break; - case "--json": - options.json = true; - break; - case "--no-launch": - options.launch = false; - break; - case "--print-env": - options.printEnv = true; - break; - case "--help": - options.help = true; - break; - case "--vault": - case "--root": - case "--worktree": - case "--data": - case "--profile-root": - case "--obsidian-app": - case "--obsidian-bin": { - const value = argv[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`${arg} requires a value.`); - } - options[toOptionKey(arg)] = value; - index += 1; - break; - } - default: - throw new Error(`Unknown option: ${arg}`); - } - } - - return options; -} - -/** - * @param {"--vault" | "--root" | "--worktree" | "--data" | "--profile-root" | "--obsidian-app" | "--obsidian-bin"} arg - * @returns {"vault" | "root" | "worktree" | "data" | "profileRoot" | "obsidianApp" | "obsidianBin"} - */ -function toOptionKey(arg) { - switch (arg) { - case "--vault": - return "vault"; - case "--root": - return "root"; - case "--worktree": - return "worktree"; - case "--data": - return "data"; - case "--profile-root": - return "profileRoot"; - case "--obsidian-app": - return "obsidianApp"; - case "--obsidian-bin": - return "obsidianBin"; - } -} - -/** - * @param {InstanceRawOptions} rawOptions - * @param {string} [cwd] - * @returns {InstanceOptions} - */ -export function resolveInstanceOptions(rawOptions, cwd = process.cwd()) { - const provisionOptions = resolveProvisionOptions(rawOptions, cwd); - const profileRoot = path.resolve(cwd, rawOptions.profileRoot ?? DEFAULT_PROFILE_ROOT); - const instanceId = stableInstanceId(provisionOptions.worktreePath, provisionOptions.vaultName); - const instancePath = path.join(profileRoot, instanceId); - const obsidianHome = path.join(instancePath, "home"); - const userDataPath = path.join(obsidianHome, "Library", "Application Support", "obsidian"); - - return { - ...provisionOptions, - instanceId, - instancePath, - launch: rawOptions.launch ?? true, - obsidianApp: rawOptions.obsidianApp ?? DEFAULT_OBSIDIAN_APP, - obsidianBin: rawOptions.obsidianBin ?? DEFAULT_OBSIDIAN_BIN, - obsidianHome, - profileRoot, - userDataPath, - }; -} - -/** - * @param {string} worktreePath - * @param {string} vaultName - */ -function stableInstanceId(worktreePath, vaultName) { - const hash = crypto - .createHash("sha256") - .update(`${path.resolve(worktreePath)}\0${vaultName}`) - .digest("hex") - .slice(0, 12); - return `${safeName(vaultName).slice(0, 32)}-${hash}`; -} - -/** @param {string} value */ -function safeName(value) { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") || "vault" - ); -} - -/** - * @param {InstanceOptions} options - * @returns {Promise} - */ -export async function prepareObsidianProfile(options) { - const { userDataPath } = options; - await fs.mkdir(userDataPath, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.join(options.obsidianHome, "Library", "Logs"), { - recursive: true, - mode: 0o700, - }); - await linkHostKeychains(options); - - const vaultId = stableVaultId(options.vaultPath); - const obsidianJsonPath = path.join(userDataPath, "obsidian.json"); - await writeJson(obsidianJsonPath, { - cli: true, - updateDisabled: true, - vaults: { - [vaultId]: { - open: true, - path: options.vaultPath, - ts: Date.now(), - }, - }, - }); - - // Record which worktree this instance belongs to so the teardown reaper can - // reap it once that worktree is removed (see INSTANCE_MARKER_FILE). - await writeJson(path.join(options.instancePath, INSTANCE_MARKER_FILE), { - worktreePath: options.worktreePath, - vaultName: options.vaultName, - vaultPath: options.vaultPath, - }); - - return { - obsidianJsonPath, - userDataPath, - vaultId, - }; -} - -/** @param {InstanceOptions} options */ -async function linkHostKeychains(options) { - const realHome = process.env.HOME; - if (!realHome) return; - - const source = path.join(realHome, "Library", "Keychains"); - const destination = path.join(options.obsidianHome, "Library", "Keychains"); - // If HOME is already the private profile (e.g. after the documented - // `export HOME=$PODNOTES_E2E_OBSIDIAN_HOME`), source and destination are the - // same path. Re-linking would unlink the real host-keychain symlink a prior - // run created and replace it with a self-referential broken link — leave it. - if (path.resolve(source) === path.resolve(destination)) return; - try { - await fs.lstat(source); - } catch (error) { - if (errorHasCode(error, "ENOENT")) return; - throw error; - } - - try { - const stat = await fs.lstat(destination); - if (!stat.isSymbolicLink()) return; - const target = await fs.readlink(destination); - if (path.resolve(path.dirname(destination), target) === source) return; - await fs.unlink(destination); - } catch (error) { - if (!errorHasCode(error, "ENOENT")) throw error; - } - - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.symlink(source, destination); -} - -/** @param {string} vaultPath */ -function stableVaultId(vaultPath) { - return crypto.createHash("sha256").update(path.resolve(vaultPath)).digest("hex").slice(0, 16); -} - -/** - * @param {string} filePath - * @param {unknown} value - */ -async function writeJson(filePath, value) { - await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); - try { - const stat = await fs.lstat(filePath); - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error(`${filePath} exists but is not a regular file.`); - } - } catch (error) { - if (!errorHasCode(error, "ENOENT")) throw error; - } - await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`, { - mode: 0o600, - }); -} - -/** - * @param {InstanceOptions} options - * @returns {Promise} - */ -export async function launchObsidianInstance(options) { - await fs.mkdir(options.instancePath, { recursive: true }); - await execFileAsync( - "/usr/bin/open", - [ - "-n", - "-g", - "-a", - options.obsidianApp, - "--env", - `HOME=${options.obsidianHome}`, - "--args", - `--user-data-dir=${options.userDataPath}`, - "--password-store=basic", - ], - { - env: obsidianEnv(options), - }, - ); - - return { - pid: null, - pidPath: null, - }; -} - -/** - * @param {InstanceOptions} options - * @returns {NodeJS.ProcessEnv} - */ -function obsidianEnv(options) { - return { - ...process.env, - HOME: options.obsidianHome, - }; -} - -/** - * @param {InstanceOptions} options - * @param {readonly string[]} args - * @param {Omit} [execOptions] - */ -async function execObsidian(options, args, execOptions = {}) { - return execFileAsync(options.obsidianBin, args, { - encoding: "utf8", - env: obsidianEnv(options), - ...execOptions, - }); -} - -/** @param {InstanceOptions} options */ -function cliSocketPath(options) { - // The obsidian CLI talks to this unix socket; its presence means an instance - // for this private HOME is up (or starting to listen). - return path.join(options.obsidianHome, ".obsidian-cli.sock"); -} - -/** @param {InstanceOptions} options */ -async function cliSocketExists(options) { - try { - await fs.lstat(cliSocketPath(options)); - return true; - } catch { - return false; - } -} - -/** @param {InstanceOptions} options */ -export async function isInstanceReady(options) { - // Non-launching readiness probe: the obsidian CLI auto-launches Obsidian on - // the first command when nothing is running, so probing a cold HOME would - // spawn a competing instance. A missing socket means "not running" — report - // not-ready without probing. - if (!(await cliSocketExists(options))) return false; - - try { - const { stdout } = await execObsidian( - options, - [`vault=${options.vaultName}`, "vault", "info=path"], - { timeout: 5_000 }, - ); - return path.resolve(stdout.trim()) === path.resolve(options.vaultPath); - } catch { - return false; - } -} - -/** - * @param {InstanceOptions} options - * @returns {Promise} - */ -export async function waitForInstanceReady(options) { - const expectedPath = path.resolve(options.vaultPath); - const deadline = Date.now() + READY_TIMEOUT_MS; - let lastError = ""; - - while (Date.now() < deadline) { - // Don't issue a CLI command before the just-launched instance is listening - // on its socket: a probe sent first would make the CLI auto-launch a second - // Obsidian on the same profile/vault, racing the one open -n started. - if (!(await cliSocketExists(options))) { - lastError = "waiting for the obsidian-cli socket to appear"; - await sleep(READY_INTERVAL_MS); - continue; - } - try { - const { stdout } = await execObsidian(options, [ - `vault=${options.vaultName}`, - "vault", - "info=path", - ]); - const actualPath = path.resolve(stdout.trim()); - if (actualPath === expectedPath) return actualPath; - lastError = `resolved ${actualPath}, expected ${expectedPath}`; - } catch (error) { - lastError = commandErrorMessage(error); - } - await sleep(READY_INTERVAL_MS); - } - - throw new Error( - `Obsidian instance did not become ready for ${options.vaultName}. Last error: ${lastError}`, - ); -} - -/** - * @param {InstanceOptions} options - * @returns {Promise} - */ -export async function trustVaultAndVerifyPodNotes(options) { - await execObsidian(options, [`vault=${options.vaultName}`, "plugins:restrict", "off"]); - - const deadline = Date.now() + READY_TIMEOUT_MS; - let lastError = ""; - while (Date.now() < deadline) { - try { - const { stdout } = await execObsidian(options, [ - `vault=${options.vaultName}`, - "eval", - `code=${PODNOTES_READY_EVAL}`, - ]); - if (stdout.includes(PODNOTES_READY_MARKER)) return true; - lastError = stdout.trim(); - } catch (error) { - lastError = commandErrorMessage(error); - } - await sleep(READY_INTERVAL_MS); - } - - throw new Error( - `PodNotes did not become available in ${options.vaultName}. Last error: ${lastError}`, - ); -} - -/** @param {InstanceOptions} options */ -export async function reloadPodNotes(options) { - // Reload the plugin so a reused instance picks up a rebuilt main.js (the - // symlink target) instead of running the bundle it loaded earlier. - await execObsidian(options, [`vault=${options.vaultName}`, "plugin:reload", "id=podnotes"]); -} - -/** @param {number} ms */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** @param {InstanceShellResult} result */ -export function toInstanceShellExports(result) { - const lines = [ - toShellExports(result), - `export PODNOTES_E2E_OBSIDIAN_HOME=${shellQuote(result.obsidianHome)}`, - ]; - // Propagate a non-default CLI binary so the Vitest harness (which reads - // OBSIDIAN_BIN ?? "obsidian") targets the same instance this script verified. - if (result.obsidianBin && result.obsidianBin !== DEFAULT_OBSIDIAN_BIN) { - lines.push(`export OBSIDIAN_BIN=${shellQuote(result.obsidianBin)}`); - } - return lines.join("\n"); -} - -/** @param {unknown} value */ -function shellQuote(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} - -// Self-healing safety net: before launching our own instance, reap any leaked -// instances whose backing worktree is gone (e.g. removed on merge without the -// orca archive hook running). Best-effort — a reap failure must never block a -// start. The reaper is imported lazily so the static module graph stays acyclic -// (the stop module imports our option resolver; we only need its reaper at -// runtime). Logs go to stderr so `--print-env` keeps stdout to `export …` lines. -/** @param {InstanceOptions} options */ -export async function reapStaleInstances(options) { - try { - const { reapOrphanedInstances } = await import("./stop-obsidian-e2e-instance.mjs"); - await reapOrphanedInstances({ - profileRoot: options.profileRoot, - exceptInstancePath: options.instancePath, - log: console.error, - }); - } catch (error) { - console.error( - `Skipping stale-instance reap: ${error instanceof Error ? error.message : error}`, - ); - } -} - -async function main() { - const rawOptions = parseArgs(process.argv.slice(2)); - if (rawOptions.help) { - printUsage(); - return; - } - - const options = resolveInstanceOptions(rawOptions); - await reapStaleInstances(options); - const provisionResult = await provisionVault(options); - const profileResult = await prepareObsidianProfile(options); - - let launchResult = { pid: null, pidPath: null }; - let resolvedVaultPath = null; - let verifiedPodNotes = false; - if (options.launch) { - if (await isInstanceReady(options)) { - // Reuse the running private instance instead of starting a second app - // on the same HOME/vault, but reload first so a rebuilt main.js takes - // effect — otherwise `--print-env` would hand the E2E suite a stale - // bundle. Mirrors the obsidian:e2e wrapper. - await reloadPodNotes(options); - } else { - launchResult = await launchObsidianInstance(options); - } - resolvedVaultPath = await waitForInstanceReady(options); - verifiedPodNotes = await trustVaultAndVerifyPodNotes(options); - } - const result = { - ...provisionResult, - ...profileResult, - ...launchResult, - obsidianBin: options.obsidianBin, - obsidianHome: options.obsidianHome, - resolvedVaultPath, - verifiedPodNotes, - }; - - if (rawOptions.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - // With --print-env, stdout must contain only the `export ...` lines so - // `eval "$(... --print-env)"` works; route the human summary to stderr. - const status = rawOptions.printEnv ? console.error : console.log; - status(`Prepared Obsidian E2E instance ${result.vaultName}`); - status(`Vault path: ${result.vaultPath}`); - status(`Obsidian HOME: ${result.obsidianHome}`); - if (result.pid) status(`Obsidian PID: ${result.pid}`); - if (result.verifiedPodNotes) status("PodNotes plugin check: ok"); - } - - if (rawOptions.printEnv) { - console.log(toInstanceShellExports(result)); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); -} diff --git a/scripts/start-obsidian-e2e-instance.test.ts b/scripts/start-obsidian-e2e-instance.test.ts deleted file mode 100644 index 561b5b49..00000000 --- a/scripts/start-obsidian-e2e-instance.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - parseArgs, - prepareObsidianProfile, - resolveInstanceOptions, - toInstanceShellExports, -} from "./start-obsidian-e2e-instance.mjs"; - -const tempRoots: string[] = []; - -async function makeTempDir(name: string) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), `${name}-`)); - tempRoots.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - -describe("start-obsidian-e2e-instance", () => { - it("derives a private profile HOME under the profile root", () => { - const options = resolveInstanceOptions( - parseArgs([ - "--vault", - "podnotes-worktree-a", - "--root", - "vaults", - "--profile-root", - "profiles", - "--no-launch", - ]), - "/tmp/podnotes-instance", - ); - - expect( - options.obsidianHome.startsWith( - path.join("/tmp/podnotes-instance", "profiles", "podnotes-worktree-a-"), - ), - ).toBe(true); - expect(options.obsidianHome.endsWith("/home")).toBe(true); - expect(options.userDataPath).toBe( - path.join(options.obsidianHome, "Library", "Application Support", "obsidian"), - ); - expect(options.vaultPath).toBe( - path.join("/tmp/podnotes-instance", "vaults", "podnotes-worktree-a"), - ); - }); - - it("creates a private Obsidian profile registered to the provisioned vault", async () => { - const cwd = await makeTempDir("podnotes-instance"); - const options = resolveInstanceOptions( - parseArgs([ - "--vault", - "podnotes-worktree-a", - "--root", - "vaults", - "--profile-root", - "profiles", - "--no-launch", - ]), - cwd, - ); - - const profile = await prepareObsidianProfile(options); - expect(profile.userDataPath).toBe(options.userDataPath); - const registry = JSON.parse(await fs.readFile(profile.obsidianJsonPath, "utf8")); - const vaults = Object.values(registry.vaults) as Array<{ - path: string; - open: boolean; - }>; - - expect(registry.cli).toBe(true); - expect(registry.updateDisabled).toBe(true); - const hostKeychains = path.join(process.env.HOME ?? "", "Library", "Keychains"); - const privateKeychains = path.join(options.obsidianHome, "Library", "Keychains"); - if (await exists(hostKeychains)) { - await expect(fs.readlink(privateKeychains)).resolves.toBe(hostKeychains); - } else { - await expect(fs.lstat(privateKeychains)).rejects.toMatchObject({ - code: "ENOENT", - }); - } - expect( - options.obsidianHome.startsWith(path.join(cwd, "profiles", "podnotes-worktree-a-")), - ).toBe(true); - expect(options.obsidianHome.endsWith("/home")).toBe(true); - expect(vaults).toEqual([ - { - open: true, - path: path.join(cwd, "vaults", "podnotes-worktree-a"), - ts: expect.any(Number), - }, - ]); - expect( - toInstanceShellExports({ - obsidianHome: options.obsidianHome, - vaultName: options.vaultName, - vaultPath: options.vaultPath, - }), - ).toContain("PODNOTES_E2E_OBSIDIAN_HOME="); - }); - - it("exports OBSIDIAN_BIN only when a non-default binary is used", () => { - const base = { - obsidianHome: "/tmp/home", - vaultName: "podnotes-a", - vaultPath: "/tmp/vault", - }; - - expect(toInstanceShellExports(base)).not.toContain("OBSIDIAN_BIN="); - expect(toInstanceShellExports({ ...base, obsidianBin: "obsidian" })).not.toContain( - "OBSIDIAN_BIN=", - ); - expect(toInstanceShellExports({ ...base, obsidianBin: "/opt/custom/obsidian" })).toContain( - "export OBSIDIAN_BIN='/opt/custom/obsidian'", - ); - }); -}); - -async function exists(filePath: string) { - try { - await fs.lstat(filePath); - return true; - } catch (error) { - if ((error as { code?: string }).code === "ENOENT") return false; - throw error; - } -} diff --git a/scripts/stop-obsidian-e2e-instance.mjs b/scripts/stop-obsidian-e2e-instance.mjs deleted file mode 100644 index 64493575..00000000 --- a/scripts/stop-obsidian-e2e-instance.mjs +++ /dev/null @@ -1,557 +0,0 @@ -#!/usr/bin/env node -import { execFile } from "node:child_process"; -import fs from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { promisify } from "node:util"; -import { errorHasCode, isRecord } from "./obsidian-e2e-errors.mjs"; -import { INSTANCE_MARKER_FILE, resolveInstanceOptions } from "./start-obsidian-e2e-instance.mjs"; - -/** @typedef {import("./obsidian-e2e-types").CollectInstanceOptions} CollectInstanceOptions */ -/** @typedef {import("./obsidian-e2e-types").InstanceReadDependencies} InstanceReadDependencies */ -/** @typedef {import("./obsidian-e2e-types").KillFunction} KillFunction */ -/** @typedef {import("./obsidian-e2e-types").ProcessInfo} ProcessInfo */ -/** @typedef {import("./obsidian-e2e-types").ReapOptions} ReapOptions */ -/** @typedef {import("./obsidian-e2e-types").ReapResult} ReapResult */ -/** @typedef {import("./obsidian-e2e-types").StopOptions} StopOptions */ -/** @typedef {import("./obsidian-e2e-types").StopRawOptions} StopRawOptions */ -/** @typedef {import("./obsidian-e2e-types").StopResult} StopResult */ - -const execFileAsync = promisify(execFile); - -// Window we give a SIGTERM'd Obsidian tree to exit cleanly before escalating to -// SIGKILL. Electron tears down its helper processes in well under a second. -const TERM_GRACE_MS = 3_000; -const TERM_POLL_MS = 100; - -// Every per-worktree instance dir is `/-<12 hex>` (see -// stableInstanceId in start-obsidian-e2e-instance.mjs). The trailing hash makes -// the dir name globally unique and is the guard that stops us ever removing a -// non-instance directory (the shared dev vault, a quickadd profile, `/tmp`, …). -const INSTANCE_DIR_PATTERN = /-[0-9a-f]{12}$/; - -const VALUE_OPTIONS = new Set([ - "--vault", - "--root", - "--worktree", - "--data", - "--profile-root", - "--obsidian-app", - "--obsidian-bin", -]); - -/** - * @param {string} arg - * @returns {arg is "--vault" | "--root" | "--worktree" | "--data" | "--profile-root" | "--obsidian-app" | "--obsidian-bin"} - */ -function isValueOption(arg) { - return VALUE_OPTIONS.has(arg); -} - -function printUsage() { - console.log(`Usage: node scripts/stop-obsidian-e2e-instance.mjs [options] - -Stops the worktree-local Obsidian E2E instance: it finds the Obsidian process -tree bound to this worktree's private HOME / --user-data-dir, terminates it, and -removes the instance's /tmp profile directory. It never touches the shared dev -vault, other worktrees, or quickadd instances. - -Options: - --vault Vault/profile name. Defaults to podnotes-. - --root Directory that contains provisioned vaults. Defaults to .obsidian-e2e-vaults. - --worktree PodNotes worktree the instance belongs to. Defaults to cwd. - --profile-root Directory for per-vault Obsidian HOME profiles. Defaults to /tmp/podnotes-obsidian-e2e. - --prune Also reap orphaned instances whose backing vault is gone. - --dry-run Report what would be stopped/removed without doing it. - --json Print a machine-readable summary. - --help Show this help. -`); -} - -/** - * @param {readonly string[]} argv - * @returns {StopRawOptions} - */ -export function parseArgs(argv) { - /** @type {StopRawOptions} */ - const options = { dryRun: false, json: false, prune: false }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case "--": - break; - case "--dry-run": - options.dryRun = true; - break; - case "--prune": - options.prune = true; - break; - case "--json": - options.json = true; - break; - case "--help": - options.help = true; - break; - default: { - if (!isValueOption(arg)) { - throw new Error(`Unknown option: ${arg}`); - } - const value = argv[index + 1]; - if (!value || value.startsWith("--")) { - throw new Error(`${arg} requires a value.`); - } - options[toOptionKey(arg)] = value; - index += 1; - } - } - } - - return options; -} - -/** - * @param {"--vault" | "--root" | "--worktree" | "--data" | "--profile-root" | "--obsidian-app" | "--obsidian-bin"} arg - * @returns {"vault" | "root" | "worktree" | "data" | "profileRoot" | "obsidianApp" | "obsidianBin"} - */ -function toOptionKey(arg) { - switch (arg) { - case "--vault": - return "vault"; - case "--root": - return "root"; - case "--worktree": - return "worktree"; - case "--data": - return "data"; - case "--profile-root": - return "profileRoot"; - case "--obsidian-app": - return "obsidianApp"; - case "--obsidian-bin": - return "obsidianBin"; - } -} - -// macOS firmlinks /tmp, /var, and /etc under /private. Obsidian's main process -// keeps the literal `--user-data-dir` we pass (e.g. /tmp/...), while Electron -// canonicalizes the same flag to /private/tmp/... for its helper processes. -// Strip a leading /private so one instance path matches the whole process tree. -/** @param {string} value */ -function stripPrivatePrefix(value) { - return value.replace(/^\/private(?=\/)/, ""); -} - -/** - * @param {string} command - * @param {string} instancePath - */ -export function commandMatchesInstance(command, instancePath) { - const stripped = stripPrivatePrefix(instancePath); - const variants = new Set([instancePath, stripped, stripped.replace(/^\//, "/private/")]); - // Seed only on the Obsidian `--user-data-dir=` flag whose value is THIS - // instance's profile dir (with a trailing separator). Scoping to the flag, not - // a bare substring, means an unrelated process that merely mentions the path — - // a log tail, an editor, a grep — is never pulled into the kill set. The - // trailing slash stops a sibling whose id shares this one as a leading string. - return [...variants].some((variant) => command.includes(`--user-data-dir=${variant}/`)); -} - -/** - * @param {string} stdout - * @returns {ProcessInfo[]} - */ -export function parsePsOutput(stdout) { - /** @type {ProcessInfo[]} */ - const processes = []; - for (const line of stdout.split("\n")) { - const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/); - if (!match) continue; - processes.push({ - pid: Number(match[1]), - ppid: Number(match[2]), - command: match[3], - }); - } - return processes; -} - -// Returns the pids of the Obsidian process tree bound to `instancePath`: the -// seed processes whose argv references the instance profile, plus every -// descendant. The descendant walk is belt-and-suspenders — Electron helpers -// already carry --user-data-dir, but this also reaps any grandchild a helper -// spawned that does not echo the flag. -/** - * @param {readonly ProcessInfo[]} processes - * @param {string} instancePath - * @param {CollectInstanceOptions} [options] - * @returns {number[]} - */ -export function collectInstancePids(processes, instancePath, options = {}) { - const selfPid = options.selfPid ?? process.pid; - /** @type {Map} */ - const childrenByParent = new Map(); - for (const proc of processes) { - const siblings = childrenByParent.get(proc.ppid) ?? []; - siblings.push(proc); - childrenByParent.set(proc.ppid, siblings); - } - - /** @type {Set} */ - const collected = new Set(); - const stack = processes - .filter((proc) => commandMatchesInstance(proc.command, instancePath)) - .map((proc) => proc.pid); - while (stack.length > 0) { - const pid = stack.pop(); - if (pid === undefined) continue; - if (collected.has(pid)) continue; - collected.add(pid); - for (const child of childrenByParent.get(pid) ?? []) { - stack.push(child.pid); - } - } - - // Never signal ourselves, init, or the kernel — defensive, the token would - // not match these anyway. - for (const guarded of [selfPid, 0, 1]) collected.delete(guarded); - return [...collected].sort((a, b) => a - b); -} - -/** - * @param {string} instancePath - * @param {string | undefined} profileRoot - */ -function assertSafeInstancePath(instancePath, profileRoot) { - if (!instancePath || !path.isAbsolute(instancePath)) { - throw new Error(`Refusing to remove non-absolute instance path: ${instancePath}`); - } - const base = path.basename(instancePath); - if (!INSTANCE_DIR_PATTERN.test(base)) { - throw new Error( - `Refusing to remove ${instancePath}: not an Obsidian E2E instance directory.`, - ); - } - // A real instance dir is several levels deep (e.g. /tmp/podnotes-obsidian-e2e/); - // reject anything shallow enough to be a system root. - if (instancePath.split(path.sep).filter(Boolean).length < 2) { - throw new Error(`Refusing to remove shallow path: ${instancePath}`); - } - // Containment guard: when the caller knows the profile root, the dir we remove - // must be a direct child of it. Real callers always do, so a bad --profile-root - // can never make us rm a path outside the e2e profile tree. - if (profileRoot && path.resolve(path.dirname(instancePath)) !== path.resolve(profileRoot)) { - throw new Error( - `Refusing to remove ${instancePath}: not a direct child of profile root ${profileRoot}.`, - ); - } -} - -/** @returns {Promise} */ -async function defaultRunPs() { - const { stdout } = await execFileAsync("ps", ["-axww", "-o", "pid=,ppid=,command="], { - maxBuffer: 16 * 1024 * 1024, - }); - return stdout; -} - -/** - * @param {number} pid - * @param {KillFunction} kill - */ -function pidAlive(pid, kill) { - try { - kill(pid, 0); - return true; - } catch (error) { - // EPERM means the process exists but is owned by someone else; for our own - // instances that should not happen, but treat it as alive to be safe. - if (errorHasCode(error, "EPERM")) return true; - if (errorHasCode(error, "ESRCH")) return false; - throw error; - } -} - -/** @param {number} ms */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// kill(2) errors we tolerate so one stubborn pid never aborts tearing down the -// rest of the tree (and never blocks the subsequent dir removal): ESRCH (already -// exited between discovery and signalling), EPERM (a pid we no longer own — e.g. -// recycled by a foreign process; skip rather than touch it), ENOENT (defensive). -const IGNORABLE_KILL_CODES = new Set(["ESRCH", "EPERM", "ENOENT"]); - -/** @param {unknown} error */ -function isIgnorableKillError(error) { - return [...IGNORABLE_KILL_CODES].some((code) => errorHasCode(error, code)); -} - -/** - * @param {readonly number[]} pids - * @param {NodeJS.Signals} signal - * @param {KillFunction} kill - * @returns {number[]} - */ -function signalPids(pids, signal, kill) { - const signalled = []; - for (const pid of pids) { - try { - kill(pid, signal); - signalled.push(pid); - } catch (error) { - if (!isIgnorableKillError(error)) throw error; - } - } - return signalled; -} - -// Stop a single instance: terminate its process tree (SIGTERM, then SIGKILL for -// stragglers) and remove its profile directory. Safe to call when nothing is -// running — it then just removes a leftover directory. All side effects are -// injectable so the orchestration is unit-testable without real processes. -/** - * @param {string} instancePath - * @param {StopOptions} [options] - * @returns {Promise} - */ -export async function stopInstance(instancePath, options = {}) { - const { - dryRun = false, - kill = process.kill.bind(process), - runPs = defaultRunPs, - removeDir = (dir) => fs.rm(dir, { recursive: true, force: true }), - selfPid = process.pid, - graceMs = TERM_GRACE_MS, - pollMs = TERM_POLL_MS, - profileRoot, - } = options; - - assertSafeInstancePath(instancePath, profileRoot); - - const processes = parsePsOutput(await runPs()); - const pids = collectInstancePids(processes, instancePath, { selfPid }); - - if (dryRun) { - return { instancePath, pids, terminated: [], killed: [], removed: false }; - } - - signalPids(pids, "SIGTERM", kill); - - let survivors = pids; - const deadline = monotonicNow() + graceMs; - while (survivors.length > 0 && monotonicNow() < deadline) { - await sleep(pollMs); - survivors = survivors.filter((pid) => pidAlive(pid, kill)); - } - const killed = survivors.length > 0 ? signalPids(survivors, "SIGKILL", kill) : []; - - await removeDir(instancePath); - - return { instancePath, pids, terminated: pids, killed, removed: true }; -} - -// Reads the vault paths an instance registered in its private obsidian.json. -// Returns null when the registration is unreadable (so callers can stay -// conservative and not reap an instance they cannot reason about). -/** - * @param {string} instancePath - * @param {InstanceReadDependencies} [deps] - * @returns {Promise} - */ -export async function readInstanceVaultPaths(instancePath, deps = {}) { - const readFile = deps.readFile ?? ((file) => fs.readFile(file, "utf8")); - const jsonPath = path.join( - instancePath, - "home", - "Library", - "Application Support", - "obsidian", - "obsidian.json", - ); - try { - const parsed = /** @type {unknown} */ (JSON.parse(await readFile(jsonPath))); - if (!isRecord(parsed) || !isRecord(parsed.vaults)) return null; - - const vaultPaths = []; - for (const vault of Object.values(parsed.vaults)) { - if (isRecord(vault) && typeof vault.path === "string") { - vaultPaths.push(vault.path); - } - } - return vaultPaths; - } catch { - return null; - } -} - -/** @param {string} target */ -async function pathExists(target) { - try { - await fs.lstat(target); - return true; - } catch (error) { - if (errorHasCode(error, "ENOENT")) return false; - throw error; - } -} - -// Reads the instance marker that start writes (worktreePath/vaultName/vaultPath). -// Returns null when it is missing/unreadable so callers can fall back. -/** - * @param {string} instancePath - * @param {InstanceReadDependencies} [deps] - * @returns {Promise | null>} - */ -export async function readInstanceMarker(instancePath, deps = {}) { - const readFile = deps.readFile ?? ((file) => fs.readFile(file, "utf8")); - try { - const parsed = /** @type {unknown} */ ( - JSON.parse(await readFile(path.join(instancePath, INSTANCE_MARKER_FILE))) - ); - return isRecord(parsed) ? parsed : null; - } catch { - return null; - } -} - -// An instance is "orphaned" once the worktree it belongs to is gone from disk — -// the signature of a worktree removed on merge. We reap it even if its Obsidian -// process is still running: a leaked-but-running instance is exactly what must be -// cleaned up. We do NOT use "no process running" or "a single vault leaf missing" -// as the signal — an idle-but-valid instance for a live worktree, or a vault on a -// momentarily-unmounted volume, must survive. The worktree path is read from the -// marker; pre-marker instances fall back to "every registered vault path is gone". -/** - * @param {string} instancePath - * @param {InstanceReadDependencies} [deps] - */ -export async function isInstanceOrphaned(instancePath, deps = {}) { - const exists = deps.exists ?? pathExists; - const marker = await readInstanceMarker(instancePath, deps); - if (marker && typeof marker.worktreePath === "string") { - return !(await exists(marker.worktreePath)); - } - const vaultPaths = await readInstanceVaultPaths(instancePath, deps); - if (vaultPaths === null || vaultPaths.length === 0) return false; - for (const vaultPath of vaultPaths) { - if (await exists(vaultPath)) return false; - } - return true; -} - -// Scans the profile root and tears down every orphaned instance. Used as a -// self-healing safety net on the next `start:e2e-obsidian` so leaks survive even -// when a worktree is removed without the orca archive hook (`--run-hooks`). -/** - * @param {ReapOptions} [options] - * @returns {Promise} - */ -export async function reapOrphanedInstances(options = {}) { - const { - profileRoot, - exceptInstancePath, - dryRun = false, - log = () => {}, - readdir = (dir) => fs.readdir(dir, { withFileTypes: true }), - ...deps - } = options; - - if (!profileRoot) return { scanned: 0, reaped: [] }; - - let entries; - try { - entries = await readdir(profileRoot); - } catch (error) { - if (errorHasCode(error, "ENOENT")) return { scanned: 0, reaped: [] }; - throw error; - } - - /** @type {string[]} */ - const reaped = []; - let scanned = 0; - const except = exceptInstancePath ? path.resolve(exceptInstancePath) : null; - for (const entry of entries) { - if (!entry.isDirectory() || !INSTANCE_DIR_PATTERN.test(entry.name)) continue; - const instancePath = path.join(profileRoot, entry.name); - if (except && path.resolve(instancePath) === except) continue; - scanned += 1; - if (!(await isInstanceOrphaned(instancePath, deps))) continue; - log(`Reaping orphaned E2E instance ${entry.name} (worktree is gone).`); - if (dryRun) { - reaped.push(instancePath); - continue; - } - // Isolate each teardown so one stubborn orphan (e.g. a permission error) - // never aborts reaping the rest of the scan. - try { - await stopInstance(instancePath, { ...deps, profileRoot, dryRun: false }); - reaped.push(instancePath); - } catch (error) { - log(`Failed to reap ${entry.name}: ${error instanceof Error ? error.message : error}`); - } - } - - return { scanned, reaped }; -} - -// setTimeout-based polling needs a monotonic clock; Date.now is adequate here and -// keeps the helper trivially mockable in tests via the injected clock. -function monotonicNow() { - return Date.now(); -} - -async function main() { - const rawOptions = parseArgs(process.argv.slice(2)); - if (rawOptions.help) { - printUsage(); - return; - } - - const options = resolveInstanceOptions(rawOptions); - const summary = await stopInstance(options.instancePath, { - dryRun: rawOptions.dryRun, - profileRoot: options.profileRoot, - }); - - /** @type {ReapResult} */ - let pruneResult = { scanned: 0, reaped: [] }; - if (rawOptions.prune) { - pruneResult = await reapOrphanedInstances({ - profileRoot: options.profileRoot, - exceptInstancePath: options.instancePath, - dryRun: rawOptions.dryRun, - log: rawOptions.json ? () => {} : console.error, - }); - } - - const result = { - ...summary, - vaultName: options.vaultName, - prune: pruneResult, - }; - if (rawOptions.json) { - console.log(JSON.stringify(result, null, 2)); - return; - } - - const verb = rawOptions.dryRun ? "Would stop" : "Stopped"; - console.log(`${verb} Obsidian E2E instance ${options.vaultName}`); - console.log(`Instance dir: ${options.instancePath}`); - console.log( - summary.pids.length > 0 - ? `Process tree: ${summary.pids.join(", ")}` - : "Process tree: none running", - ); - if (rawOptions.prune && pruneResult.reaped.length > 0) { - console.log(`Reaped ${pruneResult.reaped.length} orphaned instance(s).`); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); -} diff --git a/scripts/stop-obsidian-e2e-instance.test.ts b/scripts/stop-obsidian-e2e-instance.test.ts deleted file mode 100644 index 7443457b..00000000 --- a/scripts/stop-obsidian-e2e-instance.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - collectInstancePids, - commandMatchesInstance, - isInstanceOrphaned, - parseArgs, - parsePsOutput, - readInstanceMarker, - readInstanceVaultPaths, - reapOrphanedInstances, - stopInstance, -} from "./stop-obsidian-e2e-instance.mjs"; - -const tempRoots: string[] = []; - -async function makeTempDir(name: string) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), `${name}-`)); - tempRoots.push(dir); - return dir; -} - -afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - -const INSTANCE = "/tmp/podnotes-obsidian-e2e/podnotes-wt-abc123def456"; -const UDD = `--user-data-dir=${INSTANCE}/home/Library/Application Support/obsidian`; - -// Mirrors the real `ps -axww -o pid=,ppid=,command=` shape captured from a live -// run: the main process keeps the literal /tmp path we passed, Electron helpers -// canonicalize it to /private/tmp, and unrelated Obsidians (the shared dev vault, -// a sibling worktree, a quickadd instance) use different user-data-dirs. -const PS_FIXTURE = [ - ` 100 1 /Applications/Obsidian.app/Contents/MacOS/Obsidian ${UDD} --password-store=basic`, - ` 101 100 /Applications/Obsidian.app/Contents/Frameworks/Obsidian Helper (GPU).app/Contents/MacOS/Obsidian Helper (GPU) --type=gpu-process --user-data-dir=/private/tmp/podnotes-obsidian-e2e/podnotes-wt-abc123def456/home/Library/Application Support/obsidian`, - ` 102 100 /Applications/Obsidian.app/Contents/Frameworks/Obsidian Helper (Renderer).app/Contents/MacOS/Obsidian Helper (Renderer) --type=renderer --user-data-dir=/private/tmp/podnotes-obsidian-e2e/podnotes-wt-abc123def456/home/Library/Application Support/obsidian`, - " 103 102 /a/grandchild/process/with/no/token --type=worker", - " 200 1 /Applications/Obsidian.app/Contents/MacOS/Obsidian", - " 201 200 /Applications/Obsidian.app/Contents/Frameworks/Obsidian Helper (Renderer).app/Contents/MacOS/Obsidian Helper (Renderer) --type=renderer --user-data-dir=/Users/christian/Library/Application Support/obsidian", - " 300 1 /Applications/Obsidian.app/Contents/MacOS/Obsidian --user-data-dir=/tmp/quickadd-obsidian-e2e/quickadd-x-000000000000/home/Library/Application Support/obsidian --password-store=basic", - " 400 1 /Applications/Obsidian.app/Contents/MacOS/Obsidian --user-data-dir=/tmp/podnotes-obsidian-e2e/podnotes-other-111111111111/home/Library/Application Support/obsidian --password-store=basic", -].join("\n"); - -function makeKillSpy({ alive = false }: { alive?: boolean } = {}) { - const calls: Array<{ pid: number; signal: string | number }> = []; - const kill = (pid: number, signal: string | number) => { - calls.push({ pid, signal }); - // signal 0 is the liveness probe: throw ESRCH to report "dead" unless the - // test wants the process to survive SIGTERM (to exercise the SIGKILL path). - if (signal === 0 && !alive) { - throw Object.assign(new Error("no such process"), { code: "ESRCH" }); - } - }; - return { calls, kill }; -} - -describe("stop-obsidian-e2e-instance args", () => { - it("parses value options and boolean flags", () => { - const options = parseArgs([ - "--vault", - "podnotes-a", - "--profile-root", - "/tmp/profiles", - "--prune", - "--dry-run", - "--json", - ]); - expect(options).toMatchObject({ - vault: "podnotes-a", - profileRoot: "/tmp/profiles", - prune: true, - dryRun: true, - json: true, - }); - }); - - it("rejects unknown options and missing values", () => { - expect(() => parseArgs(["--nope"])).toThrow(/Unknown option/); - expect(() => parseArgs(["--vault"])).toThrow(/requires a value/); - }); -}); - -describe("commandMatchesInstance", () => { - it("matches both the /tmp and /private/tmp forms of the instance path", () => { - expect(commandMatchesInstance(`x ${UDD}`, INSTANCE)).toBe(true); - expect( - commandMatchesInstance( - "x --user-data-dir=/private/tmp/podnotes-obsidian-e2e/podnotes-wt-abc123def456/home/x", - INSTANCE, - ), - ).toBe(true); - }); - - it("does not match the dev vault, quickadd, or a sibling worktree", () => { - expect( - commandMatchesInstance( - "--user-data-dir=/Users/christian/Library/Application Support/obsidian", - INSTANCE, - ), - ).toBe(false); - expect( - commandMatchesInstance( - "--user-data-dir=/tmp/quickadd-obsidian-e2e/quickadd-x-000000000000/home/x", - INSTANCE, - ), - ).toBe(false); - expect( - commandMatchesInstance( - "--user-data-dir=/tmp/podnotes-obsidian-e2e/podnotes-other-111111111111/home/x", - INSTANCE, - ), - ).toBe(false); - }); - - it("does not match a sibling whose id merely shares this one as a prefix", () => { - expect(commandMatchesInstance(`--user-data-dir=${INSTANCE}-extra/home/x`, INSTANCE)).toBe( - false, - ); - }); - - it("does not match a process that mentions the path outside the --user-data-dir flag", () => { - // e.g. a log tail or editor referencing a file under the instance dir must - // never be pulled into the kill set. - expect(commandMatchesInstance(`tail -f ${INSTANCE}/home/obsidian.log`, INSTANCE)).toBe( - false, - ); - }); -}); - -describe("parsePsOutput / collectInstancePids", () => { - it("parses pid, ppid and command, skipping non-matching lines", () => { - const procs = parsePsOutput(`bogus header line\n${PS_FIXTURE}`); - expect(procs).toContainEqual({ - pid: 100, - ppid: 1, - command: expect.stringContaining(UDD), - }); - expect(procs.find((p) => p.pid === 103)?.ppid).toBe(102); - }); - - it("collects the instance tree (incl. token-less descendants) and nothing else", () => { - const pids = collectInstancePids(parsePsOutput(PS_FIXTURE), INSTANCE, { - selfPid: 999999, - }); - // 100 main + 101/102 helpers (matched by token) + 103 grandchild (matched - // only by the descendant walk). 200/201 dev vault, 300 quickadd, 400 sibling - // worktree are all excluded. - expect(pids).toEqual([100, 101, 102, 103]); - }); - - it("never includes our own pid", () => { - const procs = parsePsOutput(` 100 1 x ${UDD} y`); - expect(collectInstancePids(procs, INSTANCE, { selfPid: 100 })).toEqual([]); - }); -}); - -describe("stopInstance", () => { - const runPs = async () => PS_FIXTURE; - - it("SIGTERMs the whole tree and removes the instance dir when it exits", async () => { - const { calls, kill } = makeKillSpy({ alive: false }); - const removed: string[] = []; - const result = await stopInstance(INSTANCE, { - runPs, - kill, - removeDir: async (dir: string) => { - removed.push(dir); - }, - selfPid: 999999, - pollMs: 1, - graceMs: 20, - }); - - const termed = calls.filter((c) => c.signal === "SIGTERM").map((c) => c.pid); - expect(termed).toEqual([100, 101, 102, 103]); - expect(calls.some((c) => c.signal === "SIGKILL")).toBe(false); - expect(removed).toEqual([INSTANCE]); - expect(result).toMatchObject({ killed: [], removed: true }); - }); - - it("escalates to SIGKILL for survivors of SIGTERM", async () => { - const { calls, kill } = makeKillSpy({ alive: true }); - const result = await stopInstance(INSTANCE, { - runPs, - kill, - removeDir: async () => {}, - selfPid: 999999, - pollMs: 1, - graceMs: 5, - }); - const killed = calls.filter((c) => c.signal === "SIGKILL").map((c) => c.pid); - expect(killed).toEqual([100, 101, 102, 103]); - expect(result.killed).toEqual([100, 101, 102, 103]); - }); - - it("dry-run reports the tree without signalling or removing", async () => { - const { calls, kill } = makeKillSpy(); - let removeCalled = false; - const result = await stopInstance(INSTANCE, { - runPs, - kill, - removeDir: async () => { - removeCalled = true; - }, - selfPid: 999999, - dryRun: true, - }); - expect(calls).toEqual([]); - expect(removeCalled).toBe(false); - expect(result).toMatchObject({ - pids: [100, 101, 102, 103], - removed: false, - }); - }); - - it("refuses to remove a path that is not an instance directory", async () => { - for (const unsafe of ["/tmp", "relative/path", `${INSTANCE}-no-hash`]) { - await expect( - stopInstance(unsafe, { - runPs, - kill: () => {}, - removeDir: async () => {}, - }), - ).rejects.toThrow(/Refusing to remove/); - } - }); - - it("still removes a leftover dir when no process is running", async () => { - const { calls, kill } = makeKillSpy(); - const removed: string[] = []; - await stopInstance(INSTANCE, { - runPs: async () => "", - kill, - removeDir: async (dir: string) => { - removed.push(dir); - }, - selfPid: 999999, - }); - expect(calls).toEqual([]); - expect(removed).toEqual([INSTANCE]); - }); - - it("tolerates EPERM from kill (recycled foreign pid) and still removes the dir", async () => { - const removed: string[] = []; - const kill = (_pid: number, signal: string | number) => { - if (signal === 0) return; // liveness probe: report alive - throw Object.assign(new Error("operation not permitted"), { - code: "EPERM", - }); - }; - const result = await stopInstance(INSTANCE, { - runPs, - kill, - removeDir: async (dir: string) => { - removed.push(dir); - }, - selfPid: 999999, - pollMs: 1, - graceMs: 5, - }); - expect(removed).toEqual([INSTANCE]); - expect(result.removed).toBe(true); - }); - - it("preserves the profile when a liveness probe fails unexpectedly", async () => { - let removeCalled = false; - const kill = (_pid: number, signal: string | number) => { - if (signal === 0) { - throw Object.assign(new Error("I/O failure"), { code: "EIO" }); - } - }; - - await expect( - stopInstance(INSTANCE, { - runPs, - kill, - removeDir: async () => { - removeCalled = true; - }, - selfPid: 999999, - pollMs: 1, - graceMs: 5, - }), - ).rejects.toThrow("I/O failure"); - expect(removeCalled).toBe(false); - }); - - it("refuses to remove a dir that is not a child of the given profile root", async () => { - await expect( - stopInstance(INSTANCE, { - runPs, - kill: () => {}, - removeDir: async () => {}, - profileRoot: "/some/other/root", - }), - ).rejects.toThrow(/not a direct child of profile root/); - }); -}); - -describe("orphan detection", () => { - async function seedInstance(profileRoot: string, id: string, vaultPath: string) { - const udd = path.join( - profileRoot, - id, - "home", - "Library", - "Application Support", - "obsidian", - ); - await fs.mkdir(udd, { recursive: true }); - await fs.writeFile( - path.join(udd, "obsidian.json"), - JSON.stringify({ vaults: { v1: { path: vaultPath, open: true } } }), - ); - return path.join(profileRoot, id); - } - - it("reads the registered vault paths", async () => { - const root = await makeTempDir("podnotes-profiles"); - const instance = await seedInstance(root, "podnotes-a-aaaaaaaaaaaa", "/x/y"); - await expect(readInstanceVaultPaths(instance)).resolves.toEqual(["/x/y"]); - }); - - it("ignores malformed vault entries while keeping valid paths", async () => { - await expect( - readInstanceVaultPaths(INSTANCE, { - readFile: async () => - JSON.stringify({ - vaults: { - missing: {}, - nonObject: null, - nonString: { path: 42 }, - valid: { path: "/valid/vault" }, - }, - }), - }), - ).resolves.toEqual(["/valid/vault"]); - }); - - it("rejects a registry whose JSON value is not an object", async () => { - await expect( - readInstanceVaultPaths(INSTANCE, { readFile: async () => '"not-a-registry"' }), - ).resolves.toBeNull(); - }); - - it("rejects an array-shaped vault registry", async () => { - await expect( - readInstanceVaultPaths(INSTANCE, { - readFile: async () => JSON.stringify({ vaults: [{ path: "/unexpected/vault" }] }), - }), - ).resolves.toBeNull(); - }); - - it("is orphaned only when every backing vault is gone", async () => { - const root = await makeTempDir("podnotes-profiles"); - const liveVault = await makeTempDir("live-vault"); - const live = await seedInstance(root, "podnotes-live-bbbbbbbbbbbb", liveVault); - const gone = await seedInstance(root, "podnotes-gone-cccccccccccc", "/does/not/exist"); - - await expect(isInstanceOrphaned(live)).resolves.toBe(false); - await expect(isInstanceOrphaned(gone)).resolves.toBe(true); - }); - - it("prefers the worktree marker: worktree-gone reaps even with a live vault; worktree-alive spares even with a missing vault", async () => { - const root = await makeTempDir("podnotes-profiles"); - const liveWorktree = await makeTempDir("live-worktree"); - - // marker worktree alive, but the vault leaf is gone -> NOT orphaned - const spared = await seedInstance(root, "podnotes-m1-aaaaaaaaaaaa", "/vault/leaf/gone"); - await fs.writeFile( - path.join(spared, "podnotes-e2e-instance.json"), - JSON.stringify({ worktreePath: liveWorktree }), - ); - - // marker worktree gone, but the vault still exists -> orphaned (leaked) - const leaked = await seedInstance(root, "podnotes-m2-bbbbbbbbbbbb", liveWorktree); - await fs.writeFile( - path.join(leaked, "podnotes-e2e-instance.json"), - JSON.stringify({ worktreePath: "/worktree/removed/on/merge" }), - ); - - await expect(isInstanceOrphaned(spared)).resolves.toBe(false); - await expect(isInstanceOrphaned(leaked)).resolves.toBe(true); - }); - - it("stays conservative when the registration is unreadable", async () => { - const root = await makeTempDir("podnotes-profiles"); - const bare = path.join(root, "podnotes-bare-dddddddddddd"); - await fs.mkdir(bare, { recursive: true }); - await expect(isInstanceOrphaned(bare)).resolves.toBe(false); - }); - - it("rejects a marker whose JSON value is not an object", async () => { - await expect( - readInstanceMarker(INSTANCE, { readFile: async () => '"not-a-marker"' }), - ).resolves.toBeNull(); - }); - - it("rejects an array-shaped marker", async () => { - await expect( - readInstanceMarker(INSTANCE, { readFile: async () => "[]" }), - ).resolves.toBeNull(); - }); -}); - -describe("reapOrphanedInstances", () => { - async function seedInstance(profileRoot: string, id: string, vaultPath: string) { - const udd = path.join( - profileRoot, - id, - "home", - "Library", - "Application Support", - "obsidian", - ); - await fs.mkdir(udd, { recursive: true }); - await fs.writeFile( - path.join(udd, "obsidian.json"), - JSON.stringify({ vaults: { v1: { path: vaultPath, open: true } } }), - ); - return path.join(profileRoot, id); - } - - it("reaps only orphaned instances, skipping live ones, the exception, and non-instance dirs", async () => { - const root = await makeTempDir("podnotes-profiles"); - const liveVault = await makeTempDir("live-vault"); - const live = await seedInstance(root, "podnotes-live-bbbbbbbbbbbb", liveVault); - const orphan = await seedInstance(root, "podnotes-gone-cccccccccccc", "/does/not/exist"); - const current = await seedInstance(root, "podnotes-current-dddddddddddd", "/also/gone"); - await fs.mkdir(path.join(root, "not-an-instance"), { recursive: true }); - - const removed: string[] = []; - const result = await reapOrphanedInstances({ - profileRoot: root, - exceptInstancePath: current, - runPs: async () => "", - kill: () => {}, - removeDir: async (dir: string) => { - removed.push(dir); - }, - }); - - expect(result.reaped).toEqual([orphan]); - expect(removed).toEqual([orphan]); - expect(removed).not.toContain(live); - expect(removed).not.toContain(current); - }); - - it("continues reaping the rest of the scan when one orphan's teardown fails", async () => { - const root = await makeTempDir("podnotes-profiles"); - const bad = await seedInstance(root, "podnotes-bad-aaaaaaaaaaaa", "/gone/1"); - const good = await seedInstance(root, "podnotes-good-bbbbbbbbbbbb", "/gone/2"); - const removed: string[] = []; - const logs: string[] = []; - - const result = await reapOrphanedInstances({ - profileRoot: root, - runPs: async () => "", - kill: () => {}, - removeDir: async (dir: string) => { - if (dir === bad) throw new Error("boom"); - removed.push(dir); - }, - log: (message: string) => logs.push(message), - }); - - expect(removed).toEqual([good]); - expect(result.reaped).toEqual([good]); - expect(logs.some((m) => m.includes("Failed to reap"))).toBe(true); - }); - - it("returns an empty result when the profile root does not exist", async () => { - await expect( - reapOrphanedInstances({ profileRoot: "/no/such/profile/root" }), - ).resolves.toEqual({ scanned: 0, reaped: [] }); - }); -}); diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 9fa74151..19d355d4 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -17,7 +17,9 @@ import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; export const PLUGIN_ID = "podnotes"; export const VIEW_TYPE = "podcast_player_view"; -export const E2E_VAULT = process.env.PODNOTES_E2E_VAULT ?? "dev"; +// Canonical OBSIDIAN_E2E_* names are emitted by the shared obsidian-e2e runner; +// the legacy PODNOTES_E2E_* alias remains a fallback during the migration. +export const E2E_VAULT = process.env.OBSIDIAN_E2E_VAULT ?? process.env.PODNOTES_E2E_VAULT ?? "dev"; export const E2E_BIN = process.env.OBSIDIAN_BIN ?? "obsidian"; export const WAIT_OPTS = { timeoutMs: 15_000, intervalMs: 200 }; export const RELOAD_OPTIONS: PluginReloadOptions = { From e4a3eee92dfe8725cc90d6fdb9afae44a7044e20 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 10 Jul 2026 22:41:16 +0200 Subject: [PATCH 2/3] chore: use published obsidian-e2e ^0.7.0 --- package-lock.json | 64 +++++++++++++---------------------------------- package.json | 2 +- 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 614389aa..31723234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "jsdom": "^29.1.1", "obsidian": "1.13.1", - "obsidian-e2e": "file:../obsidian-e2e", + "obsidian-e2e": "^0.7.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", @@ -38,32 +38,6 @@ "node": "^22.14.0" } }, - "../obsidian-e2e": { - "version": "0.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yaml": "^2.8.2" - }, - "bin": { - "obsidian-e2e": "dist/runner/bin.mjs" - }, - "devDependencies": { - "@changesets/cli": "^2.30.0", - "@types/node": "^25.3.5", - "@typescript/native-preview": "7.0.0-dev.20260309.1", - "bumpp": "^10.4.1", - "typescript": "^5.9.3", - "vite-plus": "latest", - "vitest": "npm:@voidzero-dev/vite-plus-test@latest" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite-plus": "^0.1.11" - } - }, "node_modules/@actions/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", @@ -2115,7 +2089,6 @@ "integrity": "sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2127,7 +2100,6 @@ "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2875,7 +2847,6 @@ "integrity": "sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -2948,7 +2919,6 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT", - "optional": true, "peer": true }, "node_modules/@poppinss/colors": { @@ -4523,7 +4493,6 @@ "integrity": "sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -4590,7 +4559,6 @@ "integrity": "sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@oxc-project/runtime": "=0.133.0", @@ -6403,7 +6371,6 @@ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT", - "optional": true, "peer": true }, "node_modules/es-object-atoms": { @@ -9572,7 +9539,6 @@ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "engines": { "node": ">=10" @@ -11756,8 +11722,23 @@ } }, "node_modules/obsidian-e2e": { - "resolved": "../obsidian-e2e", - "link": true + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/obsidian-e2e/-/obsidian-e2e-0.7.0.tgz", + "integrity": "sha512-qbYMFRU9UgbOhH8OPwnX+gCR55IvLTmrWxyUlTFH52wfTknCtaj28/t2s3aHux10byvh61ElBH04cKqfvbReyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + }, + "bin": { + "obsidian-e2e": "dist/runner/bin.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite-plus": "^0.1.11" + } }, "node_modules/obug": { "version": "2.1.1", @@ -11943,7 +11924,6 @@ "integrity": "sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" @@ -12232,7 +12212,6 @@ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", "dev": true, "license": "ISC", - "optional": true, "peer": true, "dependencies": { "pngjs": "^7.0.0" @@ -12324,7 +12303,6 @@ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "engines": { "node": ">=14.19.0" @@ -13466,7 +13444,6 @@ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -14346,7 +14323,6 @@ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "engines": { "node": ">=6" @@ -15096,7 +15072,6 @@ "integrity": "sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@oxc-project/types": "=0.133.0", @@ -15816,7 +15791,6 @@ "integrity": "sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@oxc-project/runtime": "=0.133.0", @@ -15917,7 +15891,6 @@ "integrity": "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "tinypool": "2.1.0" @@ -15971,7 +15944,6 @@ "integrity": "sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "bin": { "oxlint": "bin/oxlint" diff --git a/package.json b/package.json index 9511c936..7eaa87f0 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "eslint-plugin-obsidianmd": "^0.4.1", "jsdom": "^29.1.1", "obsidian": "1.13.1", - "obsidian-e2e": "file:../obsidian-e2e", + "obsidian-e2e": "^0.7.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", From f81bbb5a247e7b3a16904aae681f8c814f1db3cf Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 10 Jul 2026 23:09:10 +0200 Subject: [PATCH 3/3] test(e2e): eliminate runtime fixture races --- tests/e2e/podnotes-runtime.test.ts | 183 ++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 42 deletions(-) diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index 58c2afa2..b6b704e7 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -31,6 +31,7 @@ type PlaybackState = { }; const getContext = createPodNotesE2EHarness("podnotes-runtime"); +let audioFixtureReady = false; describe("PodNotes runtime", () => { test("registers commands, protocol handling, and the player view", async () => { @@ -81,7 +82,7 @@ describe("PodNotes runtime", () => { test("opens timestamp URI links at requested progress even after an episode is played", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "finished-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Finished Episode", audioPath); await seedRuntimeData(plugin, sandbox, episode, { @@ -92,7 +93,7 @@ describe("PodNotes runtime", () => { await openPodNotesView(obsidian); await invokePodNotesUri(obsidian, episode, 240); - await dispatchLoadedMetadata(obsidian); + await stabilizeLoadedMedia(obsidian); const state = await waitForPlaybackState( obsidian, @@ -113,7 +114,7 @@ describe("PodNotes runtime", () => { test("preserves zero-second timestamp URI links at runtime", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "zero-second-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Zero Second Episode", audioPath); await seedRuntimeData(plugin, sandbox, episode, { @@ -124,7 +125,7 @@ describe("PodNotes runtime", () => { await openPodNotesView(obsidian); await invokePodNotesUri(obsidian, episode, 0); - await dispatchLoadedMetadata(obsidian); + await stabilizeLoadedMedia(obsidian); const state = await waitForPlaybackState( obsidian, @@ -145,7 +146,7 @@ describe("PodNotes runtime", () => { test("seeks immediately when the linked episode is already loaded", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "already-loaded-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Already Loaded Episode", audioPath); await seedRuntimeData(plugin, sandbox, episode, { @@ -185,7 +186,7 @@ describe("PodNotes runtime", () => { test("captures a linked timestamp into the active editor", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "capture-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Capture Episode", audioPath); const notePath = sandbox.path("capture-target.md"); @@ -214,7 +215,7 @@ describe("PodNotes runtime", () => { test("captures a linked segment into the active editor", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "capture-segment-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Capture Segment Episode", audioPath); const notePath = sandbox.path("capture-segment-target.md"); @@ -252,7 +253,7 @@ describe("PodNotes runtime", () => { test("stops playback when a segment URI reaches its end", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "segment-uri-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Segment URI Episode", audioPath); await seedRuntimeData(plugin, sandbox, episode, { @@ -263,7 +264,7 @@ describe("PodNotes runtime", () => { await invokePodNotesUri(obsidian, episode, 115, 125); await openPodNotesView(obsidian); - await dispatchLoadedMetadata(obsidian); + await stabilizeLoadedMedia(obsidian); await waitForPlaybackState( obsidian, @@ -291,7 +292,7 @@ describe("PodNotes runtime", () => { test("playback-rate commands update the live player rate and reset to the configured default", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "rate-command-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Rate Command Episode", audioPath); await seedRuntimeData(plugin, sandbox, episode, { @@ -301,7 +302,7 @@ describe("PodNotes runtime", () => { await waitForPodNotesReady(obsidian); await openPodNotesView(obsidian); await invokePodNotesUri(obsidian, episode, 0); - await dispatchLoadedMetadata(obsidian); + await stabilizeLoadedMedia(obsidian); await waitForPlaybackRate(obsidian, 1.5); await obsidian.command(`${PLUGIN_ID}:increase-playback-rate`).run(); @@ -322,7 +323,7 @@ describe("PodNotes runtime", () => { test("previous-track Media Session action captures a timestamp into the active editor", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "headphone-capture-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Headphone Capture Episode", audioPath); const notePath = sandbox.path("headphone-capture-target.md"); @@ -357,7 +358,7 @@ describe("PodNotes runtime", () => { test("previous-track Media Session action appends to the episode note without an active editor", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "headphone-background-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Background Capture Episode", audioPath); const noteRelativePath = `${episode.title}.md`; @@ -424,7 +425,7 @@ describe("PodNotes runtime", () => { test("migrates legacy dates and credentials before runtime hydration", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "legacy-date-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const isoDate = "2024-03-01T10:05:03.000Z"; const episode = { ...createLocalEpisode("E2E Legacy Date Episode", audioPath), @@ -525,7 +526,7 @@ describe("PodNotes runtime", () => { test("renders feed-controlled Markdown punctuation only as visible text", async () => { const { obsidian, plugin, sandbox } = getContext(); - const audioPath = await seedAudio(sandbox, "template-escaping-episode.mp3"); + const audioPath = await seedAudio(obsidian, sandbox); const attacks = [ { id: "link", @@ -577,13 +578,31 @@ describe("PodNotes runtime", () => { ); expect(content).toContain(attack.encoded); + // createPodcastNote opens the created file without blocking the Obsidian + // command callback. Wait for that exact leaf before switching it to + // preview mode. Creating a second leaf while the first open was still + // settling raced Obsidian's Markdown renderer and produced delayed + // `Cannot read properties of null (reading 'children')` runtime errors. + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson(` + app.workspace.getLeavesOfType("markdown").some( + (leaf) => leaf.view?.file?.path === ${JSON.stringify(notePath)}, + ) + `), + WAIT_OPTS, + ); + await evalJsonAsync( obsidian, ` (async () => { const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); if (!file) throw new Error("Template escaping note was not created."); - const leaf = app.workspace.getLeaf(true); + const leaf = app.workspace.getLeavesOfType("markdown").find( + (candidate) => candidate.view?.file?.path === file.path, + ); + if (!leaf) throw new Error("Template escaping note was not opened."); await leaf.setViewState({ type: "markdown", state: { file: file.path, mode: "preview" }, @@ -681,13 +700,57 @@ describe("PodNotes runtime", () => { }); }); -async function seedAudio(sandbox: SandboxApi, fileName: string): Promise { - await sandbox.write(fileName, "podnotes e2e audio placeholder", { - waitForContent: true, - waitOptions: WAIT_OPTS, - }); +async function seedAudio( + obsidian: Parameters[0], + sandbox: SandboxApi, +): Promise { + const fileName = "podnotes-e2e-silence.wav"; + const audioPath = sandbox.path(fileName); + if (audioFixtureReady) return audioPath; - return sandbox.path(fileName); + await evalJsonAsync( + obsidian, + ` + (async () => { + const sampleRate = 8_000; + const sampleCount = sampleRate * 600; + const bytes = new Uint8Array(44 + sampleCount); + const view = new DataView(bytes.buffer); + const ascii = (offset, value) => { + for (let index = 0; index < value.length; index++) { + bytes[offset + index] = value.charCodeAt(index); + } + }; + + ascii(0, "RIFF"); + view.setUint32(4, 36 + sampleCount, true); + ascii(8, "WAVE"); + ascii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate, true); + view.setUint16(32, 1, true); + view.setUint16(34, 8, true); + ascii(36, "data"); + view.setUint32(40, sampleCount, true); + bytes.fill(128, 44); + + const existing = app.vault.getAbstractFileByPath(${JSON.stringify(audioPath)}); + if (existing) { + await app.vault.modifyBinary(existing, bytes.buffer); + } else { + await app.vault.createBinary(${JSON.stringify(audioPath)}, bytes.buffer); + } + return true; + })() + `, + ); + await sandbox.waitForExists(fileName, WAIT_OPTS); + audioFixtureReady = true; + + return audioPath; } async function openMarkdownFile( @@ -878,7 +941,7 @@ async function invokePodNotesUri( } } -async function dispatchLoadedMetadata(obsidian: { +async function stabilizeLoadedMedia(obsidian: { dev: { evalJson: (code: string) => Promise }; sleep: (ms: number) => Promise; }): Promise { @@ -895,25 +958,38 @@ async function dispatchLoadedMetadata(obsidian: { if (!audio) { return { ok: false, error: "No PodNotes audio element found." }; } + if (audio.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + return { ok: false, error: "PodNotes audio playback data is not ready yet." }; + } + if (audio.paused) { + return { ok: false, error: "PodNotes audio playback has not started yet." }; + } + audio.pause(); + const currentTime = Math.round( + app.plugins.plugins.${PLUGIN_ID}?.api?.currentTime ?? audio.currentTime, + ); + Object.defineProperty(audio, "currentTime", { + configurable: true, + value: currentTime, + writable: true, + }); Object.defineProperty(audio, "duration", { configurable: true, value: 3600, }); - audio.dispatchEvent(new Event("loadedmetadata")); Object.defineProperty(audio, "paused", { configurable: true, value: false, }); audio.dispatchEvent(new Event("play")); - return { ok: true }; })() `); if (result.ok) return; - lastError = result.error ?? "Failed to dispatch loadedmetadata."; + lastError = result.error ?? "Failed to stabilize loaded media."; await obsidian.sleep(WAIT_OPTS.intervalMs); } @@ -922,27 +998,50 @@ async function dispatchLoadedMetadata(obsidian: { async function dispatchAudioPlay(obsidian: { dev: { evalJson: (code: string) => Promise }; + sleep: (ms: number) => Promise; }): Promise { - const result = await obsidian.dev.evalJson<{ error?: string; ok: boolean }>(` - (() => { - const audio = document.querySelector(".podcast-view audio"); - if (!audio) { - return { ok: false, error: "No PodNotes audio element found." }; - } + const startedAt = Date.now(); + let lastError = "No PodNotes audio element found."; - Object.defineProperty(audio, "paused", { - configurable: true, - value: false, - }); - audio.dispatchEvent(new Event("play")); + while (Date.now() - startedAt < WAIT_OPTS.timeoutMs) { + const result = await obsidian.dev.evalJson<{ error?: string; ok: boolean }>(` + (() => { + const audio = document.querySelector(".podcast-view audio"); + if (!audio) { + return { ok: false, error: "No PodNotes audio element found." }; + } + if (audio.paused) { + return { ok: false, error: "PodNotes audio playback has not started yet." }; + } + if (audio.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + return { ok: false, error: "PodNotes audio playback data is not ready yet." }; + } - return { ok: true }; - })() - `); + audio.pause(); + const currentTime = Math.round( + app.plugins.plugins.${PLUGIN_ID}?.api?.currentTime ?? audio.currentTime, + ); + Object.defineProperty(audio, "currentTime", { + configurable: true, + value: currentTime, + writable: true, + }); + Object.defineProperty(audio, "paused", { + configurable: true, + value: false, + }); + audio.dispatchEvent(new Event("play")); + return { ok: true }; + })() + `); - if (!result.ok) { - throw new Error(result.error ?? "Failed to dispatch audio play."); + if (result.ok) return; + + lastError = result.error ?? "Failed to dispatch audio play."; + await obsidian.sleep(WAIT_OPTS.intervalMs); } + + throw new Error(lastError); } async function dispatchAudioTimeUpdate(