Files
obsidian-livesync/test/e2e-cli/test-mirror.ts

348 lines
17 KiB
TypeScript

/**
* Node.js port of test-mirror-linux.sh
*
* Tests the `mirror` command — bidirectional synchronisation between a local
* storage directory (vault) and an in-process database.
*
* Covered cases (identical to the bash test):
* 1. Storage-only file -> synced into DB (UPDATE DATABASE)
* 2. DB-only file -> restored to storage (UPDATE STORAGE)
* 3. DB-deleted file -> NOT restored to storage (UPDATE STORAGE skip)
* 4. Both, storage newer -> DB updated (SYNC: STORAGE -> DB)
* 5. Both, DB newer -> storage updated (SYNC: DB -> STORAGE)
* 6. Compatibility mode -> omitted vault-path works (same DB + vault path)
* 7. Unknown local origin -> conflict preserved, deduplicated, and resolved
*
* No external services are required.
*
* Run:
* node --test test-mirror.ts
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { copyFile, mkdir, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
import { TempDir } from "./helpers/temp.ts";
import { runCli, runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
test("mirror: storage <-> DB synchronisation", async (t) => {
await using workDir = await TempDir.create("livesync-cli-mirror");
// -------------------------------------------------------------------
// Shared setup
// -------------------------------------------------------------------
const settingsFile = workDir.join("data.json");
const vaultDir = workDir.join("vault");
const dbDir = workDir.join("db");
await mkdir(workDir.join("vault", "test"), { recursive: true });
await mkdir(dbDir, { recursive: true });
await initSettingsFile(settingsFile);
// isConfigured=true is required for canProceedScan in the mirror command.
await markSettingsConfigured(settingsFile);
const data = JSON.parse(await readFile(settingsFile, "utf8"));
data.writeDocumentsIfConflicted = true;
await writeFile(settingsFile, JSON.stringify(data, null, 2));
// Copy settings to the DB directory (separated-path mode)
const dbSettings = workDir.join("db", "settings.json");
await copyFile(settingsFile, dbSettings);
/** Run mirror in separated-path mode: DB dir ≠ vault dir. */
const runMirror = () => runCliOrFail(dbDir, "--settings", dbSettings, "mirror", vaultDir);
/** Run mirror in compatibility mode: DB path = vault path. */
const runMirrorCompat = () => runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
// Helper wrappers
const dbRun = (...args: string[]) => runCliOrFail(dbDir, "--settings", dbSettings, ...args);
const compatRun = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args);
// -------------------------------------------------------------------
// Case 1: storage-only -> DB (UPDATE DATABASE)
// -------------------------------------------------------------------
await t.test("case 1: storage-only file is synced into DB", async () => {
const storageFile = workDir.join("vault", "test", "storage-only.md");
await writeFile(storageFile, "storage-only content\n");
await runMirror();
const resultFile = workDir.join("case1-pull.txt");
await dbRun("pull", "test/storage-only.md", resultFile);
const storageContent = await readFile(storageFile, "utf8");
const pulledContent = await readFile(resultFile, "utf8");
assert(
storageContent === pulledContent,
`storage-only file NOT synced into DB\nexpected: ${storageContent}\ngot: ${pulledContent}`
);
console.log("[PASS] case 1: storage-only file was synced into DB");
});
// -------------------------------------------------------------------
// Case 2: DB-only -> storage (UPDATE STORAGE)
// -------------------------------------------------------------------
await t.test("case 2: DB-only file is restored to storage", async () => {
await dbRun(
"push",
// write inline via push (pipe not needed — push takes a file path)
// create a temp file with content and push it
await (async () => {
const tmp = workDir.join("db-only-src.txt");
await writeFile(tmp, "db-only content\n");
return tmp;
})(),
"test/db-only.md"
);
const storagePath = workDir.join("vault", "test", "db-only.md");
assert(!(await exists(storagePath)), "db-only.md unexpectedly exists in storage before mirror");
await runMirror();
assert(await exists(storagePath), "DB-only file NOT restored to storage after mirror");
const content = await readFile(storagePath, "utf8");
assert(content === "db-only content\n", `DB-only file restored but content mismatch: '${content}'`);
console.log("[PASS] case 2: DB-only file was restored to storage");
});
// -------------------------------------------------------------------
// Case 3: DB-deleted -> storage untouched
// -------------------------------------------------------------------
await t.test("case 3: DB-deleted entry is NOT restored to storage", async () => {
const deletedSrc = workDir.join("deleted-src.txt");
await writeFile(deletedSrc, "to-be-deleted\n");
await dbRun("push", deletedSrc, "test/deleted.md");
await dbRun("rm", "test/deleted.md");
await runMirror();
const storagePath = workDir.join("vault", "test", "deleted.md");
assert(!(await exists(storagePath)), "deleted DB entry was incorrectly restored to storage");
console.log("[PASS] case 3: deleted DB entry was NOT restored to storage");
});
// -------------------------------------------------------------------
// Case 4: storage newer -> DB updated (SYNC: STORAGE -> DB)
// -------------------------------------------------------------------
await t.test("case 4: storage newer than DB -> DB is updated", async () => {
// Seed DB with old content (mtime ~ now)
const seedFile = workDir.join("case4-seed.txt");
await writeFile(seedFile, "old content\n");
await dbRun("push", seedFile, "test/sync-storage-newer.md");
// Reflect the shared base into the actual Vault before editing it.
await runMirror();
assert.deepStrictEqual(
await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"),
"old content\n"
);
// Write new content to storage with a timestamp 1 hour in the future
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
await writeFile(storageFile, "new content\n");
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
await runMirror();
const resultFile = workDir.join("case4-pull.txt");
const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md"));
assert.deepStrictEqual(info.conflicts, "N/A", "An ordinary local edit must not create a conflict");
await dbRun("pull", "test/sync-storage-newer.md", resultFile);
const storageContent = await readFile(storageFile, "utf8");
const pulledContent = await readFile(resultFile, "utf8");
assert(
storageContent === pulledContent,
`DB NOT updated to match newer storage file\nexpected: ${storageContent}\ngot: ${pulledContent}`
);
console.log("[PASS] case 4: DB updated to match newer storage file");
});
// -------------------------------------------------------------------
// Case 5: DB newer -> storage updated (SYNC: DB -> STORAGE)
// -------------------------------------------------------------------
await t.test("case 5: DB newer than storage -> storage is updated", async () => {
// Write old content to storage with a timestamp 1 hour in the past
const storageFile = workDir.join("vault", "test", "sync-db-newer.md");
await writeFile(storageFile, "old storage content\n");
await utimes(storageFile, new Date(), new Date(Date.now() - 3600_000));
// Write new content to DB only (mtime ~ now, newer than the storage file)
const dbNewFile = workDir.join("case5-db-new.txt");
await writeFile(dbNewFile, "new db content\n");
await dbRun("push", dbNewFile, "test/sync-db-newer.md");
await runMirror();
const content = await readFile(storageFile, "utf8");
assert(content === "new db content\n", `storage NOT updated to match newer DB entry (got: '${content}')`);
console.log("[PASS] case 5: storage updated to match newer DB entry");
});
// -------------------------------------------------------------------
// Case 6: compatibility mode (vault path = DB path)
// -------------------------------------------------------------------
await t.test("case 6: compatibility mode (omitted vault-path)", async () => {
const compatFile = workDir.join("vault", "compat.md");
await writeFile(compatFile, "compat-content\n");
await runMirrorCompat();
const resultFile = workDir.join("case6-pull.txt");
await compatRun("pull", "compat.md", resultFile);
const pulled = await readFile(resultFile, "utf8");
assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`);
console.log("[PASS] case 6: compatibility mode works");
});
// -------------------------------------------------------------------
// Case 7: unknown local origin must preserve both contents regardless of mtime.
// This deliberately uses put: push would record a file provenance entry.
// -------------------------------------------------------------------
await t.test("case 7: unknown local content is preserved, deduplicated, and resolved", async () => {
const path = "test/unknown-origin.md";
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
const writeUnknownFile = async () => {
await writeFile(storageFile, "unrelated local content\n");
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
};
await writeUnknownFile();
await runMirror();
const info = JSON.parse(await dbRun("info", path));
assert(/^1-[\da-f]+$/.test(info.revision), "Expected an independent winning root");
assert(/^1-[\da-f]+$/.test(info.conflicts), "Expected exactly one independent conflicting root");
assert(info.revision !== info.conflicts, "Expected two distinct revisions");
const contents = new Map<string, string>();
for (const revision of [info.revision, info.conflicts]) {
contents.set(await dbRun("cat-rev", path, revision), revision);
}
assert.deepStrictEqual([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]);
// Re-submit identical local bytes even if incoming reflection replaced
// the file under writeDocumentsIfConflicted; no third branch is needed.
await writeUnknownFile();
await runMirror();
const repeated = JSON.parse(await dbRun("info", path));
assert.deepStrictEqual(repeated.revision, info.revision);
assert.deepStrictEqual(repeated.conflicts, info.conflicts);
const localRevision = contents.get("unrelated local content\n")!;
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
const resolved = JSON.parse(await dbRun("info", path));
assert.deepStrictEqual(resolved.conflicts, "N/A");
assert.deepStrictEqual(resolved.revision, localRevision);
assert.deepStrictEqual(await readFile(storageFile, "utf8"), "unrelated local content\n");
});
});
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
test("mirror: NFC identity retains physical Unicode spelling", async () => {
await using workDir = await TempDir.create("livesync-cli-nfc-paths");
const vault = workDir.join("vault");
const database = workDir.join("db");
const settings = workDir.join("settings.json");
const parent = "Café".normalize("NFD");
const leaf = "Résumé.md".normalize("NFD");
const logical = "Café/Résumé.md";
const physical = workDir.join("vault", parent, leaf);
await mkdir(workDir.join("vault", parent), { recursive: true });
await mkdir(database);
await initSettingsFile(settings);
await markSettingsConfigured(settings);
const data = JSON.parse(await readFile(settings, "utf8"));
data.pathNormalizationVersion = 1;
await writeFile(settings, JSON.stringify(data));
await writeFile(physical, "original Unicode content\n");
await utimes(physical, new Date(), new Date(Date.now() - 3600_000));
const run = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
await run("mirror", vault);
const received = workDir.join("received.md");
await run("pull", logical, received);
assert.equal(await readFile(received, "utf8"), "original Unicode content\n");
const replacement = workDir.join("replacement.md");
await writeFile(replacement, "updated Unicode content\n");
await run("push", replacement, logical);
await run("mirror", vault);
assert.equal(await readFile(physical, "utf8"), "updated Unicode content\n");
assert.deepEqual(await readdir(vault), [parent]);
assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]);
});
test("mirror: rejects an ambiguous NFC inventory without changing prior data and recovers", async (t) => {
await using workDir = await TempDir.create("livesync-cli-nfc-scan-failure");
const vault = workDir.join("vault");
const database = workDir.join("db");
const settings = workDir.join("settings.json");
const existingContent = "retained before failed scan\n";
const existingFile = workDir.join("vault", "known.md");
const nfcParent = "Café";
const nfdParent = nfcParent.normalize("NFD");
const nfcFile = workDir.join("vault", nfcParent, "from-nfc.md");
const nfdFile = workDir.join("vault", nfdParent, "from-nfd.md");
await mkdir(vault, { recursive: true });
await mkdir(database);
await initSettingsFile(settings);
await markSettingsConfigured(settings);
const data = JSON.parse(await readFile(settings, "utf8"));
data.pathNormalizationVersion = 1;
await writeFile(settings, JSON.stringify(data));
await writeFile(existingFile, existingContent);
const runMirror = () => runCliOrFail(database, "--settings", settings, "mirror", vault);
const runDatabase = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args);
await runMirror();
const before = JSON.parse(await runDatabase("info", "known.md"));
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
await mkdir(workDir.join("vault", nfcParent), { recursive: true });
await mkdir(workDir.join("vault", nfdParent), { recursive: true });
await writeFile(nfcFile, "NFC spelling content\n");
await writeFile(nfdFile, "NFD spelling content\n");
const inventoryNames = await readdir(vault);
if (!inventoryNames.includes(nfcParent) || !inventoryNames.includes(nfdParent)) {
t.skip("This filesystem cannot represent both NFC and NFD directory entries separately");
return;
}
const failedMirror = await runCli(database, "--settings", settings, "mirror", vault);
assert.notEqual(failedMirror.code, 0, "An ambiguous inventory must fail the mirror command");
assert(
!failedMirror.combined.includes("[Done] Command 'mirror' completed"),
"A failed inventory must not claim successful mirror completion"
);
assert.equal(await readFile(existingFile, "utf8"), existingContent);
assert.equal(await readFile(nfcFile, "utf8"), "NFC spelling content\n");
assert.equal(await readFile(nfdFile, "utf8"), "NFD spelling content\n");
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
assert.equal(await runDatabase("cat-rev", "known.md", before.revision), existingContent);
await rm(workDir.join("vault", nfdParent), { recursive: true });
const recoveredMirror = await runMirror();
assert(recoveredMirror.includes("[Done] Command 'mirror' completed"));
const recovered = JSON.parse(await runDatabase("info", `${nfcParent}/from-nfc.md`));
assert.equal(await runDatabase("cat-rev", `${nfcParent}/from-nfc.md`, recovered.revision), "NFC spelling content\n");
assert.equal(await readFile(existingFile, "utf8"), existingContent);
assert.deepEqual(JSON.parse(await runDatabase("info", "known.md")), before);
});