Merge main into refactor modules checkpoint

Keep the refactor_modules branch current with main and preserve the real Obsidian E2E progress.

Build and unit tests pass; real Obsidian hidden-file create/delete now pass, while hidden JSON conflict round-trip still needs follow-up.
This commit is contained in:
vorotamoroz
2026-07-03 08:35:48 +00:00
451 changed files with 5429 additions and 502 deletions
+53
View File
@@ -0,0 +1,53 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { launchObsidian } from "../runner/launch.ts";
import { runObsidianCli } from "../runner/cli.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import { installBuiltPlugin } from "../runner/pluginInstaller.ts";
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const vault = await createTemporaryVault();
let app;
try {
await installBuiltPlugin(vault.path);
app = await launchObsidian({
binary,
vaultPath: vault.path,
homePath: vault.homePath,
xdgConfigPath: vault.xdgConfigPath,
userDataPath: vault.userDataPath,
});
const cliEnv = {
...process.env,
HOME: vault.homePath,
XDG_CONFIG_HOME: vault.xdgConfigPath,
XDG_CACHE_HOME: vault.xdgCachePath,
XDG_DATA_HOME: vault.xdgDataPath,
};
await runObsidianCli(cli.binary, [`obsidian://open?path=${encodeURIComponent(vault.path)}`], cliEnv);
await new Promise((resolve) => setTimeout(resolve, 3000));
if (process.env.E2E_OBSIDIAN_RELOAD_PLUGIN === "true") {
await runObsidianCli(cli.binary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], cliEnv);
await runObsidianCli(cli.binary, ["plugin:reload", "id=obsidian-livesync"], cliEnv);
}
const cliArgs = process.argv.slice(2);
const result = await runObsidianCli(cli.binary, cliArgs.length > 0 ? cliArgs : ["--help"], cliEnv);
console.log(result.stdout);
console.error(result.stderr);
process.exitCode = result.code ?? 1;
} finally {
if (app) {
await app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+140
View File
@@ -0,0 +1,140 @@
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const notePath = "E2E/couchdb-upload.md";
const noteContent = [
"# CouchDB upload from real Obsidian",
"",
"This note is created through Obsidian and uploaded by Self-hosted LiveSync.",
"The content is intentionally long enough to require chunk metadata in the local database.",
"0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz",
"0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz",
`Created at: ${new Date().toISOString()}`,
"",
].join("\n");
async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.ProcessEnv): Promise<LocalDatabaseEntry> {
return await evalObsidianJson<LocalDatabaseEntry>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const content=${JSON.stringify(noteContent)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"await app.vault.create(path,content);",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let entry=false;",
"for(let i=0;i<40;i++){",
"await core.services.fileProcessing.commitPendingFileEvents();",
"entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;",
"await sleep(250);",
"}",
"if(!entry||!entry._id) throw new Error('Timed out waiting for local database entry');",
"return JSON.stringify({id:entry._id,path:entry.path,type:entry.type,children:entry.children||[]});",
"})()",
].join(""),
env
);
}
async function main(): Promise<void> {
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, "obsidian-upload");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
assertEqual(configured.couchDB_URI, couchDb.uri, "Configured CouchDB URI did not match.");
assertEqual(configured.couchDB_DBNAME, dbName, "Configured CouchDB database name did not match.");
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
assertEqual(configured.syncOnStart, false, "Sync on start should remain disabled during this workflow.");
assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow.");
await prepareRemote(cli.binary, session.cliEnv);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(localEntry.id) && localEntry.children.every((childId) => ids.has(childId));
});
const remoteMetadata = remoteDocs.find((doc) => doc._id === localEntry.id);
assertEqual(
remoteMetadata?.path,
localEntry.path,
"Remote metadata path did not match the local database entry."
);
console.log(
`Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}`
);
} finally {
if (session) {
await session.app.stop();
}
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);
});
@@ -0,0 +1,603 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.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-customisation-e2e.css";
const snippetContent = [
"body {",
" --livesync-customisation-e2e-colour: #3d6f54;",
"}",
"",
".livesync-customisation-e2e {",
" color: var(--livesync-customisation-e2e-colour);",
"}",
"",
].join("\n");
const snippetUpdatedContent = [
"body {",
" --livesync-customisation-e2e-colour: #73548f;",
"}",
"",
".livesync-customisation-e2e {",
" background-color: var(--livesync-customisation-e2e-colour);",
"}",
"",
].join("\n");
const configPath = ".obsidian/livesync-customisation-e2e.json";
const configContent = JSON.stringify({ source: "customisation-sync", enabled: true }, null, 4) + "\n";
const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
const pluginManifestPath = `${pluginDir}/manifest.json`;
const pluginMainPath = `${pluginDir}/main.js`;
const pluginStylesPath = `${pluginDir}/styles.css`;
const pluginManifestContent =
JSON.stringify(
{
id: "livesync-e2e-sample",
name: "LiveSync E2E Sample",
version: "0.0.1",
minAppVersion: "1.0.0",
description: "A sample plug-in fixture for real Obsidian E2E.",
author: "Self-hosted LiveSync",
isDesktopOnly: false,
},
null,
4
) + "\n";
const pluginMainContent = [
"module.exports = class LiveSyncE2ESamplePlugin extends Plugin {",
" async onload() {",
" this.register(() => undefined);",
" }",
"};",
"",
].join("\n");
const pluginStylesContent = ".livesync-e2e-sample { color: #73548f; }\n";
const sourceDeviceName = "customisation-sync-a";
const targetDeviceName = "customisation-sync-b";
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
};
type CustomisationEntry = {
id: string;
path: string;
children: string[];
};
type CustomisationScanResult = {
enabled: boolean;
useV2: boolean;
device: string;
configDir: string;
files: 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 startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
deviceName: string
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{
deviceAndVaultName: deviceName,
usePluginSync: true,
usePluginSyncV2: true,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
syncInternalFiles: false,
}
);
await evalObsidianJson<unknown>(
context.cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`core.services.setting.setDeviceAndVaultName(${JSON.stringify(deviceName)});`,
"await core.services.setting.saveSettingData();",
"return JSON.stringify({device:core.services.setting.getDeviceAndVaultName()});",
"})()",
].join(""),
session.cliEnv
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
}
async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Promise<CustomisationScanResult> {
return await evalObsidianJson<CustomisationScanResult>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const before=await addOn.scanInternalFiles();",
"await addOn.scanAllConfigFiles(false);",
"return JSON.stringify({",
"ok:true,",
"enabled:core.settings.usePluginSync,",
"useV2:core.settings.usePluginSyncV2,",
"device:core.services.setting.getDeviceAndVaultName(),",
"configDir:addOn.configDir,",
"files:before,",
"});",
"})()",
].join(""),
env
);
}
async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const term=core.services.setting.getDeviceAndVaultName();",
"const stat=await core.storageAccess.statHidden(path);",
"const category=addOn.getFileCategory(path);",
"const result=await addOn.storeCustomizationFiles(path,term);",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>doc.path);",
"const filename=path.split('/').pop();",
"const existing=entries.some((entry)=>entry.startsWith(`ix:${term}/${category}/`)&&entry.endsWith(`%${filename}`));",
"if(!result&&!existing){",
" throw new Error(`Could not store Customisation Sync file: path=${path}; term=${term}; category=${category}; stat=${JSON.stringify(stat)}; result=${JSON.stringify(result)}; entries=${JSON.stringify(entries)}`);",
"}",
"return JSON.stringify({ok:true,path,term,category,result:!!result,existing,entries});",
"})()",
].join(""),
env
);
}
async function deleteCustomisationSyncEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
name: string,
term?: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const category=${JSON.stringify(category)};`,
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&!doc.deleted&&!doc._deleted)||false;",
"if(!entry) throw new Error(`Could not find customisation sync entry to delete: ${category}/${name}`);",
"if(!(await addOn.deleteConfigOnDatabase(entry.path))){",
" throw new Error(`Could not delete Customisation Sync entry: ${entry.path}`);",
"}",
"return JSON.stringify({ok:true,path:entry.path});",
"})()",
].join(""),
env
);
}
async function waitForCustomisationEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
name: string,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
): Promise<CustomisationEntry> {
const entries = await waitForCustomisationEntries(cliBinary, env, category, name, 1, term, timeoutMs);
return entries[0];
}
async function waitForCustomisationEntries(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
name: string,
count: number,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
): Promise<CustomisationEntry[]> {
return await evalObsidianJson<CustomisationEntry[]>(
cliBinary,
[
"(async()=>{",
`const category=${JSON.stringify(category)};`,
`const name=${JSON.stringify(name)};`,
`const count=${JSON.stringify(count)};`,
`const term=${JSON.stringify(term ?? "")};`,
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let entries=[];",
"while(Date.now()<deadline){",
" const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
" entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&Array.isArray(doc.children)&&doc.children.length>0);",
" if(entries.length>=count) break;",
" await sleep(250);",
"}",
"if(entries.length<count){",
" const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
" const found=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>({id:doc._id,path:doc.path,children:doc.children?.length??0}));",
" throw new Error(`Timed out waiting for customisation sync entries: ${category}/${name}; expected=${count}; entries=${JSON.stringify(found)}`);",
"}",
"return JSON.stringify(entries.map((entry)=>({id:entry._id,path:entry.path,children:entry.children||[]})));",
"})()",
].join(""),
env
);
}
async function waitForCustomisationEntryAbsent(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
name: string,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const category=${JSON.stringify(category)};`,
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
`const timeoutMs=${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let entry=false;",
"while(Date.now()<deadline){",
" const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
" entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&!doc.deleted&&!doc._deleted)||false;",
" if(!entry) return JSON.stringify({ok:true});",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for customisation sync entry deletion: ${category}/${name}; entry=${JSON.stringify(entry)}`);",
"})()",
].join(""),
env
);
}
async function applyRemoteCustomisationEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
name: string,
term?: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const category=${JSON.stringify(category)};`,
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)))||false;",
"if(!entry) throw new Error(`Could not find remote customisation entry: ${category}/${name}`);",
"const display=addOn.createPluginDataFromV2(entry.path);",
"if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entry.path}`);",
"const file=await addOn.createPluginDataExFileV2(entry.path);",
"if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);",
"await display.setFile(file);",
"if(!(await addOn.applyDataV2(display))){",
" throw new Error(`Could not apply Customisation Sync entry: ${entry.path}`);",
"}",
"return JSON.stringify({ok:true,path:entry.path});",
"})()",
].join(""),
env
);
}
async function applyRemoteCustomisationGroup(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "PLUGIN_MAIN",
name: string,
term?: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const category=${JSON.stringify(category)};`,
`const name=${JSON.stringify(name)};`,
`const term=${JSON.stringify(term ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('ConfigSync');",
"const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
"const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)));",
"if(entries.length===0) throw new Error(`Could not find remote customisation entries: ${category}/${name}`);",
"const display=addOn.createPluginDataFromV2(entries[0].path);",
"if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entries[0].path}`);",
"for(const entry of entries){",
" const file=await addOn.createPluginDataExFileV2(entry.path);",
" if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);",
" await display.setFile(file);",
"}",
"if(!(await addOn.applyDataV2(display))){",
" throw new Error(`Could not apply Customisation Sync group: ${category}/${name}`);",
"}",
"return JSON.stringify({ok:true,count:entries.length});",
"})()",
].join(""),
env
);
}
async function main(): Promise<void> {
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, "customisation-sync");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const snippetPathParts = snippetPath.split("/");
const snippetName = snippetPathParts[snippetPathParts.length - 1] ?? snippetPath;
const configName = configPath.split("/").pop() ?? configPath;
const pluginName = pluginDir.split("/").pop() ?? pluginDir;
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}`);
await writeVaultFile(vaultA.path, snippetPath, snippetContent);
await writeVaultFile(vaultA.path, configPath, configContent);
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
console.log(`Customisation scan files: ${scanResult.files.join(", ") || "(none)"}`);
await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, configPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath);
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath);
const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName);
const pluginEntries = await waitForCustomisationEntries(
context.cliBinary,
session.cliEnv,
"PLUGIN_MAIN",
pluginName,
3
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
const entries = [entry, configEntry, ...pluginEntries];
return entries.every(
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
);
});
await session.app.stop();
session = await startConfiguredSession(context, vaultB, targetDeviceName);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
assertEqual(
await pathExists(vaultB.path, snippetPath),
false,
"Customisation Sync snippet was reflected before explicit application."
);
await applyRemoteCustomisationEntry(
context.cliBinary,
session.cliEnv,
"SNIPPET",
snippetName,
sourceDeviceName
);
const applied = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
await applyRemoteCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName, sourceDeviceName);
const appliedConfig = await waitForPathContent(vaultB.path, configPath, (content) => content === configContent);
await applyRemoteCustomisationGroup(
context.cliBinary,
session.cliEnv,
"PLUGIN_MAIN",
pluginName,
sourceDeviceName
);
const appliedPluginManifest = await waitForPathContent(
vaultB.path,
pluginManifestPath,
(content) => content === pluginManifestContent
);
const appliedPluginMain = await waitForPathContent(
vaultB.path,
pluginMainPath,
(content) => content === pluginMainContent
);
const appliedPluginStyles = await waitForPathContent(
vaultB.path,
pluginStylesPath,
(content) => content === pluginStylesContent
);
await session.app.stop();
assertEqual(applied, snippetContent, "Customisation Sync snippet content did not match after application.");
assertEqual(appliedConfig, configContent, "Customisation Sync config content did not match after application.");
assertEqual(
appliedPluginManifest,
pluginManifestContent,
"Customisation Sync plug-in manifest did not match after application."
);
assertEqual(appliedPluginMain, pluginMainContent, "Customisation Sync plug-in main file did not match.");
assertEqual(appliedPluginStyles, pluginStylesContent, "Customisation Sync plug-in stylesheet did not match.");
await writeVaultFile(vaultA.path, snippetPath, snippetUpdatedContent);
session = await startConfiguredSession(context, vaultA, sourceDeviceName);
await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
session = await startConfiguredSession(context, vaultB, targetDeviceName);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await applyRemoteCustomisationEntry(
context.cliBinary,
session.cliEnv,
"SNIPPET",
snippetName,
sourceDeviceName
);
const updated = await waitForPathContent(
vaultB.path,
snippetPath,
(content) => content === snippetUpdatedContent
);
await session.app.stop();
assertEqual(updated, snippetUpdatedContent, "Updated Customisation Sync snippet did not apply.");
await removeVaultFile(vaultA.path, snippetPath);
session = await startConfiguredSession(context, vaultA, sourceDeviceName);
await deleteCustomisationSyncEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
await waitForCustomisationEntryAbsent(
context.cliBinary,
session.cliEnv,
"SNIPPET",
snippetName,
sourceDeviceName
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
session = await startConfiguredSession(context, vaultB, targetDeviceName);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCustomisationEntryAbsent(
context.cliBinary,
session.cliEnv,
"SNIPPET",
snippetName,
sourceDeviceName
);
await session.app.stop();
console.log(
`Customisation Sync applied snippet, config, and plug-in fixtures, then propagated snippet update and sync-data deletion.`
);
} finally {
await vaultA.dispose();
await vaultB.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);
});
+60
View File
@@ -0,0 +1,60 @@
import { launchObsidian } from "../runner/launch.ts";
import { installBuiltPlugin } from "../runner/pluginInstaller.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import { requireObsidianBinary } from "../runner/environment.ts";
import { writeFile } from "node:fs/promises";
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, withObsidianPage } from "../runner/ui.ts";
const port = obsidianRemoteDebuggingPort();
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const vault = await createTemporaryVault();
await installBuiltPlugin(vault.path);
const app = await launchObsidian({
binary,
vaultPath: vault.path,
homePath: vault.homePath,
xdgConfigPath: vault.xdgConfigPath,
xdgCachePath: vault.xdgCachePath,
xdgDataPath: vault.xdgDataPath,
userDataPath: vault.userDataPath,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
try {
await preseedTrustedVaultState(port, vault.id);
const { screenshotPath, textPath } = await withObsidianPage(port, async (page) => {
await page.waitForTimeout(Number(process.env.E2E_OBSIDIAN_DEBUG_WAIT_MS ?? 5000));
const title = await page.title().catch((error: unknown) => `title error: ${String(error)}`);
const url = page.url();
const text = await page
.locator("body")
.innerText({ timeout: 5000 })
.catch((error: unknown) => {
return `body text error: ${String(error)}`;
});
if (process.env.E2E_OBSIDIAN_DEBUG_CLICK_TRUST === "true") {
await page.getByText("Trust author and enable plugins").click({ timeout: 10000 });
await page.waitForTimeout(Number(process.env.E2E_OBSIDIAN_DEBUG_AFTER_CLICK_WAIT_MS ?? 3000));
}
const screenshotPath = process.env.E2E_OBSIDIAN_DEBUG_SCREENSHOT ?? "/tmp/obsidian-e2e-debug.png";
const textPath = process.env.E2E_OBSIDIAN_DEBUG_TEXT ?? "/tmp/obsidian-e2e-debug.txt";
await page.screenshot({ path: screenshotPath, fullPage: true });
await writeFile(textPath, [`title: ${title}`, `url: ${url}`, "", text].join("\n"), "utf-8");
return { screenshotPath, textPath };
});
console.log(`Temporary vault: ${vault.path}`);
console.log(`Temporary Obsidian state: ${vault.userDataPath}`);
console.log(`Debug text: ${textPath}`);
console.log(`Debug screenshot: ${screenshotPath}`);
} finally {
await app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
import { discoverObsidianBinary } from "../runner/environment.ts";
const result = discoverObsidianBinary();
if (result.binary) {
console.log(`Obsidian executable: ${result.binary}`);
console.log(`Source: ${result.source}`);
process.exit(0);
}
console.error("Obsidian executable was not found.");
console.error("Set OBSIDIAN_BINARY to the installed Obsidian executable path.");
console.error(`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`);
process.exit(1);
@@ -0,0 +1,557 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.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 targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: 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)}`);
}
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()=>{",
"await app.commands.commands['obsidian-livesync:livesync-scaninternal-storage'].callback();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function scanHiddenDatabase(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"await app.commands.commands['obsidian-livesync:livesync-scaninternal-database'].callback();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env,
hiddenFileCliTimeoutMs
);
}
async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"await app.commands.commands['obsidian-livesync:livesync-resolveinternal-conflicts'].callback();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env,
hiddenFileCliTimeoutMs
);
}
async function autoMergeHiddenJsonConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const prefixedPath=`i:${path}`;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"let doc=false;",
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
" if(entry.path===prefixedPath){ doc=entry; break; }",
"}",
"if(!doc) throw new Error(`Could not find hidden conflict candidate: ${path}`);",
"if(!doc._conflicts?.length) throw new Error(`Hidden file has no conflicts: ${path}`);",
"const conflicts=doc._conflicts.sort((a,b)=>Number(a.split('-')[0])-Number(b.split('-')[0]));",
"const conflictedRev=conflicts[0];",
"const conflictedRevNo=Number(conflictedRev.split('-')[0]);",
"const revFrom=await core.localDatabase.getRaw(doc._id,{revs_info:true});",
"const commonBase=(revFrom._revs_info||[])",
" .filter((rev)=>rev.status==='available'&&Number(rev.rev.split('-')[0])<conflictedRevNo)",
" .map((rev)=>rev.rev)[0]||'';",
"const result=await core.localDatabase.managers.conflictManager.mergeObject(",
" doc.path, commonBase, doc._rev, conflictedRev",
");",
"if(!result){",
" throw new Error(`Hidden JSON conflict was not auto-mergeable: ${path}; base=${commonBase}; current=${doc._rev}; conflict=${conflictedRev}`);",
"}",
"await app.vault.adapter.write(path,result);",
"const stat=await app.vault.adapter.stat(path);",
"if(!stat) throw new Error(`Could not stat merged hidden file: ${path}`);",
"const baseData=await core.localDatabase.getDBEntry(prefixedPath,{conflicts:true},false,true);",
"if(baseData===false) throw new Error(`Could not load base save data: ${path}`);",
"const saveData={",
" ...baseData,",
" data:new Blob([result]),",
" mtime:stat.mtime,",
" ctime:stat.ctime,",
" size:stat.size,",
" children:[],",
" deleted:false,",
" type:baseData.datatype,",
"};",
"delete saveData._conflicts;",
"const stored=await core.localDatabase.putDBEntry(saveData);",
"if(!stored?.ok) throw new Error(`Could not store merged hidden file: ${path}`);",
"await core.localDatabase.removeRevision(doc._id,conflictedRev);",
"await app.commands.commands['obsidian-livesync:livesync-scaninternal-storage'].callback();",
"await app.commands.commands['obsidian-livesync:livesync-scaninternal-database'].callback();",
"const content=await app.vault.adapter.read(path);",
"return JSON.stringify({ok:true,merged:JSON.parse(content)});",
"})()",
].join(""),
env
);
}
async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const prefixedPath=`i:${path}`;",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"let doc=false;",
"for await (const entry of core.localDatabase.findEntries('i:','i;',{conflicts:true})){",
" if(entry.path===prefixedPath){ doc=entry; break; }",
"}",
"if(!doc?._conflicts?.length) throw new Error(`Could not find hidden JSON conflict: ${path}`);",
"void app.commands.commands['obsidian-livesync:livesync-resolveinternal-conflicts'].callback();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
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 prefixedPath=`i:${path}`;",
"const stat=await app.vault.adapter.stat(path);",
"if(!stat||stat.type!=='file') throw new Error(`Hidden file was unexpectedly missing: ${path}`);",
"const content=await app.vault.adapter.readBinary(path);",
"const oldFile=await core.localDatabase.getDBEntry(prefixedPath,{conflicts:true},false,true);",
"const baseData=oldFile===false?{",
" _id:prefixedPath,",
" path:prefixedPath,",
" mtime:0,",
" ctime:Date.now(),",
" size:0,",
" children:[],",
" deleted:false,",
" type:'newnote',",
" datatype:'newnote',",
" data:'',",
" eden:{},",
"}:oldFile;",
"const saveData={",
" ...baseData,",
" data:new Blob([content]),",
" mtime:stat.mtime,",
" ctime:stat.ctime,",
" size: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);
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
{
syncInternalFiles: true,
syncInternalFilesBeforeReplication: true,
watchInternalFileChanges: false,
syncInternalFilesTargetPatterns: "",
...overrides,
}
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return 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));
});
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 session.app.stop();
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
await session.app.stop();
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 session.app.stop();
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
await waitForPathDeleted(vaultB.path, snippetPath);
await session.app.stop();
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 autoMergeHiddenJsonConflict(context.cliBinary, session.cliEnv, mergeJsonPath);
const mergedEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, mergeJsonPath, {
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(mergedEntry.id) && mergedEntry.children.every((childId) => ids.has(childId));
});
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, mergeJsonPath, { hidden: true });
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await session.app.stop();
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 openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
);
await session.app.stop();
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.");
}
async function runTargetMismatch(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
await writeVaultFile(vaultA.path, targetPath, targetContent);
let session = await startConfiguredSession(context, vaultA);
try {
await uploadHiddenFile(context, session, targetPath);
} finally {
await session.app.stop();
}
session = await startConfiguredSession(context, vaultB, {
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 session.app.stop();
}
session = await startConfiguredSession(context, vaultB, {
syncInternalFilesTargetPatterns: "",
});
let received = "";
try {
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
} finally {
await session.app.stop();
}
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file.");
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
}
async function main(): Promise<void> {
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 };
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}`);
await runCreateRoundTrip(context, vaultA, vaultB);
await runDeleteRoundTrip(context, vaultA, vaultB);
await runJsonConflictRoundTrip(context, vaultA, vaultB);
await runJsonManualConflictResolution(context, vaultB);
await runTargetMismatch(context, vaultA, vaultB);
} finally {
await vaultA.dispose();
await vaultB.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);
});
@@ -0,0 +1,121 @@
import { createWriteStream, existsSync } from "node:fs";
import { chmod, mkdir } from "node:fs/promises";
import { get } from "node:https";
import { arch } from "node:process";
import { basename, join, resolve } from "node:path";
import { spawn } from "node:child_process";
const defaultVersion = "1.12.7";
function appImageArch(): string {
const requestedArch = process.env.E2E_OBSIDIAN_APPIMAGE_ARCH?.trim();
if (requestedArch) {
return requestedArch;
}
if (arch === "arm64") {
return "arm64";
}
if (arch === "x64") {
return "x86_64";
}
throw new Error(`Unsupported architecture for Obsidian AppImage: ${arch}`);
}
function appImageUrl(version: string, imageArch: string): string {
return `https://github.com/obsidianmd/obsidian-releases/releases/download/v${version}/Obsidian-${version}-${imageArch}.AppImage`;
}
function download(url: string, destination: string, redirectsLeft = 5): Promise<void> {
return new Promise((resolveDownload, reject) => {
const request = get(url, (response) => {
const statusCode = response.statusCode ?? 0;
const location = response.headers.location;
if (statusCode >= 300 && statusCode < 400 && location) {
response.resume();
if (redirectsLeft <= 0) {
reject(new Error(`Too many redirects while downloading ${url}`));
return;
}
download(new URL(location, url).toString(), destination, redirectsLeft - 1)
.then(resolveDownload)
.catch(reject);
return;
}
if (statusCode !== 200) {
response.resume();
reject(new Error(`Failed to download ${url}: HTTP ${statusCode}`));
return;
}
const file = createWriteStream(destination, { mode: 0o755 });
response.pipe(file);
file.on("finish", () => {
file.close((error) => {
if (error) {
reject(error);
} else {
resolveDownload();
}
});
});
file.on("error", reject);
});
request.on("error", reject);
});
}
function extractAppImage(appImagePath: string, cwd: string): Promise<void> {
return new Promise((resolveExtract, reject) => {
const child = spawn(appImagePath, ["--appimage-extract"], {
cwd,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) {
resolveExtract();
return;
}
reject(new Error(`AppImage extraction failed. code=${code}, signal=${signal}`));
});
});
}
async function main(): Promise<void> {
const version = process.env.E2E_OBSIDIAN_VERSION?.trim() || defaultVersion;
const imageArch = appImageArch();
const targetDir = resolve(process.env.E2E_OBSIDIAN_DOWNLOAD_DIR?.trim() || "_testdata/obsidian");
const url = process.env.E2E_OBSIDIAN_APPIMAGE_URL?.trim() || appImageUrl(version, imageArch);
const appImagePath = join(targetDir, basename(new URL(url).pathname));
const extractedBinary = join(targetDir, "squashfs-root", "obsidian");
const forceDownload = process.env.E2E_OBSIDIAN_FORCE_DOWNLOAD === "true";
const skipExtract = process.env.E2E_OBSIDIAN_SKIP_EXTRACT === "true";
await mkdir(targetDir, { recursive: true });
if (!existsSync(appImagePath) || forceDownload) {
console.log(`Downloading Obsidian AppImage: ${url}`);
console.log(`Destination: ${appImagePath}`);
await download(url, appImagePath);
await chmod(appImagePath, 0o755);
} else {
console.log(`Using existing Obsidian AppImage: ${appImagePath}`);
}
if (!skipExtract) {
if (existsSync(extractedBinary)) {
console.log(`Using existing extracted Obsidian binary: ${extractedBinary}`);
} else {
console.log(`Extracting Obsidian AppImage in ${targetDir}`);
await extractAppImage(appImagePath, targetDir);
console.log(`Extracted Obsidian binary: ${extractedBinary}`);
}
}
console.log(`Set OBSIDIAN_BINARY=${extractedBinary} to use the extracted binary explicitly.`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
import { spawn } from "node:child_process";
type Step = {
name: string;
args: string[];
optional?: boolean;
};
const testSteps: Step[] = [
{ name: "build", args: ["run", "build"] },
{ name: "discover", args: ["run", "test:e2e:obsidian:discover"] },
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
{ name: "setting Markdown export", args: ["run", "test:e2e:obsidian:setting-markdown-export"] },
];
const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services");
const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services");
const keepServices = process.argv.includes("--keep-services");
const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb");
const keepMinio = keepServices || process.argv.includes("--keep-minio");
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runStep(step: Step): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`\n# ${step.name}`);
const child = spawn(npmBinary(), step.args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const message = `${step.name} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`;
if (step.optional) {
console.warn(message);
resolve();
return;
}
reject(new Error(message));
});
});
}
async function stopManagedCouchDb(): Promise<void> {
await runStep({
name: "stop CouchDB fixture",
args: ["run", "test:docker-couchdb:stop"],
optional: true,
});
}
async function stopManagedMinio(): Promise<void> {
await runStep({
name: "stop MinIO fixture",
args: ["run", "test:docker-s3:stop"],
optional: true,
});
}
async function main(): Promise<void> {
let shouldStopCouchDb = false;
let shouldStopMinio = false;
try {
if (manageCouchDb) {
await stopManagedCouchDb();
await runStep({ name: "start CouchDB fixture", args: ["run", "test:docker-couchdb:start"] });
shouldStopCouchDb = !keepCouchDb;
}
if (manageMinio) {
await stopManagedMinio();
await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] });
shouldStopMinio = !keepMinio;
}
for (const step of testSteps) {
await runStep(step);
}
} finally {
if (shouldStopMinio) {
await stopManagedMinio();
}
if (shouldStopCouchDb) {
await stopManagedCouchDb();
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+142
View File
@@ -0,0 +1,142 @@
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureObjectStorage,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
} from "../runner/objectStorage.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const notePath = "E2E/minio-upload.md";
const noteContent = [
"# Object Storage upload from real Obsidian",
"",
"This note is created through Obsidian and uploaded by Self-hosted LiveSync to S3-compatible Object Storage.",
"The test is intentionally small, but it crosses the real Obsidian, Journal Sync, and AWS SDK boundary.",
`Created at: ${new Date().toISOString()}`,
"",
].join("\n");
async function createNoteAndWaitForLocalDb(cliBinary: string, env: NodeJS.ProcessEnv): Promise<LocalDatabaseEntry> {
return await evalObsidianJson<LocalDatabaseEntry>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const content=${JSON.stringify(noteContent)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"await app.vault.create(path,content);",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let entry=false;",
"for(let i=0;i<40;i++){",
"await core.services.fileProcessing.commitPendingFileEvents();",
"entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;",
"await sleep(250);",
"}",
"if(!entry||!entry._id) throw new Error('Timed out waiting for local database entry');",
"return JSON.stringify({id:entry._id,path:entry.path,type:entry.type,children:entry.children||[]});",
"})()",
].join(""),
env
);
}
async function waitForObjectStorageObjects(prefix: string): Promise<string[]> {
const objectStorage = await loadObjectStorageConfig();
const timeoutMs = Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 20000);
const deadline = Date.now() + timeoutMs;
let keys: string[] = [];
while (Date.now() < deadline) {
const objects = await listObjectStorageObjects(objectStorage, prefix);
keys = objects.flatMap((object) => (object.Key ? [object.Key] : []));
if (keys.length > 0) {
return keys;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for Object Storage objects under ${prefix}. Last keys: ${keys.join(", ")}`);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const objectStorage = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("minio-upload");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await ensureObjectStorageBucket(objectStorage);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
console.log(`Temporary Object Storage bucket: ${objectStorage.bucket}`);
console.log(`Temporary Object Storage prefix: ${bucketPrefix}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureObjectStorage(cli.binary, session.cliEnv, {
...objectStorage,
bucketPrefix,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not marked as configured.");
assertEqual(configured.remoteType, "MINIO", "Remote type was not Object Storage.");
assertEqual(configured.endpoint, objectStorage.endpoint, "Configured Object Storage endpoint did not match.");
assertEqual(configured.bucket, objectStorage.bucket, "Configured Object Storage bucket did not match.");
assertEqual(configured.bucketPrefix, bucketPrefix, "Configured Object Storage bucket prefix did not match.");
assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow.");
await prepareRemote(cli.binary, session.cliEnv);
const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv);
await pushLocalChanges(cli.binary, session.cliEnv);
const keys = await waitForObjectStorageObjects(bucketPrefix);
console.log(
`Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))`
);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(objectStorage, bucketPrefix).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);
});
@@ -0,0 +1,106 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { assertEqual, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const settingPath = "LiveSync/settings-export.md";
async function waitForFileContaining(
vaultPath: string,
path: string,
predicates: ((content: string) => boolean)[],
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
): Promise<string> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + timeoutMs;
let lastContent = "";
let lastError: unknown;
while (Date.now() < deadline) {
try {
lastContent = await readFile(fullPath, "utf-8");
if (predicates.every((predicate) => predicate(lastContent))) {
return lastContent;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for setting Markdown: ${fullPath}\nLast error: ${String(lastError)}`);
}
async function configureSettingMarkdown(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"await core.services.setting.applyExternalSettings({",
`settingSyncFile:${JSON.stringify(settingPath)},`,
"writeCredentialsForSettingSync:false,",
"couchDB_USER:'e2e-user',",
"couchDB_PASSWORD:'e2e-password',",
"passphrase:'e2e-passphrase',",
"showVerboseLog:true,",
"},true);",
"await core.services.setting.saveSettingData();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await configureSettingMarkdown(cli.binary, session.cliEnv);
const content = await waitForFileContaining(vault.path, settingPath, [
(value) => value.includes("````yaml:livesync-setting"),
(value) => value.includes(`settingSyncFile: ${settingPath}`),
(value) => value.includes("showVerboseLog: true"),
]);
assertEqual(
content.includes("couchDB_PASSWORD: e2e-password"),
false,
"Credential leaked into setting Markdown."
);
assertEqual(content.includes("passphrase: e2e-passphrase"), false, "Passphrase leaked into setting Markdown.");
console.log(`Generated setting Markdown without credentials: ${settingPath}`);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
console.log(`Installed plug-in artifacts: ${session.install.copied.join(", ")}`);
const { readiness } = session;
console.log(
`Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}`
);
await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000)));
console.log("Obsidian stayed alive after the plug-in readiness check.");
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+116
View File
@@ -0,0 +1,116 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
const notePath = "E2E/startup-scan.md";
const noteContent = [
"# Startup scan",
"",
"This note was written while Obsidian was stopped.",
"The test verifies that the next real Obsidian boot scans it into the local database.",
`Created at: ${new Date().toISOString()}`,
"",
].join("\n");
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 main(): Promise<void> {
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, "startup-scan");
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
console.log(`Temporary CouchDB database: ${dbName}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const configured = await configureCouchDb(cli.binary, session.cliEnv, {
uri: couchDb.uri,
username: couchDb.username,
password: couchDb.password,
dbName,
});
assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured.");
await prepareRemote(cli.binary, session.cliEnv);
await session.app.stop();
session = undefined;
await writeVaultFile(vault.path, notePath, noteContent);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath);
await pushLocalChanges(cli.binary, session.cliEnv);
const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
return ids.has(localEntry.id) && localEntry.children.every((childId) => ids.has(childId));
});
const remoteMetadata = remoteDocs.find((doc) => doc._id === localEntry.id);
assertEqual(remoteMetadata?.path, localEntry.path, "Startup-scanned remote metadata path did not match.");
console.log(`Startup scan uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s).`);
} finally {
if (session) {
await session.app.stop();
}
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);
});
+525
View File
@@ -0,0 +1,525 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
waitForCouchDbDocs,
type CouchDbConfig,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
configureCouchDb,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
type LocalDatabaseEntry,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.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 createPath = "E2E/two-vault/create.md";
const updatePath = "E2E/two-vault/update.md";
const deletePath = "E2E/two-vault/delete.md";
const renameFromPath = "E2E/two-vault/rename-source.md";
const renameToPath = "E2E/two-vault/renamed/rename-target.md";
const conflictPath = "E2E/two-vault/conflict.md";
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
const encryptedPath = "E2E/two-vault/encrypted.md";
type RunnerContext = {
binary: string;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: 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 writeNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const folder=path.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.modify(existing,content);",
"else await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function deleteNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function renameNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, fromPath: string, toPath: string) {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const fromPath=${JSON.stringify(fromPath)};`,
`const toPath=${JSON.stringify(toPath)};`,
"const folder=toPath.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(fromPath);",
"if(!existing) throw new Error(`Could not find note to rename: ${fromPath}`);",
"await app.vault.rename(existing,toPath);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
overrides: Record<string, unknown> = {}
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{
uri: context.couchDb.uri,
username: context.couchDb.username,
password: context.couchDb.password,
dbName: context.dbName,
},
overrides
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
return session;
}
async function uploadNote(
context: RunnerContext,
session: ObsidianLiveSyncSession,
path: string
): Promise<LocalDatabaseEntry> {
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
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));
});
return entry;
}
async function syncAndApply(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
await pushLocalChanges(context.cliBinary, session.cliEnv);
}
async function storeFileRevision(
cliBinary: string,
env: NodeJS.ProcessEnv,
path: string,
content: string,
baseRev?: string
): Promise<string> {
const result = await evalObsidianJson<{ rev: string }>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
`const baseRev=${JSON.stringify(baseRev ?? "")};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const blob=new Blob([content],{type:'text/plain'});",
"const id=await core.services.path.path2id(path);",
"const now=Date.now();",
"const result=await core.localDatabase.putDBEntry({",
" _id:id,",
" path,",
" data:blob,",
" ctime:now,",
" mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,",
" children:[],",
" datatype:'plain',",
" type:'plain',",
" eden:{},",
"},false,baseRev||undefined);",
"if(!result?.ok) throw new Error(`Could not store file revision: ${path}`);",
"return JSON.stringify({ok:true,rev:result.rev});",
"})()",
].join(""),
env
);
return result.rev;
}
async function createMarkdownConflict(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
path: string,
base: string,
left: string,
right: string
): Promise<void> {
const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev);
await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev);
await writeVaultFile(vault.path, path, right);
}
async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const result=await core.localDatabase.managers.conflictManager.tryAutoMerge(path,true);",
"if(!('result' in result)){",
" throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);",
"}",
"if(!(await core.databaseFileAccess.storeContent(path,result.result))){",
" throw new Error(`Could not store merged Markdown content: ${path}`);",
"}",
"if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){",
" throw new Error(`Could not delete conflicted revision: ${path}`);",
"}",
"if(!(await core.fileHandler.dbToStorage(path,path,true))){",
" throw new Error(`Could not reflect merged Markdown content: ${path}`);",
"}",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
}
async function runCreateUpdateDelete(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const createdContent = "# Created on A\n\nThis note should appear on B.\n";
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent);
await uploadNote(context, session, createPath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent);
await session.app.stop();
assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault.");
const initialUpdateContent = "# Update target\n\nInitial content.\n";
const updatedContent = "# Update target\n\nUpdated content from A.\n";
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, initialUpdateContent);
await uploadNote(context, session, updatePath);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent);
await uploadNote(context, session, updatePath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent);
await session.app.stop();
assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault.");
const deleteContent = "# Delete target\n\nThis note should be removed from B.\n";
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent);
await uploadNote(context, session, deletePath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathDeleted(vaultB.path, deletePath);
await session.app.stop();
console.log("Two-vault note creation, update, and deletion round-tripped.");
}
async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB: TemporaryVault): Promise<void> {
const renamedContent = "# Rename target\n\nThis note should move from A to B.\n";
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renamedContent);
await uploadNote(context, session, renameFromPath);
await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath);
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent);
await waitForPathDeleted(vaultB.path, renameFromPath);
await session.app.stop();
assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault.");
console.log("Two-vault note rename round-tripped.");
}
async function runEncryptedRoundTrip(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const encryptedContent = "# Encrypted round-trip\n\nThis note should synchronise with E2EE enabled.\n";
const encryptedOverrides = {
encrypt: true,
passphrase: "real-obsidian-e2e-passphrase",
usePathObfuscation: true,
E2EEAlgorithm: "v2",
};
let session = await startConfiguredSession(context, vaultA, encryptedOverrides);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, encryptedPath, encryptedContent);
await uploadNote(context, session, encryptedPath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB, encryptedOverrides);
await syncAndApply(context, session);
const received = await waitForPathContent(vaultB.path, encryptedPath, (content) => content === encryptedContent);
await session.app.stop();
assertEqual(received, encryptedContent, "Encrypted note did not round-trip to the second vault.");
console.log("Two-vault encrypted note synchronisation round-tripped.");
}
async function runMarkdownAutoMerge(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n";
const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n";
const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n";
let session = await startConfiguredSession(context, vaultB);
await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right);
await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath);
await pushLocalChanges(context.cliBinary, session.cliEnv);
const mergedOnB = await waitForPathContent(
vaultB.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
await syncAndApply(context, session);
const mergedOnA = await waitForPathContent(
vaultA.path,
conflictPath,
(content) => content.includes("Left line") && content.includes("Right tail"),
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults.");
console.log("Markdown conflict was automatically merged and propagated by the next synchronisation.");
}
async function runTargetMismatch(
context: RunnerContext,
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const ignoredContent = "# Target mismatch\n\nB should ignore this revision.\n";
const acceptedContent = "# Target mismatch\n\nB should accept this revision after its target filter changes.\n";
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
});
await syncAndApply(context, session);
assertEqual(
await pathExists(vaultB.path, targetMismatchPath),
false,
"A note was reflected on a device where it was not a target file."
);
await session.app.stop();
session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent);
await uploadNote(context, session, targetMismatchPath);
await session.app.stop();
session = await startConfiguredSession(context, vaultB, {
syncOnlyRegEx: "",
});
await syncAndApply(context, session);
const received = await waitForPathContent(
vaultB.path,
targetMismatchPath,
(content) => content === acceptedContent
);
await session.app.stop();
assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path.");
console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target.");
}
async function main(): Promise<void> {
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, "two-vault-sync");
const encryptedDbName = makeUniqueDatabaseName(couchDb.dbPrefix, "two-vault-sync-e2ee");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName };
try {
await assertCouchDbReachable(couchDb);
await createCouchDbDatabase(couchDb, dbName);
await createCouchDbDatabase(couchDb, encryptedDbName);
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}`);
console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`);
await runCreateUpdateDelete(context, vaultA, vaultB);
await runRename(context, vaultA, vaultB);
if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") {
await runMarkdownAutoMerge(context, vaultA, vaultB);
}
await runTargetMismatch(context, vaultA, vaultB);
await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB);
} finally {
await vaultA.dispose();
await vaultB.dispose();
await encryptedVaultA.dispose();
await encryptedVaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await deleteCouchDbDatabase(couchDb, encryptedDbName).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);
});
@@ -0,0 +1,122 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
type CreatedNote = {
path: string;
read: string;
exists: boolean;
};
type ReadNote = {
exists: boolean;
read: string | null;
};
const notePath = "E2E/real-vault-reflection.md";
const noteContent = [
"# Real Obsidian E2E",
"",
"This note was created through Obsidian's own vault API.",
`Created at: ${new Date().toISOString()}`,
"",
].join("\n");
async function waitForFileContent(vaultPath: string, path: string, expectedContent: string): Promise<void> {
const fullPath = join(vaultPath, path);
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000);
let lastError: unknown;
while (Date.now() < deadline) {
try {
const content = await readFile(fullPath, "utf-8");
if (content === expectedContent) {
return;
}
lastError = new Error(`Unexpected content in ${fullPath}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for reflected vault file: ${fullPath}\nLast error: ${String(lastError)}`);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
const created = await evalObsidianJson<CreatedNote>(
cli.binary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const content=${JSON.stringify(noteContent)};`,
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"const file=await app.vault.create(path,content);",
"const read=await app.vault.read(file);",
"return JSON.stringify({path:file.path,read,exists:await app.vault.adapter.exists(path)});",
"})()",
].join(""),
session.cliEnv
);
assertEqual(created.path, notePath, "Obsidian created the note at an unexpected path.");
assertEqual(created.exists, true, "Obsidian adapter did not report the created note.");
assertEqual(created.read, noteContent, "Obsidian did not read back the created note content.");
await waitForFileContent(vault.path, notePath, noteContent);
const readBack = await evalObsidianJson<ReadNote>(
cli.binary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
"const file=app.vault.getAbstractFileByPath(path);",
"return JSON.stringify({exists:!!file,read:file?await app.vault.read(file):null});",
"})()",
].join(""),
session.cliEnv
);
assertEqual(readBack.exists, true, "Obsidian did not find the reflected note on read-back.");
assertEqual(readBack.read, noteContent, "Obsidian read-back content did not match the reflected file.");
console.log(`Created and verified reflected note: ${notePath}`);
} finally {
if (session) {
await session.app.stop();
}
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});