From 2746a0d3ce4d6001ae6ccea036b82918c7242756 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 11 Jul 2026 01:04:34 +0200 Subject: [PATCH 1/2] chore(e2e): adopt shared createPluginHarness --- package-lock.json | 16 ++- package.json | 2 +- tests/e2e/harness.ts | 300 +++++++++---------------------------------- 3 files changed, 71 insertions(+), 247 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef27513..bdc3dfc 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.7.0", + "obsidian-e2e": "^0.8.1", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", @@ -11722,9 +11722,9 @@ } }, "node_modules/obsidian-e2e": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/obsidian-e2e/-/obsidian-e2e-0.7.0.tgz", - "integrity": "sha512-qbYMFRU9UgbOhH8OPwnX+gCR55IvLTmrWxyUlTFH52wfTknCtaj28/t2s3aHux10byvh61ElBH04cKqfvbReyA==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/obsidian-e2e/-/obsidian-e2e-0.8.1.tgz", + "integrity": "sha512-tcIjZtGxeI5wkOMGAlSWRyEfxIG/nN9834gWgb3l92R41h0fTre8wG05KUamFkk0Lj2I1ibfN+njOIjo2WCftQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11737,7 +11737,13 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite-plus": "^0.1.11" + "vite-plus": "^0.1.11", + "vitest": ">=3" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/obug": { diff --git a/package.json b/package.json index 4473007..0447d26 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": "^0.7.0", + "obsidian-e2e": "^0.8.1", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", "semantic-release": "^25.0.5", diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 19d355d..54fdb27 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -1,26 +1,13 @@ -import { readlink } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { - acquireVaultRunLock, - captureFailureArtifacts, - clearVaultRunLockMarker, - createObsidianClient, - createSandboxApi, type ObsidianClient, - type PluginHandle, type PluginReloadOptions, - type SandboxApi, - type VaultRunLock, + resolveObsidianEnvOptions, } from "obsidian-e2e"; -import { afterAll, afterEach, beforeAll, beforeEach } from "vitest"; +import { createPluginHarness } from "obsidian-e2e/vitest"; export const PLUGIN_ID = "podnotes"; export const VIEW_TYPE = "podcast_player_view"; -// 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"; +// Reused by tests for sandbox content polling and the plugin-ready poll. export const WAIT_OPTS = { timeoutMs: 15_000, intervalMs: 200 }; export const RELOAD_OPTIONS: PluginReloadOptions = { waitUntilReady: true, @@ -34,131 +21,66 @@ export const RELOAD_OPTIONS: PluginReloadOptions = { }, }; -type HarnessState = { - lock?: VaultRunLock; - obsidian?: ObsidianClient; - plugin?: PluginHandle; - sandbox?: SandboxApi; -}; - -export type PodNotesE2EContext = { - obsidian: ObsidianClient; - plugin: PluginHandle; - sandbox: SandboxApi; -}; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); - -export function createPodNotesE2EHarness(testName: string) { - const state: HarnessState = {}; - - beforeAll(async () => { - state.obsidian = createObsidianClient({ - vault: E2E_VAULT, - bin: E2E_BIN, - timeoutMs: 20_000, - intervalMs: 200, - }); - await state.obsidian.verify(); - - state.lock = await acquireVaultRunLock({ - vaultName: E2E_VAULT, - vaultPath: await state.obsidian.vaultPath(), - onBusy: "wait", - timeoutMs: 60_000, - }); - await state.lock.publishMarker(state.obsidian); - - await assertDevVaultSymlinks(await state.obsidian.vaultPath()); - - state.plugin = state.obsidian.plugin(PLUGIN_ID); - state.sandbox = await createSandboxApi({ - obsidian: state.obsidian, - sandboxRoot: "__obsidian_e2e__", - testName, - }); - - await state.obsidian.dev.resetDiagnostics().catch(() => undefined); - await reloadPodNotes(state.plugin, state.obsidian); - }, 90_000); - - beforeEach((ctx) => { - ctx.onTestFailed(async () => { - if (!state.obsidian) return; - - await captureFailureArtifacts( - { id: ctx.task.id, name: ctx.task.name }, - state.obsidian, - { - captureOnFailure: true, - plugin: state.plugin, - }, - ).catch((error) => { - console.warn("PodNotes E2E artifact capture failed", error); - }); - }); - }); - - beforeEach(async () => { - await state.obsidian?.dev.resetDiagnostics().catch(() => undefined); - }); - - afterEach(async () => { - if (!state.plugin || !state.obsidian) return; - - await restorePodNotesData(state.plugin, state.obsidian); - }); - - afterAll(async () => { - const errors: unknown[] = []; - - await runTeardown("restore plugin data", errors, () => { - if (!state.plugin || !state.obsidian) return undefined; - return restorePodNotesData(state.plugin, state.obsidian); - }); - await runTeardown("clean sandbox", errors, () => state.sandbox?.cleanup()); - await runTeardown("clear vault lock marker", errors, () => { - if (!state.obsidian) return undefined; - return clearVaultRunLockMarker(state.obsidian); - }); - await runTeardown("release vault lock", errors, () => state.lock?.release()); - - if (errors.length > 0) { - throw errors[0]; - } - }, 30_000); - - return (): PodNotesE2EContext => { - if (!state.obsidian || !state.plugin || !state.sandbox) { - throw new Error("PodNotes E2E harness is not initialized."); - } - - return { - obsidian: state.obsidian, - plugin: state.plugin, - sandbox: state.sandbox, - }; - }; +/** + * Suite-scoped PodNotes E2E harness built on obsidian-e2e's shared + * `createPluginHarness`: one vault lock + sandbox + reload per file, per-test + * diagnostics reset and data restore, failure-artifact capture, and the dev + * vault symlink preflight. Returns the `(testName) => () => context` getter the + * test bodies already consume. + * + * PodNotes flushes pending saves and detaches its player views before every + * `restoreData()` via `beforeDataRestore`, preserving the original + * detach -> flush -> restore ordering. + * + * Canonical `OBSIDIAN_E2E_*` env is emitted by the shared runner; the legacy + * `PODNOTES_E2E_*` aliases remain a fallback during the migration. + */ +export const createPodNotesE2EHarness = createPluginHarness({ + ...resolveObsidianEnvOptions({ legacyPrefix: "PODNOTES" }), + pluginId: PLUGIN_ID, + reload: { + readyCommandId: RELOAD_OPTIONS.readyOptions?.commandId, + timeoutMs: 30_000, + intervalMs: WAIT_OPTS.intervalMs, + }, + // Beyond plugin-loaded and the ready command, PodNotes is only usable once its + // public API is attached and its protocol handler is registered. + waitUntilReady: podNotesReady, + // PodNotes persists asynchronously and keeps live player views; both must + // settle before the settings file is rolled back, so they run while the + // plugin is still enabled. + beforeDataRestore: async (obsidian) => { + await detachPodNotesViews(obsidian); + await flushPodNotesSaves(obsidian); + }, + symlinkArtifacts: ["main.js", "manifest.json"], + captureOnFailure: true, +}); + +/** + * Evaluate an async body in the Obsidian runtime and decode the JSON result, + * rethrowing remote failures (as `DevEvalError`) with their message and stack. + * Thin adapter over the package's `obsidian.dev.evalJsonAsync` that keeps the + * `(obsidian, code)` call shape the test bodies use. + */ +export function evalJsonAsync(obsidian: ObsidianClient, code: string): Promise { + return obsidian.dev.evalJsonAsync(code); } -export async function reloadPodNotes( - plugin: PluginHandle, - obsidian: ObsidianClient, -): Promise { - await plugin.reload(RELOAD_OPTIONS); - await waitForPodNotesReady(obsidian); +export async function waitForPodNotesReady(obsidian: ObsidianClient): Promise { + await obsidian.waitFor(() => podNotesReady(obsidian), { + ...WAIT_OPTS, + message: "PodNotes plugin did not become ready.", + }); } -export async function restorePodNotesData( - plugin: PluginHandle, - obsidian: ObsidianClient, -): Promise { - await detachPodNotesViews(obsidian); - await flushPodNotesSaves(obsidian); - await plugin.disable(); - await plugin.restoreData(); - await plugin.enable(); - await waitForPodNotesReady(obsidian); +function podNotesReady(obsidian: ObsidianClient): Promise { + return obsidian.dev.evalJson(` + Boolean( + app.plugins.plugins.${PLUGIN_ID}?.api && + (app.workspace.protocolHandlers ?? app.workspace.protocolHandler?.handlers)?.has(${JSON.stringify(PLUGIN_ID)}) + ) + `); } async function detachPodNotesViews(obsidian: ObsidianClient): Promise { @@ -199,60 +121,6 @@ async function flushPodNotesSaves(obsidian: ObsidianClient): Promise { ); } -export async function waitForPodNotesReady(obsidian: ObsidianClient): Promise { - await obsidian.waitFor( - async () => { - return await obsidian.dev.evalJson(` - Boolean( - app.plugins.plugins.${PLUGIN_ID}?.api && - (app.workspace.protocolHandlers ?? app.workspace.protocolHandler?.handlers)?.has(${JSON.stringify(PLUGIN_ID)}) - ) - `); - }, - { - ...WAIT_OPTS, - message: "PodNotes plugin did not become ready.", - }, - ); -} - -type AsyncEvalEnvelope = - | { ok: true; value: T } - | { error: { message: string; stack?: string }; ok: false }; - -export async function evalJsonAsync(obsidian: ObsidianClient, code: string): Promise { - const envelope = await obsidian.dev.eval>(` - (async () => { - const code = ${JSON.stringify(code)}; - try { - const value = await (0, eval)(code); - return JSON.stringify({ ok: true, value }); - } catch (error) { - return JSON.stringify({ - ok: false, - error: { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }, - }); - } - })() - `); - - if (!envelope.ok) { - throw new Error( - [ - `Failed to evaluate async Obsidian code: ${envelope.error.message}`, - envelope.error.stack ?? "", - ] - .filter(Boolean) - .join("\n"), - ); - } - - return envelope.value; -} - export async function openPodNotesView(obsidian: ObsidianClient): Promise { const result = await evalJsonAsync<{ activeViewType: string | null; @@ -286,53 +154,3 @@ export async function openPodNotesView(obsidian: ObsidianClient): Promise throw new Error(`Failed to open PodNotes view: ${JSON.stringify(result)}`); } } - -async function assertDevVaultSymlinks(vaultPath: string): Promise { - const pluginDir = path.join(vaultPath, ".obsidian", "plugins", PLUGIN_ID); - - await assertSymlinkTarget(pluginDir, "main.js"); - await assertSymlinkTarget(pluginDir, "manifest.json"); -} - -async function assertSymlinkTarget(pluginDir: string, fileName: string): Promise { - const linkPath = path.join(pluginDir, fileName); - const expected = path.join(repoRoot, fileName); - let target: string; - - try { - target = await readlink(linkPath); - } catch (error) { - throw new Error( - [ - "PodNotes E2E preflight failed.", - `Expected ${linkPath} to be a symlink to ${expected}.`, - `Could not read symlink: ${error instanceof Error ? error.message : String(error)}`, - ].join(" "), - ); - } - - const resolvedTarget = path.resolve(path.dirname(linkPath), target); - if (resolvedTarget !== expected) { - throw new Error( - [ - "PodNotes E2E preflight failed.", - `Expected ${linkPath} to point at ${expected}.`, - `It currently points at ${resolvedTarget}.`, - "Repoint the dev vault plugin symlink intentionally before running npm run test:e2e.", - ].join(" "), - ); - } -} - -async function runTeardown( - label: string, - errors: unknown[], - step: () => Promise | unknown, -): Promise { - try { - await step(); - } catch (error) { - errors.push(error); - console.warn(`PodNotes E2E teardown failed during ${label}`, error); - } -} From c68b9a38e9198be5fbe2bfbe7c7166cd4401edc1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 11 Jul 2026 12:24:57 +0200 Subject: [PATCH 2/2] fix(e2e): pass a module-relative symlinkRepoRoot to the harness factory The factory defaults symlinkRepoRoot to process.cwd(), so the dev-vault symlink preflight compared against /main.js and rejected correct symlinks when Vitest was launched from an IDE or parent directory. Derive the repo root from import.meta.url like the old harness did. (Adversarial review finding on this PR.) --- tests/e2e/harness.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index 54fdb27..8a9ca09 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { type ObsidianClient, type PluginReloadOptions, @@ -54,6 +56,9 @@ export const createPodNotesE2EHarness = createPluginHarness({ await flushPodNotesSaves(obsidian); }, symlinkArtifacts: ["main.js", "manifest.json"], + // Module-relative, not process.cwd(): the factory otherwise compares dev-vault + // symlinks against /main.js, which breaks IDE-launched runs. + symlinkRepoRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."), captureOnFailure: true, });