Files
obsidian-livesync/test/e2e-cli/test-mirror.ts
T
vorotamoroz d557298ceb Adopt Commonlib storage path contracts
Use the shared Node adapter and retain one comparison policy throughout
each CLI scan. Propagate permission and I/O failures instead of reporting
absence or a successful partial scan.

Use shared namespace and comparison helpers in Obsidian while preserving
its Vault APIs and alias policy. Cover native spelling and host filename
compatibility, and document the dependency and integration boundaries.
2026-11-03 08:44:31 +00:00

289 lines
14 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, stat, utimes, writeFile } from "node:fs/promises";
import { TempDir } from "./helpers/temp.ts";
import { 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]);
});