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 { 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); });