import { readFile } from "node:fs/promises"; import { 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, createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; const observerKey = "__livesyncE2eSyncGuard"; const minimumFiles = 3; const notes = Array.from({ length: 6 }, (_, index) => ({ path: `E2E/sync-guard/incoming-${index}.md`, content: [ `# Incoming SyncGuard note ${index}`, "", ...Array.from( { length: 6 }, (_, paragraph) => `Paragraph ${paragraph} of note ${index} travels through real CouchDB Metadata and Chunks.` ), "", ].join("\n"), })); const workspacePath = "local-workspace.md"; const workspaceContent = "# Local workspace\n\nLocal work can continue after bypass.\n"; const workspaceEdit = "Written after bypass.\n"; type GuardObservation = { phase: string; observedPhases: string[]; finiteActivity: number; forwardedIds: string[]; transferStoppedAt: number | null; reflectionHeld: boolean; storageApplying: number; parsing: number; reflecting: number; startError: string | null; visible: boolean; stage: string; count: string; }; async function readObservation(cliBinary: string, env: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( cliBinary, [ "(()=>{", `const state=globalThis[${JSON.stringify(observerKey)}];`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "const overlay=document.querySelector('.sls-sync-guard');", "return JSON.stringify({", "phase:core.services.replicator.replicationStatics.value.couchDB?.phase??'idle',", "observedPhases:state.observedPhases,finiteActivity:core.services.replicator.finiteReplicationActivityCount.value,", "forwardedIds:[...state.forwardedIds],transferStoppedAt:state.transferStoppedAt,", "reflectionHeld:state.reflectionHeld,storageApplying:core.services.replication.storageApplyingCount.value,", "parsing:state.parsing,reflecting:state.reflecting,startError:state.startError,", "visible:!!overlay&&overlay.getBoundingClientRect().width>0,", "stage:overlay?.querySelector('.sls-sync-guard-stage')?.textContent??'',", "count:overlay?.querySelector('.sls-sync-guard-count')?.textContent??'',", "});})()", ].join(""), env ); } function assertCatchUpPending(state: GuardObservation): void { assertEqual(state.observedPhases.includes("catch-up"), true, "The provider never entered CouchDB catch-up."); assertEqual( state.observedPhases.includes("continuous"), false, "Catch-up completed before the held receive callback." ); assertEqual(state.finiteActivity > 0, true, "The held receive callback lost its finite replication activity."); assertEqual(state.parsing > 0, true, "The real receive callback was no longer pending."); assertEqual( state.phase === "catch-up" || state.phase === "idle", true, `Unexpected phase while the finite catch-up still owns its receive callback: ${state.phase}` ); } async function waitForObservation( cliBinary: string, env: NodeJS.ProcessEnv, description: string, predicate: (state: GuardObservation) => boolean ): Promise { const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000); let state: GuardObservation; do { state = await readObservation(cliBinary, env); if (state.startError) throw new Error(`Continuous replication failed: ${state.startError}`); if (predicate(state)) return state; await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); throw new Error(`Timed out waiting for ${description}: ${JSON.stringify(state)}`); } async function installGates( cliBinary: string, env: NodeJS.ProcessEnv, incomingEntries: LocalDatabaseEntry[] ): Promise { await evalObsidianJson( cliBinary, [ "(()=>{", `const key=${JSON.stringify(observerKey)};`, "if(globalThis[key]) throw new Error('SyncGuard gates are already installed');", "const services=app.plugins.plugins['obsidian-livesync'].core.services;", "const replication=services.replication;", `const incomingIds=new Set(${JSON.stringify(incomingEntries.map((entry) => entry.id))});`, `const heldPath=${JSON.stringify(notes[0].path)};`, `const minimumFiles=${minimumFiles};`, "const state={forwardedIds:new Set(),transferStoppedAt:null,reflectionHeld:false,", "parsing:0,reflecting:0,startError:null,releases:[],restore:null,observedPhases:[]};", "const observePhase=()=>{const phase=services.replicator.replicationStatics.value.couchDB?.phase??'idle';", "if(state.observedPhases.at(-1)!==phase) state.observedPhases.push(phase);};", "services.replicator.replicationStatics.onChanged(observePhase);observePhase();", "const gate=()=>{let release;const promise=new Promise((resolve)=>{release=resolve;});", "state.releases.push(release);return {promise,release};};", "const below=gate(),threshold=gate(),reflection=gate();", "state.releaseBelow=below.release;state.releaseThreshold=threshold.release;state.releaseReflection=reflection.release;", "const originalParse=replication.parseSynchroniseResult;", "const originalReflect=replication.processSynchroniseResult;", // Forward only documents supplied by the real pull. Pausing its awaited // callback retains the provider's own catch-up lifetime and counters. "const parse=Object.assign(async(documents)=>{", "state.parsing++;try{let accepted=true;for(const document of documents){", "accepted=(await originalParse([document]))&&accepted;", "if(!incomingIds.has(document._id)||state.forwardedIds.has(document._id)) continue;", "state.forwardedIds.add(document._id);", "if(state.forwardedIds.size===minimumFiles-1){state.transferStoppedAt=minimumFiles-1;await below.promise;state.transferStoppedAt=null;}", "if(state.forwardedIds.size===minimumFiles){state.transferStoppedAt=minimumFiles;await threshold.promise;state.transferStoppedAt=null;}", "}return accepted;}finally{state.parsing--;}", "},originalParse);", // Result processing has already incremented storageApplyingCount when // this real reflection enters. The test never assigns queue counts. "const reflect=Object.assign(async(entry)=>{", "state.reflecting++;try{if(entry.path===heldPath){state.reflectionHeld=true;await reflection.promise;state.reflectionHeld=false;}", "return await originalReflect(entry);}finally{state.reflecting--;}", "},originalReflect);", "replication.parseSynchroniseResult=parse;replication.processSynchroniseResult=reflect;", "state.restore=()=>{", "services.replicator.replicationStatics.offChanged(observePhase);", "if(replication.parseSynchroniseResult===parse) replication.parseSynchroniseResult=originalParse;", "if(replication.processSynchroniseResult===reflect) replication.processSynchroniseResult=originalReflect;", "for(const release of state.releases) release();};", "globalThis[key]=state;return JSON.stringify(true);})()", ].join(""), env ); } async function releaseGate( cliBinary: string, env: NodeJS.ProcessEnv, gate: "Below" | "Threshold" | "Reflection" ): Promise { await evalObsidianJson( cliBinary, `(()=>{globalThis[${JSON.stringify(observerKey)}].release${gate}();return JSON.stringify(true);})()`, env ); } async function removeGates(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, `(()=>{globalThis[${JSON.stringify(observerKey)}]?.restore();return JSON.stringify(true);})()`, env ); await waitForObservation( cliBinary, env, "released receive and reflection hooks", (state) => state.parsing === 0 && state.reflecting === 0 ); await evalObsidianJson( cliBinary, `(()=>{delete globalThis[${JSON.stringify(observerKey)}];return JSON.stringify(true);})()`, env ); } async function startCatchUp(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.setting.setSyncGuardSetting('syncGuardEnabled',true);", `await core.services.setting.setSyncGuardSetting('syncGuardMinimumFiles',${minimumFiles});`, `const state=globalThis[${JSON.stringify(observerKey)}];`, "state.startTask=(async()=>{", "await core.services.setting.applyExternalSettings({liveSync:true},true);", "await core.services.control.applySettings();", "})().catch((error)=>{state.startError=String(error);});", "return JSON.stringify(core.services.setting.syncGuardSettings);})()", ].join(""), env ); } async function assertVaultPathMissing(vault: TemporaryVault, path: string): Promise { try { await readFile(join(vault.path, path), "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } throw new Error(`The held reflection wrote its Vault note before release: ${path}`); } async function waitForVaultContent(vault: TemporaryVault, path: string, expected: string): Promise { const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000); let actual: string | null = null; do { try { actual = await readFile(join(vault.path, path), "utf8"); if (actual === expected) return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } await new Promise((resolve) => setTimeout(resolve, 100)); } while (Date.now() < deadline); throw new Error(`Vault content did not converge for ${path}: ${JSON.stringify(actual)}`); } async function assertIncomingContents(cliBinary: string, env: NodeJS.ProcessEnv, vault: TemporaryVault): Promise { for (const note of notes) await waitForVaultContent(vault, note.path, note.content); const contents = await evalObsidianJson( cliBinary, `(async()=>{const paths=${JSON.stringify(notes.map((note) => note.path))};return JSON.stringify(await Promise.all(paths.map(async(path)=>{const file=app.vault.getAbstractFileByPath(path);return file?await app.vault.read(file):null;})));})()`, env ); notes.forEach((note, index) => assertEqual(contents[index], note.content, `Obsidian read back incorrect content at ${note.path}.`) ); } async function createWorkspaceNote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, `(async()=>{const file=await app.vault.create(${JSON.stringify(workspacePath)},${JSON.stringify(workspaceContent)});const leaf=app.workspace.getLeaf(false);await leaf.openFile(file,{state:{mode:'source'}});leaf.view.editor.focus();return JSON.stringify(true);})()`, env ); } async function runTarget( binary: string, cliBinary: string, couchDb: CouchDbConfig, dbName: string, incomingEntries: LocalDatabaseEntry[], bypass: boolean ): Promise { const label = bypass ? "bypass" : "automatic"; const vault = await createTemporaryVault(`obsidian-livesync-sync-guard-${label}-`); let session: ObsidianLiveSyncSession | undefined; try { session = await startObsidianLiveSyncSession({ binary, cliBinary, vault, pluginData: createE2eCouchDbPluginData({ ...couchDb, dbName }), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); const { cliEnv } = session; await waitForLiveSyncCoreReady(cliBinary, cliEnv); await prepareRemote(cliBinary, cliEnv); if (bypass) await createWorkspaceNote(cliBinary, cliEnv); await installGates(cliBinary, cliEnv, incomingEntries); await startCatchUp(cliBinary, cliEnv); const below = await waitForObservation( cliBinary, cliEnv, "real incoming notes below the threshold", (state) => state.transferStoppedAt === minimumFiles - 1 ); assertCatchUpPending(below); assertEqual(below.forwardedIds.length, minimumFiles - 1, "Unexpected incoming note count below the threshold."); assertEqual(below.visible, false, "SyncGuard blocked the workspace before the configured threshold."); await releaseGate(cliBinary, cliEnv, "Below"); const threshold = await waitForObservation( cliBinary, cliEnv, "SyncGuard at the configured threshold", (state) => state.transferStoppedAt === minimumFiles && state.visible ); assertCatchUpPending(threshold); assertEqual( threshold.forwardedIds.length, minimumFiles, "SyncGuard did not activate at the configured threshold." ); assertEqual( threshold.count, `At least ${minimumFiles} changed files received`, "SyncGuard showed an incorrect received-file count." ); assertEqual(threshold.stage, "Receiving changes from the server…", "SyncGuard did not show transfer progress."); console.log( await captureObsidianPage( session.remoteDebuggingPort, `sync-guard-${label}-threshold.png`, async (page) => { await page .getByRole("button", { name: "Continue without waiting", exact: true }) .waitFor({ state: "visible" }); } ) ); if (bypass) { const editorFocused = await evalObsidianJson( cliBinary, "(()=>{const editor=app.workspace.activeLeaf.view.editor;editor.focus();return JSON.stringify(editor.hasFocus());})()", cliEnv ); assertEqual(editorFocused, true, "The guarded keyboard check did not focus the editor."); await withObsidianPage(session.remoteDebuggingPort, async (page) => { await page.keyboard.type("This input must stay blocked."); }); const guardedContent = await evalObsidianJson( cliBinary, "(()=>JSON.stringify(app.workspace.activeLeaf.view.editor.getValue()))()", cliEnv ); assertEqual(guardedContent, workspaceContent, "The protected editor accepted keyboard input."); await withObsidianPage(session.remoteDebuggingPort, async (page) => { await page.getByRole("button", { name: "Continue without waiting", exact: true }).click(); await page.locator(".sls-sync-guard").waitFor({ state: "hidden" }); const editor = page.locator(".workspace-leaf.mod-active .cm-content"); await editor.click(); await page.keyboard.press("ControlOrMeta+End"); await page.keyboard.type(workspaceEdit); }); await waitForVaultContent(vault, workspacePath, workspaceContent + workspaceEdit); const continuing = await readObservation(cliBinary, cliEnv); assertCatchUpPending(continuing); assertEqual( continuing.transferStoppedAt, minimumFiles, "The receive callback was not pending during bypass." ); assertEqual(continuing.visible, false, "The bypass did not restore the workspace."); await releaseGate(cliBinary, cliEnv, "Threshold"); await waitForObservation( cliBinary, cliEnv, "continuous replication after bypass", (state) => state.phase === "continuous" && state.reflectionHeld ); await assertVaultPathMissing(vault, notes[0].path); assertEqual( (await readObservation(cliBinary, cliEnv)).visible, false, "The guard reopened during the bypassed catch-up." ); } else { await releaseGate(cliBinary, cliEnv, "Threshold"); const applying = await waitForObservation( cliBinary, cliEnv, "network completion with a held Vault reflection", (state) => state.phase === "continuous" && state.reflectionHeld && state.storageApplying > 0 ); assertEqual( applying.forwardedIds.length, notes.length, "The real catch-up did not deliver every incoming note." ); assertEqual(applying.visible, true, "SyncGuard released before the held Vault reflection finished."); assertEqual( applying.stage, "Applying received changes to local notes…", "SyncGuard did not show local application progress." ); await assertVaultPathMissing(vault, notes[0].path); // Exceed the guard's ordinary quiet period while real application work remains pending. await new Promise((resolve) => setTimeout(resolve, 2500)); const stillApplying = await readObservation(cliBinary, cliEnv); assertEqual(stillApplying.visible, true, "SyncGuard released while real reflection was still pending."); assertEqual(stillApplying.reflectionHeld, true, "The reflection gate was released unexpectedly."); console.log( await captureObsidianPage(session.remoteDebuggingPort, "sync-guard-applying.png", async (page) => { await page.locator(".sls-sync-guard").waitFor({ state: "visible" }); }) ); } await releaseGate(cliBinary, cliEnv, "Reflection"); await assertIncomingContents(cliBinary, cliEnv, vault); await waitForObservation( cliBinary, cliEnv, "workspace release after exact Vault reflection", (state) => !state.visible && state.phase === "continuous" && state.storageApplying === 0 ); if (bypass) await waitForVaultContent(vault, workspacePath, workspaceContent + workspaceEdit); console.log( `SyncGuard ${label}: threshold ${minimumFiles}, ${notes.length} exact incoming notes, workspace released.` ); } catch (error) { if (session) { await captureObsidianPage( session.remoteDebuggingPort, `sync-guard-${label}-failure.png`, async () => {} ).catch(() => undefined); const observation = await readObservation(cliBinary, session.cliEnv).catch(() => null); console.error(`SyncGuard ${label} failure state: ${JSON.stringify(observation)}`); } throw error; } finally { if (session) { await removeGates(cliBinary, session.cliEnv).catch((error: unknown) => { console.warn(`SyncGuard hook cleanup: ${error instanceof Error ? error.message : String(error)}`); }); await session.app.stop(); } await vault.dispose(); } } 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, "sync-guard"); const sourceVault = await createTemporaryVault("obsidian-livesync-sync-guard-source-"); let source: ObsidianLiveSyncSession | undefined; try { await assertCouchDbReachable(couchDb); await createCouchDbDatabase(couchDb, dbName); source = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault: sourceVault, pluginData: createE2eCouchDbPluginData({ ...couchDb, dbName }), localStorageEntries: createE2eObsidianDeviceLocalState(sourceVault.name), }); await waitForLiveSyncCoreReady(cli.binary, source.cliEnv); await prepareRemote(cli.binary, source.cliEnv); await evalObsidianJson( cli.binary, `(async()=>{const notes=${JSON.stringify(notes)};await app.vault.createFolder('E2E');await app.vault.createFolder('E2E/sync-guard');for(const note of notes){const file=await app.vault.create(note.path,note.content);if(await app.vault.read(file)!==note.content) throw new Error('Source note read-back failed');}return JSON.stringify(true);})()`, source.cliEnv ); const entries: LocalDatabaseEntry[] = []; for (const note of notes) entries.push(await waitForLocalDatabaseEntry(cli.binary, source.cliEnv, note.path)); if (entries.some((entry) => entry.children.length === 0)) throw new Error("Source notes did not produce real Chunks."); await pushLocalChanges(cli.binary, source.cliEnv); await waitForCouchDbDocs(couchDb, dbName, (documents) => { const ids = new Set(documents.map((document) => document._id)); return entries.every((entry) => ids.has(entry.id) && entry.children.every((id) => ids.has(id))); }); await source.app.stop(); source = undefined; await runTarget(binary, cli.binary, couchDb, dbName, entries, false); await runTarget(binary, cli.binary, couchDb, dbName, entries, true); } finally { if (source) await source.app.stop(); await sourceVault.dispose(); if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") await deleteCouchDbDatabase(couchDb, dbName); } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });