diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index 7fa8045..e4621fb 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -125,7 +125,8 @@ Initial discovery on Linux ARM64 found that: Current implementation status: - Added `test/e2e-obsidian/runner` helpers for Obsidian discovery, CLI discovery, temporary vault creation, plug-in installation, process launch, CLI execution, and readiness polling. -- Added `test:e2e:obsidian:discover`, `test:e2e:obsidian:cli-help`, `test:e2e:obsidian:smoke`, and `test:e2e:obsidian:install-appimage`. +- Added `test:e2e:obsidian:discover`, `test:e2e:obsidian:cli-help`, `test:e2e:obsidian:smoke`, `test:e2e:obsidian:vault-reflection`, and `test:e2e:obsidian:install-appimage`. +- Added `startObsidianLiveSyncSession()` so future workflows can reuse the launch, vault open, community plug-in enablement, plug-in reload, and readiness sequence without duplicating smoke runner code. - Added a manual AppImage installer that downloads Obsidian `1.12.7` for `arm64` or `x86_64`, stores it under `_testdata/obsidian`, and extracts it for FUSE-free execution. - Confirmed the smoke runner on Linux ARM64 with the extracted Obsidian `1.12.7` AppImage, `xvfb-run`, and the built Self-hosted LiveSync bundle. - Confirmed the runner can enable the Obsidian CLI through isolated `obsidian.json` state, open the temporary vault through `obsidian-cli`, enable community plug-ins through `app.plugins.setEnable(true)`, reload Self-hosted LiveSync, and verify readiness through `obsidian-cli eval`. @@ -137,6 +138,7 @@ Current verification: - `npm run build` passes with existing Svelte warnings. - `npm run test:e2e:obsidian:discover` finds `_testdata/obsidian/squashfs-root/obsidian` when the extracted AppImage is present. - `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS=1000 npm run test:e2e:obsidian:smoke` passes locally. +- `npm run test:e2e:obsidian:vault-reflection` creates a note through Obsidian's vault API, verifies the reflected file on disk, and reads it back through Obsidian. - `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present. Known limits: @@ -156,6 +158,10 @@ Known limits: This validates real boot-up, settings persistence, vault file access, database writes, and restart-sensitive state. +Current implementation status: + +- Added a pre-CouchDB workflow that creates a note through Obsidian's vault API, confirms the note is reflected as a real vault file, and reads the same note back through Obsidian. This covers the vault reflection part of the Phase 2 path before remote database setup is introduced. + ### Phase 3: Two-Vault Synchronisation - Launch two Obsidian instances with two temporary vaults. diff --git a/package.json b/package.json index 09f2be1..1185c30 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts", "test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts", "test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts", + "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:coverage": "vitest run --coverage", "test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh", "test:docker-couchdb:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-init.sh", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 25ffd96..1fbb2d4 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -17,6 +17,8 @@ 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. + ## Local Setup Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. @@ -42,6 +44,7 @@ npm run test:e2e:obsidian:install-appimage npm run test:e2e:obsidian:discover npm run test:e2e:obsidian:cli-help -- vaults verbose npm run test:e2e:obsidian:smoke +npm run test:e2e:obsidian:vault-reflection ``` Useful environment variables: @@ -57,6 +60,7 @@ Useful environment variables: - `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds. - `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue. - `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation. +- `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk. - `E2E_OBSIDIAN_STARTUP_GRACE_MS`: early process-exit detection window in milliseconds. - `E2E_OBSIDIAN_KEEP_VAULT=true`: keep the temporary vault for inspection. - `E2E_OBSIDIAN_USE_XVFB=false`: disable automatic `xvfb-run` on headless Linux. diff --git a/test/e2e-obsidian/runner/cli.ts b/test/e2e-obsidian/runner/cli.ts index f1766b8..cc46be8 100644 --- a/test/e2e-obsidian/runner/cli.ts +++ b/test/e2e-obsidian/runner/cli.ts @@ -7,6 +7,13 @@ export type ObsidianCliResult = { 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[], @@ -60,3 +67,23 @@ export async function openVaultWithObsidianCli( ); } } + +export async function evalObsidianJson( + cliBinary: string, + code: string, + env: NodeJS.ProcessEnv = process.env +): Promise { + const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env); + if (result.code !== 0 || result.stdout.includes("Error:")) { + 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") + ); + } + return parseEvalJson(result.stdout) as T; +} diff --git a/test/e2e-obsidian/runner/readiness.ts b/test/e2e-obsidian/runner/readiness.ts index d56c2af..a6fa5a9 100644 --- a/test/e2e-obsidian/runner/readiness.ts +++ b/test/e2e-obsidian/runner/readiness.ts @@ -1,4 +1,4 @@ -import { runObsidianCli } from "./cli.ts"; +import { evalObsidianJson } from "./cli.ts"; export type PluginReadiness = { status: "ready"; @@ -7,13 +7,6 @@ export type PluginReadiness = { vaultName: 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 waitForPluginReady( cliBinary: string, env: NodeJS.ProcessEnv, @@ -22,28 +15,24 @@ export async function waitForPluginReady( const deadline = Date.now() + timeoutMs; let lastOutput = ""; while (Date.now() < deadline) { - const result = await runObsidianCli( - cliBinary, - [ - "eval", + try { + const readiness = await evalObsidianJson( + cliBinary, [ - "code=(async()=>JSON.stringify({", + "(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 - ); - lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n"); - try { - const readiness = parseEvalJson(result.stdout) as PluginReadiness; + env + ); if (readiness.status === "ready") { return readiness; } - } catch { + } 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)); diff --git a/test/e2e-obsidian/runner/session.ts b/test/e2e-obsidian/runner/session.ts new file mode 100644 index 0000000..ff7b931 --- /dev/null +++ b/test/e2e-obsidian/runner/session.ts @@ -0,0 +1,94 @@ +import { 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 type { TemporaryVault } from "./vault.ts"; + +export type ObsidianLiveSyncSession = { + app: ObsidianProcess; + cliEnv: NodeJS.ProcessEnv; + install: PluginInstallResult; + readiness: PluginReadiness; +}; + +export type StartObsidianLiveSyncSessionOptions = { + binary: string; + cliBinary: string; + vault: TemporaryVault; + startupGraceMs?: number; +}; + +async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 15000); + let lastOutput = ""; + while (Date.now() < deadline) { + const result = await runObsidianCli(cliBinary, ["plugins", "filter=community"], env); + lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n"); + if (result.stdout.includes("obsidian-livesync")) { + return; + } + 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 app = await launchObsidian({ + binary: options.binary, + vaultPath: options.vault.path, + homePath: options.vault.homePath, + xdgConfigPath: options.vault.xdgConfigPath, + userDataPath: options.vault.userDataPath, + startupGraceMs: options.startupGraceMs, + }); + const cliEnv = { + ...process.env, + HOME: options.vault.homePath, + XDG_CONFIG_HOME: options.vault.xdgConfigPath, + }; + + try { + await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv); + 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) { + await app.stop(); + throw error; + } +} diff --git a/test/e2e-obsidian/scripts/smoke.ts b/test/e2e-obsidian/scripts/smoke.ts index ad9bf8e..c00644f 100644 --- a/test/e2e-obsidian/scripts/smoke.ts +++ b/test/e2e-obsidian/scripts/smoke.ts @@ -1,38 +1,6 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; -import { launchObsidian } from "../runner/launch.ts"; -import { installBuiltPlugin } from "../runner/pluginInstaller.ts"; -import { waitForPluginReady } from "../runner/readiness.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; -import { openVaultWithObsidianCli, runObsidianCli } from "../runner/cli.ts"; - -async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 15000); - let lastOutput = ""; - while (Date.now() < deadline) { - const result = await runObsidianCli(cliBinary, ["plugins", "filter=community"], env); - lastOutput = [result.stdout, result.stderr].filter(Boolean).join("\n"); - if (result.stdout.includes("obsidian-livesync")) { - return; - } - 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 main(): Promise { const binary = requireObsidianBinary(); @@ -41,50 +9,27 @@ async function main(): Promise { throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); } const vault = await createTemporaryVault(); - let app; + let session: ObsidianLiveSyncSession | undefined; try { - const install = await installBuiltPlugin(vault.path); console.log(`Using Obsidian executable: ${binary}`); console.log(`Temporary vault: ${vault.path}`); - console.log(`Installed plug-in artifacts: ${install.copied.join(", ")}`); - app = await launchObsidian({ + session = await startObsidianLiveSyncSession({ binary, - vaultPath: vault.path, - homePath: vault.homePath, - xdgConfigPath: vault.xdgConfigPath, - userDataPath: vault.userDataPath, + cliBinary: cli.binary, + vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); - const cliEnv = { - ...process.env, - HOME: vault.homePath, - XDG_CONFIG_HOME: vault.xdgConfigPath, - }; - await openVaultWithObsidianCli(cli.binary, vault.path, cliEnv); - await waitForPluginCatalogue(cli.binary, cliEnv); - await enableCommunityPlugins(cli.binary, cliEnv); - const reload = await runObsidianCli(cli.binary, ["plugin:reload", "id=obsidian-livesync"], cliEnv); - 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") - ); - } - const readiness = await waitForPluginReady(cli.binary, cliEnv); + console.log(`Installed plug-in artifacts: ${session.install.copied.join(", ")}`); + const { readiness } = session; console.log( `Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}` ); await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000))); console.log("Obsidian stayed alive after the plug-in readiness check."); } finally { - if (app) { - await app.stop(); + if (session) { + await session.app.stop(); } await vault.dispose(); } diff --git a/test/e2e-obsidian/scripts/vault-reflection.ts b/test/e2e-obsidian/scripts/vault-reflection.ts new file mode 100644 index 0000000..6ba1473 --- /dev/null +++ b/test/e2e-obsidian/scripts/vault-reflection.ts @@ -0,0 +1,122 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +type CreatedNote = { + path: string; + read: string; + exists: boolean; +}; + +type ReadNote = { + exists: boolean; + read: string | null; +}; + +const notePath = "E2E/real-vault-reflection.md"; +const noteContent = [ + "# Real Obsidian E2E", + "", + "This note was created through Obsidian's own vault API.", + `Created at: ${new Date().toISOString()}`, + "", +].join("\n"); + +async function waitForFileContent(vaultPath: string, path: string, expectedContent: string): Promise { + const fullPath = join(vaultPath, path); + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000); + let lastError: unknown; + while (Date.now() < deadline) { + try { + const content = await readFile(fullPath, "utf-8"); + if (content === expectedContent) { + return; + } + lastError = new Error(`Unexpected content in ${fullPath}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for reflected vault file: ${fullPath}\nLast error: ${String(lastError)}`); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault: ${vault.path}`); + + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + + const created = await evalObsidianJson( + cli.binary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const content=${JSON.stringify(noteContent)};`, + "if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.delete(existing);", + "const file=await app.vault.create(path,content);", + "const read=await app.vault.read(file);", + "return JSON.stringify({path:file.path,read,exists:await app.vault.adapter.exists(path)});", + "})()", + ].join(""), + session.cliEnv + ); + + assertEqual(created.path, notePath, "Obsidian created the note at an unexpected path."); + assertEqual(created.exists, true, "Obsidian adapter did not report the created note."); + assertEqual(created.read, noteContent, "Obsidian did not read back the created note content."); + + await waitForFileContent(vault.path, notePath, noteContent); + + const readBack = await evalObsidianJson( + cli.binary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "return JSON.stringify({exists:!!file,read:file?await app.vault.read(file):null});", + "})()", + ].join(""), + session.cliEnv + ); + assertEqual(readBack.exists, true, "Obsidian did not find the reflected note on read-back."); + assertEqual(readBack.read, noteContent, "Obsidian read-back content did not match the reflected file."); + + console.log(`Created and verified reflected note: ${notePath}`); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +});