mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-22 09:03:21 +00:00
Merge pull request #1256 from vrtmrz/test/metadata-deletion-reflection-main
Reflect deletions and renames with Commonlib 0.1.41
This commit is contained in:
5 files changed
+314
-47
No files matched your search
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.40",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.41",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4389,9 +4389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.40",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.40.tgz",
|
||||
"integrity": "sha512-GVskcVFoQIpb45rR+QT9Wjtdqc6oAxYz5IB2WeJwusWZ8lTmqVrba+6YD9FTtsbzqbPOyJ/LdQxnET7SUexVtw==",
|
||||
"version": "0.1.41",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.41.tgz",
|
||||
"integrity": "sha512-ZJBRM+7HSX5jvbFZ45jlCHRkSwxf0kphN+zh3xs8sF1GQS40NEcGiM/CiXVDEMpE2YUlB5dtrUpdtJYu4l/rHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
+1
-1
@@ -191,7 +191,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.40",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.41",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -212,7 +212,7 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
|
||||
|
||||
`E2E_OBSIDIAN_ONLY_SETUP_URI_GENERATION=true npm run test:e2e:obsidian:focused -- dialog-mounts` runs the Setup URI generation slice in an isolated real Obsidian Vault without a CouchDB service. It checks the Time-bound choice and displayed end time, the generated URI's round trip, Compatible encryption with the original passphrase, and desktop and mobile dialogue layout. Unit and protocol tests cover the time-window boundary without changing the host clock.
|
||||
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside the ordinary workflow because they require directory-aware rename handling.
|
||||
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Creation, update, deletion, and ordinary rename run with `deleteMetadataOfDeletedFiles` explicitly disabled and enabled, using separate paths for each setting. The receiver must contain the original file before deletion or rename, and the source must contain the expected logical deletion or PouchDB tombstone before the receiver reflects it. A further regression check delivers a real CouchDB restoration after the final deleted-winner index read, while ordinary incoming deletion reflection is in progress. The receiving Vault must retain the exact file bytes, emit no deletion event, and record the restored winning revision. The timing gate and its observers are removed after the check. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside the ordinary workflow because they require directory-aware rename handling.
|
||||
|
||||
During focused development, `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true` runs an Issue #1168 check which renames `parent/test3` to `parent/Test3` through external `node:fs/promises.rename` while Vault A is open, and verifies that the note content, Metadata, and Chunk references are not logically deleted locally, remotely, or after restart. It accepts either case spelling on Vault B, so it does not provide directory rename support or exact case convergence between devices. The natural Obsidian event sequence and resulting database state are evidence for the selected build; an existing-version reproduction result must be reported separately from fixed-version safety evidence.
|
||||
|
||||
@@ -335,6 +335,7 @@ A short physical-device check is optional when a specific concern remains about
|
||||
- `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_ONLY_METADATA_DELETION=true`: run only the creation, update, deletion, and ordinary rename checks with `deleteMetadataOfDeletedFiles` disabled and enabled, plus restoration during the final deletion check, in `two-vault-sync`.
|
||||
- `E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION=true`: run only the focused external parent-directory case-rename protection check in `two-vault-sync`.
|
||||
- `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents.
|
||||
- `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchAllCouchDbDocs,
|
||||
fetchCouchDbDocument,
|
||||
fetchCouchDbLocalDocs,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
import { captureObsidianPage } from "../runner/ui.ts";
|
||||
import { FILE_REFLECTION_PROVENANCE_STORE } from "../../../src/serviceModules/FileReflectionProvenance.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
@@ -132,6 +134,12 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function metadataDeletionModePath(path: string, deleteMetadataOfDeletedFiles?: boolean): string {
|
||||
if (deleteMetadataOfDeletedFiles === undefined) return path;
|
||||
const prefix = "E2E/two-vault/";
|
||||
return `${prefix}delete-metadata-of-deleted-files-${deleteMetadataOfDeletedFiles}/${path.slice(prefix.length)}`;
|
||||
}
|
||||
|
||||
async function installParentCaseEventObserver(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -230,6 +238,44 @@ async function waitForPathDeleted(
|
||||
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||
}
|
||||
|
||||
async function waitForLocalDeletedMetadataMarker(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
id: string,
|
||||
path: string,
|
||||
deleteMetadataOfDeletedFiles: boolean
|
||||
): Promise<void> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const id=${JSON.stringify(id)};`,
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const expectHardDeletion=${JSON.stringify(deleteMetadataOfDeletedFiles)};`,
|
||||
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"let state={id,path,rowFound:false,hardDeleted:false,logicallyDeleted:false,documentPresent:false,documentPath:null};",
|
||||
"while(Date.now()<deadline){",
|
||||
" const rows=(await core.localDatabase.allDocsRaw({keys:[id],include_docs:true})).rows??[];",
|
||||
" const row=rows.find((candidate)=>candidate.id===id||candidate.key===id);",
|
||||
" const doc=row?.doc??null;",
|
||||
" const hardDeleted=Boolean(row?.value?.deleted||doc?._deleted);",
|
||||
" const logicallyDeleted=Boolean(doc?.deleted);",
|
||||
" const documentPresent=Boolean(doc&&!doc._deleted);",
|
||||
" state={id,path,rowFound:Boolean(row),hardDeleted,logicallyDeleted,documentPresent,documentPath:doc?.path??null};",
|
||||
" const matches=expectHardDeletion?Boolean(row&&hardDeleted&&!documentPresent):Boolean(row&&!hardDeleted&&logicallyDeleted&&documentPresent&&doc.path===path);",
|
||||
" if(matches) return JSON.stringify(state);",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,250));",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for the expected local Metadata deletion marker: ${JSON.stringify(state)}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForExactObsidianPath(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -458,6 +504,7 @@ async function startConfiguredSession(
|
||||
binary: context.binary,
|
||||
cliBinary: context.cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: createE2eCouchDbPluginData(couchDbSettings, persistedOverrides),
|
||||
});
|
||||
@@ -692,81 +739,269 @@ async function deleteRevisionAndReflect(
|
||||
);
|
||||
}
|
||||
|
||||
async function runCreateUpdateDelete(
|
||||
async function runMetadataDeletionRestoration(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault
|
||||
): Promise<void> {
|
||||
const createdContent = "# Created on A\n\nThis note should appear on B.\n";
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent);
|
||||
await uploadNote(context, session, createPath);
|
||||
const path = "E2E/two-vault/delete-metadata-of-deleted-files-true/restored-during-deletion.md";
|
||||
const content = "# Restored note\n\nThese bytes must survive the final deletion check.\n";
|
||||
const overrides = { deleteMetadataOfDeletedFiles: true };
|
||||
let session = await startConfiguredSession(context, vaultA, overrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, content);
|
||||
const entry = await uploadNote(context, session, path);
|
||||
const originalMetadata = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
session = await startConfiguredSession(context, vaultB, overrides);
|
||||
await syncAndApply(context, session);
|
||||
const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent);
|
||||
await waitForPathContent(vaultB.path, path, (value) => value === content);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, overrides);
|
||||
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, path);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await waitForLocalDeletedMetadataMarker(context.cliBinary, session.cliEnv, entry.id, path, true);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, overrides);
|
||||
await evalObsidianJson<unknown>(
|
||||
context.cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(content)};`,
|
||||
`const originalMetadata=${JSON.stringify(originalMetadata)};`,
|
||||
`const storeName=${JSON.stringify(FILE_REFLECTION_PROVENANCE_STORE)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const access=core.serviceModules.databaseFileAccess;",
|
||||
"const db=core.localDatabase.localDatabase;",
|
||||
"const store=core.services.keyValueDB.openSimpleStore(storeName);",
|
||||
"const base=await store.get(path);",
|
||||
"if(base?.revision!==originalMetadata._rev) throw new Error('The receiving Vault has no matching displayed base revision');",
|
||||
"const fetchEntryMeta=access.fetchEntryMeta;",
|
||||
"const allDocs=db.allDocs;",
|
||||
"let latestReads=0;",
|
||||
"let restoredRevision='';",
|
||||
"let gateError='';",
|
||||
"let gatePhase='index';",
|
||||
"let indexReads=0;",
|
||||
"let deletionEvents=0;",
|
||||
"const ref=app.vault.on('delete',(file)=>{if(file.path===path) deletionEvents++;});",
|
||||
"try{",
|
||||
// Keep the production reflection path; control only the arrival of a
|
||||
// real remote restoration after the final deleted-winner index read.
|
||||
"access.fetchEntryMeta=async function(file,rev,...args){",
|
||||
" const target=typeof file==='string'?file:file?.path;",
|
||||
" if(target===path&&rev===undefined&&++latestReads===2){",
|
||||
" db.allDocs=async function(...options){",
|
||||
" indexReads++;",
|
||||
" try{",
|
||||
" const snapshot=await allDocs.apply(this,options);",
|
||||
" if(options[0]?.keys?.length!==1||options[0].keys[0]!==originalMetadata._id) return snapshot;",
|
||||
" db.allDocs=allDocs;",
|
||||
" const row=snapshot.rows[0];",
|
||||
" if(!row?.value?.deleted) throw new Error('The final lookup did not observe a PouchDB tombstone');",
|
||||
" const replicator=core.services.replicator.getActiveReplicator();",
|
||||
" gatePhase='connection';",
|
||||
" const connection=await replicator.connectRemoteCouchDBWithSetting(core.services.setting.currentSettings(),false,false,true);",
|
||||
" if(typeof connection==='string') throw new Error('Could not open the restoration fixture connection');",
|
||||
" let restorationSource;",
|
||||
" try{",
|
||||
" const remoteRow=(await connection.db.allDocs({keys:[originalMetadata._id]})).rows[0];",
|
||||
" if(remoteRow?.value?.rev!==row.value.rev||!remoteRow.value.deleted) throw new Error(`Remote deletion differs from the snapshot: ${JSON.stringify({local:row.value,remote:remoteRow?.value})}`);",
|
||||
" gatePhase='restoration source';",
|
||||
" restorationSource=new db.constructor(`e2e-deletion-restoration-${Date.now()}`,{adapter:db.adapter});",
|
||||
" await restorationSource.replicate.from(connection.db,{doc_ids:[originalMetadata._id]});",
|
||||
" const restored=await restorationSource.put({...originalMetadata,_rev:row.value.rev,deleted:false,mtime:Number(originalMetadata.mtime)+10000});",
|
||||
" gatePhase='restoration replication';",
|
||||
" await restorationSource.replicate.to(connection.db,{doc_ids:[originalMetadata._id]});",
|
||||
" await db.replicate.from(connection.db,{doc_ids:[originalMetadata._id]});",
|
||||
" const winner=await db.get(originalMetadata._id);",
|
||||
" if(winner._rev!==restored.rev||winner._deleted||winner.deleted) throw new Error('The restoration did not become the local winning revision');",
|
||||
" restoredRevision=restored.rev;",
|
||||
" }finally{try{if(restorationSource) await restorationSource.destroy();}finally{await connection.close();}}",
|
||||
" return snapshot;",
|
||||
" }catch(error){gateError=JSON.stringify({phase:gatePhase,name:error?.name,message:error?.message,status:error?.status,reason:error?.reason});throw error;}",
|
||||
" };",
|
||||
" }",
|
||||
" return await fetchEntryMeta.call(this,file,rev,...args);",
|
||||
"};",
|
||||
"await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
"if(!(await core.services.replication.replicate(true))) throw new Error('Finite replication did not complete');",
|
||||
"const deadline=Date.now()+15000;",
|
||||
"while(Date.now()<deadline){",
|
||||
" if(gateError) throw new Error(`The restoration timing gate failed: ${gateError}`);",
|
||||
" if(restoredRevision){",
|
||||
" if(deletionEvents>0) throw new Error('The restored note was deleted during the final deletion check');",
|
||||
" const file=app.vault.getAbstractFileByPath(path);",
|
||||
" if(!file) throw new Error('The restored note is missing from the receiving Vault');",
|
||||
" const record=await store.get(path);",
|
||||
" if(record?.revision===restoredRevision){",
|
||||
" if(await app.vault.read(file)!==content) throw new Error('The restored Vault bytes changed');",
|
||||
" const winner=await db.get(originalMetadata._id,{conflicts:true});",
|
||||
" if(winner._rev!==restoredRevision||winner._deleted||winner.deleted||(winner._conflicts??[]).length>0) throw new Error('The restored winning revision was not retained');",
|
||||
" return JSON.stringify({restoredRevision,deletionEvents});",
|
||||
" }",
|
||||
" }",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,100));",
|
||||
"}",
|
||||
"throw new Error(`The restoration race did not settle: ${JSON.stringify({latestReads,indexReads,restoredRevision,deletionEvents})}`);",
|
||||
"}finally{access.fetchEntryMeta=fetchEntryMeta;db.allDocs=allDocs;app.vault.offref(ref);}",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(
|
||||
await readVaultFile(vaultB.path, path),
|
||||
content,
|
||||
"Restored file bytes did not survive deletion reflection."
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
console.log("A restoration replicated during the final deletion check preserved the receiving Vault file.");
|
||||
}
|
||||
|
||||
async function runCreateUpdateDelete(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault,
|
||||
deleteMetadataOfDeletedFiles?: boolean
|
||||
): Promise<void> {
|
||||
const deletionOverrides = deleteMetadataOfDeletedFiles === undefined ? {} : { deleteMetadataOfDeletedFiles };
|
||||
const currentCreatePath = metadataDeletionModePath(createPath, deleteMetadataOfDeletedFiles);
|
||||
const currentUpdatePath = metadataDeletionModePath(updatePath, deleteMetadataOfDeletedFiles);
|
||||
const currentDeletePath = metadataDeletionModePath(deletePath, deleteMetadataOfDeletedFiles);
|
||||
const createdContent = "# Created on A\n\nThis note should appear on B.\n";
|
||||
let session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, currentCreatePath, createdContent);
|
||||
await uploadNote(context, session, currentCreatePath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const createdOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
currentCreatePath,
|
||||
(content) => content === createdContent
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault.");
|
||||
|
||||
const initialUpdateContent = "# Update target\n\nInitial content.\n";
|
||||
const updatedContent = "# Update target\n\nUpdated content from A.\n";
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, initialUpdateContent);
|
||||
await uploadNote(context, session, updatePath);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent);
|
||||
await uploadNote(context, session, updatePath);
|
||||
session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, currentUpdatePath, initialUpdateContent);
|
||||
await uploadNote(context, session, currentUpdatePath);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, currentUpdatePath, updatedContent);
|
||||
await uploadNote(context, session, currentUpdatePath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent);
|
||||
const updatedOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
currentUpdatePath,
|
||||
(content) => content === updatedContent
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault.");
|
||||
|
||||
const deleteContent = "# Delete target\n\nThis note should be removed from B.\n";
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent);
|
||||
await uploadNote(context, session, deletePath);
|
||||
session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, currentDeletePath, deleteContent);
|
||||
await uploadNote(context, session, currentDeletePath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent);
|
||||
await waitForPathContent(vaultB.path, currentDeletePath, (content) => content === deleteContent);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultA);
|
||||
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath);
|
||||
session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
const deleteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, currentDeletePath);
|
||||
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, currentDeletePath);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
if (deleteMetadataOfDeletedFiles !== undefined) {
|
||||
await waitForLocalDeletedMetadataMarker(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
deleteEntry.id,
|
||||
currentDeletePath,
|
||||
deleteMetadataOfDeletedFiles
|
||||
);
|
||||
}
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
await waitForPathDeleted(vaultB.path, deletePath);
|
||||
await waitForPathDeleted(vaultB.path, currentDeletePath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
console.log("Two-vault note creation, update, and deletion round-tripped.");
|
||||
const deletionModeLabel =
|
||||
deleteMetadataOfDeletedFiles === undefined
|
||||
? ""
|
||||
: ` with deleteMetadataOfDeletedFiles=${deleteMetadataOfDeletedFiles}`;
|
||||
console.log(`Two-vault note creation, update, and deletion round-tripped${deletionModeLabel}.`);
|
||||
}
|
||||
|
||||
async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB: TemporaryVault): Promise<void> {
|
||||
async function runRename(
|
||||
context: RunnerContext,
|
||||
vaultA: TemporaryVault,
|
||||
vaultB: TemporaryVault,
|
||||
deleteMetadataOfDeletedFiles?: boolean
|
||||
): Promise<void> {
|
||||
const deletionOverrides = deleteMetadataOfDeletedFiles === undefined ? {} : { deleteMetadataOfDeletedFiles };
|
||||
const currentRenameFromPath = metadataDeletionModePath(renameFromPath, deleteMetadataOfDeletedFiles);
|
||||
const currentRenameToPath = metadataDeletionModePath(renameToPath, deleteMetadataOfDeletedFiles);
|
||||
const renamedContent = "# Rename target\n\nThis note should move from A to B.\n";
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renamedContent);
|
||||
await uploadNote(context, session, renameFromPath);
|
||||
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
let session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, currentRenameFromPath, renamedContent);
|
||||
await uploadNote(context, session, currentRenameFromPath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB);
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent);
|
||||
await waitForPathDeleted(vaultB.path, renameFromPath);
|
||||
const originalOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
currentRenameFromPath,
|
||||
(content) => content === renamedContent
|
||||
);
|
||||
await stopTrackedSession(context, session);
|
||||
assertEqual(originalOnB, renamedContent, "Original note content did not reach the second vault before rename.");
|
||||
|
||||
session = await startConfiguredSession(context, vaultA, deletionOverrides);
|
||||
const renamedEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, currentRenameFromPath);
|
||||
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, currentRenameFromPath, currentRenameToPath);
|
||||
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, currentRenameToPath);
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
if (deleteMetadataOfDeletedFiles !== undefined) {
|
||||
await waitForLocalDeletedMetadataMarker(
|
||||
context.cliBinary,
|
||||
session.cliEnv,
|
||||
renamedEntry.id,
|
||||
currentRenameFromPath,
|
||||
deleteMetadataOfDeletedFiles
|
||||
);
|
||||
}
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, deletionOverrides);
|
||||
await syncAndApply(context, session);
|
||||
const renamedOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
currentRenameToPath,
|
||||
(content) => content === renamedContent
|
||||
);
|
||||
await waitForPathDeleted(vaultB.path, currentRenameFromPath);
|
||||
await stopTrackedSession(context, session);
|
||||
|
||||
assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault.");
|
||||
console.log("Two-vault note rename round-tripped.");
|
||||
const deletionModeLabel =
|
||||
deleteMetadataOfDeletedFiles === undefined
|
||||
? ""
|
||||
: ` with deleteMetadataOfDeletedFiles=${deleteMetadataOfDeletedFiles}`;
|
||||
console.log(`Two-vault note rename round-tripped${deletionModeLabel}.`);
|
||||
}
|
||||
|
||||
async function runCaseOnlyRename(
|
||||
@@ -1593,32 +1828,56 @@ async function main(): Promise<void> {
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
|
||||
|
||||
const onlyMetadataDeletion = process.env.E2E_OBSIDIAN_ONLY_METADATA_DELETION === "true";
|
||||
const onlyParentCaseDeletion = process.env.E2E_OBSIDIAN_ONLY_PARENT_CASE_DELETION === "true";
|
||||
const onlyIndependentIds = process.env.E2E_OBSIDIAN_ONLY_INDEPENDENT_IDS === "true";
|
||||
const onlyDifferentChunkIdKeys = process.env.E2E_OBSIDIAN_ONLY_DIFFERENT_CHUNK_ID_KEYS === "true";
|
||||
if (onlyParentCaseDeletion) {
|
||||
if (onlyMetadataDeletion) {
|
||||
await runMetadataDeletionRestoration(context, vaultA, vaultB);
|
||||
for (const deleteMetadataOfDeletedFiles of [false, true]) {
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB, deleteMetadataOfDeletedFiles);
|
||||
await runRename(context, vaultA, vaultB, deleteMetadataOfDeletedFiles);
|
||||
}
|
||||
}
|
||||
if (!onlyMetadataDeletion && onlyParentCaseDeletion) {
|
||||
await runParentCaseDeletionProtection(context, vaultA, vaultB);
|
||||
}
|
||||
const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true";
|
||||
if (!onlyParentCaseDeletion && !onlyConflictOperations && !onlyIndependentIds && !onlyDifferentChunkIdKeys) {
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB);
|
||||
await runRename(context, vaultA, vaultB);
|
||||
if (
|
||||
!onlyMetadataDeletion &&
|
||||
!onlyParentCaseDeletion &&
|
||||
!onlyConflictOperations &&
|
||||
!onlyIndependentIds &&
|
||||
!onlyDifferentChunkIdKeys
|
||||
) {
|
||||
await runMetadataDeletionRestoration(context, vaultA, vaultB);
|
||||
for (const deleteMetadataOfDeletedFiles of [false, true]) {
|
||||
await runCreateUpdateDelete(context, vaultA, vaultB, deleteMetadataOfDeletedFiles);
|
||||
await runRename(context, vaultA, vaultB, deleteMetadataOfDeletedFiles);
|
||||
}
|
||||
await runCaseOnlyRename(context, vaultA, vaultB);
|
||||
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
|
||||
await runMarkdownAutoMerge(context, vaultA, vaultB);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!onlyMetadataDeletion &&
|
||||
!onlyParentCaseDeletion &&
|
||||
(onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true")
|
||||
) {
|
||||
await runConflictTimeStorageOperations(context, vaultA, vaultB);
|
||||
}
|
||||
if (!onlyParentCaseDeletion && !onlyConflictOperations && !onlyIndependentIds && !onlyDifferentChunkIdKeys) {
|
||||
if (
|
||||
!onlyMetadataDeletion &&
|
||||
!onlyParentCaseDeletion &&
|
||||
!onlyConflictOperations &&
|
||||
!onlyIndependentIds &&
|
||||
!onlyDifferentChunkIdKeys
|
||||
) {
|
||||
await runTargetMismatch(context, vaultA, vaultB);
|
||||
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
|
||||
}
|
||||
if (!onlyParentCaseDeletion && !onlyConflictOperations) {
|
||||
if (!onlyMetadataDeletion && !onlyParentCaseDeletion && !onlyConflictOperations) {
|
||||
if (onlyDifferentChunkIdKeys) {
|
||||
await runDifferentChunkIdKeysRoundTrip(independentContext, independentVaultA, independentVaultB);
|
||||
} else {
|
||||
|
||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation
|
||||
|
||||
#### Fixed
|
||||
|
||||
- We can synchronise file deletions and renames when **Do not keep metadata of deleted files.** is enabled.
|
||||
- A file restored during the final deletion check remains in the receiving Vault.
|
||||
|
||||
## 1.0.37
|
||||
|
||||
16th October, 2026
|
||||
|
||||
Reference in new issue
Block a user