mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-14 03:21:17 +00:00
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
import { assert } from "@std/assert";
|
|
import { TempDir } from "./helpers/temp.ts";
|
|
import {
|
|
runCli,
|
|
runCliOrFail,
|
|
runCliWithInputOrFail,
|
|
sanitiseCatStdout,
|
|
assertFilesEqual,
|
|
jsonStringField,
|
|
} from "./helpers/cli.ts";
|
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
|
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
|
type RemoteType = "COUCHDB" | "MINIO";
|
|
|
|
function requireEnv(...keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = Deno.env.get(key)?.trim();
|
|
if (value) return value;
|
|
}
|
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
|
}
|
|
|
|
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
|
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
|
|
|
const couchdbUri = remoteType === "COUCHDB" ? requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "") : "";
|
|
const couchdbUser = remoteType === "COUCHDB" ? requireEnv("COUCHDB_USER", "username") : "";
|
|
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv("COUCHDB_PASSWORD", "password") : "";
|
|
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
|
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
|
|
|
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
|
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
|
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
|
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
|
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
|
|
|
const passphrase = "e2e-passphrase";
|
|
|
|
await using workDir = await TempDir.create(
|
|
`livesync-cli-e2e-${remoteType.toLowerCase()}-${encrypt ? "enc1" : "enc0"}`
|
|
);
|
|
const vaultA = workDir.join("testvault_a");
|
|
const vaultB = workDir.join("testvault_b");
|
|
const settingsA = workDir.join("test-settings-a.json");
|
|
const settingsB = workDir.join("test-settings-b.json");
|
|
const pushSrc = workDir.join("push-source.txt");
|
|
const pullDst = workDir.join("pull-destination.txt");
|
|
const pushBinarySrc = workDir.join("push-source.bin");
|
|
const pullBinaryDst = workDir.join("pull-destination.bin");
|
|
await Deno.mkdir(vaultA, { recursive: true });
|
|
await Deno.mkdir(vaultB, { recursive: true });
|
|
|
|
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
|
if (remoteType === "COUCHDB") {
|
|
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
|
} else {
|
|
await startMinio(minioEndpoint, minioAccessKey, minioSecretKey, minioBucket);
|
|
}
|
|
|
|
try {
|
|
await initSettingsFile(settingsA);
|
|
await initSettingsFile(settingsB);
|
|
await applyRemoteSyncSettings(settingsA, {
|
|
remoteType,
|
|
couchdbUri,
|
|
couchdbUser,
|
|
couchdbPassword,
|
|
couchdbDbname: dbname,
|
|
minioBucket,
|
|
minioEndpoint,
|
|
minioAccessKey,
|
|
minioSecretKey,
|
|
encrypt,
|
|
passphrase,
|
|
});
|
|
await applyRemoteSyncSettings(settingsB, {
|
|
remoteType,
|
|
couchdbUri,
|
|
couchdbUser,
|
|
couchdbPassword,
|
|
couchdbDbname: dbname,
|
|
minioBucket,
|
|
minioEndpoint,
|
|
minioAccessKey,
|
|
minioSecretKey,
|
|
encrypt,
|
|
passphrase,
|
|
});
|
|
|
|
const syncBoth = async () => {
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
|
};
|
|
|
|
const targetAOnly = "e2e/a-only-info.md";
|
|
const targetSync = "e2e/sync-info.md";
|
|
const targetSyncTwiceFirst = "e2e/sync-twice-first.md";
|
|
const targetSyncTwiceSecond = "e2e/sync-twice-second.md";
|
|
const targetPush = "e2e/pushed-from-a.md";
|
|
const targetPut = "e2e/put-from-a.md";
|
|
const targetPushBinary = "e2e/pushed-from-a.bin";
|
|
const targetConflict = "e2e/conflict.md";
|
|
|
|
await runCliWithInputOrFail("alpha-from-a\n", vaultA, "--settings", settingsA, "put", targetAOnly);
|
|
const infoAOnly = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetAOnly);
|
|
assert(infoAOnly.includes(`"path": "${targetAOnly}"`));
|
|
|
|
await runCliWithInputOrFail("visible-after-sync\n", vaultA, "--settings", settingsA, "put", targetSync);
|
|
await syncBoth();
|
|
const infoBSync = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetSync);
|
|
assert(infoBSync.includes(`"path": "${targetSync}"`));
|
|
|
|
await runCliWithInputOrFail(
|
|
`first-sync-round-${dbSuffix}\n`,
|
|
vaultA,
|
|
"--settings",
|
|
settingsA,
|
|
"put",
|
|
targetSyncTwiceFirst
|
|
);
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
|
const firstVisible = sanitiseCatStdout(
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetSyncTwiceFirst)
|
|
).trimEnd();
|
|
assert(firstVisible === `first-sync-round-${dbSuffix}`);
|
|
|
|
await runCliWithInputOrFail(
|
|
`second-sync-round-${dbSuffix}\n`,
|
|
vaultA,
|
|
"--settings",
|
|
settingsA,
|
|
"put",
|
|
targetSyncTwiceSecond
|
|
);
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
|
const secondVisible = sanitiseCatStdout(
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetSyncTwiceSecond)
|
|
).trimEnd();
|
|
assert(secondVisible === `second-sync-round-${dbSuffix}`);
|
|
|
|
await Deno.writeTextFile(pushSrc, `pushed-content-${dbSuffix}\n`);
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushSrc, targetPush);
|
|
await runCliWithInputOrFail(`put-content-${dbSuffix}\n`, vaultA, "--settings", settingsA, "put", targetPut);
|
|
await syncBoth();
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPush, pullDst);
|
|
await assertFilesEqual(pushSrc, pullDst, "B pull result does not match pushed source");
|
|
const catBPut = sanitiseCatStdout(
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetPut)
|
|
).trimEnd();
|
|
assert(catBPut === `put-content-${dbSuffix}`);
|
|
|
|
const binary = new Uint8Array(4096);
|
|
binary.fill(0x61);
|
|
await Deno.writeFile(pushBinarySrc, binary);
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushBinarySrc, targetPushBinary);
|
|
await syncBoth();
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPushBinary, pullBinaryDst);
|
|
await assertFilesEqual(pushBinarySrc, pullBinaryDst, "B pull result does not match pushed binary source");
|
|
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "rm", targetPut);
|
|
await syncBoth();
|
|
const removed = await runCli(vaultB, "--settings", settingsB, "cat", targetPut);
|
|
assert(removed.code !== 0, `B cat should fail after A removed the file\n${removed.combined}`);
|
|
|
|
await runCliWithInputOrFail("conflict-base\n", vaultA, "--settings", settingsA, "put", targetConflict);
|
|
await syncBoth();
|
|
await runCliWithInputOrFail(
|
|
`conflict-from-a-${dbSuffix}\n`,
|
|
vaultA,
|
|
"--settings",
|
|
settingsA,
|
|
"put",
|
|
targetConflict
|
|
);
|
|
await runCliWithInputOrFail(
|
|
`conflict-from-b-${dbSuffix}\n`,
|
|
vaultB,
|
|
"--settings",
|
|
settingsB,
|
|
"put",
|
|
targetConflict
|
|
);
|
|
|
|
let infoAConflict = "";
|
|
let infoBConflict = "";
|
|
let conflictDetected = false;
|
|
for (const side of ["a", "b", "a"] as const) {
|
|
await runCliOrFail(
|
|
side === "a" ? vaultA : vaultB,
|
|
"--settings",
|
|
side === "a" ? settingsA : settingsB,
|
|
"sync"
|
|
);
|
|
infoAConflict = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetConflict);
|
|
infoBConflict = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetConflict);
|
|
if (
|
|
jsonStringField(infoAConflict, "conflicts") !== "N/A" ||
|
|
jsonStringField(infoBConflict, "conflicts") !== "N/A"
|
|
) {
|
|
conflictDetected = true;
|
|
break;
|
|
}
|
|
}
|
|
assert(conflictDetected, `conflict was expected\nA: ${infoAConflict}\nB: ${infoBConflict}`);
|
|
|
|
const lsAConflict =
|
|
(await runCliOrFail(vaultA, "--settings", settingsA, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
|
const lsBConflict =
|
|
(await runCliOrFail(vaultB, "--settings", settingsB, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
|
const revA = lsAConflict.split("\t")[3] ?? "";
|
|
const revB = lsBConflict.split("\t")[3] ?? "";
|
|
assert(
|
|
revA.includes("*") || revB.includes("*"),
|
|
`conflicted entry should be marked with '*'\nA: ${lsAConflict}\nB: ${lsBConflict}`
|
|
);
|
|
|
|
const keepRevision = jsonStringField(infoAConflict, "revision");
|
|
assert(keepRevision.length > 0, `could not extract revision\n${infoAConflict}`);
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "resolve", targetConflict, keepRevision);
|
|
|
|
let resolved = false;
|
|
let infoAResolved = "";
|
|
let infoBResolved = "";
|
|
for (let i = 0; i < 6; i++) {
|
|
await syncBoth();
|
|
infoAResolved = await runCliOrFail(vaultA, "--settings", settingsA, "info", targetConflict);
|
|
infoBResolved = await runCliOrFail(vaultB, "--settings", settingsB, "info", targetConflict);
|
|
if (
|
|
jsonStringField(infoAResolved, "conflicts") === "N/A" &&
|
|
jsonStringField(infoBResolved, "conflicts") === "N/A"
|
|
) {
|
|
resolved = true;
|
|
break;
|
|
}
|
|
const retryRevision = jsonStringField(infoAResolved, "revision");
|
|
if (retryRevision) {
|
|
await runCli(vaultA, "--settings", settingsA, "resolve", targetConflict, retryRevision);
|
|
}
|
|
}
|
|
assert(resolved, `conflicts should be resolved\nA: ${infoAResolved}\nB: ${infoBResolved}`);
|
|
|
|
const lsAResolved =
|
|
(await runCliOrFail(vaultA, "--settings", settingsA, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
|
const lsBResolved =
|
|
(await runCliOrFail(vaultB, "--settings", settingsB, "ls", targetConflict)).trim().split(/\r?\n/)[0] ?? "";
|
|
assert(!(lsAResolved.split("\t")[3] ?? "").includes("*"));
|
|
assert(!(lsBResolved.split("\t")[3] ?? "").includes("*"));
|
|
|
|
const catAResolved = sanitiseCatStdout(
|
|
await runCliOrFail(vaultA, "--settings", settingsA, "cat", targetConflict)
|
|
).trimEnd();
|
|
const catBResolved = sanitiseCatStdout(
|
|
await runCliOrFail(vaultB, "--settings", settingsB, "cat", targetConflict)
|
|
).trimEnd();
|
|
assert(catAResolved === catBResolved, `resolved content should match\nA: ${catAResolved}\nB: ${catBResolved}`);
|
|
} finally {
|
|
if (!keepDocker) {
|
|
if (remoteType === "COUCHDB") {
|
|
await stopCouchdb().catch(() => {});
|
|
} else {
|
|
await stopMinio().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Deno.test("e2e: two vaults over CouchDB without encryption", async () => {
|
|
await runScenario("COUCHDB", false);
|
|
});
|
|
|
|
Deno.test("e2e: two vaults over CouchDB with encryption", async () => {
|
|
await runScenario("COUCHDB", true);
|
|
});
|