winters27_obsidian-byoc/tests/syncConvergence.test.ts
winters27 22ab7b39ed test(sync): end-to-end convergence and duplication coverage
Drives the real sync engine over an in-memory filesystem: push idempotency,
delete without resurrection, move without duplicating the old path, folder
delete without recreation, and a conflict copy that does not multiply.
2026-06-16 13:21:52 -07:00

259 lines
7.9 KiB
TypeScript

import { strict as assert } from "assert";
import { FakeFs } from "../src/fsAll";
import { syncer } from "../src/sync/syncer";
// Minimal in-memory filesystem for driving the real sync engine end to end,
// no cloud account needed. Used for both the "local" and "remote" sides.
class MemFs extends FakeFs {
kind: string;
files = new Map<
string,
{ content: ArrayBuffer; mtime: number; ctime: number; folder: boolean }
>();
writeCount = 0;
constructor(kind: string) {
super();
this.kind = kind;
}
async walk(): Promise<any[]> {
const out: any[] = [];
for (const [key, f] of this.files) {
if (f.folder) {
out.push({ key, keyRaw: key, size: 0, sizeRaw: 0 });
} else {
out.push({
key,
keyRaw: key,
mtimeCli: f.mtime,
mtimeSvr: f.mtime,
size: f.content.byteLength,
sizeRaw: f.content.byteLength,
});
}
}
return out;
}
async walkPartial(): Promise<any[]> {
return this.walk();
}
async stat(key: string): Promise<any> {
const f = this.files.get(key);
if (!f) throw new Error(`status 404 not found: ${key}`);
const base = { key, keyRaw: key, mtimeCli: f.mtime, mtimeSvr: f.mtime, ctimeCli: f.ctime };
return f.folder
? { ...base, size: 0, sizeRaw: 0 }
: { ...base, size: f.content.byteLength, sizeRaw: f.content.byteLength };
}
async mkdir(key: string, mtime = 1, ctime = 1): Promise<any> {
this.files.set(key, { content: new ArrayBuffer(0), mtime, ctime, folder: true });
return { key, keyRaw: key, size: 0, sizeRaw: 0, mtimeCli: mtime, mtimeSvr: mtime };
}
async writeFile(key: string, content: ArrayBuffer, mtime: number, ctime: number): Promise<any> {
this.writeCount++;
this.files.set(key, { content, mtime, ctime, folder: false });
return {
key,
keyRaw: key,
mtimeCli: mtime,
mtimeSvr: mtime,
ctimeCli: ctime,
size: content.byteLength,
sizeRaw: content.byteLength,
};
}
async readFile(key: string): Promise<ArrayBuffer> {
const f = this.files.get(key);
if (!f) throw new Error(`status 404 not found: ${key}`);
return f.content;
}
async rename(k1: string, k2: string): Promise<void> {
const f = this.files.get(k1);
if (!f) throw new Error(`status 404 ${k1}`);
this.files.delete(k1);
this.files.set(k2, f);
}
async rm(key: string): Promise<void> {
this.files.delete(key);
}
supportsRename(): boolean {
return true;
}
async checkConnect(): Promise<boolean> {
return true;
}
async getUserDisplayName(): Promise<string> {
return "test";
}
async revokeAuth(): Promise<void> {}
allowEmptyFile(): boolean {
return true;
}
// test helpers
put(key: string, body: string, mtime: number): void {
this.files.set(key, {
content: new TextEncoder().encode(body).buffer as ArrayBuffer,
mtime,
ctime: mtime,
folder: false,
});
}
putFolder(key: string, mtime: number): void {
this.files.set(key, { content: new ArrayBuffer(0), mtime, ctime: mtime, folder: true });
}
read(key: string): string {
const f = this.files.get(key);
return f ? new TextDecoder().decode(f.content) : "";
}
keys(): string[] {
return [...this.files.keys()].sort();
}
fileKeys(): string[] {
return [...this.files.keys()].filter((k) => !k.endsWith("/")).sort();
}
}
function makeDb(seed?: any[]) {
let store: any[] | undefined = seed;
return {
_get: () => store,
prevSyncRecordsTbl: {
async getItem() {
return store;
},
async setItem(_id: string, v: any[]) {
store = v;
},
},
};
}
async function runSync(local: MemFs, remote: MemFs, db: any): Promise<void> {
const settings: any = {
password: "",
conflictAction: "smart_conflict",
ignorePaths: [],
onlyAllowPaths: [],
protectModifyPercentage: 100,
};
let syncErr: Error | null = null;
await syncer(
local as any,
remote as any,
{} as any, // fsEncrypt: unused because password === ""
undefined,
db,
"manual" as any,
"profile1",
"vault1",
".obsidian",
settings,
"1.0.0",
async () => {},
() => "", // getProtectError: never trips
async () => {},
async () => {},
async (_s: any, err: Error) => {
syncErr = err;
},
async () => {},
async () => {},
async () => {}
);
if (syncErr) throw syncErr;
}
describe("Sync engine convergence (in-memory, no encryption)", () => {
it("pushes a new file once and is stable on re-sync (no duplication or re-upload)", async () => {
const local = new MemFs("local");
const remote = new MemFs("remote");
const db = makeDb();
local.put("note.md", "hello", 1000);
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), ["note.md"]);
assert.equal(remote.read("note.md"), "hello");
const writesAfterFirst = remote.writeCount;
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), ["note.md"]); // still one
assert.equal(remote.writeCount, writesAfterFirst); // no redundant re-upload
});
it("propagates a local delete and does not resurrect it", async () => {
const local = new MemFs("local");
const remote = new MemFs("remote");
const db = makeDb();
local.put("a.md", "x", 1000);
await runSync(local, remote, db); // a.md on both, baseline set
local.files.delete("a.md"); // user deletes locally
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), []); // deleted on remote
await runSync(local, remote, db);
assert.deepEqual(local.fileKeys(), []); // does not come back
assert.deepEqual(remote.fileKeys(), []);
});
it("moves a file without duplicating the old path (#1127)", async () => {
const local = new MemFs("local");
const remote = new MemFs("remote");
const db = makeDb();
local.put("a/note.md", "body", 1000);
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), ["a/note.md"]);
// move a/note.md -> b/note.md locally
local.files.delete("a/note.md");
local.put("b/note.md", "body", 1000);
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), ["b/note.md"]); // moved, not duplicated
await runSync(local, remote, db);
assert.deepEqual(remote.fileKeys(), ["b/note.md"]); // stable, a/ does not reappear
});
it("deletes a folder and its contents without recreating them (#1127)", async () => {
const local = new MemFs("local");
const remote = new MemFs("remote");
const db = makeDb();
local.putFolder("docs/", 1000);
local.put("docs/x.md", "data", 1000);
await runSync(local, remote, db);
assert.deepEqual(remote.keys(), ["docs/", "docs/x.md"]);
// delete the folder and its file locally
local.files.delete("docs/x.md");
local.files.delete("docs/");
await runSync(local, remote, db);
assert.deepEqual(remote.keys(), []); // gone on remote
await runSync(local, remote, db);
assert.deepEqual(local.keys(), []); // does not reappear locally
assert.deepEqual(remote.keys(), []);
});
it("creates a single conflict copy and does not multiply it on re-sync (#1074)", async () => {
const local = new MemFs("local");
const remote = new MemFs("remote");
const db = makeDb();
local.put("c.md", "base", 1000);
await runSync(local, remote, db); // in sync
// both sides diverge from baseline
local.put("c.md", "local-edit", 2000);
remote.put("c.md", "remote-edit-longer", 3000);
await runSync(local, remote, db);
const conflictsAfterFirst = local.fileKeys().filter((k) => k !== "c.md");
assert.equal(conflictsAfterFirst.length, 1, "exactly one conflict copy");
await runSync(local, remote, db);
await runSync(local, remote, db);
const conflictsAfterMore = local.fileKeys().filter((k) => k !== "c.md");
assert.equal(conflictsAfterMore.length, 1, "conflict copy did not multiply");
});
});