mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Add Journal Chunk recovery regression coverage and guidance
This commit is contained in:
1 parent
0e458e5e20
commit
901c125723
7 files changed
+249
-1
No files matched your search
@@ -14,5 +14,6 @@
|
||||
"test:e2e-matrix:couchdb-enc0",
|
||||
"test:e2e-matrix:couchdb-enc1",
|
||||
"test:e2e-matrix:minio-enc0",
|
||||
"test:e2e-matrix:minio-enc1"
|
||||
"test:e2e-matrix:minio-enc1",
|
||||
"test:journal-chunk-recovery"
|
||||
]
|
||||
@@ -166,6 +166,11 @@
|
||||
"test-e2e-two-vaults-couchdb.ts"
|
||||
]
|
||||
},
|
||||
"test:journal-chunk-recovery": {
|
||||
"test": [
|
||||
"test-journal-chunk-recovery.ts"
|
||||
]
|
||||
},
|
||||
"test:e2e-matrix": {
|
||||
"test": [
|
||||
"test-e2e-two-vaults-matrix.ts"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { assertFilesEqual, runCliOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startMinio, stopMinio } from "./helpers/docker.ts";
|
||||
|
||||
function requireEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
function createJournalFixture(): string {
|
||||
return [
|
||||
"Journal chunk recovery fixture: UTF-8, newlines, and backslashes.\n",
|
||||
...Array.from(
|
||||
{ length: 128 },
|
||||
(_, index) =>
|
||||
`record ${String(index).padStart(3, "0")}: prefix\\path café 日本語 \u001f middle-${index} \u001f suffix-${index} remains after the separator.\n`
|
||||
),
|
||||
"Final suffix after every control separator remains present.\n",
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function runScenario(encrypt: boolean): Promise<void> {
|
||||
const suffix = `${encrypt ? "enc1" : "enc0"}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const minioEndpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "");
|
||||
const minioAccessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
|
||||
const minioSecretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
|
||||
const minioBucket = `${requireEnv("MINIO_BUCKET_NAME", "bucketName")}-journal-recovery-${suffix}`;
|
||||
const passphrase = "e2e-passphrase";
|
||||
const fileContent = createJournalFixture();
|
||||
const expectedBytes = Buffer.from(fileContent, "utf8");
|
||||
const targetPath = "e2e/journal-chunk-recovery.md";
|
||||
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||
|
||||
await using workDir = await TempDir.create(`livesync-cli-journal-recovery-${suffix}`);
|
||||
const sourceDatabase = workDir.join("source-db");
|
||||
const receiverDatabase = workDir.join("receiver-db");
|
||||
const sourceVault = workDir.join("source-vault");
|
||||
const receiverVault = workDir.join("receiver-vault");
|
||||
const sourceSettings = workDir.join("source-settings.json");
|
||||
const receiverSettings = workDir.join("receiver-settings.json");
|
||||
const sourceFile = join(sourceVault, targetPath);
|
||||
const receiverFile = join(receiverVault, targetPath);
|
||||
await Promise.all([
|
||||
mkdir(sourceDatabase, { recursive: true }),
|
||||
mkdir(receiverDatabase, { recursive: true }),
|
||||
mkdir(sourceVault, { recursive: true }),
|
||||
mkdir(receiverVault, { recursive: true }),
|
||||
]);
|
||||
await mkdir(dirname(sourceFile), { recursive: true });
|
||||
await writeFile(sourceFile, expectedBytes);
|
||||
|
||||
try {
|
||||
await startMinio(minioEndpoint, minioAccessKey, minioSecretKey, minioBucket);
|
||||
await initSettingsFile(sourceSettings);
|
||||
await initSettingsFile(receiverSettings);
|
||||
await applyRemoteSyncSettings(sourceSettings, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket,
|
||||
minioEndpoint,
|
||||
minioAccessKey,
|
||||
minioSecretKey,
|
||||
encrypt,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
});
|
||||
await applyRemoteSyncSettings(receiverSettings, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket,
|
||||
minioEndpoint,
|
||||
minioAccessKey,
|
||||
minioSecretKey,
|
||||
encrypt,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
});
|
||||
|
||||
await runCliOrFail(
|
||||
sourceDatabase,
|
||||
"--settings",
|
||||
sourceSettings,
|
||||
"--vault",
|
||||
sourceVault,
|
||||
"rebuild",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
|
||||
await assertFilesEqual(sourceFile, receiverFile, "Fetched Vault file does not match the rebuild source");
|
||||
const fetchedContent = sanitiseCatStdout(
|
||||
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "cat", targetPath)
|
||||
);
|
||||
assert.equal(fetchedContent, fileContent, "A fresh CLI process should read the exact fetched file content");
|
||||
|
||||
const damagedContent = "Damaged receiver Vault copy; the remote still has the complete source.\n";
|
||||
await writeFile(receiverFile, damagedContent);
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await readFile(receiverFile, "utf8"),
|
||||
damagedContent,
|
||||
"Fetch does not forcibly overwrite a newer local file"
|
||||
);
|
||||
const info: unknown = JSON.parse(
|
||||
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "info", targetPath)
|
||||
);
|
||||
assert.ok(info && typeof info === "object" && "revision" in info && "conflicts" in info);
|
||||
assert.ok(typeof info.revision === "string" && typeof info.conflicts === "string");
|
||||
assert.match(info.revision, /^1-[\da-f]+$/, "The fetched file should retain an independent root");
|
||||
assert.match(info.conflicts, /^1-[\da-f]+$/, "The differing local file should be preserved as a conflict");
|
||||
assert.notEqual(info.revision, info.conflicts);
|
||||
const retainedContents: string[] = [];
|
||||
for (const revision of [info.revision, info.conflicts]) {
|
||||
retainedContents.push(
|
||||
sanitiseCatStdout(
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"cat-rev",
|
||||
targetPath,
|
||||
revision
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
retainedContents.sort(),
|
||||
[fileContent, damagedContent].sort(),
|
||||
"Fetch should retain both the intact remote and differing local contents without inferring their ancestry"
|
||||
);
|
||||
await rename(receiverFile, workDir.join("damaged-vault-backup.md"));
|
||||
await runCliOrFail(
|
||||
receiverDatabase,
|
||||
"--settings",
|
||||
receiverSettings,
|
||||
"--vault",
|
||||
receiverVault,
|
||||
"fetch",
|
||||
"--force",
|
||||
"--write-settings"
|
||||
);
|
||||
await assertFilesEqual(sourceFile, receiverFile, "Fetching into an empty Vault should restore the remote file");
|
||||
assert.equal(await readFile(workDir.join("damaged-vault-backup.md"), "utf8"), damagedContent);
|
||||
const restoredContent = sanitiseCatStdout(
|
||||
await runCliOrFail(receiverDatabase, "--settings", receiverSettings, "cat", targetPath)
|
||||
);
|
||||
assert.equal(restoredContent, fileContent, "A repeated fetch should retain the remote file content");
|
||||
assert.deepEqual(
|
||||
await readFile(sourceFile),
|
||||
expectedBytes,
|
||||
"The complete source Vault fixture should remain intact"
|
||||
);
|
||||
} finally {
|
||||
if (!keepDocker) {
|
||||
await stopMinio().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("journal chunk recovery through MINIO", async (t) => {
|
||||
await t.test("without encryption", async () => runScenario(false));
|
||||
await t.test("with encryption", async () => runScenario(true));
|
||||
});
|
||||
Reference in new issue
Block a user