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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
301 changes: 62 additions & 239 deletions tests/e2e/harness.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 <caller-cwd>/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<T>(obsidian: ObsidianClient, code: string): Promise<T> {
return obsidian.dev.evalJsonAsync<T>(code);
}

export async function reloadPodNotes(
plugin: PluginHandle,
obsidian: ObsidianClient,
): Promise<void> {
await plugin.reload(RELOAD_OPTIONS);
await waitForPodNotesReady(obsidian);
export async function waitForPodNotesReady(obsidian: ObsidianClient): Promise<void> {
await obsidian.waitFor(() => podNotesReady(obsidian), {
...WAIT_OPTS,
message: "PodNotes plugin did not become ready.",
});
}

export async function restorePodNotesData(
plugin: PluginHandle,
obsidian: ObsidianClient,
): Promise<void> {
await detachPodNotesViews(obsidian);
await flushPodNotesSaves(obsidian);
await plugin.disable();
await plugin.restoreData();
await plugin.enable();
await waitForPodNotesReady(obsidian);
function podNotesReady(obsidian: ObsidianClient): Promise<boolean> {
return obsidian.dev.evalJson<boolean>(`
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<void> {
Expand Down Expand Up @@ -199,60 +126,6 @@ async function flushPodNotesSaves(obsidian: ObsidianClient): Promise<void> {
);
}

export async function waitForPodNotesReady(obsidian: ObsidianClient): Promise<void> {
await obsidian.waitFor(
async () => {
return await obsidian.dev.evalJson<boolean>(`
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<T> =
| { ok: true; value: T }
| { error: { message: string; stack?: string }; ok: false };

export async function evalJsonAsync<T>(obsidian: ObsidianClient, code: string): Promise<T> {
const envelope = await obsidian.dev.eval<AsyncEvalEnvelope<T>>(`
(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<void> {
const result = await evalJsonAsync<{
activeViewType: string | null;
Expand Down Expand Up @@ -286,53 +159,3 @@ export async function openPodNotesView(obsidian: ObsidianClient): Promise<void>
throw new Error(`Failed to open PodNotes view: ${JSON.stringify(result)}`);
}
}

async function assertDevVaultSymlinks(vaultPath: string): Promise<void> {
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<void> {
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> | unknown,
): Promise<void> {
try {
await step();
} catch (error) {
errors.push(error);
console.warn(`PodNotes E2E teardown failed during ${label}`, error);
}
}