mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-01 02:15:19 +00:00
(test): add local Obsidian E2E suite
This commit is contained in:
@@ -25,6 +25,8 @@ async function main(): Promise<void> {
|
||||
...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));
|
||||
|
||||
@@ -228,10 +228,12 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
|
||||
"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);",
|
||||
"if(!result){",
|
||||
"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,entries});",
|
||||
"return JSON.stringify({ok:true,path,term,category,result:!!result,existing,entries});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
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";
|
||||
@@ -41,6 +42,7 @@ const snippetContent = [
|
||||
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;
|
||||
@@ -145,7 +147,8 @@ async function scanHiddenDatabase(cliBinary: string, env: NodeJS.ProcessEnv): Pr
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
env,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +164,8 @@ async function resolveHiddenConflicts(cliBinary: string, env: NodeJS.ProcessEnv)
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
env,
|
||||
hiddenFileCliTimeoutMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,34 +237,6 @@ async function openHiddenJsonResolveModal(cliBinary: string, env: NodeJS.Process
|
||||
);
|
||||
}
|
||||
|
||||
async function clickJsonResolveOption(cliBinary: string, env: NodeJS.ProcessEnv, mode: "AB" | "BA"): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const mode=${JSON.stringify(mode)};`,
|
||||
"const deadline=Date.now()+10000;",
|
||||
"while(Date.now()<deadline){",
|
||||
" const input=[...document.querySelectorAll('input[name=\"disp\"]')].find((candidate)=>candidate.value===mode);",
|
||||
" const apply=[...document.querySelectorAll('button')].find((button)=>button.textContent?.trim()==='Apply');",
|
||||
" if(input&&apply){",
|
||||
" input.click();",
|
||||
" input.dispatchEvent(new Event('change',{bubbles:true}));",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,100));",
|
||||
" apply.click();",
|
||||
" return JSON.stringify({ok:true});",
|
||||
" }",
|
||||
" await new Promise((resolve)=>setTimeout(resolve,250));",
|
||||
"}",
|
||||
"const buttons=[...document.querySelectorAll('button')].map((button)=>button.textContent?.trim()).filter(Boolean);",
|
||||
"const inputs=[...document.querySelectorAll('input[name=\"disp\"]')].map((input)=>input.value);",
|
||||
"throw new Error(`Timed out waiting for JSON resolve modal; buttons=${JSON.stringify(buttons)}; inputs=${JSON.stringify(inputs)}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function storeHiddenFileAsConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -368,9 +344,15 @@ async function uploadHiddenFile(
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function pullAndApplyHiddenFiles(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||
async function pullAndApplyHiddenFiles(
|
||||
context: RunnerContext,
|
||||
session: ObsidianLiveSyncSession,
|
||||
options: { resolveConflicts?: boolean } = {}
|
||||
): Promise<void> {
|
||||
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
|
||||
if (options.resolveConflicts === true) {
|
||||
await resolveHiddenConflicts(context.cliBinary, session.cliEnv);
|
||||
}
|
||||
await scanHiddenDatabase(context.cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
@@ -449,7 +431,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
|
||||
const session = await startConfiguredSession(context, vault);
|
||||
await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right);
|
||||
await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath);
|
||||
await clickJsonResolveOption(context.cliBinary, session.cliEnv, "AB");
|
||||
await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB");
|
||||
|
||||
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
|
||||
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
|
||||
@@ -472,26 +454,36 @@ async function runTargetMismatch(
|
||||
await writeVaultFile(vaultA.path, targetPath, targetContent);
|
||||
|
||||
let session = await startConfiguredSession(context, vaultA);
|
||||
await uploadHiddenFile(context, session, targetPath);
|
||||
await session.app.stop();
|
||||
try {
|
||||
await uploadHiddenFile(context, session, targetPath);
|
||||
} finally {
|
||||
await session.app.stop();
|
||||
}
|
||||
|
||||
session = await startConfiguredSession(context, vaultB, {
|
||||
syncInternalFilesTargetPatterns: "snippets",
|
||||
});
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
assertEqual(
|
||||
await pathExists(vaultB.path, targetPath),
|
||||
false,
|
||||
"Hidden file was applied on a device where it was not a target file."
|
||||
);
|
||||
await session.app.stop();
|
||||
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: "",
|
||||
});
|
||||
await pullAndApplyHiddenFiles(context, session);
|
||||
const received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
|
||||
await session.app.stop();
|
||||
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.");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -29,8 +29,11 @@ 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;
|
||||
@@ -134,6 +137,25 @@ async function deleteNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -319,14 +341,62 @@ async function runCreateUpdateDelete(
|
||||
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\nBase line\n\nShared tail\n";
|
||||
const left = "# Conflict\n\nLeft line\n\nShared tail\n";
|
||||
const right = "# Conflict\n\nBase line\n\nRight tail\n";
|
||||
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);
|
||||
@@ -335,7 +405,8 @@ async function runMarkdownAutoMerge(
|
||||
const mergedOnB = await waitForPathContent(
|
||||
vaultB.path,
|
||||
conflictPath,
|
||||
(content) => content.includes("Left line") && content.includes("Right tail")
|
||||
(content) => content.includes("Left line") && content.includes("Right tail"),
|
||||
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
|
||||
);
|
||||
await session.app.stop();
|
||||
|
||||
@@ -344,7 +415,8 @@ async function runMarkdownAutoMerge(
|
||||
const mergedOnA = await waitForPathContent(
|
||||
vaultA.path,
|
||||
conflictPath,
|
||||
(content) => content.includes("Left line") && content.includes("Right tail")
|
||||
(content) => content.includes("Left line") && content.includes("Right tail"),
|
||||
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
|
||||
);
|
||||
await session.app.stop();
|
||||
|
||||
@@ -405,29 +477,44 @@ async function main(): Promise<void> {
|
||||
|
||||
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 runMarkdownAutoMerge(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user