diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index a0787e4..eefe4e3 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -125,7 +125,7 @@ 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`, `test:e2e:obsidian:couchdb-upload`, `test:e2e:obsidian:startup-scan`, `test:e2e:obsidian:hidden-file-snippet-sync`, `test:e2e:obsidian:setting-markdown-export`, 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`, `test:e2e:obsidian:startup-scan`, `test:e2e:obsidian:two-vault-sync`, `test:e2e:obsidian:hidden-file-snippet-sync`, `test:e2e:obsidian:customisation-sync`, `test:e2e:obsidian:setting-markdown-export`, 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. @@ -142,7 +142,9 @@ Current verification: - `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:startup-scan` verifies that a file written while Obsidian is stopped is picked up during the next real Obsidian boot and uploaded to CouchDB after one-shot synchronisation. +- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, deletion, Markdown conflict automatic merging with the merged result propagated by a second synchronisation, and per-device target-filter differences. - `npm run test:e2e:obsidian:hidden-file-snippet-sync` verifies hidden file synchronisation as a two-vault round-trip: creation, deletion, automatic JSON conflict merging with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target-pattern differences. +- `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync snippet workflow: scan a real snippet CSS file into per-file Customisation Sync data, synchronise it through CouchDB, apply it on the second vault, and assert the resulting `.obsidian/snippets/*.css` file. - `npm run test:e2e:obsidian:setting-markdown-export` verifies that setting Markdown export creates a vault file and omits credentials when credential export is disabled. - `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present. @@ -167,7 +169,7 @@ 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. -- Added Obsidian-specific workflows for boot-time vault scanning, hidden `.obsidian/snippets` file round-tripping, hidden JSON conflict resolution, per-device hidden target-pattern differences, and setting Markdown export. These scenarios assert against CouchDB documents, vault files, or real Obsidian UI outcomes instead of internal service state. +- Added Obsidian-specific workflows for boot-time vault scanning, two-vault note synchronisation, hidden `.obsidian/snippets` file round-tripping, hidden JSON conflict resolution, Customisation Sync snippet application, per-device target-filter differences, and setting Markdown export. These scenarios assert against CouchDB documents, vault files, or real Obsidian UI outcomes instead of internal service state. ### Phase 3: Two-Vault Synchronisation diff --git a/package.json b/package.json index e7c6651..682b101 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "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:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", + "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts", + "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", "test:coverage": "vitest run --coverage", "test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 6593d98..6bf895d 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -48,7 +48,9 @@ npm run test:e2e:obsidian:smoke npm run test:e2e:obsidian:vault-reflection npm run test:e2e:obsidian:couchdb-upload npm run test:e2e:obsidian:startup-scan +npm run test:e2e:obsidian:two-vault-sync npm run test:e2e:obsidian:hidden-file-snippet-sync +npm run test:e2e:obsidian:customisation-sync npm run test:e2e:obsidian:setting-markdown-export ``` @@ -56,8 +58,12 @@ npm run test:e2e:obsidian:setting-markdown-export `test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. +`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, deletion, Markdown conflict automatic merging with the merged result propagated by a second synchronisation, and per-device target filters where one vault ignores a note that the other vault synchronises. + `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. +`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file into per-file Customisation Sync data, synchronises the entry through CouchDB, applies it on the second vault, and verifies the resulting `.obsidian/snippets/*.css` file. + `test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`. Start the local CouchDB fixture first when one is not already running: diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts new file mode 100644 index 0000000..03c086c --- /dev/null +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -0,0 +1,326 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + configureCouchDb, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; + +const snippetPath = ".obsidian/snippets/livesync-customisation-e2e.css"; +const snippetContent = [ + "body {", + " --livesync-customisation-e2e-colour: #3d6f54;", + "}", + "", + ".livesync-customisation-e2e {", + " color: var(--livesync-customisation-e2e-colour);", + "}", + "", +].join("\n"); + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; +}; + +type CustomisationEntry = { + id: string; + path: string; + children: string[]; +}; + +type CustomisationScanResult = { + enabled: boolean; + useV2: boolean; + device: string; + configDir: string; + files: string[]; +}; + +async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { + const fullPath = join(vaultPath, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf-8"); +} + +async function readVaultFile(vaultPath: string, path: string): Promise { + return await readFile(join(vaultPath, path), "utf-8"); +} + +async function pathExists(vaultPath: string, path: string): Promise { + try { + await readFile(join(vaultPath, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function waitForPathContent( + vaultPath: string, + path: string, + predicate: (content: string) => boolean, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + if (await pathExists(vaultPath, path)) { + lastContent = await readVaultFile(vaultPath, path); + if (predicate(lastContent)) { + return lastContent; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function startConfiguredSession( + context: RunnerContext, + vault: TemporaryVault, + deviceName: string +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }, + { + deviceAndVaultName: deviceName, + usePluginSync: true, + usePluginSyncV2: true, + autoSweepPlugins: false, + autoSweepPluginsPeriodic: false, + syncInternalFiles: false, + } + ); + await evalObsidianJson( + context.cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `core.services.setting.setDeviceAndVaultName(${JSON.stringify(deviceName)});`, + "await core.services.setting.saveSettingData();", + "return JSON.stringify({device:core.services.setting.getDeviceAndVaultName()});", + "})()", + ].join(""), + session.cliEnv + ); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + return session; +} + +async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('ConfigSync');", + "const before=await addOn.scanInternalFiles();", + "await addOn.scanAllConfigFiles(false);", + "return JSON.stringify({", + "ok:true,", + "enabled:core.settings.usePluginSync,", + "useV2:core.settings.usePluginSyncV2,", + "device:core.services.setting.getDeviceAndVaultName(),", + "configDir:addOn.configDir,", + "files:before,", + "});", + "})()", + ].join(""), + env + ); +} + +async function storeCustomisationSnippet(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('ConfigSync');", + "const term=core.services.setting.getDeviceAndVaultName();", + "const stat=await core.storageAccess.statHidden(path);", + "const category=addOn.getFileCategory(path);", + "const result=await addOn.storeCustomizationFiles(path,term);", + "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + "const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>doc.path);", + "if(!result){", + " throw new Error(`Could not store Customisation Sync snippet: path=${path}; term=${term}; category=${category}; stat=${JSON.stringify(stat)}; result=${JSON.stringify(result)}; entries=${JSON.stringify(entries)}`);", + "}", + "return JSON.stringify({ok:true,path,term,category,entries});", + "})()", + ].join(""), + env + ); +} + +async function waitForCustomisationEntry( + cliBinary: string, + env: NodeJS.ProcessEnv, + filename: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const filename=${JSON.stringify(filename)};`, + `const timeoutMs=${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let entry=false;", + "while(Date.now()row.doc).find((doc)=>doc?.path?.includes('/SNIPPET/')&&doc.path?.endsWith(`%${filename}`))||false;", + " if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;", + " await sleep(250);", + "}", + "if(!entry||!entry._id){", + " const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + " const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>({id:doc._id,path:doc.path,children:doc.children?.length??0}));", + " throw new Error(`Timed out waiting for customisation sync entry: ${filename}; entries=${JSON.stringify(entries)}`);", + "}", + "return JSON.stringify({id:entry._id,path:entry.path,children:entry.children||[]});", + "})()", + ].join(""), + env + ); +} + +async function applyRemoteCustomisationSnippet( + cliBinary: string, + env: NodeJS.ProcessEnv, + filename: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const filename=${JSON.stringify(filename)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('ConfigSync');", + "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + "const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes('/SNIPPET/')&&doc.path?.endsWith(`%${filename}`))||false;", + "if(!entry) throw new Error(`Could not find remote customisation entry: ${filename}`);", + "const display=addOn.createPluginDataFromV2(entry.path);", + "if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entry.path}`);", + "const file=await addOn.createPluginDataExFileV2(entry.path);", + "if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);", + "await display.setFile(file);", + "if(!(await addOn.applyDataV2(display))){", + " throw new Error(`Could not apply Customisation Sync entry: ${entry.path}`);", + "}", + "return JSON.stringify({ok:true,path:entry.path});", + "})()", + ].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, "customisation-sync"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; + const snippetPathParts = snippetPath.split("/"); + const snippetName = snippetPathParts[snippetPathParts.length - 1] ?? snippetPath; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault A: ${vaultA.path}`); + console.log(`Temporary vault B: ${vaultB.path}`); + console.log(`Temporary CouchDB database: ${dbName}`); + + await writeVaultFile(vaultA.path, snippetPath, snippetContent); + + let session = await startConfiguredSession(context, vaultA, "customisation-sync-a"); + const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv); + console.log(`Customisation scan files: ${scanResult.files.join(", ") || "(none)"}`); + await storeCustomisationSnippet(context.cliBinary, session.cliEnv, snippetPath); + const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, snippetName); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, "customisation-sync-b"); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForCustomisationEntry(context.cliBinary, session.cliEnv, snippetName); + assertEqual( + await pathExists(vaultB.path, snippetPath), + false, + "Customisation Sync snippet was reflected before explicit application." + ); + await applyRemoteCustomisationSnippet(context.cliBinary, session.cliEnv, snippetName); + const applied = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent); + await session.app.stop(); + + assertEqual(applied, snippetContent, "Customisation Sync snippet content did not match after application."); + console.log(`Customisation Sync applied snippet ${snippetName} from the remote database.`); + } finally { + await vaultA.dispose(); + await vaultB.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); +}); diff --git a/test/e2e-obsidian/scripts/two-vault-sync.ts b/test/e2e-obsidian/scripts/two-vault-sync.ts new file mode 100644 index 0000000..4708682 --- /dev/null +++ b/test/e2e-obsidian/scripts/two-vault-sync.ts @@ -0,0 +1,438 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + configureCouchDb, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; + +const createPath = "E2E/two-vault/create.md"; +const updatePath = "E2E/two-vault/update.md"; +const deletePath = "E2E/two-vault/delete.md"; +const conflictPath = "E2E/two-vault/conflict.md"; +const targetMismatchPath = "E2E/two-vault/target-mismatch.md"; + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; +}; + +async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { + const fullPath = join(vaultPath, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf-8"); +} + +async function removeVaultFile(vaultPath: string, path: string): Promise { + await rm(join(vaultPath, path), { force: true }); +} + +async function readVaultFile(vaultPath: string, path: string): Promise { + return await readFile(join(vaultPath, path), "utf-8"); +} + +async function pathExists(vaultPath: string, path: string): Promise { + try { + await readFile(join(vaultPath, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function waitForPathContent( + vaultPath: string, + path: string, + predicate: (content: string) => boolean, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + if (await pathExists(vaultPath, path)) { + lastContent = await readVaultFile(vaultPath, path); + if (predicate(lastContent)) { + return lastContent; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function waitForPathDeleted( + vaultPath: string, + path: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await pathExists(vaultPath, path))) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`); +} + +async function writeNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string) { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function deleteNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string) { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.delete(existing);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function startConfiguredSession( + context: RunnerContext, + vault: TemporaryVault, + overrides: Record = {} +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }, + overrides + ); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + return session; +} + +async function uploadNote( + context: RunnerContext, + session: ObsidianLiveSyncSession, + path: string +): Promise { + const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); + return entry; +} + +async function syncAndApply(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + await pushLocalChanges(context.cliBinary, session.cliEnv); +} + +async function storeFileRevision( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + content: string, + baseRev?: string +): Promise { + const result = await evalObsidianJson<{ rev: string }>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + `const baseRev=${JSON.stringify(baseRev ?? "")};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const blob=new Blob([content],{type:'text/plain'});", + "const id=await core.services.path.path2id(path);", + "const now=Date.now();", + "const result=await core.localDatabase.putDBEntry({", + " _id:id,", + " path,", + " data:blob,", + " ctime:now,", + " mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,", + " children:[],", + " datatype:'plain',", + " type:'plain',", + " eden:{},", + "},false,baseRev||undefined);", + "if(!result?.ok) throw new Error(`Could not store file revision: ${path}`);", + "return JSON.stringify({ok:true,rev:result.rev});", + "})()", + ].join(""), + env + ); + return result.rev; +} + +async function createMarkdownConflict( + context: RunnerContext, + session: ObsidianLiveSyncSession, + vault: TemporaryVault, + path: string, + base: string, + left: string, + right: string +): Promise { + const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev); + await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev); + await writeVaultFile(vault.path, path, right); +} + +async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const result=await core.localDatabase.managers.conflictManager.tryAutoMerge(path,true);", + "if(!('result' in result)){", + " throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);", + "}", + "if(!(await core.databaseFileAccess.storeContent(path,result.result))){", + " throw new Error(`Could not store merged Markdown content: ${path}`);", + "}", + "if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){", + " throw new Error(`Could not delete conflicted revision: ${path}`);", + "}", + "if(!(await core.fileHandler.dbToStorage(path,path,true))){", + " throw new Error(`Could not reflect merged Markdown content: ${path}`);", + "}", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function runCreateUpdateDelete( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const createdContent = "# Created on A\n\nThis note should appear on B.\n"; + let session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent); + await uploadNote(context, session, createPath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent); + await session.app.stop(); + assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault."); + + const initialUpdateContent = "# Update target\n\nInitial content.\n"; + const updatedContent = "# Update target\n\nUpdated content from A.\n"; + session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, initialUpdateContent); + await uploadNote(context, session, updatePath); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent); + await uploadNote(context, session, updatePath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent); + await session.app.stop(); + assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault."); + + const deleteContent = "# Delete target\n\nThis note should be removed from B.\n"; + session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent); + await uploadNote(context, session, deletePath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultA); + await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + await waitForPathDeleted(vaultB.path, deletePath); + await session.app.stop(); + + console.log("Two-vault note creation, update, and deletion round-tripped."); +} + +async function runMarkdownAutoMerge( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const base = "# Conflict\n\nBase line\n\nShared tail\n"; + const left = "# Conflict\n\nLeft line\n\nShared tail\n"; + const right = "# Conflict\n\nBase line\n\nRight tail\n"; + + let session = await startConfiguredSession(context, vaultB); + await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right); + await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const mergedOnB = await waitForPathContent( + vaultB.path, + conflictPath, + (content) => content.includes("Left line") && content.includes("Right tail") + ); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultA); + await syncAndApply(context, session); + const mergedOnA = await waitForPathContent( + vaultA.path, + conflictPath, + (content) => content.includes("Left line") && content.includes("Right tail") + ); + await session.app.stop(); + + assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults."); + console.log("Markdown conflict was automatically merged and propagated by the next synchronisation."); +} + +async function runTargetMismatch( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const ignoredContent = "# Target mismatch\n\nB should ignore this revision.\n"; + const acceptedContent = "# Target mismatch\n\nB should accept this revision after its target filter changes.\n"; + + let session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent); + await uploadNote(context, session, targetMismatchPath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, { + syncOnlyRegEx: "^E2E/two-vault/allowed/.*", + }); + await syncAndApply(context, session); + assertEqual( + await pathExists(vaultB.path, targetMismatchPath), + false, + "A note was reflected on a device where it was not a target file." + ); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent); + await uploadNote(context, session, targetMismatchPath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, { + syncOnlyRegEx: "", + }); + await syncAndApply(context, session); + const received = await waitForPathContent( + vaultB.path, + targetMismatchPath, + (content) => content === acceptedContent + ); + await session.app.stop(); + + assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path."); + console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target."); +} + +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, "two-vault-sync"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault A: ${vaultA.path}`); + console.log(`Temporary vault B: ${vaultB.path}`); + console.log(`Temporary CouchDB database: ${dbName}`); + + await runCreateUpdateDelete(context, vaultA, vaultB); + await runMarkdownAutoMerge(context, vaultA, vaultB); + await runTargetMismatch(context, vaultA, vaultB); + } finally { + await vaultA.dispose(); + await vaultB.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); +});