diff --git a/package.json b/package.json index 8b8368cd..b40ea114 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts", "test:e2e:obsidian:livesync-batch": "tsx test/e2e-obsidian/scripts/issue1207-livesync-batch.ts", "test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts", + "test:e2e:obsidian:sync-guard": "tsx test/e2e-obsidian/scripts/sync-guard.ts", "test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts", "test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts", "test:e2e:obsidian:internal-metadata-doctor": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts --doctor", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index c61427a0..361db22c 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -86,6 +86,7 @@ npm run test:e2e:obsidian:focused -- folder-batch npm run test:e2e:obsidian:focused -- security-seed-reconnect npm run test:e2e:obsidian:focused -- couchdb-http-cache npm run test:e2e:obsidian:focused -- replication-status +npm run test:e2e:obsidian:focused -- sync-guard ``` The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite. @@ -165,7 +166,7 @@ The Harness also measures ID generation with fixed in-memory data on desktop and `test:e2e:obsidian:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database. -`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, configuration encryption and its CouchDB synchronisation workflow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. +`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, configuration encryption and its CouchDB synchronisation workflow, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI and QR round trips, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, SyncGuard catch-up protection, Hidden File Sync, Customisation Sync, internal Metadata Doctor, and setting Markdown export in sequence. Start the local CouchDB, RustFS, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `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, starts from configured plug-in data without the device-local compatibility marker, and verifies that the marker is initialised without a compatibility dialogue, reminder, or runtime pause. It then creates a note in real Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the Metadata document and its Chunks. @@ -293,6 +294,8 @@ The workflow also opens the Customisation Sync dialogue. `--case=visibility` che `test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`. +`test:e2e:obsidian:sync-guard` uploads six notes and their Chunks from real Obsidian to an isolated CouchDB database, then receives them in two fresh sequential Vaults. It pauses the real receive callback below and at the device-local threshold to verify that the workspace is protected only after enough notes arrive. A held real Vault reflection must retain the overlay after network catch-up completes, beyond the ordinary quiet period, before automatic release and exact content checks. The second target verifies that keyboard input to the focused editor is blocked, clicks **Continue without waiting**, edits an ordinary note through the visible editor while catch-up remains pending, and then verifies every incoming note and the retained local edit after transfer and reflection resume. The scenario restores its runtime hooks in `finally`, retains stage screenshots, and runs in the local suite. It requires CouchDB; no mobile operating-system lifecycle is simulated. + `test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary. `test:e2e:obsidian:internal-metadata-migration` enables internal Metadata encryption through the settings UI without Rebuild. It checks unchanged plaintext and rewritten encrypted Hidden File Sync and Customisation Sync Metadata in CouchDB, stable document IDs, mismatch rejection on a second device, and file restoration after aligning settings. It then turns the preference OFF, runs Fast Fetch, and compares content loaded from both Metadata representations and their Chunks while retaining the remote feature declaration. These focused tests use the local CouchDB fixture and are outside `test:e2e:obsidian:local-suite`. diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index ee953160..99893534 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -58,6 +58,7 @@ const testSteps: Step[] = [ { name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] }, { name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] }, { name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] }, + { name: "SyncGuard catch-up protection", args: ["run", "test:e2e:obsidian:sync-guard"] }, { name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] }, { name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] }, { name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] }, diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index ffb805ed..3025a628 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -51,6 +51,7 @@ const focusedScenarios = new Set([ "optional-file-save-completion", "livesync-batch", "received-change-readiness", + "sync-guard", "internal-metadata-doctor", "setting-markdown-export", "setting-markdown-roundtrip", diff --git a/test/e2e-obsidian/scripts/sync-guard.ts b/test/e2e-obsidian/scripts/sync-guard.ts new file mode 100644 index 00000000..b70fa36e --- /dev/null +++ b/test/e2e-obsidian/scripts/sync-guard.ts @@ -0,0 +1,498 @@ +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); +}); diff --git a/updates.md b/updates.md index c3ca46db..ee6e8b61 100644 --- a/updates.md +++ b/updates.md @@ -12,32 +12,58 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased -### Fixes +### Synchronisation -- Buttons in confirmation and setup dialogues now align with the content above them. Confirmation dialogue buttons also have space for keyboard focus outlines. +#### New Feature -- Mobile confirmation dialogues now retain Obsidian's standard content spacing. Text and password prompts stay above the software keyboard. +- We can avoid accidentally editing out-of-date notes while a large CouchDB catch-up is in progress. (#1241, #1275) + - Turn on **Enable SyncGuard** under **General Settings** → **Appearance** to cover the workspace until incoming changes have been received and applied to the Vault. + - Disabled by default. During CouchDB LiveSync mode start-up or resume, protection begins after a configurable number of distinct incoming notes is detected (default: 20). + - **Continue without waiting** releases the workspace, including after a failed or interrupted transfer. Synchronisation can continue, and some notes may still be out of date. + - The settings apply only to this device. + - Small catch-up transfers and ordinary live changes remain uninterrupted. Some changes can arrive before the guard activates; it does not prevent other plug-ins or external tools from editing files. -- Setup dialogues no longer add a large blank area below their buttons when the software keyboard is open. +#### Improved -- CouchDB connection fields have more room above the software keyboard. The warning about saving without a connection test now scrolls with the form. +- We can tell that CouchDB synchronisation is progressing while it compares existing revisions, even before any files are uploaded. (#1293, #1294) + - Separate `⏩↑` and `⏩↓` counts show the change-feed entries compared for push and pull. These are comparison counts; the upload and download totals remain separate. + - Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. The status display guide includes updated examples. -- A new Join or scheduled Fetch asks for data-handling choices again, even after a previous Fetch changed the local database suffix. - - Fast Setup retains interrupted choices in an editable Markdown code block in the fetch flag. On restart, we confirm the current remote and saved choices, choose again, or cancel; a remaining flag never reapplies choices silently. +### Setup and recovery + +#### Fixed + +- We can choose how a new Join or scheduled Fetch handles our existing files, without silently reusing choices from an earlier Fetch. (#1244, #1296) + - Each new operation asks again, including after a previous Fetch changed the local database suffix. Fast Setup retains interrupted choices in an editable code block in the fetch flag. + - On restart, we confirm the current remote and saved choices, choose again, or cancel; a remaining flag never reapplies choices silently. - If the saved selection cannot be read, we can choose new options for that attempt without repairing or rewriting the flag. Completing or cancelling the operation removes the whole flag. - After updating during an interrupted Fetch, confirm the choices once more. Older separately stored choices are ignored; downloaded data and the existing checkpoint are not removed by this change. -### Synchronisation +### Dialogues -- Added an optional, device-local editing guard for substantial CouchDB LiveSync catch-up transfers. When enough incoming notes are detected, it covers the workspace until the transfer and local application queues have settled. The overlay can always be bypassed, and ordinary small transfers remain uninterrupted. +#### Fixed -- We can see CouchDB change-feed comparison progress separately for push and pull. - - The `⏩↑` and `⏩↓` counts show entries compared in each direction; the upload and download totals remain separate. - - Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. See the [status display guide](docs/tips/status-display.md). +- Confirmation and setup actions are easier to follow, and keyboard focus remains visible. (#1297) + - Buttons align with the content above them, with room for confirmation buttons' focus outlines. +- Mobile dialogues retain Obsidian's standard spacing and leave more usable space above the software keyboard. (#1297) ### Translations -- Simplified Chinese translations have been expanded across settings, setup, recovery, and configuration messages. (#1258) Thank you to @kannazuki-c for the contribution! +- More settings, setup, recovery, and configuration messages are available in Simplified Chinese. (#1258) + - Expanded the translation catalogue to include previously untranslated messages and shared-library labels. + +### Miscellaneous + +- Docker setup now handles shell-sensitive characters in health-check credentials correctly, avoiding false authentication failures which could prevent CouchDB initialisation. (#1295) + +### Acknowledgements + +Thank you for your contributions! + +- @Akilaydin, for adding the optional SyncGuard workspace protection (#1275). +- @ARCJ137442, for adding separate push and pull comparison progress displays (#1294). +- @Jovlang, for correcting Docker health-check credential quoting and authenticated readiness checks (#1295). +- @kannazuki-c, for expanding the Simplified Chinese translations (#1258). ## 1.0.42