diff --git a/docs/design_docs/internal_metadata_encryption.md b/docs/design_docs/internal_metadata_encryption.md index c73d0d01..5ceaf218 100644 --- a/docs/design_docs/internal_metadata_encryption.md +++ b/docs/design_docs/internal_metadata_encryption.md @@ -181,10 +181,15 @@ scenarios. The dedicated active-connection scenario changed a generation 12 remote to generation 13 with an unknown feature whilst continuous replication was running. The local control document arrived, the active Replicator retired, a subsequent replication was refused, and an earlier accepted note stayed in -the Vault. Focused host tests cover both batch orders, pending-work snapshots, -restart, stale callbacks, and the older local-generation case. Recovery after -upgrading to a future client that understands the unknown feature has not been -exercised in real Obsidian. +the Vault. The same real Obsidian scenario now shortens both control-document +feature lists and restarts the Vault. The saved observation remains associated +with the same physical database, and both OneShot and Continuous replication +are refused. Focused host tests cover both batch orders, pending-work snapshots, +stale callbacks, and the older local-generation case. Recovery after upgrading +to a future client that understands the unknown feature has not been exercised +in real Obsidian. The existing [readiness queue issue](https://github.com/vrtmrz/obsidian-livesync/issues/1200) +still affects when restored pending documents resume after the application +becomes ready; snapshot preservation alone does not resolve that issue. Keep the primary-language settings and troubleshooting guides, the database-compatibility ADR, and Unreleased notes aligned with this behaviour. diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 954ab14f..feb8ec48 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -200,7 +200,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. -`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. It is a focused test outside `test:e2e:obsidian:local-suite`; pending-work recovery with a future compatible client remains a separate validation boundary. +`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. It then shortens the feature lists in the remote and local control documents, restarts the same Vault, and verifies that the saved observation still blocks OneShot and Continuous replication. It is a focused test outside `test:e2e:obsidian:local-suite`; pending-work recovery with a future compatible client remains a separate validation boundary. `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`. diff --git a/test/e2e-obsidian/scripts/remote-feature-change.ts b/test/e2e-obsidian/scripts/remote-feature-change.ts index f75e6b24..afdf945a 100644 --- a/test/e2e-obsidian/scripts/remote-feature-change.ts +++ b/test/e2e-obsidian/scripts/remote-feature-change.ts @@ -32,6 +32,9 @@ const unknownFeature = "future-format-v7"; type FeatureState = { version: number | null; features: string[]; + snapshotFeatures: string[]; + snapshotDatabaseId: string | null; + databaseId: string | null; hasActiveReplicator: boolean; }; @@ -43,9 +46,14 @@ async function readFeatureState(cliBinary: string, env: NodeJS.ProcessEnv): Prom "const core=app.plugins.plugins['obsidian-livesync'].core;", `const id=${JSON.stringify(VERSIONING_DOCID)};`, "const info=await core.localDatabase.getRaw(id).catch(()=>null);", + "const snapshot=await core.kvDB.get('replicationResultProcessorSnapshot');", + "const databaseId=await core.localDatabase.localDatabase.id();", "return JSON.stringify({", "version:typeof info?.version==='number'?info.version:null,", "features:Array.isArray(info?.used_features)?info.used_features:[],", + "snapshotFeatures:Array.isArray(snapshot?.observedFeatures)?snapshot.observedFeatures:[],", + "snapshotDatabaseId:snapshot?.databaseId??null,", + "databaseId,", "hasActiveReplicator:!!core.services.replicator.getActiveReplicator(),", "});", "})()", @@ -145,8 +153,12 @@ async function main(): Promise { const observed = await waitForState( cli.binary, session.cliEnv, - (state) => state.version === 13 && state.features.includes(unknownFeature) && !state.hasActiveReplicator, - "the live feature change and Replicator retirement" + (state) => + state.version === 13 && + state.features.includes(unknownFeature) && + state.snapshotFeatures.includes(unknownFeature) && + !state.hasActiveReplicator, + "the live feature change, durable observation, and Replicator retirement" ); const replicated = await evalObsidianJson( @@ -158,8 +170,63 @@ async function main(): Promise { const acceptedAfterStop = await readFile(fullPath, "utf-8"); if (acceptedAfterStop !== acceptedContent) throw new Error("Previously accepted Vault content changed on stop."); + + // Shorten both visible lists so only the snapshot can retain the observed requirement. + const shortenedRemoteVersion = await fetchCouchDbDocument(couchDb, dbName, VERSIONING_DOCID); + await putCouchDbDocument(couchDb, dbName, { ...shortenedRemoteVersion, used_features: [] }); + const shortenedLocalFeatures = await evalObsidianJson( + cli.binary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const id=${JSON.stringify(VERSIONING_DOCID)};`, + "const info=await core.localDatabase.getRaw(id);", + "await core.localDatabase.putRaw({...info,used_features:[]});", + "return JSON.stringify((await core.localDatabase.getRaw(id)).used_features);", + "})()", + ].join(""), + session.cliEnv + ); + if (shortenedLocalFeatures.length !== 0) + throw new Error("The local control document did not shorten for the restart scenario."); + + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ binary, cliBinary: cli.binary, vault }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + const afterRestart = await waitForState( + cli.binary, + session.cliEnv, + (state) => + state.version === 13 && state.features.length === 0 && state.snapshotFeatures.includes(unknownFeature), + "the retained unknown feature after restart" + ); + if (afterRestart.snapshotDatabaseId !== afterRestart.databaseId) + throw new Error("The retained feature observation belongs to a different physical database."); + const replicatedAfterRestart = await evalObsidianJson( + cli.binary, + "(async()=>JSON.stringify(!!(await app.plugins.plugins['obsidian-livesync'].core.services.replication.replicate(true))))()", + session.cliEnv + ); + const continuousAfterRestart = await evalObsidianJson<{ status: string }>( + cli.binary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const result=await core.services.replication.startContinuous({trigger:'daemon',interaction:{kind:'forbidden'}});", + "return JSON.stringify(result);", + "})()", + ].join(""), + session.cliEnv + ); + if (replicatedAfterRestart || continuousAfterRestart.status !== "blocked") + throw new Error( + `Replication resumed after restart despite a previously observed unknown feature: ${JSON.stringify({ afterRestart, replicatedAfterRestart, continuousAfterRestart })}` + ); + if ((await readFile(fullPath, "utf-8")) !== acceptedContent) + throw new Error("Previously accepted Vault content changed after restart."); console.log( - `Active feature change retired the Replicator and kept accepted content: ${JSON.stringify(observed)}` + `Active feature change retired the Replicator; a shortened control document did not clear the block after restart: ${JSON.stringify({ observed, afterRestart })}` ); } finally { await session?.app.stop();