mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-28 13:32:31 +00:00
Preserve colon-containing Vault paths with Commonlib 0.1.28
This commit is contained in:
@@ -82,7 +82,7 @@ npm run test:e2e:obsidian:focused -- security-seed-reconnect
|
||||
|
||||
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.
|
||||
|
||||
`folder-batch` needs no remote service. It creates 24 notes in nested folders, renames and deletes the parent through the Obsidian Vault API, and checks descendant events, content, Chunks, deletion markers, and provenance. A note outside the parent must remain writable.
|
||||
`folder-batch` needs no remote service. It creates 24 notes and imports three notes with colons in their names into nested folders, reflects the database content into the existing files, then renames and deletes the parent through the Obsidian Vault API. It checks exact paths, descendant events, content, Chunks, deletion markers, provenance, and the absence of unexpected files. A note outside the parent must remain writable. The colon fixtures use the adapter to represent externally created files because Obsidian's Vault creation API rejects those names. The scenario also seeds a database-only colon-named note and verifies that Obsidian's refusal to create it preserves its Metadata without writing a differently named Vault file. It does not establish successful restoration of that absent note or behaviour on other operating systems.
|
||||
|
||||
`stale-file-restart` needs no remote service. It advances the local database while old Vault bytes remain, persists pending storage events, and restarts the same isolated Vault and profile. It checks that an unchanged file with exact provenance receives the newer database content without creating a revision, that unknown-origin content is preserved on a fresh independent branch, and that losing provenance and processing the file again does not duplicate or automatically merge that branch. The database advance and pending snapshot are controlled fixtures; startup processing, persistence, file reflection, and conflict checking run in real Obsidian. The scenario does not simulate a mobile operating system suspending the application.
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@ process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
const originalRoot = "batch/original";
|
||||
const renamedRoot = "batch/renamed";
|
||||
const outsidePath = "batch/outside.md";
|
||||
const missingColonPath = "batch/incoming/Poem: Example.md";
|
||||
const folders = ["alpha", "alpha/deep", "beta"];
|
||||
const notes = Array.from({ length: 24 }, (_, index) => ({
|
||||
relativePath: `${folders[index % folders.length]}/note-${index}.md`,
|
||||
body: `# Descendant ${index}\n\nThis body must survive a parent folder rename.\n`,
|
||||
}));
|
||||
notes.push(
|
||||
{ relativePath: "alpha/Poem: Example.md", body: "First poem\n" },
|
||||
{ relativePath: "beta/Poem: Example.md", body: "Second poem\n" },
|
||||
{ relativePath: "alpha/deep/Poem: Part: Example.md", body: "Poem with multiple colons\n" }
|
||||
);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
@@ -45,7 +51,7 @@ async function main(): Promise<void> {
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number }>(
|
||||
const result = await evalObsidianJson<{ descendants: number; renamed: number; deleted: number; missingRejected: boolean }>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
@@ -54,6 +60,7 @@ async function main(): Promise<void> {
|
||||
const originalRoot=${JSON.stringify(originalRoot)};
|
||||
const renamedRoot=${JSON.stringify(renamedRoot)};
|
||||
const outsidePath=${JSON.stringify(outsidePath)};
|
||||
const missingColonPath=${JSON.stringify(missingColonPath)};
|
||||
const renamed=new Set(), deleted=new Set();
|
||||
const refs=[
|
||||
app.vault.on('rename',(file,oldPath)=>{
|
||||
@@ -112,11 +119,23 @@ async function main(): Promise<void> {
|
||||
await app.vault.createFolder(originalRoot);
|
||||
for(const folder of ${JSON.stringify(folders)})
|
||||
await app.vault.createFolder(originalRoot+'/'+folder);
|
||||
await Promise.all(notes.map(note=>app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
||||
// Obsidian indexes imported colon names but rejects them in Vault.create.
|
||||
await Promise.all(notes.map(note=>note.relativePath.includes(':')
|
||||
? app.vault.adapter.write(originalRoot+'/'+note.relativePath,note.body)
|
||||
: app.vault.create(originalRoot+'/'+note.relativePath,note.body)));
|
||||
await app.vault.create(outsidePath,'Outside note');
|
||||
await waitFor('Initial batch',async()=>[
|
||||
...await liveBatch(originalRoot), ...await liveErrors(outsidePath,'Outside note'),
|
||||
]);
|
||||
for(const note of notes){
|
||||
const path=originalRoot+'/'+note.relativePath;
|
||||
if(!await core.serviceModules.fileHandler.dbToStorage(await meta(path),null,true))
|
||||
throw new Error('Database reflection failed: '+path);
|
||||
}
|
||||
await waitFor('Reflected batch',()=>liveBatch(originalRoot));
|
||||
const expectedPaths=new Set([outsidePath,...notes.map(note=>originalRoot+'/'+note.relativePath)]);
|
||||
const unexpected=app.vault.getFiles().map(file=>file.path).filter(path=>!expectedPaths.has(path));
|
||||
if(unexpected.length) throw new Error('Unexpected reflected files: '+unexpected.join(', '));
|
||||
const originalIds=await Promise.all(notes.map(async note=>(await meta(originalRoot+'/'+note.relativePath))._id));
|
||||
|
||||
// Rename the parent once: Obsidian must emit every descendant event.
|
||||
@@ -144,7 +163,40 @@ async function main(): Promise<void> {
|
||||
if(app.vault.getAbstractFileByPath(renamedRoot)) throw new Error('Deleted folder remains');
|
||||
await app.vault.modify(app.vault.getAbstractFileByPath(outsidePath),'Outside note updated');
|
||||
await waitFor('Outside update',()=>liveErrors(outsidePath,'Outside note updated'));
|
||||
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size});
|
||||
|
||||
// A received database entry must not create a different Vault file when Obsidian rejects its name.
|
||||
const filesBefore=new Set(app.vault.getFiles().map(file=>file.path));
|
||||
const incomingBody='Received colon note\\n';
|
||||
const incomingData=new Blob([incomingBody],{type:'text/plain'});
|
||||
const incomingId=await core.services.path.path2id(missingColonPath);
|
||||
const incomingTime=Date.now();
|
||||
const saved=await core.localDatabase.putDBEntry({
|
||||
_id:incomingId,path:missingColonPath,data:incomingData,
|
||||
ctime:incomingTime,mtime:incomingTime,size:incomingData.size,
|
||||
children:[],datatype:'plain',type:'plain',eden:{},
|
||||
});
|
||||
if(!saved?.ok) throw new Error('Could not seed received Metadata: '+missingColonPath);
|
||||
const incomingMeta=await meta(missingColonPath);
|
||||
if(!incomingMeta || incomingMeta._id!==incomingId || incomingMeta.path!==missingColonPath)
|
||||
throw new Error('Received Metadata has the wrong path: '+missingColonPath);
|
||||
const incomingEntry=await core.localDatabase.getDBEntry(missingColonPath,{rev:incomingMeta._rev},false,true,true);
|
||||
if(!incomingEntry || getContent(incomingEntry)!==incomingBody)
|
||||
throw new Error('Received content could not be read: '+missingColonPath);
|
||||
let creationFailure='';
|
||||
try{
|
||||
const reflected=await core.serviceModules.fileHandler.dbToStorage(incomingMeta,null,true);
|
||||
if(reflected) throw new Error('Obsidian unexpectedly created: '+missingColonPath);
|
||||
}catch(error){
|
||||
creationFailure=String(error);
|
||||
if(!creationFailure.includes('File name cannot contain')) throw error;
|
||||
}
|
||||
if(!creationFailure) throw new Error('Missing name rejection: '+missingColonPath);
|
||||
const filesAfter=app.vault.getFiles().map(file=>file.path);
|
||||
const newFiles=filesAfter.filter(path=>!filesBefore.has(path));
|
||||
if(newFiles.length) throw new Error('Received note was written under another name: '+newFiles.join(', '));
|
||||
if((await meta(missingColonPath))?.path!==missingColonPath)
|
||||
throw new Error('Received Metadata changed after rejection: '+missingColonPath);
|
||||
return JSON.stringify({descendants:notes.length,renamed:renamed.size,deleted:deleted.size,missingRejected:true});
|
||||
}finally{
|
||||
for(const ref of refs) app.vault.offref(ref);
|
||||
}
|
||||
@@ -153,7 +205,8 @@ async function main(): Promise<void> {
|
||||
);
|
||||
console.log(
|
||||
`Folder batch: ${result.descendants} descendants persisted, renamed, and deleted; ` +
|
||||
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable.`
|
||||
`${result.renamed} rename and ${result.deleted} delete events observed; outside note remained writable; ` +
|
||||
`missing colon note rejected without an alternate file: ${result.missingRejected}.`
|
||||
);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
|
||||
Reference in New Issue
Block a user