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..8a9ca09 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -1,26 +1,15 @@ -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 +23,69 @@ 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"], + // 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, +}); + +/** + * 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 +126,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 +159,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); - } -}