mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-14 20:20:12 +00:00
344 lines
20 KiB
TypeScript
344 lines
20 KiB
TypeScript
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;
|
|
});
|