mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
Integrate watched file names with CLI path exclusions
This commit is contained in:
commit
8316a14ef8
41 files changed
+6093
-78
No files matched your search
@@ -0,0 +1,343 @@
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
type ObsidianStorageAccess = {
|
||||
getFileNames(options?: { fresh?: boolean }): Promise<string[]>;
|
||||
};
|
||||
|
||||
type ObsidianTestPlugin = {
|
||||
core?: {
|
||||
serviceModules?: {
|
||||
storageAccess?: ObsidianStorageAccess;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
plugins?: {
|
||||
disablePlugin(pluginId: string): Promise<void>;
|
||||
enablePlugin(pluginId: string): Promise<void>;
|
||||
plugins?: Record<string, ObsidianTestPlugin | undefined>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function membershipProbeSource(): string {
|
||||
return [
|
||||
"const assertMembership=async(core,label,expectedNotes,missingPaths)=>{",
|
||||
" const storage=core.serviceModules.storageAccess;",
|
||||
" if(!storage) throw new Error(label+': public storage-access service is unavailable');",
|
||||
" const expected=expectedNotes.map(note=>note.path).sort();",
|
||||
" const equal=(left,right)=>JSON.stringify([...left].sort())===JSON.stringify([...right].sort());",
|
||||
" const vaultPaths=app.vault.getFiles().map(file=>file.path).sort();",
|
||||
" if(!equal(vaultPaths,expected)) throw new Error(label+': Vault membership differs: '+JSON.stringify({expected,actual:vaultPaths}));",
|
||||
" const cachedBeforeFresh=(await storage.getFileNames()).sort();",
|
||||
" if(!equal(cachedBeforeFresh,expected)) throw new Error(label+': cached membership differs: '+JSON.stringify({expected,actual:cachedBeforeFresh}));",
|
||||
" const fresh=(await storage.getFileNames({fresh:true})).sort();",
|
||||
" if(!equal(fresh,expected)) throw new Error(label+': fresh membership differs: '+JSON.stringify({expected,actual:fresh}));",
|
||||
" const cachedAfterFresh=(await storage.getFileNames()).sort();",
|
||||
" if(!equal(cachedAfterFresh,expected)) throw new Error(label+': membership differs after fresh read: '+JSON.stringify({expected,actual:cachedAfterFresh}));",
|
||||
" const settings=core.services.setting.currentSettings();",
|
||||
" const policy={pathNormalizationVersion:settings.pathNormalizationVersion??0,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive??true};",
|
||||
" for(const note of expectedNotes){",
|
||||
" const file=app.vault.getAbstractFileByPath(note.path);",
|
||||
" if(!file||!file.stat||file.path!==note.path) throw new Error(label+': Vault file is missing: '+note.path);",
|
||||
" if(await app.vault.read(file)!==note.body) throw new Error(label+': Vault content differs: '+note.path);",
|
||||
" const candidates=[...(await storage.findPathIdentityCandidates(note.path,policy))].sort();",
|
||||
" if(!equal(candidates,[note.path])) throw new Error(label+': identity candidates differ for '+note.path+': '+JSON.stringify(candidates));",
|
||||
" }",
|
||||
" for(const path of missingPaths){",
|
||||
" const candidates=await storage.findPathIdentityCandidates(path,policy);",
|
||||
" if(candidates.length) throw new Error(label+': stale identity candidates remain for '+path+': '+JSON.stringify(candidates));",
|
||||
" }",
|
||||
" return {paths:expected};",
|
||||
"};",
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error("Could not find obsidian-cli. Checked: " + cli.checked.join(", "));
|
||||
const cliBinary = cli.binary;
|
||||
const vault = await createTemporaryVault("obsidian-livesync-file-name-source-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
couchDB_URI: "http://127.0.0.1:5984",
|
||||
couchDB_DBNAME: "file-name-source",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
useEden: false,
|
||||
pathNormalizationVersion: 0,
|
||||
handleFilenameCaseSensitive: true,
|
||||
},
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
|
||||
const root = "file-name-source";
|
||||
const parent = root + "/parent";
|
||||
const movedParent = root + "/parent-renamed";
|
||||
const oldFile = root + "/rename-before.md";
|
||||
const renamedFile = root + "/rename-after.md";
|
||||
const deletedFile = root + "/delete.md";
|
||||
const notes = [
|
||||
{ path: root + "/created.md", body: "Created by Obsidian Vault.create.\n" },
|
||||
{ path: oldFile, body: "Content survives a file rename.\n" },
|
||||
{ path: deletedFile, body: "This file is removed from membership.\n" },
|
||||
{ path: parent + "/direct.md", body: "Direct folder descendant.\n" },
|
||||
{ path: parent + "/nested/deep.md", body: "Nested folder descendant.\n" },
|
||||
];
|
||||
const expectedAfterFileRename = notes.map((note) =>
|
||||
note.path === oldFile ? { ...note, path: renamedFile } : note
|
||||
);
|
||||
const expectedAfterFolderRename = expectedAfterFileRename.map((note) =>
|
||||
note.path.startsWith(parent + "/") ? { ...note, path: movedParent + note.path.slice(parent.length) } : note
|
||||
);
|
||||
const expectedFinal = expectedAfterFolderRename.filter((note) => note.path !== deletedFile);
|
||||
const oldPaths = [oldFile, deletedFile, parent + "/direct.md", parent + "/nested/deep.md"];
|
||||
|
||||
const membership = await evalObsidianJson<{ paths: string[] }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
membershipProbeSource(),
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await app.vault.createFolder(" + JSON.stringify(root) + ");",
|
||||
"await assertMembership(core,'empty Vault',[],[]);",
|
||||
"await app.vault.createFolder(" + JSON.stringify(parent) + ");",
|
||||
"await app.vault.createFolder(" + JSON.stringify(parent + "/nested") + ");",
|
||||
"for(const note of " + JSON.stringify(notes) + ") await app.vault.create(note.path,note.body);",
|
||||
"await assertMembership(core,'Vault create'," + JSON.stringify(notes) + ",[]);",
|
||||
"await app.vault.rename(app.vault.getAbstractFileByPath(" +
|
||||
JSON.stringify(oldFile) +
|
||||
")," +
|
||||
JSON.stringify(renamedFile) +
|
||||
");",
|
||||
"await assertMembership(core,'file rename'," +
|
||||
JSON.stringify(expectedAfterFileRename) +
|
||||
"," +
|
||||
JSON.stringify([oldFile]) +
|
||||
");",
|
||||
"await app.vault.rename(app.vault.getAbstractFileByPath(" +
|
||||
JSON.stringify(parent) +
|
||||
")," +
|
||||
JSON.stringify(movedParent) +
|
||||
");",
|
||||
"await assertMembership(core,'parent-folder rename'," +
|
||||
JSON.stringify(expectedAfterFolderRename) +
|
||||
"," +
|
||||
JSON.stringify([oldFile, parent + "/direct.md", parent + "/nested/deep.md"]) +
|
||||
");",
|
||||
"await app.vault.delete(app.vault.getAbstractFileByPath(" + JSON.stringify(deletedFile) + "),false);",
|
||||
"const finalMembership=await assertMembership(core,'Vault delete'," +
|
||||
JSON.stringify(expectedFinal) +
|
||||
"," +
|
||||
JSON.stringify(oldPaths) +
|
||||
");",
|
||||
"return JSON.stringify(finalMembership);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
|
||||
const unload = await withObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
async (page) =>
|
||||
await page.evaluate(async () => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
const plugins = host.app?.plugins;
|
||||
const plugin = plugins?.plugins?.["obsidian-livesync"];
|
||||
const oldStorage = plugin?.core?.serviceModules?.storageAccess;
|
||||
if (!plugins || !plugin || !oldStorage) {
|
||||
throw new Error("Could not capture the public storage-access service");
|
||||
}
|
||||
await plugins.disablePlugin("obsidian-livesync");
|
||||
if (plugins.plugins?.["obsidian-livesync"] !== undefined) {
|
||||
throw new Error("LiveSync remained loaded after disablePlugin completed");
|
||||
}
|
||||
let lookupError = "";
|
||||
// Obsidian calls the void unload hook before asynchronous core cleanup completes.
|
||||
const deadline = Date.now() + 20_000;
|
||||
do {
|
||||
try {
|
||||
await oldStorage.getFileNames();
|
||||
} catch (error) {
|
||||
lookupError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
if (lookupError) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} while (Date.now() < deadline);
|
||||
if (!lookupError.toLowerCase().includes("disposed")) {
|
||||
throw new Error(
|
||||
"The captured public lookup did not reject after plug-in unload: " + lookupError
|
||||
);
|
||||
}
|
||||
return { oldLookupDisposed: true };
|
||||
})
|
||||
);
|
||||
if (!unload.oldLookupDisposed)
|
||||
throw new Error("The old storage-access lookup remained usable after plug-in unload");
|
||||
|
||||
await withObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
async (page) =>
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
if (!plugins) throw new Error("Obsidian plug-in manager is unavailable");
|
||||
await plugins.enablePlugin("obsidian-livesync");
|
||||
})
|
||||
);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
|
||||
const reloadedMembership = await evalObsidianJson<{ paths: string[] }>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
membershipProbeSource(),
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const membership=await assertMembership(core,'reloaded service'," +
|
||||
JSON.stringify(expectedFinal) +
|
||||
"," +
|
||||
JSON.stringify(oldPaths) +
|
||||
");",
|
||||
"return JSON.stringify(membership);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
if (JSON.stringify(reloadedMembership.paths) !== JSON.stringify(membership.paths)) {
|
||||
throw new Error("The reloaded storage-access service returned different Vault membership");
|
||||
}
|
||||
|
||||
const identityFolder = root + "/identity";
|
||||
const mixedCasePath = identityFolder + "/Caf\u00e9.md";
|
||||
const upperCasePath = identityFolder + "/CAF\u00c9.md";
|
||||
const mixedCaseBody = "Mixed-case NFC file.\n";
|
||||
const upperCaseBody = "Upper-case NFC file.\n";
|
||||
const preflight = await evalObsidianJson<{
|
||||
collisionRefused: boolean;
|
||||
sentinelId: string;
|
||||
sentinelRev: string;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const storage=core.serviceModules.storageAccess;",
|
||||
"const identityFolder=" + JSON.stringify(identityFolder) + ";",
|
||||
"const mixedCasePath=" + JSON.stringify(mixedCasePath) + ";",
|
||||
"const upperCasePath=" + JSON.stringify(upperCasePath) + ";",
|
||||
"const mixedCaseBody=" + JSON.stringify(mixedCaseBody) + ";",
|
||||
"const upperCaseBody=" + JSON.stringify(upperCaseBody) + ";",
|
||||
"if(mixedCasePath.normalize('NFC').toLowerCase()!==upperCasePath.normalize('NFC').toLowerCase()) throw new Error('The fixture paths do not share NFC/case identity');",
|
||||
"await app.vault.createFolder(identityFolder);",
|
||||
"let vaultCreateFailure='';",
|
||||
"try{await app.vault.create(mixedCasePath,mixedCaseBody);await app.vault.create(upperCasePath,upperCaseBody);}catch(error){vaultCreateFailure=error instanceof Error?error.message:String(error);}",
|
||||
// Vault.create normalises NFD input, so reject that alias without replacing the original contents.
|
||||
"const decomposedPath=mixedCasePath.normalize('NFD');",
|
||||
"let aliasRefused=false;",
|
||||
"try{await app.vault.create(decomposedPath,'Must not replace the original');}catch(error){aliasRefused=String(error).includes('File already exists');}",
|
||||
"if(!aliasRefused||await app.vault.adapter.read(mixedCasePath)!==mixedCaseBody) throw new Error('The NFD alias was not safely refused by Vault.create');",
|
||||
"let disk=await app.vault.adapter.list(identityFolder);",
|
||||
"if(!disk.files.includes(mixedCasePath)||!disk.files.includes(upperCasePath)){",
|
||||
" await app.vault.adapter.write(mixedCasePath,mixedCaseBody);",
|
||||
" await app.vault.adapter.write(upperCasePath,upperCaseBody);",
|
||||
" disk=await app.vault.adapter.list(identityFolder);",
|
||||
"}",
|
||||
"if(!disk.files.includes(mixedCasePath)||!disk.files.includes(upperCasePath)) throw new Error('The Vault adapter could not preserve both exact physical paths: '+JSON.stringify({files:disk.files,createFailure:vaultCreateFailure}));",
|
||||
"const indexedUntil=Date.now()+20000;",
|
||||
"let bothIndexed=false;",
|
||||
"while(Date.now()<indexedUntil){",
|
||||
" const indexed=new Set(app.vault.getFiles().map(file=>file.path));",
|
||||
" if(indexed.has(mixedCasePath)&&indexed.has(upperCasePath)){bothIndexed=true;break;}",
|
||||
" await new Promise(resolve=>setTimeout(resolve,50));",
|
||||
"}",
|
||||
"if(!bothIndexed) throw new Error('Obsidian did not index both physically distinct NFC/case files');",
|
||||
"disk=await app.vault.adapter.list(identityFolder);",
|
||||
"if(!disk.files.includes(mixedCasePath)||!disk.files.includes(upperCasePath)) throw new Error('The Vault adapter did not retain both exact physical paths: '+JSON.stringify(disk.files));",
|
||||
"if(await app.vault.adapter.read(mixedCasePath)!==mixedCaseBody||await app.vault.adapter.read(upperCasePath)!==upperCaseBody) throw new Error('The exact NFC/case physical file contents differ');",
|
||||
"const indexedNames=await storage.getFileNames({fresh:true});",
|
||||
"if(!indexedNames.includes(mixedCasePath)||!indexedNames.includes(upperCasePath)) throw new Error('The public fresh inventory omitted an exact NFC/case path: '+JSON.stringify(indexedNames));",
|
||||
"const database=core.services.database.localDatabase;",
|
||||
"const sentinelPath='file-name-source/rebuild-preflight-sentinel.md';",
|
||||
"const sentinelBody='This real Metadata and Chunk revision must survive the refused rebuild.\\n';",
|
||||
"const sentinelId=await core.services.path.path2id(sentinelPath);",
|
||||
"const sentinelBlob=new Blob([sentinelBody],{type:'text/plain'});",
|
||||
"const now=Date.now();",
|
||||
"const saved=await database.putDBEntry({_id:sentinelId,path:sentinelPath,data:sentinelBlob,ctime:now,mtime:now,size:sentinelBlob.size,children:[],datatype:'plain',type:'plain',eden:{}});",
|
||||
"if(!saved?.ok) throw new Error('Could not seed the real local database sentinel');",
|
||||
"const before=await database.getRaw(sentinelId,{conflicts:true});",
|
||||
"if(!before||before._id!==sentinelId||!before._rev) throw new Error('The local database sentinel Metadata is missing');",
|
||||
"const readText=async(entry)=>{",
|
||||
" if(!entry) throw new Error('The local database sentinel content is missing');",
|
||||
" const data=entry.data;",
|
||||
" if(typeof data==='string') return data;",
|
||||
" if(data instanceof Blob) return await data.text();",
|
||||
" if(data instanceof ArrayBuffer) return new TextDecoder().decode(data);",
|
||||
" if(Array.isArray(data)) return data.join('');",
|
||||
" throw new Error('The local database sentinel content is not readable text');",
|
||||
"};",
|
||||
"const beforeEntry=await database.getDBEntryFromMeta(before,false,true,true);",
|
||||
"if(await readText(beforeEntry)!==sentinelBody) throw new Error('The seeded local database sentinel content differs');",
|
||||
"if(!Array.isArray(before.children)||before.children.length===0) throw new Error('The sentinel does not have real Chunk references');",
|
||||
"await core.services.setting.applyPartial({pathNormalizationVersion:1,handleFilenameCaseSensitive:false});",
|
||||
"if(core.services.setting.currentSettings().pathNormalizationVersion!==1) throw new Error('The test did not select pathNormalizationVersion 1');",
|
||||
"let refusal='';",
|
||||
"try{await core.serviceModules.rebuilder.$rebuildEverything();}catch(error){refusal=error instanceof Error?error.message:String(error);}",
|
||||
"if(!refusal.includes('File path identity collision')||!refusal.includes(mixedCasePath)||!refusal.includes(upperCasePath)) throw new Error('Rebuild did not refuse the NFC/case collision before resetting local state: '+refusal);",
|
||||
"const after=await database.getRaw(sentinelId,{conflicts:true});",
|
||||
"if(!after||after._id!==sentinelId||after._rev!==before._rev) throw new Error('The sentinel Metadata ID or revision changed during collision refusal');",
|
||||
"const afterEntry=await database.getDBEntryFromMeta(after,false,true,true);",
|
||||
"if(await readText(afterEntry)!==sentinelBody) throw new Error('The sentinel content changed during collision refusal');",
|
||||
"if(await app.vault.adapter.read(mixedCasePath)!==mixedCaseBody||await app.vault.adapter.read(upperCasePath)!==upperCaseBody) throw new Error('The NFC/case Vault contents changed during collision refusal');",
|
||||
"return JSON.stringify({collisionRefused:true,sentinelId:before._id,sentinelRev:before._rev});",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
|
||||
console.log(
|
||||
"File-name source: " +
|
||||
membership.paths.length +
|
||||
" Vault files stayed aligned across create, file rename, parent-folder rename, delete, fresh reads, and plug-in reload; " +
|
||||
"NFC/case rebuild collision was refused with sentinel revision " +
|
||||
preflight.sentinelRev +
|
||||
" intact: " +
|
||||
preflight.collisionRefused +
|
||||
"."
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (session) await session.app.stop();
|
||||
} finally {
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -45,6 +45,7 @@ const focusedScenarios = new Set([
|
||||
"deletion-recovery",
|
||||
"local-file-retry",
|
||||
"folder-batch",
|
||||
"file-name-source",
|
||||
"setup-uri-workflow",
|
||||
"two-vault-sync",
|
||||
"security-seed-reconnect",
|
||||
|
||||
Reference in new issue
Block a user