From 36590ee76208f6493a17ebbcb21ad8dcf51ca9a1 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 26 Jun 2026 10:55:06 +0000 Subject: [PATCH] (test): improve e2e CouchDB --- docs/adr/2026_06_real_obsidian_e2e.md | 5 +- package.json | 1 + test/e2e-obsidian/README.md | 15 +- test/e2e-obsidian/runner/couchdb.ts | 174 +++++++++++++ test/e2e-obsidian/scripts/couchdb-upload.ts | 273 ++++++++++++++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 test/e2e-obsidian/runner/couchdb.ts create mode 100644 test/e2e-obsidian/scripts/couchdb-upload.ts diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index e4621fb..68da95e 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -125,8 +125,9 @@ 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`, `test:e2e:obsidian:vault-reflection`, 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`, `test:e2e:obsidian:couchdb-upload`, 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 CouchDB runner utilities that reuse `.test.env`/process environment values, create unique temporary databases, query uploaded documents directly, and clean up the database unless `E2E_OBSIDIAN_KEEP_COUCHDB=true` is set. - 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`. @@ -139,6 +140,7 @@ Current verification: - `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:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents. - `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present. Known limits: @@ -161,6 +163,7 @@ This validates real boot-up, settings persistence, vault file access, database w 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. +- Added a first CouchDB-backed upload workflow, modelled after the CLI Deno tests: reuse the standard CouchDB environment variables, create a unique remote database, apply CouchDB settings through the plug-in's setting service, commit the note through the real Obsidian vault path, run one-shot synchronisation, and assert that remote metadata and chunks exist. ### Phase 3: Two-Vault Synchronisation diff --git a/package.json b/package.json index 1185c30..08c5ee6 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "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:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.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 1fbb2d4..c201470 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -11,7 +11,8 @@ The current smoke runner verifies only the launch path: 5. enable Obsidian community plug-ins for the temporary app profile, 6. reload Self-hosted LiveSync through `obsidian-cli`, 7. verify through `obsidian-cli eval` that the plug-in is loaded, -8. terminate Obsidian and remove the temporary vault. +8. optionally drive a real vault or CouchDB workflow through Obsidian's own API, +9. terminate Obsidian and remove the temporary vault. The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. Readiness is checked from outside the plug-in through Obsidian's own CLI. @@ -45,6 +46,15 @@ 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 +npm run test:e2e:obsidian:couchdb-upload +``` + +`test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. + +Start the local CouchDB fixture first when one is not already running: + +```bash +npm run test:docker-couchdb:start ``` Useful environment variables: @@ -61,6 +71,9 @@ Useful environment variables: - `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_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready. +- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents. +- `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection. - `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/couchdb.ts b/test/e2e-obsidian/runner/couchdb.ts new file mode 100644 index 0000000..753d422 --- /dev/null +++ b/test/e2e-obsidian/runner/couchdb.ts @@ -0,0 +1,174 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export type CouchDbConfig = { + uri: string; + username: string; + password: string; + dbPrefix: string; +}; + +export type CouchDbDocument = { + _id: string; + _rev?: string; + type?: string; + path?: string; + children?: string[]; + [key: string]: unknown; +}; + +export type CouchDbAllDocsResponse = { + rows: Array<{ + id: string; + key: string; + value: { rev: string; deleted?: boolean }; + doc?: CouchDbDocument; + }>; +}; + +function parseEnvFile(content: string): Record { + const entries = content + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => { + const equalsAt = line.indexOf("="); + if (equalsAt < 0) { + return undefined; + } + const key = line.slice(0, equalsAt).trim(); + const rawValue = line.slice(equalsAt + 1).trim(); + const value = rawValue.replace(/^['"]|['"]$/gu, ""); + return [key, value] as const; + }) + .filter((entry): entry is readonly [string, string] => entry !== undefined); + return Object.fromEntries(entries); +} + +function getEnvValue(values: Record, ...keys: string[]): string { + for (const key of keys) { + const value = values[key]?.trim(); + if (value) { + return value; + } + } + throw new Error(`Required CouchDB environment value is missing: ${keys.join(" or ")}`); +} + +function authHeader(config: Pick): string { + return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString("base64")}`; +} + +function databaseUrl(config: Pick, dbName: string, suffix = ""): string { + return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`; +} + +async function couchDbRequest( + config: Pick, + path: string, + init: RequestInit = {} +): Promise { + const response = await fetch(`${config.uri.replace(/\/+$/u, "")}${path}`, { + ...init, + headers: { + authorization: authHeader(config), + ...init.headers, + }, + }); + return response; +} + +export async function loadCouchDbConfig(envFile = ".test.env"): Promise { + let fileValues: Record = {}; + try { + fileValues = parseEnvFile(await readFile(resolve(envFile), "utf-8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + const values = { ...fileValues, ...process.env }; + return { + uri: getEnvValue(values, "COUCHDB_URI", "hostname").replace(/\/+$/u, ""), + username: getEnvValue(values, "COUCHDB_USER", "username"), + password: getEnvValue(values, "COUCHDB_PASSWORD", "password"), + dbPrefix: getEnvValue(values, "COUCHDB_DBNAME", "dbname"), + }; +} + +export function makeUniqueDatabaseName(prefix: string, label: string): string { + const safePrefix = prefix + .toLowerCase() + .replace(/[^a-z0-9_$()+/-]+/gu, "-") + .replace(/^-+/u, "") + .slice(0, 80); + const random = Math.random().toString(36).slice(2, 8); + return `${safePrefix || "livesync-e2e"}-${label}-${Date.now()}-${random}`; +} + +export async function assertCouchDbReachable(config: CouchDbConfig): Promise { + const response = await couchDbRequest(config, "/_up"); + if (!response.ok) { + throw new Error(`CouchDB is not reachable at ${config.uri}. HTTP ${response.status}: ${await response.text()}`); + } +} + +export async function createCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + method: "PUT", + headers: { authorization: authHeader(config) }, + }); + if (!response.ok && response.status !== 412) { + throw new Error( + `Failed to create CouchDB database ${dbName}. HTTP ${response.status}: ${await response.text()}` + ); + } +} + +export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + method: "DELETE", + headers: { authorization: authHeader(config) }, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to delete CouchDB database ${dbName}. HTTP ${response.status}: ${await response.text()}` + ); + } +} + +export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error( + `Failed to read CouchDB documents from ${dbName}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbAllDocsResponse; +} + +export async function waitForCouchDbDocs( + config: CouchDbConfig, + dbName: string, + predicate: (docs: CouchDbDocument[]) => boolean, + timeoutMs = Number(process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ?? 15000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastDocs: CouchDbDocument[] = []; + while (Date.now() < deadline) { + const response = await fetchAllCouchDbDocs(config, dbName); + lastDocs = response.rows.flatMap((row) => (row.doc ? [row.doc] : [])); + if (predicate(lastDocs)) { + return lastDocs; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for CouchDB documents in ${dbName}. Last document IDs: ${lastDocs + .map((doc) => doc._id) + .join(", ")}` + ); +} diff --git a/test/e2e-obsidian/scripts/couchdb-upload.ts b/test/e2e-obsidian/scripts/couchdb-upload.ts new file mode 100644 index 0000000..ed094d2 --- /dev/null +++ b/test/e2e-obsidian/scripts/couchdb-upload.ts @@ -0,0 +1,273 @@ +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +type ConfiguredSettings = { + isConfigured: boolean; + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + couchDB_URI: string; + couchDB_DBNAME: string; +}; + +type LocalDatabaseEntry = { + id: string; + path: string; + type: string; + children: string[]; +}; + +type CoreReadiness = { + databaseReady: boolean; + appReady: boolean; +}; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; + +const notePath = "E2E/couchdb-upload.md"; +const noteContent = [ + "# CouchDB upload from real Obsidian", + "", + "This note is created through Obsidian and uploaded by Self-hosted LiveSync.", + "The content is intentionally long enough to require chunk metadata in the local database.", + "0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz", + "0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz", + `Created at: ${new Date().toISOString()}`, + "", +].join("\n"); + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +async function configureCouchDb( + cliBinary: string, + env: NodeJS.ProcessEnv, + settings: { + uri: string; + username: string; + password: string; + dbName: string; + } +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const core=plugin.core;", + "const nextSettings={", + `couchDB_URI:${JSON.stringify(settings.uri)},`, + `couchDB_USER:${JSON.stringify(settings.username)},`, + `couchDB_PASSWORD:${JSON.stringify(settings.password)},`, + `couchDB_DBNAME:${JSON.stringify(settings.dbName)},`, + "remoteType:'',", + "liveSync:false,", + "syncOnStart:false,", + "syncOnSave:false,", + "usePluginSync:false,", + "usePluginSyncV2:true,", + "useEden:false,", + "customChunkSize:1,", + "sendChunksBulkMaxSize:1,", + "chunkSplitterVersion:'v3-rabin-karp',", + "readChunksOnline:false,", + "disableCheckingConfigMismatch:true,", + "isConfigured:true,", + "};", + "await core.services.setting.applyExternalSettings(nextSettings,true);", + "await core.services.control.applySettings();", + "const current=core.services.setting.currentSettings();", + "return JSON.stringify({", + "isConfigured:current.isConfigured,", + "liveSync:current.liveSync,", + "syncOnStart:current.syncOnStart,", + "syncOnSave:current.syncOnSave,", + "couchDB_URI:current.couchDB_URI,", + "couchDB_DBNAME:current.couchDB_DBNAME,", + "});", + "})()", + ].join(""), + env + ); +} + +async function waitForLiveSyncCoreReady( + cliBinary: string, + env: NodeJS.ProcessEnv, + timeoutMs = Number(process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ?? 20000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastReadiness: CoreReadiness | undefined; + while (Date.now() < deadline) { + lastReadiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "return JSON.stringify({", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "});", + "})()", + ].join(""), + env + ); + if (lastReadiness.databaseReady && lastReadiness.appReady) { + return lastReadiness; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`); +} + +async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "await replicator.tryCreateRemoteDatabase(settings);", + "await replicator.markRemoteResolved(settings);", + "const status=await replicator.getRemoteStatus(settings);", + "return JSON.stringify({status});", + "})()", + ].join(""), + env + ); +} + +async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const content=${JSON.stringify(noteContent)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "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);", + "await app.vault.create(path,content);", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let entry=false;", + "for(let i=0;i<40;i++){", + "await core.services.fileProcessing.commitPendingFileEvents();", + "entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);", + "if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;", + "await sleep(250);", + "}", + "if(!entry||!entry._id) throw new Error('Timed out waiting for local database entry');", + "return JSON.stringify({id:entry._id,path:entry.path,type:entry.type,children:entry.children||[]});", + "})()", + ].join(""), + env + ); +} + +async function pushLocalChanges(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.fileProcessing.commitPendingFileEvents();", + "const result=await core.services.replication.replicate(true);", + "return JSON.stringify({result:!!result});", + "})()", + ].join(""), + env + ); +} + +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 couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload"); + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault: ${vault.path}`); + console.log(`Temporary CouchDB database: ${dbName}`); + + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + const configured = await configureCouchDb(cli.binary, session.cliEnv, { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured."); + assertEqual(configured.couchDB_URI, couchDb.uri, "Configured CouchDB URI did not match."); + assertEqual(configured.couchDB_DBNAME, dbName, "Configured CouchDB database name did not match."); + assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow."); + assertEqual(configured.syncOnStart, false, "Sync on start should remain disabled during this workflow."); + assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow."); + + await prepareRemote(cli.binary, session.cliEnv); + const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); + await pushLocalChanges(cli.binary, session.cliEnv); + + const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(localEntry.id) && localEntry.children.every((childId) => ids.has(childId)); + }); + const remoteMetadata = remoteDocs.find((doc) => doc._id === localEntry.id); + assertEqual( + remoteMetadata?.path, + localEntry.path, + "Remote metadata path did not match the local database entry." + ); + + console.log( + `Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}` + ); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +});