diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index 68da95e..a0787e4 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`, 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:hidden-file-snippet-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. @@ -141,6 +141,9 @@ Current verification: - `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: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: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: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. Known limits: @@ -164,6 +167,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. ### Phase 3: Two-Vault Synchronisation diff --git a/package.json b/package.json index 08c5ee6..e7c6651 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "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:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", + "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-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", "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 c201470..6593d98 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -47,10 +47,19 @@ 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 +npm run test:e2e:obsidian:startup-scan +npm run test:e2e:obsidian:hidden-file-snippet-sync +npm run test:e2e:obsidian:setting-markdown-export ``` `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. +`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: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: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: ```bash @@ -72,6 +81,7 @@ Useful environment variables: - `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_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database. - `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. diff --git a/test/e2e-obsidian/runner/cli.ts b/test/e2e-obsidian/runner/cli.ts index cc46be8..e38a874 100644 --- a/test/e2e-obsidian/runner/cli.ts +++ b/test/e2e-obsidian/runner/cli.ts @@ -74,7 +74,7 @@ export async function evalObsidianJson( env: NodeJS.ProcessEnv = process.env ): Promise { const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env); - if (result.code !== 0 || result.stdout.includes("Error:")) { + if (result.code !== 0) { throw new Error( [ `Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`, @@ -85,5 +85,18 @@ export async function evalObsidianJson( .join("\n") ); } - return parseEvalJson(result.stdout) as T; + 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") + ); + } } diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts new file mode 100644 index 0000000..b47c1cb --- /dev/null +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -0,0 +1,182 @@ +import { evalObsidianJson } from "./cli.ts"; +import type { CouchDbConfig } from "./couchdb.ts"; + +export type ConfiguredSettings = { + isConfigured: boolean; + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + couchDB_URI: string; + couchDB_DBNAME: string; +}; + +export type CoreReadiness = { + databaseReady: boolean; + appReady: boolean; +}; + +export type LocalDatabaseEntry = { + id: string; + rev: string; + path: string; + type: string; + children: string[]; +}; + +export function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +export async function configureCouchDb( + cliBinary: string, + env: NodeJS.ProcessEnv, + settings: Pick & { dbName: string }, + overrides: Record = {} +): 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,", + ...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`), + "};", + "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 + ); +} + +export 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)}`); +} + +export 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 + ); +} + +export 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 + ); +} + +export async function waitForLocalDatabaseEntry( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + options: { hidden?: boolean; timeoutMs?: number } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const hidden=${JSON.stringify(options.hidden === true)};`, + `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()false);", + "if(!entry||!entry._id){", + "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + "entry=rows.map((row)=>row.doc).find((doc)=>doc&&(", + "doc._id===dbPath||doc._id===path||doc.path===dbPath||doc.path===path||", + "(typeof doc.path==='string'&&doc.path.endsWith(path))||", + "(typeof doc._id==='string'&&doc._id.endsWith(path))", + "))||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: ${path}`);", + "return JSON.stringify({id:entry._id,rev:entry._rev,path:entry.path,type:entry.type,children:entry.children||[]});", + "})()", + ].join(""), + env + ); +} diff --git a/test/e2e-obsidian/runner/vault.ts b/test/e2e-obsidian/runner/vault.ts index 4f151b4..e24f2d6 100644 --- a/test/e2e-obsidian/runner/vault.ts +++ b/test/e2e-obsidian/runner/vault.ts @@ -13,11 +13,12 @@ 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(vaultPath, ".obsidian", "e2e-home"); - const xdgConfigPath = join(vaultPath, ".obsidian", "e2e-xdg-config"); - const userDataPath = join(vaultPath, ".obsidian", "e2e-user-data"); + const homePath = join(statePath, "home"); + const xdgConfigPath = join(statePath, "xdg-config"); + const userDataPath = join(statePath, "user-data"); await mkdir(homePath, { recursive: true }); await mkdir(xdgConfigPath, { recursive: true }); await mkdir(userDataPath, { recursive: true }); @@ -36,9 +37,13 @@ export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): P 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 rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + await Promise.all([ + rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), + rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), + ]); }, }; } diff --git a/test/e2e-obsidian/scripts/couchdb-upload.ts b/test/e2e-obsidian/scripts/couchdb-upload.ts index ed094d2..70a5368 100644 --- a/test/e2e-obsidian/scripts/couchdb-upload.ts +++ b/test/e2e-obsidian/scripts/couchdb-upload.ts @@ -8,30 +8,17 @@ import { waitForCouchDbDocs, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + configureCouchDb, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.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"; @@ -46,111 +33,6 @@ const noteContent = [ "", ].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, @@ -179,21 +61,6 @@ async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.Proces ); } -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(); diff --git a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts new file mode 100644 index 0000000..01be4fb --- /dev/null +++ b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts @@ -0,0 +1,541 @@ +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 snippetPath = ".obsidian/snippets/livesync-e2e.css"; +const snippetContent = [ + "body {", + " --livesync-e2e-snippet-colour: #245a70;", + "}", + "", + ".livesync-e2e-snippet {", + " color: var(--livesync-e2e-snippet-colour);", + "}", + "", +].join("\n"); + +const mergeJsonPath = ".obsidian/livesync-e2e-merge.json"; +const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json"; +const targetPath = ".obsidian/livesync-targeted/only-a.json"; + +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)}`); +} + +function hasJsonValues(content: string, values: Record): boolean { + try { + const parsed = JSON.parse(content) as Record; + return Object.entries(values).every(([key, value]) => parsed[key] === value); + } catch { + return false; + } +} + +async function scanHiddenStorage(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllStorageChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function scanHiddenDatabase(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllDatabaseChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.resolveConflictOnInternalFiles();", + "await addOn.scanAllDatabaseChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const prefixedPath=`i:${path}`;", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "let doc=false;", + "for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){", + " if(entry.path===prefixedPath){ doc=entry; break; }", + "}", + "if(!doc) throw new Error(`Could not find hidden conflict candidate: ${path}`);", + "if(!doc._conflicts?.length) throw new Error(`Hidden file has no conflicts: ${path}`);", + "const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));", + "const conflictedRev=conflicts[0];", + "const conflictedRevNo=Number(conflictedRev.split('-')[0]);", + "const revFrom=await core.localDatabase.getRaw(doc._id,{revs_info:true});", + "const commonBase=(revFrom._revs_info||[])", + " .filter((rev)=>rev.status==='available'&&Number(rev.rev.split('-')[0])rev.rev)[0]||'';", + "const result=await core.localDatabase.managers.conflictManager.mergeObject(", + " doc.path, commonBase, doc._rev, conflictedRev", + ");", + "if(!result){", + " throw new Error(`Hidden JSON conflict was not auto-mergeable: ${path}; base=${commonBase}; current=${doc._rev}; conflict=${conflictedRev}`);", + "}", + "await addOn.ensureDir(path);", + "const stat=await addOn.writeFile(path,result);", + "if(!stat) throw new Error(`Could not write merged hidden file: ${path}`);", + "await addOn.storeInternalFileToDatabase({path,mtime:stat.mtime,ctime:stat.ctime,size:stat.size},true);", + "await core.localDatabase.removeRevision(doc._id,conflictedRev);", + "await addOn.extractInternalFileFromDatabase(path);", + "await addOn.scanAllDatabaseChanges(true);", + "return JSON.stringify({ok:true,merged:JSON.parse(result)});", + "})()", + ].join(""), + env + ); +} + +async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const prefixedPath=`i:${path}`;", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "let doc=false;", + "for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){", + " if(entry.path===prefixedPath){ doc=entry; break; }", + "}", + "if(!doc?._conflicts?.length) throw new Error(`Could not find hidden JSON conflict: ${path}`);", + "const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));", + "const docA=await core.localDatabase.getDBEntry(prefixedPath,{rev:doc._rev});", + "const docB=await core.localDatabase.getDBEntry(prefixedPath,{rev:conflicts[0]});", + "if(docA===false||docB===false) throw new Error(`Could not load conflicted hidden JSON entries: ${path}`);", + "void addOn.showJSONMergeDialogAndMerge(docA,docB);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function clickJsonResolveOption(cliBinary: string, env: NodeJS.ProcessEnv, mode: "AB" | "BA"): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const mode=${JSON.stringify(mode)};`, + "const deadline=Date.now()+10000;", + "while(Date.now()candidate.value===mode);", + " const apply=[...document.querySelectorAll('button')].find((button)=>button.textContent?.trim()==='Apply');", + " if(input&&apply){", + " input.click();", + " input.dispatchEvent(new Event('change',{bubbles:true}));", + " await new Promise((resolve)=>setTimeout(resolve,100));", + " apply.click();", + " return JSON.stringify({ok:true});", + " }", + " await new Promise((resolve)=>setTimeout(resolve,250));", + "}", + "const buttons=[...document.querySelectorAll('button')].map((button)=>button.textContent?.trim()).filter(Boolean);", + "const inputs=[...document.querySelectorAll('input[name=\"disp\"]')].map((input)=>input.value);", + "throw new Error(`Timed out waiting for JSON resolve modal; buttons=${JSON.stringify(buttons)}; inputs=${JSON.stringify(inputs)}`);", + "})()", + ].join(""), + env + ); +} + +async function storeHiddenFileAsConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + baseRev: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const baseRev=${JSON.stringify(baseRev)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "const fileInfo=await addOn.loadFileWithInfo(path);", + "if(fileInfo.deleted) throw new Error(`Hidden file was unexpectedly deleted: ${path}`);", + "const baseData=await addOn.__loadBaseSaveData(path,true);", + "if(baseData===false) throw new Error(`Could not load base save data: ${path}`);", + "const saveData={", + " ...baseData,", + " data:fileInfo.body,", + " mtime:fileInfo.stat.mtime,", + " ctime:fileInfo.stat.ctime,", + " size:fileInfo.stat.size,", + " children:[],", + " deleted:false,", + " type:baseData.datatype,", + "};", + "const result=await core.localDatabase.putDBEntry(saveData,false,baseRev);", + "if(!result?.ok) throw new Error(`Could not store conflicted hidden file: ${path}`);", + "return JSON.stringify({ok:true,rev:result.rev});", + "})()", + ].join(""), + env + ); +} + +async function createHiddenJsonConflict( + context: RunnerContext, + session: ObsidianLiveSyncSession, + vault: TemporaryVault, + path: string, + base: string, + left: string, + right: string +): Promise { + await writeVaultFile(vault.path, path, base); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + const baseEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }); + + await writeVaultFile(vault.path, path, left); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }); + + await writeVaultFile(vault.path, path, right); + await storeHiddenFileAsConflict(context.cliBinary, session.cliEnv, path, baseEntry.rev); +} + +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, + }, + { + syncInternalFiles: true, + syncInternalFilesBeforeReplication: true, + watchInternalFileChanges: false, + syncInternalFilesTargetPatterns: "", + ...overrides, + } + ); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + return session; +} + +async function uploadHiddenFile( + context: RunnerContext, + session: ObsidianLiveSyncSession, + path: string +): Promise { + await scanHiddenStorage(context.cliBinary, session.cliEnv); + const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }); + 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 pullAndApplyHiddenFiles(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + await pushLocalChanges(context.cliBinary, session.cliEnv); + await resolveHiddenConflicts(context.cliBinary, session.cliEnv); + await scanHiddenDatabase(context.cliBinary, session.cliEnv); +} + +async function runCreateRoundTrip( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + await writeVaultFile(vaultA.path, snippetPath, snippetContent); + let session = await startConfiguredSession(context, vaultA); + const entry = await uploadHiddenFile(context, session, snippetPath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await pullAndApplyHiddenFiles(context, session); + const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent); + await session.app.stop(); + + assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault."); + console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`); +} + +async function runDeleteRoundTrip( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + await removeVaultFile(vaultA.path, snippetPath); + let session = await startConfiguredSession(context, vaultA); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB); + await pullAndApplyHiddenFiles(context, session); + await waitForPathDeleted(vaultB.path, snippetPath); + await session.app.stop(); + + console.log("Hidden delete round-trip removed the snippet from the second vault."); +} + +async function runJsonConflictRoundTrip( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const base = JSON.stringify({ base: true, fromA: false, fromB: false }, null, 4) + "\n"; + const left = JSON.stringify({ base: true, fromA: true, fromB: false }, null, 4) + "\n"; + const right = JSON.stringify({ base: true, fromA: false, fromB: true }, null, 4) + "\n"; + + let session = await startConfiguredSession(context, vaultB); + await createHiddenJsonConflict(context, session, vaultB, mergeJsonPath, base, left, right); + await autoMergeHiddenJsonConflict(context.cliBinary, session.cliEnv, mergeJsonPath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) => + hasJsonValues(content, { fromA: true, fromB: true }) + ); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultA); + await pullAndApplyHiddenFiles(context, session); + const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) => + hasJsonValues(content, { fromA: true, fromB: true }) + ); + await session.app.stop(); + + assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults."); + console.log("Hidden JSON conflict was automatically merged and round-tripped."); +} + +async function runJsonManualConflictResolution(context: RunnerContext, vault: TemporaryVault): Promise { + const base = JSON.stringify({ shared: "base" }, null, 4) + "\n"; + const left = JSON.stringify({ shared: "left", fromA: true }, null, 4) + "\n"; + const right = JSON.stringify({ shared: "right", fromB: true }, null, 4) + "\n"; + + const session = await startConfiguredSession(context, vault); + await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right); + await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath); + await clickJsonResolveOption(context.cliBinary, session.cliEnv, "AB"); + + const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) => + hasJsonValues(content, { shared: "right", fromA: true, fromB: true }) + ); + await session.app.stop(); + + const parsed = JSON.parse(merged); + assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result."); + assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value."); + assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value."); + console.log("Hidden JSON conflict modal applied the selected merged result."); +} + +async function runTargetMismatch( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n"; + await writeVaultFile(vaultA.path, targetPath, targetContent); + + let session = await startConfiguredSession(context, vaultA); + await uploadHiddenFile(context, session, targetPath); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, { + syncInternalFilesTargetPatterns: "snippets", + }); + await pullAndApplyHiddenFiles(context, session); + assertEqual( + await pathExists(vaultB.path, targetPath), + false, + "Hidden file was applied on a device where it was not a target file." + ); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, { + syncInternalFilesTargetPatterns: "", + }); + await pullAndApplyHiddenFiles(context, session); + const received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent); + await session.app.stop(); + + assertEqual(received, targetContent, "Hidden file was not applied after it became a target file."); + console.log("Hidden target mismatch respected per-device target patterns, then applied 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, "hidden-roundtrip"); + 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 runCreateRoundTrip(context, vaultA, vaultB); + await runDeleteRoundTrip(context, vaultA, vaultB); + await runJsonConflictRoundTrip(context, vaultA, vaultB); + await runJsonManualConflictResolution(context, 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); +}); diff --git a/test/e2e-obsidian/scripts/setting-markdown-export.ts b/test/e2e-obsidian/scripts/setting-markdown-export.ts new file mode 100644 index 0000000..440df8d --- /dev/null +++ b/test/e2e-obsidian/scripts/setting-markdown-export.ts @@ -0,0 +1,106 @@ +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 { assertEqual, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; + +const settingPath = "LiveSync/settings-export.md"; + +async function waitForFileContaining( + vaultPath: string, + path: string, + predicates: ((content: string) => boolean)[], + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const fullPath = join(vaultPath, path); + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastContent = await readFile(fullPath, "utf-8"); + if (predicates.every((predicate) => predicate(lastContent))) { + return lastContent; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for setting Markdown: ${fullPath}\nLast error: ${String(lastError)}`); +} + +async function configureSettingMarkdown(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyExternalSettings({", + `settingSyncFile:${JSON.stringify(settingPath)},`, + "writeCredentialsForSettingSync:false,", + "couchDB_USER:'e2e-user',", + "couchDB_PASSWORD:'e2e-password',", + "passphrase:'e2e-passphrase',", + "showVerboseLog:true,", + "},true);", + "await core.services.setting.saveSettingData();", + "return JSON.stringify({ok:true});", + "})()", + ].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 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), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + await configureSettingMarkdown(cli.binary, session.cliEnv); + const content = await waitForFileContaining(vault.path, settingPath, [ + (value) => value.includes("````yaml:livesync-setting"), + (value) => value.includes(`settingSyncFile: ${settingPath}`), + (value) => value.includes("showVerboseLog: true"), + ]); + + assertEqual( + content.includes("couchDB_PASSWORD: e2e-password"), + false, + "Credential leaked into setting Markdown." + ); + assertEqual(content.includes("passphrase: e2e-passphrase"), false, "Passphrase leaked into setting Markdown."); + + console.log(`Generated setting Markdown without credentials: ${settingPath}`); + } 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); +}); diff --git a/test/e2e-obsidian/scripts/startup-scan.ts b/test/e2e-obsidian/scripts/startup-scan.ts new file mode 100644 index 0000000..9aaa4de --- /dev/null +++ b/test/e2e-obsidian/scripts/startup-scan.ts @@ -0,0 +1,116 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + configureCouchDb, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; + +const notePath = "E2E/startup-scan.md"; +const noteContent = [ + "# Startup scan", + "", + "This note was written while Obsidian was stopped.", + "The test verifies that the next real Obsidian boot scans it into the local database.", + `Created at: ${new Date().toISOString()}`, + "", +].join("\n"); + +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 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, "startup-scan"); + 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, + }); + assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured."); + await prepareRemote(cli.binary, session.cliEnv); + await session.app.stop(); + session = undefined; + + await writeVaultFile(vault.path, notePath, noteContent); + + 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 localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath); + 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, "Startup-scanned remote metadata path did not match."); + + console.log(`Startup scan uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s).`); + } 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); +});