Files
obsidian-livesync/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts
T

1367 lines
61 KiB
TypeScript

import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { decodeBinary } from "octagonal-wheels/binary";
import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityMarker,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianPage,
captureJsonResolveDialogue,
clickJsonResolveOption,
obsidianRemoteDebuggingPort,
openLiveSyncSettings,
withObsidianPage,
} from "../runner/ui.ts";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
const snippetPath = ".obsidian/snippets/livesync-e2e.css";
const snippetContent = [
"body {",
" --livesync-e2e-snippet-colour: #245a70;",
"}",
"",
".livesync-e2e-snippet {",
" color: var(--livesync-e2e-snippet-colour);",
"}",
"",
].join("\n");
const mergeJsonPath = ".obsidian/livesync-e2e-merge.json";
const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
const hiddenConflictStateKey = "__livesyncE2EHiddenConflictResolution";
const targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
const disabledInitialisationProbeKey = "__livesyncE2EDisabledHiddenInitialisation";
type DisabledInitialisationSnapshot = {
syncInternalFiles: boolean;
hiddenFiles: { id: string; path: string; rev: string; deleted: boolean }[];
processedFiles: [string, string | undefined][];
processedDatabaseFiles: [string, string | undefined][];
};
type DisabledInitialisationObservation = {
result: boolean | null;
before: DisabledInitialisationSnapshot;
after: DisabledInitialisationSnapshot;
progressNotices: string[];
};
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
temporaryVaults: Set<TemporaryVault>;
diagnosticPaths: string[];
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
const fullPath = join(vaultPath, path);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, "utf-8");
}
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
await rm(join(vaultPath, path), { force: true });
}
async function readVaultFile(vaultPath: string, path: string): Promise<string> {
return await readFile(join(vaultPath, path), "utf-8");
}
async function pathExists(vaultPath: string, path: string): Promise<boolean> {
try {
await readFile(join(vaultPath, path));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
async function waitForPathContent(
vaultPath: string,
path: string,
predicate: (content: string) => boolean,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<string> {
const deadline = Date.now() + timeoutMs;
let lastContent = "";
while (Date.now() < deadline) {
if (await pathExists(vaultPath, path)) {
lastContent = await readVaultFile(vaultPath, path);
if (predicate(lastContent)) {
return lastContent;
}
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
async function waitForPathDeleted(
vaultPath: string,
path: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await pathExists(vaultPath, path))) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
}
async function assertDisabledInitialisationIsNoOp(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault
): Promise<void> {
const snippetExisted = await pathExists(vault.path, snippetPath);
const originalSnippet = snippetExisted ? await readVaultFile(vault.path, snippetPath) : undefined;
const originalJson = await readVaultFile(vault.path, mergeJsonPath);
const preservedSnippet = "body { --disabled-hidden-initialisation: preserved; }\n";
const preservedJson = JSON.stringify({ disabledInitialisation: "preserved" }, null, 4) + "\n";
await writeVaultFile(vault.path, snippetPath, preservedSnippet);
await writeVaultFile(vault.path, mergeJsonPath, preservedJson);
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.evaluate((stateKey) => {
const state = { progressNotices: [] as string[] };
const observer = new MutationObserver(() => {
for (const notice of document.querySelectorAll<HTMLElement>(".notice")) {
const text = notice.textContent?.trim() ?? "";
if (text.includes("[⚙") && !state.progressNotices.includes(text)) {
state.progressNotices.push(text);
}
}
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
(globalThis as unknown as Record<string, { progressNotices: string[]; observer: MutationObserver }>)[
stateKey
] = { ...state, observer };
}, disabledInitialisationProbeKey);
});
const observation = await evalObsidianJson<DisabledInitialisationObservation>(
context.cliBinary,
[
"(async()=>{",
`const targetFiles=${JSON.stringify([snippetPath, mergeJsonPath])};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const snapshot=async()=>{",
"const files=await addOn.getAllDatabaseFiles();",
"const markers=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
"return {",
"syncInternalFiles:core.settings.syncInternalFiles,",
"hiddenFiles:files.map((entry)=>({id:entry._id,path:addOn.getPath(entry),rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)})).sort((a,b)=>a.path.localeCompare(b.path)),",
"processedFiles:markers(addOn._fileInfoLastProcessed),",
"processedDatabaseFiles:markers(addOn._databaseInfoLastProcessed),",
"};};",
"const before=await snapshot();",
"const result=await addOn.initialiseInternalFileSync('pullForce',true,targetFiles);",
"const after=await snapshot();",
"return JSON.stringify({result:result===undefined?null:result,before,after});",
"})()",
].join(""),
session.cliEnv,
hiddenFileCliTimeoutMs
);
const progressNotices = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
return await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<
string,
{ progressNotices: string[]; observer: MutationObserver } | undefined
>
)[stateKey];
state?.observer.disconnect();
return state?.progressNotices ?? [];
}, disabledInitialisationProbeKey);
});
const failures: string[] = [];
if (observation.result !== false)
failures.push(`expected false result, received ${String(observation.result)}`);
if (observation.before.syncInternalFiles !== false || observation.after.syncInternalFiles !== false) {
failures.push("Hidden File Sync setting changed while initialisation was disabled");
}
if (JSON.stringify(observation.before) !== JSON.stringify(observation.after)) {
failures.push("local hidden database state or processed markers changed");
}
if ((await readVaultFile(vault.path, snippetPath)) !== preservedSnippet) {
failures.push("the existing local snippet was deleted or replaced");
}
if ((await readVaultFile(vault.path, mergeJsonPath)) !== preservedJson) {
failures.push("the existing local JSON file was replaced");
}
if (progressNotices.length > 0)
failures.push(`initialisation created progress Notices: ${JSON.stringify(progressNotices)}`);
if (failures.length > 0) {
throw new Error(
`Disabled Hidden File Sync initialisation was not a no-op: ${failures.join("; ")}\n${JSON.stringify({
observation,
progressNotices,
})}`
);
}
console.log(
"Disabled Hidden File Sync initialisation returned false without changing hidden files or cached state."
);
} finally {
await writeVaultFile(vault.path, mergeJsonPath, originalJson);
if (snippetExisted && originalSnippet !== undefined) {
await writeVaultFile(vault.path, snippetPath, originalSnippet);
} else {
await removeVaultFile(vault.path, snippetPath);
}
}
}
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
return Object.entries(values).every(([key, value]) => parsed[key] === value);
} catch {
return false;
}
}
async function scanHiddenStorage(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllStorageChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function scanHiddenDatabase(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env,
hiddenFileCliTimeoutMs
);
}
async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"await addOn.resolveConflictOnInternalFiles();",
"await addOn.scanAllDatabaseChanges(true);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env,
hiddenFileCliTimeoutMs
);
}
async function startHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson(
cliBinary,
[
"(()=>{",
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
"const state={done:false,error:null};",
"globalThis[key]=state;",
"const addOn=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
"void addOn.resolveConflictOnInternalFiles().then(",
"()=>{state.done=true;},",
"error=>{state.error=String(error);state.done=true;}",
");",
"return JSON.stringify({started:true});",
"})()",
].join(""),
env
);
}
async function waitForHiddenConflictResolution(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson(
cliBinary,
[
"(async()=>{",
`const key=${JSON.stringify(hiddenConflictStateKey)};`,
"const state=globalThis[key];",
"if(!state)throw new Error('Hidden conflict resolution was not started');",
"const deadline=Date.now()+60000;",
"while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));",
"if(!state.done)throw new Error('Hidden conflict pipeline did not complete');",
"if(state.error)throw new Error(state.error);",
"delete globalThis[key];",
"return JSON.stringify({done:true});",
"})()",
].join(""),
env,
hiddenFileCliTimeoutMs
);
}
async function assertResolvedHiddenContent(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
expectedContent: string
): Promise<void> {
const stored = await evalObsidianJson<{
rev: string;
currentRev: string;
conflicts: string[];
datatype: string;
data: string | string[];
}>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;",
"const entry=await db.getDBEntry(`i:${path}`);",
"if(!entry)throw new Error(`Resolved hidden file is missing: ${path}`);",
"const current=await db.getRaw(entry._id,{conflicts:true});",
"return JSON.stringify({rev:entry._rev,currentRev:current._rev,conflicts:current._conflicts||[],datatype:entry.datatype,data:entry.data});",
"})()",
].join(""),
env
);
assertEqual(stored.rev, stored.currentRev, `Hidden result changed while reading ${path}.`);
assertEqual(stored.conflicts.length, 0, `Hidden conflict remains for ${path}.`);
const content =
stored.datatype === "plain"
? typeof stored.data === "string"
? stored.data
: stored.data.join("")
: new TextDecoder().decode(decodeBinary(stored.data));
assertEqual(content, expectedContent, `Stored hidden content differs from the Vault file: ${path}.`);
}
async function storeHiddenFileAsConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
baseRev: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const baseRev=${JSON.stringify(baseRev)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const fileInfo=await addOn.loadFileWithInfo(path);",
"if(fileInfo.deleted) throw new Error(`Hidden file was unexpectedly deleted: ${path}`);",
"const baseData=await addOn.__loadBaseSaveData(path,true);",
"if(baseData===false) throw new Error(`Could not load base save data: ${path}`);",
"const saveData={",
" ...baseData,",
" data:fileInfo.body,",
" mtime:fileInfo.stat.mtime,",
" ctime:fileInfo.stat.ctime,",
" size:fileInfo.stat.size,",
" children:[],",
" deleted:false,",
" type:baseData.datatype,",
"};",
"const result=await core.localDatabase.putDBEntry(saveData,false,baseRev);",
"if(!result?.ok) throw new Error(`Could not store conflicted hidden file: ${path}`);",
"return JSON.stringify({ok:true,rev:result.rev});",
"})()",
].join(""),
env
);
}
async function createHiddenJsonConflict(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
path: string,
base: string,
left: string,
right: string
): Promise<void> {
await writeVaultFile(vault.path, path, base);
await scanHiddenStorage(context.cliBinary, session.cliEnv);
const baseEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
await writeVaultFile(vault.path, path, left);
await scanHiddenStorage(context.cliBinary, session.cliEnv);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
await writeVaultFile(vault.path, path, right);
await storeHiddenFileAsConflict(context.cliBinary, session.cliEnv, path, baseEntry.rev);
const conflicts = await evalObsidianJson<number>(
context.cliBinary,
`(async()=>{
const entry=await app.plugins.plugins['obsidian-livesync'].core.localDatabase.getDBEntryMeta(${JSON.stringify(`i:${path}`)},{conflicts:true});
return JSON.stringify(entry?entry._conflicts?.length||0:0);
})()`,
session.cliEnv
);
assertEqual(conflicts, 1, "The hidden conflict fixture did not retain two live revisions.");
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
};
const hiddenFileSettings = {
encrypt: true,
passphrase: "internal-metadata-e2e-secret",
usePathObfuscation: true,
encryptInternalMetadata: true,
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
};
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
// A fresh Vault waits for onboarding before opening its local database.
// Seed the same isolated settings used by configureCouchDb so that the
// application lifecycle can become ready without a user interaction.
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
context.activeSessions.add(session);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
await session.app.stop();
context.activeSessions.delete(session);
}
async function uploadHiddenFile(
context: RunnerContext,
session: ObsidianLiveSyncSession,
path: string
): Promise<LocalDatabaseEntry> {
await scanHiddenStorage(context.cliBinary, session.cliEnv);
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId));
});
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
if (
!remoteEntry.path?.startsWith("/\\:") ||
remoteEntry.children?.length !== 0 ||
remoteEntry.ctime !== 0 ||
remoteEntry.mtime !== 0 ||
remoteEntry.size !== 0
) {
throw new Error(`Hidden File Sync Metadata was not encrypted for ${entry.id}.`);
}
const versionInfo = await fetchCouchDbDocument(context.couchDb, context.dbName, VERSIONING_DOCID);
if (
versionInfo.version !== REMOTE_FEATURE_GENERATION ||
!(versionInfo.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE)
) {
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
}
return entry;
}
async function pullAndApplyHiddenFiles(
context: RunnerContext,
session: ObsidianLiveSyncSession,
options: { resolveConflicts?: boolean } = {}
): Promise<void> {
await pushLocalChanges(context.cliBinary, session.cliEnv);
if (options.resolveConflicts === true) {
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
}
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
}
async function runCreateRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
await writeVaultFile(vaultA.path, snippetPath, snippetContent);
let session = await startConfiguredSession(context, vaultA);
const entry = await uploadHiddenFile(context, session, snippetPath);
await evalObsidianJson(
context.cliBinary,
[
"(async()=>{",
"const rebuilder=app.plugins.plugins['obsidian-livesync'].core.rebuilder;",
"const inform=rebuilder.informOptionalFeatures;",
"rebuilder.informOptionalFeatures=async()=>{};",
"try{await rebuilder.$rebuildRemote();}finally{rebuilder.informOptionalFeatures=inform;}",
"return JSON.stringify(true);",
"})()",
].join(""),
session.cliEnv
);
const rebuiltVersion = await fetchCouchDbDocument(context.couchDb, context.dbName, VERSIONING_DOCID);
const rebuiltEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, entry.id);
if (
rebuiltVersion.version !== REMOTE_FEATURE_GENERATION ||
!(rebuiltVersion.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE) ||
!rebuiltEntry.path?.startsWith("/\\:")
) {
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
}
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
await stopSession(context, session);
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
await stopSession(context, session);
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
}
async function runDeleteRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
await removeVaultFile(vaultA.path, snippetPath);
let session = await startConfiguredSession(context, vaultA);
await scanHiddenStorage(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopSession(context, session);
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
await waitForPathDeleted(vaultB.path, snippetPath);
await stopSession(context, session);
console.log("Hidden delete round-trip removed the snippet from the second vault.");
}
async function runJsonConflictRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const base = JSON.stringify({ base: true, fromA: false, fromB: false }, null, 4) + "\n";
const left = JSON.stringify({ base: true, fromA: true, fromB: false }, null, 4) + "\n";
const right = JSON.stringify({ base: true, fromA: false, fromB: true }, null, 4) + "\n";
let session = await startConfiguredSession(context, vaultB);
await createHiddenJsonConflict(context, session, vaultB, mergeJsonPath, base, left, right);
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnB);
await stopSession(context, session);
session = await startConfiguredSession(context, vaultA);
await pullAndApplyHiddenFiles(context, session);
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, mergeJsonPath, mergedOnA);
await stopSession(context, session);
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
console.log("Hidden JSON conflict was automatically merged and round-tripped.");
}
async function runJsonManualConflictResolution(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const base = JSON.stringify({ shared: "base" }, null, 4) + "\n";
const left = JSON.stringify({ shared: "left", fromA: true }, null, 4) + "\n";
const right = JSON.stringify({ shared: "right", fromB: true }, null, 4) + "\n";
const session = await startConfiguredSession(context, vault);
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
await startHiddenConflictResolution(context.cliBinary, session.cliEnv);
const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort());
const completedBeforeSelection = await evalObsidianJson<boolean>(
context.cliBinary,
`JSON.stringify(globalThis[${JSON.stringify(hiddenConflictStateKey)}].done)`,
session.cliEnv
);
assertEqual(completedBeforeSelection, false, "The hidden conflict pipeline completed before selection.");
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
await waitForHiddenConflictResolution(context.cliBinary, session.cliEnv);
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
);
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, manualMergeJsonPath, merged);
await stopSession(context, session);
const parsed = JSON.parse(merged);
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value.");
assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value.");
console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`);
}
async function runTargetMismatch(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
await writeVaultFile(vaultA.path, targetPath, targetContent);
let session = await startConfiguredSession(context, vaultA, selectors);
try {
await uploadHiddenFile(context, session, targetPath);
} finally {
await stopSession(context, session);
}
session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "snippets",
});
try {
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
assertEqual(
await pathExists(vaultB.path, targetPath),
false,
"Hidden file was applied on a device where it was not a target file."
);
} finally {
await stopSession(context, session);
}
session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "",
});
let received = "";
try {
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
} finally {
await stopSession(context, session);
}
assertEqual(
received,
targetContent,
"The normal file allow-list blocked a selected hidden file from the other device."
);
console.log(
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
);
}
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(
({ nextItemIds, nextIncludeRestart }) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.manifests[pluginId] = {
id: pluginId,
name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`,
version: "1.0.0",
minAppVersion: "1.0.0",
description: "E2E fixture",
author: "Self-hosted LiveSync",
isDesktopOnly: false,
dir: `.obsidian/plugins/${pluginId}`,
};
obsidianApp.plugins.enabledPlugins.add(pluginId);
}
addOn.queuedNotificationFiles.clear();
for (const id of nextItemIds) {
addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`);
}
if (nextIncludeRestart) {
addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir());
}
addOn.notifyConfigChange();
},
{ nextItemIds: itemIds, nextIncludeRestart: includeRestart }
);
});
}
async function clearHiddenFileNoticeFixtures(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
await page.evaluate(() => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
plugin.core.services.context.noticeGroups.hide("hidden-file-changes");
for (const id of ["alpha", "beta", "gamma"]) {
const pluginId = `livesync-e2e-${id}`;
obsidianApp.plugins.enabledPlugins.delete(pluginId);
delete obsidianApp.plugins.manifests[pluginId];
}
});
});
}
async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault, {
syncInternalFiles: false,
syncInternalFilesBeforeReplication: false,
});
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await withObsidianPage(port, async (page) => {
const deadline = Date.now() + timeoutMs;
while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) {
await page
.locator(".notice:visible")
.first()
.click({
force: true,
position: { x: 2, y: 2 },
timeout: timeoutMs,
});
}
assertEqual(
await page.locator(".notice:visible").count(),
0,
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
);
});
await assertDisabledInitialisationIsNoOp(context, session, vault);
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
const plugin = obsidianApp.plugins.plugins["obsidian-livesync"];
const core = plugin.core;
const addOn = core.getAddOn("HiddenFileSync");
const setting = core.services.setting;
const originalApplyPartial = setting.applyPartial;
const originalRebuildMerging = addOn.rebuildMerging;
const state = {
done: false,
reachedPreparation: false,
reachedInitialisation: false,
maxVisibleProgressNotices: 0,
visibleProgressNoticeTexts: [] as string[],
sawStandaloneGatheringNotice: false,
sawStandaloneRestartNotice: false,
releasePreparation: undefined as (() => void) | undefined,
releaseInitialisation: undefined as (() => void) | undefined,
error: undefined as string | undefined,
};
(globalThis as unknown as Record<string, typeof state>)[stateKey] = state;
const observer = new MutationObserver(() => {
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
setting.applyPartial = async (...args: unknown[]) => {
const update = args[0] as { syncInternalFiles?: unknown } | undefined;
if (update?.syncInternalFiles === true && !state.reachedPreparation) {
state.reachedPreparation = true;
await new Promise<void>((resolve) => {
state.releasePreparation = resolve;
});
}
return await originalApplyPartial.apply(setting, args);
};
addOn.rebuildMerging = async (...args: unknown[]) => {
state.reachedInitialisation = true;
await new Promise<void>((resolve) => {
state.releaseInitialisation = resolve;
});
return await originalRebuildMerging.apply(addOn, args);
};
void core.services.setting
.enableOptionalFeature("MERGE")
.then(
() => {
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
setting.applyPartial = originalApplyPartial;
addOn.rebuildMerging = originalRebuildMerging;
const notices = Array.from(document.querySelectorAll<HTMLElement>(".notice"));
const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙"));
state.sawStandaloneGatheringNotice ||= notices.some((notice) =>
notice.textContent?.includes("Gathering files for enabling Hidden File Sync")
);
state.sawStandaloneRestartNotice ||= notices.some((notice) =>
notice.textContent?.includes("Done! Restarting the app is strongly recommended!")
);
if (progressNotices.length > state.maxVisibleProgressNotices) {
state.maxVisibleProgressNotices = progressNotices.length;
state.visibleProgressNoticeTexts = progressNotices.map(
(notice) => notice.textContent?.trim() ?? ""
);
}
observer.disconnect();
});
}, hiddenFileInitialisationStateKey);
});
const screenshotPath = await captureObsidianPage(
port,
"hidden-file-initial-scan-progress.png",
async (page) => {
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedPreparation?: boolean } | undefined>)[
stateKey
]?.reachedPreparation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync showed more than one progress Notice before saving its enabled setting."
);
await progressNotices
.filter({ hasText: "Preparing Hidden File Sync..." })
.waitFor({ state: "visible", timeout: timeoutMs });
}
);
const result = await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<string, { releasePreparation?: () => void } | undefined>
)[stateKey];
state?.releasePreparation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { reachedInitialisation?: boolean } | undefined>)[stateKey]
?.reachedInitialisation === true,
hiddenFileInitialisationStateKey,
{ timeout: timeoutMs }
);
const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" });
assertEqual(
await progressNotices.count(),
1,
"Hidden File Sync replaced its parent progress Notice when the first child phase started."
);
await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<string, { releaseInitialisation?: () => void } | undefined>
)[stateKey];
state?.releaseInitialisation?.();
}, hiddenFileInitialisationStateKey);
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, { done?: boolean } | undefined>)[stateKey]?.done === true,
hiddenFileInitialisationStateKey,
{ timeout: hiddenFileCliTimeoutMs }
);
return await page.evaluate(
(stateKey) =>
(
globalThis as unknown as Record<
string,
{
error?: string;
maxVisibleProgressNotices: number;
visibleProgressNoticeTexts: string[];
sawStandaloneGatheringNotice: boolean;
sawStandaloneRestartNotice: boolean;
}
>
)[stateKey],
hiddenFileInitialisationStateKey
);
});
if (result.error) {
throw new Error(`Hidden File Sync initialisation failed: ${result.error}`);
}
assertEqual(
result.maxVisibleProgressNotices,
1,
`Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify(
result.visibleProgressNoticeTexts
)}`
);
assertEqual(
result.sawStandaloneGatheringNotice,
false,
"Hidden File Sync showed the old standalone gathering Notice."
);
assertEqual(
result.sawStandaloneRestartNotice,
false,
"Hidden File Sync showed the old standalone restart recommendation."
);
console.log(
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
);
} finally {
await stopSession(context, session);
}
}
async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise<void> {
const session = await startConfiguredSession(context, vault);
const port = session.remoteDebuggingPort;
const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000);
try {
await setObsidianMobileTestMode(port, true, timeoutMs);
await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true);
await withObsidianPage(port, async (page) => {
const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible");
await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs });
assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group.");
const notice = visibleGroups.first();
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action.");
await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor();
await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor();
await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor();
await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" });
await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" });
await assertLocatorWithinSafeArea(page, notice, {
label: "Hidden File Sync notification group",
safeAreaInsets: iPhoneSafeArea,
});
const buttons = notice.getByRole("button");
for (let index = 0; index < (await buttons.count()); index += 1) {
await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), {
label: `Hidden File Sync notification action ${index + 1}`,
});
}
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png");
await mkdir(dirname(screenshotPath), { recursive: true });
await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" });
await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click();
await notice.waitFor({ state: "hidden", timeout: timeoutMs });
});
await setHiddenFileNoticeFixtures(port, ["gamma"], false);
await withObsidianPage(port, async (page) => {
const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first();
await notice.waitFor({ state: "visible", timeout: timeoutMs });
const rows = notice.locator(".vpk-keyed-notice-group__item");
assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows.");
await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor();
});
console.log(
"Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555."
);
} finally {
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
await stopSession(context, session);
}
}
type HiddenEnablementMode = "Merge" | "Fetch" | "Overwrite";
async function enableHiddenSyncThroughSettings(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
mode: HiddenEnablementMode
): Promise<void> {
const stateKey = "__livesyncE2EHiddenEnablement";
await evalObsidianJson(
context.cliBinary,
`(()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
if(core.settings.syncInternalFiles)throw new Error('Hidden File Sync must start disabled');
const setting=core.services.setting;
const original=setting.enableOptionalFeature;
const state={done:false,error:null,mode:null,restore:()=>{setting.enableOptionalFeature=original;}};
globalThis[${JSON.stringify(stateKey)}]=state;
setting.enableOptionalFeature=async function(mode){
state.mode=mode;
try{return await original.call(this,mode);}
catch(error){state.error=String(error);throw error;}
finally{state.done=true;state.restore();}
};
return JSON.stringify(true);
})()`,
session.cliEnv
);
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const navigator = await openLiveSyncSettings(page);
const settingsPage = await navigator.openPage("Sync Settings");
const row = settingsPage.locator(".setting-item").filter({
has: navigator.page.getByRole("button", { name: "Merge", exact: true }),
});
await row.getByRole("button", { name: mode, exact: true }).click();
});
const result = await evalObsidianJson<{ mode: string; enabled: boolean }>(
context.cliBinary,
`
(async()=>{
const state=globalThis[${JSON.stringify(stateKey)}];
const deadline=Date.now()+60000;
while(!state.done&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,50));
if(!state.done)throw new Error('Hidden File Sync enablement did not complete');
if(state.error)throw new Error(state.error);
const core=app.plugins.plugins['obsidian-livesync'].core;
return JSON.stringify({mode:state.mode,enabled:core.settings.syncInternalFiles});
})()
`,
session.cliEnv,
hiddenFileCliTimeoutMs
);
assertEqual(result.mode, mode.toUpperCase(), "The settings action invoked a different enablement mode.");
assertEqual(result.enabled, true, "Hidden File Sync was not enabled by its settings action.");
const saved = JSON.parse(await readVaultFile(vault.path, ".obsidian/plugins/obsidian-livesync/data.json"));
assertEqual(saved.syncInternalFiles, true, "Hidden File Sync enablement was not persisted.");
} finally {
await evalObsidianJson(
context.cliBinary,
`(()=>{
globalThis[${JSON.stringify(stateKey)}]?.restore();
delete globalThis[${JSON.stringify(stateKey)}];
return JSON.stringify(true);
})()`,
session.cliEnv
);
}
}
async function startPersistedSession(context: RunnerContext, vault: TemporaryVault): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault });
context.activeSessions.add(session);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
const enabled = await evalObsidianJson<boolean>(
context.cliBinary,
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.syncInternalFiles)",
session.cliEnv
);
assertEqual(enabled, true, "Hidden File Sync enablement did not survive restart.");
return session;
}
async function verifyHiddenFiles(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
expected: Record<string, string>
): Promise<void> {
for (const [path, content] of Object.entries(expected)) {
await waitForPathContent(vault.path, path, (actual) => actual === content);
await assertResolvedHiddenContent(context.cliBinary, session.cliEnv, path, content);
}
}
async function runHiddenEnablement(context: RunnerContext, mode: HiddenEnablementMode): Promise<void> {
const source = await createTemporaryVault();
context.temporaryVaults.add(source);
const target = await createTemporaryVault();
context.temporaryVaults.add(target);
const root = `.obsidian/livesync-enable-${mode.toLowerCase()}`;
const localNewer = `${root}/local-newer.json`;
const remoteNewer = `${root}/remote-newer.json`;
const remoteOnly = `${root}/remote-only.json`;
const localOnly = `${root}/local-only.json`;
context.diagnosticPaths = [localNewer, remoteNewer, remoteOnly, localOnly];
const remoteContent = JSON.stringify({ source: "remote", mode }) + "\n";
const localContent = JSON.stringify({ source: "local", mode }) + "\n";
const timestamp = Math.floor(Date.now() / 1000) - 120;
for (const path of [localNewer, remoteNewer, remoteOnly]) {
await writeVaultFile(source.path, path, remoteContent);
await utimes(join(source.path, path), timestamp + 10, timestamp + 10);
}
const settings = {
syncInternalFiles: false,
useAdvancedMode: true,
syncInternalFilesTargetPatterns: `^\\.obsidian(?:$|/livesync-enable-${mode.toLowerCase()}(?:/|$))`,
};
let session = await startConfiguredSession(context, source, settings);
await enableHiddenSyncThroughSettings(context, session, source, "Overwrite");
await verifyHiddenFiles(context, session, source, {
[localNewer]: remoteContent,
[remoteNewer]: remoteContent,
[remoteOnly]: remoteContent,
});
await pushLocalChanges(context.cliBinary, session.cliEnv);
const uploaded: LocalDatabaseEntry[] = [];
for (const path of [localNewer, remoteNewer, remoteOnly]) {
uploaded.push(await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true }));
}
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) =>
uploaded.every((entry) => docs.some((doc) => doc._id === entry.id && doc._rev === entry.rev))
);
await stopSession(context, session);
for (const [path, time] of [
[localNewer, timestamp + 30],
[remoteNewer, timestamp],
[localOnly, timestamp + 30],
] as const) {
await writeVaultFile(target.path, path, localContent);
await utimes(join(target.path, path), time, time);
}
session = await startConfiguredSession(context, target, settings);
await pushLocalChanges(context.cliBinary, session.cliEnv);
for (const path of [localNewer, remoteNewer]) {
assertEqual(await readVaultFile(target.path, path), localContent, "Disabled sync replaced a local fixture.");
}
const receivedRevisions = await evalObsidianJson<string[]>(
context.cliBinary,
`(async()=>{
const db=app.plugins.plugins['obsidian-livesync'].core.localDatabase;
const revisions=[];
for(const id of ${JSON.stringify(uploaded.map((entry) => entry.id))}) {
const doc=await db.getRaw(id);
revisions.push(doc._rev);
}
return JSON.stringify(revisions);
})()`,
session.cliEnv
);
assertEqual(
JSON.stringify(receivedRevisions),
JSON.stringify(uploaded.map((entry) => entry.rev)),
"Disabled Hidden File Sync did not retain the uploaded revisions in its database."
);
assertEqual(await pathExists(target.path, remoteOnly), false, "Disabled sync reflected a remote-only file.");
await enableHiddenSyncThroughSettings(context, session, target, mode);
const expected = {
[localNewer]: mode === "Fetch" ? remoteContent : localContent,
[remoteNewer]: mode === "Overwrite" ? localContent : remoteContent,
[remoteOnly]: remoteContent,
[localOnly]: localContent,
};
await verifyHiddenFiles(context, session, target, expected);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await stopSession(context, session);
session = await startPersistedSession(context, target);
await verifyHiddenFiles(context, session, target, expected);
await stopSession(context, session);
session = await startPersistedSession(context, source);
await pullAndApplyHiddenFiles(context, session);
await verifyHiddenFiles(context, session, source, expected);
await stopSession(context, session);
console.log(
`Hidden File Sync ${mode} preserved the selected contents in files and the database, after restart, and on the other device.`
);
}
async function main(): Promise<void> {
const selectedCase = process.argv.find((argument) => argument.startsWith("--case="))?.slice(7);
if (selectedCase && selectedCase !== "conflicts" && selectedCase !== "enablement") {
throw new Error("Use --case=conflicts or --case=enablement.");
}
const conflictsOnly = selectedCase === "conflicts";
const enablementOnly = selectedCase === "enablement";
const selectedMode = process.argv.find((argument) => argument.startsWith("--mode="))?.slice(7);
const enablementModes = (["Merge", "Fetch", "Overwrite"] as const).filter(
(mode) => !selectedMode || selectedMode === mode
);
if (selectedMode && (!enablementOnly || enablementModes.length !== 1)) {
throw new Error("Use --case=enablement with --mode=Merge, --mode=Fetch, or --mode=Overwrite.");
}
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const couchDb = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
couchDb,
dbName,
activeSessions: new Set(),
temporaryVaults: new Set([vaultA, vaultB]),
diagnosticPaths: [mergeJsonPath, manualMergeJsonPath, snippetPath],
};
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault A: ${vaultA.path}`);
console.log(`Temporary vault B: ${vaultB.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
if (!conflictsOnly && !enablementOnly) {
await runCreateRoundTrip(context, vaultA, vaultB);
await runDeleteRoundTrip(context, vaultA, vaultB);
}
if (!enablementOnly) {
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
}
if (!conflictsOnly && !enablementOnly) {
await runTargetMismatch(context, vaultA, vaultB);
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
}
if (!conflictsOnly) {
for (const mode of enablementModes) await runHiddenEnablement(context, mode);
}
} catch (error) {
for (const session of context.activeSessions) {
await captureObsidianPage(
session.remoteDebuggingPort,
"hidden-file-workflow-failure.png",
async () => {}
).catch((captureError: unknown) => console.error(String(captureError)));
await evalObsidianJson(
cli.binary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
`const paths=${JSON.stringify(context.diagnosticPaths)};`,
"const files=await addOn.scanInternalFileNames();",
"const observations=[];",
"for(const path of paths){",
"const stat=await core.storageAccess.statHidden(path);",
"const entry=await core.localDatabase.getDBEntryMeta(`i:${path}`,{conflicts:true});",
"observations.push({path,selected:files.includes(path),target:await addOn.isTargetFile(path),stat,processed:addOn.getLastProcessedFileKey(path),entry:entry?{rev:entry._rev,conflicts:entry._conflicts||[]}:false});",
"}",
`const operation=globalThis[${JSON.stringify(hiddenConflictStateKey)}];`,
"return JSON.stringify({enabled:core.settings.syncInternalFiles,ready:core.localDatabase.isReady,remaining:addOn.conflictResolutionProcessor.totalRemaining,operation,observations});",
"})()",
].join(""),
session.cliEnv
)
.then((snapshot) => console.error("Hidden-file failure state:", JSON.stringify(snapshot)))
.catch((snapshotError: unknown) => console.error(String(snapshotError)));
}
throw error;
} finally {
for (const session of context.activeSessions) await stopSession(context, session);
for (const vault of context.temporaryVaults) await vault.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});