diff --git a/package-lock.json b/package-lock.json index 566c7cb..eb1393e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4944,6 +4945,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vrtmrz/obsidian-test-session": { + "version": "0.0.0", + "resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", + "integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "playwright": ">=1.50.0" + } + }, "node_modules/@wdio/config": { "version": "9.27.0", "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz", diff --git a/package.json b/package.json index c1e15dd..d6438c6 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index ca32189..a0b77c3 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -2,6 +2,8 @@ This directory contains the experimental real Obsidian end-to-end runner. +The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository. + The current smoke runner verifies only the launch path: 1. create a temporary vault, @@ -18,7 +20,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available. -Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. +Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here. Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry. diff --git a/test/e2e-obsidian/runner/cli.ts b/test/e2e-obsidian/runner/cli.ts index 052f410..56c3c08 100644 --- a/test/e2e-obsidian/runner/cli.ts +++ b/test/e2e-obsidian/runner/cli.ts @@ -1,103 +1,6 @@ -import { spawn } from "node:child_process"; - -export type ObsidianCliResult = { - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -}; - -function parseEvalJson(stdout: string): unknown { - const marker = "=> "; - const markerIndex = stdout.indexOf(marker); - const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout; - return JSON.parse(text.trim()); -} - -export async function runObsidianCli( - cliBinary: string, - args: string[], - env: NodeJS.ProcessEnv = process.env, - timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000) -): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(cliBinary, args, { - stdio: ["ignore", "pipe", "pipe"], - env, - }); - let stdout = ""; - let stderr = ""; - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`)); - }, timeoutMs); - - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.on("exit", (code, signal) => { - clearTimeout(timeout); - resolve({ code, signal, stdout, stderr }); - }); - }); -} - -export async function openVaultWithObsidianCli( - cliBinary: string, - vaultPath: string, - env: NodeJS.ProcessEnv = process.env -): Promise { - const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env); - if (result.code !== 0) { - throw new Error( - [ - `Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - -export async function evalObsidianJson( - cliBinary: string, - code: string, - env: NodeJS.ProcessEnv = process.env, - timeoutMs?: number -): Promise { - const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs); - if (result.code !== 0) { - throw new Error( - [ - `Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } - try { - return parseEvalJson(result.stdout) as T; - } catch (error) { - throw new Error( - [ - `Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`, - error instanceof Error ? `parse error: ${error.message}` : undefined, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} +export { + evalObsidianJson, + openVaultWithObsidianCli, + runObsidianCli, + type ObsidianCliResult, +} from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/environment.ts b/test/e2e-obsidian/runner/environment.ts index 46b08a6..35511c4 100644 --- a/test/e2e-obsidian/runner/environment.ts +++ b/test/e2e-obsidian/runner/environment.ts @@ -1,149 +1,7 @@ -import { accessSync, constants, existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { platform } from "node:process"; - -export type ObsidianDiscoveryResult = { - binary?: string; - source?: string; - checked: string[]; -}; - -const defaultCandidatesByPlatform: Record = { - aix: [], - android: [], - darwin: [ - "/Applications/Obsidian.app/Contents/MacOS/Obsidian", - "/Applications/Obsidian.app/Contents/MacOS/obsidian", - ], - freebsd: [], - haiku: [], - linux: [ - "_testdata/obsidian/squashfs-root/obsidian", - "_testdata/obsidian/squashfs-root/AppRun", - "_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage", - "_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage", - "/usr/bin/obsidian", - "/usr/local/bin/obsidian", - "/snap/bin/obsidian", - "/opt/Obsidian/obsidian", - "/opt/obsidian/obsidian", - "/app/bin/obsidian", - ], - openbsd: [], - sunos: [], - win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"], - cygwin: [], - netbsd: [], -}; - -const defaultCliCandidatesByPlatform: Record = { - aix: [], - android: [], - darwin: [ - "/Applications/Obsidian.app/Contents/MacOS/obsidian-cli", - "/Applications/Obsidian.app/Contents/Resources/obsidian-cli", - ], - freebsd: [], - haiku: [], - linux: [ - "_testdata/obsidian/squashfs-root/obsidian-cli", - "/usr/bin/obsidian-cli", - "/usr/local/bin/obsidian-cli", - "/snap/bin/obsidian-cli", - "/opt/Obsidian/obsidian-cli", - "/opt/obsidian/obsidian-cli", - ], - openbsd: [], - sunos: [], - win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"], - cygwin: [], - netbsd: [], -}; - -function isUsableFile(path: string): boolean { - const resolvedPath = resolve(path); - if (!existsSync(resolvedPath)) { - return false; - } - if (platform === "win32") { - return true; - } - try { - accessSync(resolvedPath, constants.X_OK); - return true; - } catch { - return false; - } -} - -export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult { - const checked: string[] = []; - const envBinary = env.OBSIDIAN_BINARY?.trim(); - if (envBinary) { - checked.push(envBinary); - if (isUsableFile(envBinary)) { - return { - binary: resolve(envBinary), - source: "OBSIDIAN_BINARY", - checked, - }; - } - } - - const candidates = defaultCandidatesByPlatform[platform] ?? []; - for (const candidate of candidates) { - checked.push(candidate); - if (isUsableFile(candidate)) { - return { - binary: resolve(candidate), - source: "default-path", - checked, - }; - } - } - - return { checked }; -} - -export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string { - const result = discoverObsidianBinary(env); - if (!result.binary) { - throw new Error( - [ - "Could not find an Obsidian executable.", - "Set OBSIDIAN_BINARY to the installed Obsidian executable path.", - `Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`, - ].join("\n") - ); - } - return result.binary; -} - -export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult { - const checked: string[] = []; - const envBinary = env.OBSIDIAN_CLI?.trim(); - if (envBinary) { - checked.push(envBinary); - if (isUsableFile(envBinary)) { - return { - binary: resolve(envBinary), - source: "OBSIDIAN_CLI", - checked, - }; - } - } - - const candidates = defaultCliCandidatesByPlatform[platform] ?? []; - for (const candidate of candidates) { - checked.push(candidate); - if (isUsableFile(candidate)) { - return { - binary: resolve(candidate), - source: "default-path", - checked, - }; - } - } - - return { checked }; -} +export { + discoverObsidianBinary, + discoverObsidianCli, + requireObsidianBinary, + requireObsidianCli, + type ObsidianDiscoveryResult, +} from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/launch.ts b/test/e2e-obsidian/runner/launch.ts index 3fe745f..db02d74 100644 --- a/test/e2e-obsidian/runner/launch.ts +++ b/test/e2e-obsidian/runner/launch.ts @@ -1,196 +1,26 @@ -import { execFile, spawn, type ChildProcess } from "node:child_process"; -import { once } from "node:events"; -import { existsSync } from "node:fs"; -import { dirname } from "node:path"; -import { platform } from "node:process"; -import { promisify } from "node:util"; +import { + cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses, + launchObsidian as launchObsidianSession, + type LaunchObsidianOptions, + type ObsidianProcess, + type ObsidianProcessOutput, +} from "@vrtmrz/obsidian-test-session"; -export type ObsidianProcess = { - process: ChildProcess; - output: () => { stdout: string; stderr: string }; - stop: () => Promise; -}; +export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput }; -export type LaunchObsidianOptions = { - binary: string; - vaultPath: string; - homePath?: string; - xdgConfigPath?: string; - xdgCachePath?: string; - xdgDataPath?: string; - userDataPath?: string; - startupGraceMs?: number; -}; - -const execFileAsync = promisify(execFile); - -function splitArgs(args: string): string[] { - return args.split(" ").filter((arg) => arg.length > 0); -} - -function launchArgs(options: LaunchObsidianOptions): string[] { - const explicitArgs = process.env.E2E_OBSIDIAN_ARGS; - if (explicitArgs) { - return splitArgs(explicitArgs); - } - return [ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - ...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath - ? [`--user-data-dir=${options.userDataPath}`] - : []), - ...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT - ? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`] - : []), - `obsidian://open?path=${encodeURIComponent(options.vaultPath)}`, - ]; -} - -function shouldUseXvfb(): boolean { - if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") { - return false; - } - if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) { - return false; - } - return platform === "linux" && existsSync("/usr/bin/xvfb-run"); -} - -async function listChildPids(pid: number): Promise { - if (platform === "win32") { - return []; - } - const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({ - stdout: "", - })); - const directChildren = stdout - .split("\n") - .map((line) => Number(line.trim())) - .filter((childPid) => Number.isInteger(childPid) && childPid > 0); - const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid))); - return [...directChildren, ...descendants.flat()]; -} - -async function killPids(pids: number[], signal: NodeJS.Signals): Promise { - for (const pid of pids) { - if (pid === process.pid) { - continue; - } - try { - process.kill(pid, signal); - } catch { - // The process may have exited between discovery and signalling. - } - } -} - -async function waitForExit(exitPromise: Promise, timeoutMs: number): Promise<"exited" | "timeout"> { - const stopTimer = new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), timeoutMs); - }); - const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]); - return stopResult; -} +const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state"; export async function cleanupStaleObsidianE2EProcesses(): Promise { - if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") { - return; - } - const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({ - stdout: "", - })); - const pids = stdout - .split("\n") - .map((line) => Number(line.trim())) - .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); - if (pids.length === 0) { - return; - } - await killPids(pids, "SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await killPids(pids, "SIGKILL"); + await cleanupStaleProcesses(STALE_PROCESS_PATTERN); } export async function launchObsidian(options: LaunchObsidianOptions): Promise { - await cleanupStaleObsidianE2EProcesses(); - const startupGraceMs = options.startupGraceMs ?? 1000; - const args = launchArgs(options); - const useXvfb = shouldUseXvfb(); - const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary; - const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args; - const child = spawn(command, commandArgs, { - cwd: dirname(options.binary), - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - ...(options.homePath ? { HOME: options.homePath } : {}), - ...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}), - ...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}), - ...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}), - OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1", - }, + const configuredPort = + options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT; + return await launchObsidianSession({ + ...options, + remoteDebuggingPort: + options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)), + staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN, }); - - let stderr = ""; - let stdout = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - - const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal })); - const timer = new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), startupGraceMs); - }); - const firstResult = await Promise.race([exitPromise, timer]); - if (firstResult !== "timeout") { - throw new Error( - [ - `Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`, - stdout ? `stdout:\n${stdout}` : undefined, - stderr ? `stderr:\n${stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } - - return { - process: child, - output: () => ({ stdout, stderr }), - stop: async () => { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - const descendantPids = child.pid ? await listChildPids(child.pid) : []; - if (child.pid) { - try { - process.kill(-child.pid, "SIGTERM"); - } catch { - child.kill("SIGTERM"); - } - } else { - child.kill("SIGTERM"); - } - await killPids(descendantPids.reverse(), "SIGTERM"); - const stopResult = await waitForExit(exitPromise, 5000); - if (stopResult === "timeout") { - if (child.pid) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - child.kill("SIGKILL"); - } - } else { - child.kill("SIGKILL"); - } - await killPids(descendantPids, "SIGKILL"); - await exitPromise; - } - }, - }; } diff --git a/test/e2e-obsidian/runner/pluginInstaller.ts b/test/e2e-obsidian/runner/pluginInstaller.ts index db28cc9..4857935 100644 --- a/test/e2e-obsidian/runner/pluginInstaller.ts +++ b/test/e2e-obsidian/runner/pluginInstaller.ts @@ -1,39 +1,13 @@ -import { copyFile, mkdir, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { + installBuiltPlugin as installGenericBuiltPlugin, + type PluginInstallResult, +} from "@vrtmrz/obsidian-test-session"; -export type PluginInstallResult = { - pluginDir: string; - copied: string[]; -}; - -const pluginId = "obsidian-livesync"; +export type { PluginInstallResult }; export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise { - const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId); - const copied: string[] = []; - await mkdir(pluginDir, { recursive: true }); - - const requiredArtifacts = ["main.js", "manifest.json"]; - for (const artifact of requiredArtifacts) { - const source = resolve(rootDir, artifact); - if (!existsSync(source)) { - throw new Error(`Required plug-in artifact is missing: ${source}`); - } - await copyFile(source, join(pluginDir, artifact)); - copied.push(artifact); - } - - const optionalArtifacts = ["styles.css"]; - for (const artifact of optionalArtifacts) { - const source = resolve(rootDir, artifact); - if (!existsSync(source)) { - continue; - } - await copyFile(source, join(pluginDir, artifact)); - copied.push(artifact); - } - - await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4)); - return { pluginDir, copied }; + return await installGenericBuiltPlugin(vaultPath, { + pluginId: "obsidian-livesync", + artifactRoot: rootDir, + }); } diff --git a/test/e2e-obsidian/runner/readiness.ts b/test/e2e-obsidian/runner/readiness.ts index a6fa5a9..df187a0 100644 --- a/test/e2e-obsidian/runner/readiness.ts +++ b/test/e2e-obsidian/runner/readiness.ts @@ -1,41 +1 @@ -import { evalObsidianJson } from "./cli.ts"; - -export type PluginReadiness = { - status: "ready"; - pluginId: string; - pluginVersion: string; - vaultName: string; -}; - -export async function waitForPluginReady( - cliBinary: string, - env: NodeJS.ProcessEnv, - timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000) -): Promise { - const deadline = Date.now() + timeoutMs; - let lastOutput = ""; - while (Date.now() < deadline) { - try { - const readiness = await evalObsidianJson( - cliBinary, - [ - "(async()=>JSON.stringify({", - "status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',", - "pluginId:'obsidian-livesync',", - "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,", - "vaultName:app.vault.getName()", - "}))()", - ].join(""), - env - ); - if (readiness.status === "ready") { - return readiness; - } - } catch (error) { - lastOutput = error instanceof Error ? error.message : String(error); - // Keep polling until Obsidian exposes the vault-side CLI and plug-in state. - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`); -} +export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/session.ts b/test/e2e-obsidian/runner/session.ts index 5d7bdd1..ede769d 100644 --- a/test/e2e-obsidian/runner/session.ts +++ b/test/e2e-obsidian/runner/session.ts @@ -1,16 +1,7 @@ -import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts"; -import { launchObsidian, type ObsidianProcess } from "./launch.ts"; -import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts"; -import { waitForPluginReady, type PluginReadiness } from "./readiness.ts"; +import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; import type { TemporaryVault } from "./vault.ts"; -import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts"; -export type ObsidianLiveSyncSession = { - app: ObsidianProcess; - cliEnv: NodeJS.ProcessEnv; - install: PluginInstallResult; - readiness: PluginReadiness; -}; +export type ObsidianLiveSyncSession = ObsidianPluginSession; export type StartObsidianLiveSyncSessionOptions = { binary: string; @@ -19,101 +10,15 @@ export type StartObsidianLiveSyncSessionOptions = { startupGraceMs?: number; }; -async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000); - let lastOutput = ""; - while (Date.now() < deadline) { - try { - const result = await evalObsidianJson<{ hasLiveSync: boolean }>( - cliBinary, - ["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""), - env - ); - lastOutput = JSON.stringify(result); - if (result.hasLiveSync) { - return; - } - } catch (error) { - lastOutput = error instanceof Error ? error.message : String(error); - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`); -} - -async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env); - if (result.code !== 0 || result.stdout.includes("Error:")) { - throw new Error( - [ - `Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - -async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env); - if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) { - throw new Error( - [ - `Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`, - reload.stdout ? `stdout:\n${reload.stdout}` : undefined, - reload.stderr ? `stderr:\n${reload.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - export async function startObsidianLiveSyncSession( options: StartObsidianLiveSyncSessionOptions ): Promise { - const install = await installBuiltPlugin(options.vault.path); - const remoteDebuggingPort = obsidianRemoteDebuggingPort(); - const app = await launchObsidian({ + return await startObsidianPluginSession({ binary: options.binary, - vaultPath: options.vault.path, - homePath: options.vault.homePath, - xdgConfigPath: options.vault.xdgConfigPath, - xdgCachePath: options.vault.xdgCachePath, - xdgDataPath: options.vault.xdgDataPath, - userDataPath: options.vault.userDataPath, + cliBinary: options.cliBinary, + vault: options.vault, + pluginId: "obsidian-livesync", + artifactRoot: process.cwd(), startupGraceMs: options.startupGraceMs, }); - const cliEnv = { - ...process.env, - HOME: options.vault.homePath, - XDG_CONFIG_HOME: options.vault.xdgConfigPath, - XDG_CACHE_HOME: options.vault.xdgCachePath, - XDG_DATA_HOME: options.vault.xdgDataPath, - }; - - try { - await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id); - await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv); - await trustVaultIfPrompted(remoteDebuggingPort); - await waitForPluginCatalogue(options.cliBinary, cliEnv); - await enableCommunityPlugins(options.cliBinary, cliEnv); - await reloadLiveSyncPlugin(options.cliBinary, cliEnv); - const readiness = await waitForPluginReady(options.cliBinary, cliEnv); - return { app, cliEnv, install, readiness }; - } catch (error) { - const output = app.output(); - await app.stop(); - throw new Error( - [ - error instanceof Error ? error.message : String(error), - output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined, - output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } } diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index d6c8848..8c4f36d 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -1,74 +1,11 @@ -import { chromium, type Page } from "playwright"; +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; -export function obsidianRemoteDebuggingPort(): number { - const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222); - process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port); - return port; -} - -async function waitForCdp(port: number): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000); - while (Date.now() < deadline) { - try { - const response = await fetch(`http://127.0.0.1:${port}/json/version`); - if (response.ok) { - return; - } - } catch { - // Keep polling until Obsidian exposes the debugging endpoint. - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`); -} - -export async function withObsidianPage(port: number, operation: (page: Page) => Promise): Promise { - await waitForCdp(port); - const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); - try { - const context = browser.contexts()[0]; - const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 })); - return await operation(page); - } finally { - await browser.close(); - } -} - -export async function preseedTrustedVaultState(port: number, vaultId: string): Promise { - await withObsidianPage(port, async (page) => { - await page.evaluate((id) => { - localStorage.setItem(`enable-plugin-${id}`, "true"); - }, vaultId); - await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined); - await page.waitForTimeout(1000); - }); -} - -export async function trustVaultIfPrompted(port: number): Promise { - await withObsidianPage(port, async (page) => { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000); - while (Date.now() < deadline) { - const yesButton = page.getByRole("button", { name: "Yes" }); - if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await yesButton.click(); - await page.waitForTimeout(500); - continue; - } - - const trustButton = page.getByText("Trust author and enable plugins"); - if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await trustButton.click(); - await page.waitForTimeout(500); - continue; - } - - const workspace = page.locator(".workspace"); - if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) { - return; - } - } - }); -} +export { + obsidianRemoteDebuggingPort, + preseedTrustedVaultState, + trustVaultIfPrompted, + withObsidianPage, +} from "@vrtmrz/obsidian-test-session"; export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise { await withObsidianPage(port, async (page) => { diff --git a/test/e2e-obsidian/runner/vault.ts b/test/e2e-obsidian/runner/vault.ts index 6c75f95..53d645f 100644 --- a/test/e2e-obsidian/runner/vault.ts +++ b/test/e2e-obsidian/runner/vault.ts @@ -1,94 +1,14 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { + createTemporaryVault as createGenericTemporaryVault, + type TemporaryVault, +} from "@vrtmrz/obsidian-test-session"; -export type TemporaryVault = { - path: string; - name: string; - id: string; - homePath: string; - xdgConfigPath: string; - xdgCachePath: string; - xdgDataPath: string; - userDataPath: string; - dispose: () => Promise; -}; +export type { TemporaryVault }; export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise { - const vaultPath = await mkdtemp(join(tmpdir(), prefix)); - const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`)); - const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e"; - await mkdir(join(vaultPath, ".obsidian"), { recursive: true }); - const homePath = join(statePath, "home"); - const xdgConfigPath = join(statePath, "xdg-config"); - const xdgCachePath = join(statePath, "xdg-cache"); - const xdgDataPath = join(statePath, "xdg-data"); - const userDataPath = join(statePath, "user-data"); - const id = `livesync-e2e-${Date.now()}`; - await mkdir(homePath, { recursive: true }); - await mkdir(xdgConfigPath, { recursive: true }); - await mkdir(xdgCachePath, { recursive: true }); - await mkdir(xdgDataPath, { recursive: true }); - await mkdir(userDataPath, { recursive: true }); - await writeFile( - join(vaultPath, ".obsidian", "app.json"), - JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4) - ); - await writeFile( - join(vaultPath, ".obsidian", "community-plugins.json"), - JSON.stringify(["obsidian-livesync"], null, 4) - ); - await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath); - - return { - path: vaultPath, - name, - id, - homePath, - xdgConfigPath, - xdgCachePath, - xdgDataPath, - userDataPath, - dispose: async () => { - if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") { - console.log(`Keeping temporary vault: ${vaultPath}`); - console.log(`Keeping temporary Obsidian state: ${statePath}`); - return; - } - await Promise.all([ - rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), - rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), - ]); - }, - }; -} - -async function writeObsidianVaultRegistry( - vaultId: string, - vaultPath: string, - vaultName: string, - homePath: string, - xdgConfigPath: string, - userDataPath: string -): Promise { - const vaultRecord = { - path: vaultPath, - ts: Date.now(), - open: true, - name: vaultName, - }; - const registry = { - cli: true, - vaults: { - [vaultId]: vaultRecord, - }, - }; - const registryText = JSON.stringify(registry, null, 4); - for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) { - const obsidianConfigDir = join(configRoot, "obsidian"); - await mkdir(obsidianConfigDir, { recursive: true }); - await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText); - } - await writeFile(join(userDataPath, "obsidian.json"), registryText); - await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4)); + return await createGenericTemporaryVault({ + prefix, + pluginIds: ["obsidian-livesync"], + idPrefix: "livesync-e2e", + }); }