mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-02 22:38:18 +00:00
1367 lines
61 KiB
TypeScript
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);
|
|
});
|