Files
plainleaf/client/spaces/sync.test.ts
T

890 lines
31 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { SpaceSync, SyncSnapshot } from "./sync.ts";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { MemoryKvPrimitives } from "../data/memory_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
const encode = (s: string) => new TextEncoder().encode(s);
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
function createSyncSetup(opts?: {
isSyncCandidate?: (path: string) => boolean;
}) {
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const snapshot = new SyncSnapshot();
const sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: opts?.isSyncCandidate ?? (() => true),
});
return { primary, secondary, snapshot, sync };
}
async function doSync(
sync: SpaceSync,
snapshot: SyncSnapshot,
): Promise<number> {
await sleep(10);
return sync.syncFiles(snapshot);
}
describe("Sync with no filtering", () => {
test("bidirectional create, update, delete, conflict", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
console.log("Write one page to primary");
await primary.writeFile("index.md", encode("Hello"));
expect((await secondary.fetchFileList()).length).toEqual(0);
console.log("Initial sync ops", await doSync(sync, snapshot));
expect((await secondary.fetchFileList()).length).toEqual(1);
expect((await secondary.readFile("index.md")).data).toEqual(
encode("Hello"),
);
// Should be a no-op
let ops = await doSync(sync, snapshot);
expect(ops).toEqual(0);
expect(snapshot.nonSyncedFiles.size).toEqual(0);
// Now let's make a change on the secondary
await secondary.writeFile("index.md", encode("Hello!!"));
await secondary.writeFile("test.md", encode("Test page"));
// And sync it
ops = await doSync(sync, snapshot);
expect(ops).toEqual(2);
expect(snapshot.nonSyncedFiles.size).toEqual(0);
expect((await primary.fetchFileList()).length).toEqual(2);
expect((await secondary.fetchFileList()).length).toEqual(2);
expect((await primary.readFile("index.md")).data).toEqual(
encode("Hello!!"),
);
// Let's make some random edits on both ends
await primary.writeFile("index.md", encode("1"));
await primary.writeFile("index2.md", encode("2"));
await secondary.writeFile("index3.md", encode("3"));
await secondary.writeFile("index4.md", encode("4"));
await doSync(sync, snapshot);
expect((await primary.fetchFileList()).length).toEqual(5);
expect((await secondary.fetchFileList()).length).toEqual(5);
ops = await doSync(sync, snapshot);
expect(ops).toEqual(0);
console.log("Deleting pages");
// Delete some pages
await primary.deleteFile("index.md");
await primary.deleteFile("index3.md");
await doSync(sync, snapshot);
expect((await primary.fetchFileList()).length).toEqual(3);
expect((await secondary.fetchFileList()).length).toEqual(3);
// No-op
ops = await doSync(sync, snapshot);
expect(ops).toEqual(0);
await secondary.deleteFile("index4.md");
await primary.deleteFile("index2.md");
await doSync(sync, snapshot);
// Just "test" left
expect((await primary.fetchFileList()).length).toEqual(1);
expect((await secondary.fetchFileList()).length).toEqual(1);
// No-op
ops = await doSync(sync, snapshot);
expect(ops).toEqual(0);
await secondary.writeFile("index.md", encode("I'm back"));
await doSync(sync, snapshot);
expect((await primary.readFile("index.md")).data).toEqual(
encode("I'm back"),
);
// Cause a conflict
console.log("Introducing a conflict now");
await primary.writeFile("index.md", encode("Hello 1"));
await secondary.writeFile("index.md", encode("Hello 2"));
await doSync(sync, snapshot);
// Sync conflicting copy back
await doSync(sync, snapshot);
// Verify that primary won
expect((await primary.readFile("index.md")).data).toEqual(
encode("Hello 1"),
);
expect((await secondary.readFile("index.md")).data).toEqual(
encode("Hello 1"),
);
// test + index + index.conflicting copy
expect((await primary.fetchFileList()).length).toEqual(3);
expect((await secondary.fetchFileList()).length).toEqual(3);
// Introducing a fake conflict (same content, so not really conflicting)
await primary.writeFile("index.md", encode("Hello 1"));
await secondary.writeFile("index.md", encode("Hello 1"));
await doSync(sync, snapshot);
await doSync(sync, snapshot);
// test + index + index.md + previous index.conflicting copy but nothing more
expect((await primary.fetchFileList()).length).toEqual(3);
});
});
describe("Sync with filtering", () => {
test("filter mode switching", async () => {
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const snapshot = new SyncSnapshot();
let sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
});
console.log(
"Write one non-sync file on the primary, which SHOULD sync to the secondary",
);
await primary.writeFile("index.txt", encode("Hello"));
expect((await secondary.fetchFileList()).length).toEqual(0);
let ops = await doSync(sync, snapshot);
expect(ops).toEqual(1);
// Note: this number should be 0 because the file DOES have a local representation so it's not technically non-synced
expect(snapshot.nonSyncedFiles.size).toEqual(0);
expect((await secondary.fetchFileList()).length).toEqual(1);
expect((await secondary.readFile("index.txt")).data).toEqual(
encode("Hello"),
);
console.log("Updating on secondary");
await secondary.writeFile("index.txt", encode("Hello Updated"));
ops = await doSync(sync, snapshot);
expect(ops).toEqual(1);
expect(snapshot.nonSyncedFiles.size).toEqual(1);
try {
await primary.getFileMeta("index.md");
expect(
false,
"Local file should have been deleted locally since it's out of date",
).toBeTruthy();
} catch {
// Expected
}
console.log("Deleting remote files");
await secondary.deleteFile("index.txt");
ops = await doSync(sync, snapshot);
expect(ops).toEqual(1);
expect(snapshot.nonSyncedFiles.size).toEqual(0);
console.log("Creating a remote non-synced file");
await secondary.writeFile("index2.txt", encode("Hello 2"));
ops = await doSync(sync, snapshot);
expect(ops).toEqual(0); // No-op, metadata only
expect(snapshot.nonSyncedFiles.size).toEqual(1);
ops = await doSync(sync, snapshot);
expect(ops).toEqual(0); // No-op, metadata only
expect(snapshot.nonSyncedFiles.size).toEqual(1);
await primary.writeFile("index2.txt", encode("Hello local"));
ops = await doSync(sync, snapshot);
expect(ops).toEqual(1);
expect(snapshot.nonSyncedFiles.size).toEqual(0);
console.log("Getting into a state with some synced and non-synced files");
await secondary.writeFile("index.md", encode("This will sync"));
await secondary.writeFile("index.txt", encode("This will not sync"));
await secondary.writeFile("index2.txt", encode("This will not sync"));
ops = await doSync(sync, snapshot);
expect(ops).toEqual(2);
expect(snapshot.nonSyncedFiles.size).toEqual(2);
// Check file listings on both ends
expect((await secondary.fetchFileList()).length).toEqual(3);
expect((await primary.fetchFileList()).length).toEqual(1);
////////////
// Now let's start another sync session, but now wanting to sync everything
console.log("Going to switch to syncing everything now");
sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: () => true,
});
ops = await doSync(sync, snapshot);
// This should pull 2 files from remote to local
expect(ops).toEqual(2);
expect((await primary.fetchFileList()).length).toEqual(3);
expect((await secondary.fetchFileList()).length).toEqual(3);
console.log("And now to syncing nothing");
sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: () => false,
});
ops = await doSync(sync, snapshot);
// This should delete 3 files from the primary
expect(ops).toEqual(3);
// Leaving nothing on primary
expect((await primary.fetchFileList()).length).toEqual(0);
// And everything unchanged on secondary
expect((await secondary.fetchFileList()).length).toEqual(3);
// Ok, now we're going to sync everything again
sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: () => true,
});
ops = await doSync(sync, snapshot);
expect(ops).toEqual(3);
expect((await primary.fetchFileList()).length).toEqual(3);
expect((await secondary.fetchFileList()).length).toEqual(3);
});
});
describe("syncSingleFile", () => {
test("basic push sync", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
const operations = await sync.syncFiles(snapshot);
expect(operations).toEqual(0);
await primary.writeFile("index.md", encode("Hello"));
expect(1).toEqual(await sync.syncSingleFile("index.md", snapshot));
expect((await secondary.readFile("index.md")).data).toEqual(
encode("Hello"),
);
console.log(
"Let's write a new file on primary that is not a sync candidate",
);
await primary.writeFile("test.txt", encode("Hello"));
expect(1).toEqual(await sync.syncSingleFile("test.txt", snapshot));
expect(snapshot.nonSyncedFiles.size).toEqual(0);
});
test("sync new file from primary to secondary", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("new.md", encode("New file"));
await sleep(10);
const ops = await sync.syncSingleFile("new.md", snapshot);
expect(ops).toBe(1);
expect(decode((await secondary.readFile("new.md")).data)).toBe("New file");
});
test("sync changes from primary to secondary", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("page.md", encode("v1"));
await doSync(sync, snapshot);
await primary.writeFile("page.md", encode("v2"));
await sleep(10);
const ops = await sync.syncSingleFile("page.md", snapshot);
expect(ops).toBe(1);
expect(decode((await secondary.readFile("page.md")).data)).toBe("v2");
});
test("skip non-synced files", async () => {
const { secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
await secondary.writeFile("img.png", encode("image"));
await doSync(sync, snapshot);
expect(snapshot.nonSyncedFiles.has("img.png")).toBe(true);
const ops = await sync.syncSingleFile("img.png", snapshot);
expect(ops).toBe(0); // Skipped
});
test("propagate deletion when primary file is deleted", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("test.md", encode("Hello"));
await doSync(sync, snapshot);
expect(snapshot.files.has("test.md")).toBe(true);
expect(decode((await secondary.readFile("test.md")).data)).toBe("Hello");
await primary.deleteFile("test.md");
const ops = await sync.syncSingleFile("test.md", snapshot);
expect(ops).toBeGreaterThanOrEqual(0);
// File should be deleted from secondary
try {
await secondary.getFileMeta("test.md");
expect.fail("Expected file to be deleted from secondary");
} catch {
// Expected: file not found
}
expect(snapshot.files.has("test.md")).toBe(false);
});
test("handle file not existing on either side", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("gone.md", encode("Hello"));
await doSync(sync, snapshot);
await primary.deleteFile("gone.md");
await secondary.deleteFile("gone.md");
const ops = await sync.syncSingleFile("gone.md", snapshot);
expect(ops).toBeGreaterThanOrEqual(0);
expect(snapshot.files.has("gone.md")).toBe(false);
});
test("sync file only on secondary (not in snapshot)", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
// File exists only on secondary, no snapshot entry
await secondary.writeFile("remote-only.md", encode("Remote content"));
await sleep(10);
const ops = await sync.syncSingleFile("remote-only.md", snapshot);
// syncSingleFile calls syncFile with syncBack=true, so it should copy to primary
expect(ops).toBe(1);
expect(decode((await primary.readFile("remote-only.md")).data)).toBe(
"Remote content",
);
});
});
// =================================================================
// nonSyncedFiles snapshot persistence
// =================================================================
describe("nonSyncedFiles snapshot persistence", () => {
test("snapshotUpdated fires even when only nonSyncedFiles changed", async () => {
const { secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
let snapshotSaveCount = 0;
sync.on({
snapshotUpdated: () => {
snapshotSaveCount++;
},
syncProgress: () => {},
});
await secondary.writeFile("image.png", encode("binary data"));
await sleep(10);
snapshotSaveCount = 0;
const ops = await sync.syncFiles(snapshot);
expect(ops).toBe(0);
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
expect(snapshotSaveCount).toBeGreaterThan(0);
});
test("nonSyncedFiles persist across sync cycles", async () => {
const { secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
await secondary.writeFile("img1.png", encode("img1"));
await secondary.writeFile("img2.png", encode("img2"));
await sleep(10);
await sync.syncFiles(snapshot);
expect(snapshot.nonSyncedFiles.size).toBe(2);
const ops = await doSync(sync, snapshot);
expect(ops).toBe(0);
expect(snapshot.nonSyncedFiles.size).toBe(2);
});
});
// =================================================================
// Mutex/concurrency
// =================================================================
describe("Sync mutex behavior", () => {
test("concurrent syncFiles calls should be mutexed", async () => {
const { primary, snapshot, sync } = createSyncSetup();
await primary.writeFile("test.md", encode("Hello"));
await sleep(10);
const [result1, result2] = await Promise.all([
sync.syncFiles(snapshot),
sync.syncFiles(snapshot),
]);
const results = [result1, result2].sort((a, b) => a - b);
expect(results[0]).toBe(-1);
expect(results[1]).toBeGreaterThanOrEqual(0);
});
test("syncSingleFile during syncFiles should return -1", async () => {
const { secondary, snapshot, sync } = createSyncSetup();
for (let i = 0; i < 10; i++) {
await secondary.writeFile(`file${i}.md`, encode(`content ${i}`));
}
await sleep(10);
const fullSyncPromise = sync.syncFiles(snapshot);
const singleResult = await sync.syncSingleFile("file0.md", snapshot);
expect(singleResult).toBe(-1);
await fullSyncPromise;
});
});
// =================================================================
// Conflict resolution
// =================================================================
describe("Conflict resolution", () => {
test("fake conflict (same content) should NOT create conflict copy", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("test.md", encode("Same content"));
await doSync(sync, snapshot);
await primary.writeFile("test.md", encode("Updated same"));
await secondary.writeFile("test.md", encode("Updated same"));
await doSync(sync, snapshot);
const files = await primary.fetchFileList();
const conflictFiles = files.filter((f) => f.name.includes(".conflicted:"));
expect(conflictFiles.length).toBe(0);
});
test("real conflict creates conflict copy, primary wins", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("test.md", encode("Original"));
await doSync(sync, snapshot);
await primary.writeFile("test.md", encode("Primary version"));
await secondary.writeFile("test.md", encode("Secondary version"));
await doSync(sync, snapshot);
expect(decode((await primary.readFile("test.md")).data)).toBe(
"Primary version",
);
expect(decode((await secondary.readFile("test.md")).data)).toBe(
"Primary version",
);
const primaryFiles = await primary.fetchFileList();
const conflictFile = primaryFiles.find((f) =>
f.name.includes(".conflicted:"),
);
expect(conflictFile).toBeDefined();
const conflictData = await primary.readFile(conflictFile!.name);
expect(decode(conflictData.data)).toBe("Secondary version");
await doSync(sync, snapshot);
const secondaryFiles = await secondary.fetchFileList();
const secondaryConflict = secondaryFiles.find((f) =>
f.name.includes(".conflicted:"),
);
expect(secondaryConflict).toBeDefined();
});
test("conflict on file without extension", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("LICENSE", encode("License v1"));
await doSync(sync, snapshot);
await primary.writeFile("LICENSE", encode("License v2 primary"));
await secondary.writeFile("LICENSE", encode("License v2 secondary"));
await doSync(sync, snapshot);
expect(decode((await primary.readFile("LICENSE")).data)).toBe(
"License v2 primary",
);
const files = await primary.fetchFileList();
const conflictFile = files.find((f) =>
f.name.startsWith("LICENSE.conflicted:"),
);
expect(conflictFile).toBeDefined();
});
});
// =================================================================
// Deletion scenarios
// =================================================================
describe("Deletion scenarios", () => {
test("primary deletion propagates to secondary", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("delete-me.md", encode("Will be deleted"));
await doSync(sync, snapshot);
expect(decode((await secondary.readFile("delete-me.md")).data)).toBe(
"Will be deleted",
);
await primary.deleteFile("delete-me.md");
await doSync(sync, snapshot);
try {
await secondary.getFileMeta("delete-me.md");
expect.fail("Expected file to be deleted from secondary");
} catch {
// Expected
}
});
test("secondary deletion propagates to primary", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await secondary.writeFile("delete-me.md", encode("Will be deleted"));
await doSync(sync, snapshot);
expect(decode((await primary.readFile("delete-me.md")).data)).toBe(
"Will be deleted",
);
await secondary.deleteFile("delete-me.md");
await doSync(sync, snapshot);
try {
await primary.getFileMeta("delete-me.md");
expect.fail("Expected file to be deleted from primary");
} catch {
// Expected
}
});
test("both sides delete cleans up snapshot", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("both-delete.md", encode("Content"));
await doSync(sync, snapshot);
expect(snapshot.files.has("both-delete.md")).toBe(true);
await primary.deleteFile("both-delete.md");
await secondary.deleteFile("both-delete.md");
await doSync(sync, snapshot);
expect(snapshot.files.has("both-delete.md")).toBe(false);
});
test("file deleted on secondary, was previously non-synced (sync.ts:321-329)", async () => {
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const snapshot = new SyncSnapshot();
// Start with filtered sync so file is tracked as nonSynced
let sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: (path) => path.endsWith(".md"),
});
// Create a non-sync-candidate file on both sides via primary push
await primary.writeFile("data.json", encode('{"key":"value"}'));
await doSync(sync, snapshot);
// Secondary should have the file, and it's tracked
expect(decode((await secondary.readFile("data.json")).data)).toBe(
'{"key":"value"}',
);
// Now update on secondary so it becomes nonSynced
await secondary.writeFile("data.json", encode('{"key":"updated"}'));
await doSync(sync, snapshot);
expect(snapshot.nonSyncedFiles.has("data.json")).toBe(true);
// Now switch to unfiltered sync (syncBack=true), but delete on secondary
sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: () => true,
});
await secondary.deleteFile("data.json");
await doSync(sync, snapshot);
// Snapshot should be cleaned up, primary should NOT be deleted
// (because it was previously non-synced, the code skips the primary delete)
expect(snapshot.files.has("data.json")).toBe(false);
expect(snapshot.nonSyncedFiles.has("data.json")).toBe(false);
});
test("file deleted on secondary with !syncBack (sync.ts:339-356)", async () => {
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const snapshot = new SyncSnapshot();
const sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: (path) => path.endsWith(".md"),
});
// Create file on primary (which always pushes regardless of filter)
await primary.writeFile("config.txt", encode("config"));
await doSync(sync, snapshot);
// File should be on secondary
expect(decode((await secondary.readFile("config.txt")).data)).toBe(
"config",
);
// Now delete on secondary
await secondary.deleteFile("config.txt");
await doSync(sync, snapshot);
// Snapshot should be cleaned
expect(snapshot.files.has("config.txt")).toBe(false);
expect(snapshot.nonSyncedFiles.has("config.txt")).toBe(false);
// Primary copy should also be deleted (the !syncBack path attempts deletion)
try {
await primary.getFileMeta("config.txt");
expect.fail("Expected file to be deleted from primary");
} catch {
// Expected: file was deleted
}
});
});
// =================================================================
// Sync candidate filtering
// =================================================================
describe("Sync candidate filtering", () => {
test("non-sync candidate on primary still syncs to secondary", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
await primary.writeFile("script.js", encode("console.log('hi')"));
await doSync(sync, snapshot);
expect(decode((await secondary.readFile("script.js")).data)).toBe(
"console.log('hi')",
);
});
test("non-sync candidate on secondary tracked in nonSyncedFiles", async () => {
const { secondary, snapshot, sync } = createSyncSetup({
isSyncCandidate: (path) => path.endsWith(".md"),
});
await secondary.writeFile("image.png", encode("binary"));
await doSync(sync, snapshot);
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
});
test("switching from filtered to unfiltered pulls missing files", async () => {
const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
const snapshot = new SyncSnapshot();
let sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: (path) => path.endsWith(".md"),
});
await secondary.writeFile("page.md", encode("markdown"));
await secondary.writeFile("image.png", encode("image"));
await doSync(sync, snapshot);
expect((await primary.fetchFileList()).length).toBe(1);
expect(snapshot.nonSyncedFiles.has("image.png")).toBe(true);
sync = new SpaceSync(primary, secondary, {
conflictResolver: SpaceSync.primaryConflictResolver,
isSyncCandidate: () => true,
});
await doSync(sync, snapshot);
expect((await primary.fetchFileList()).length).toBe(2);
expect(snapshot.nonSyncedFiles.size).toBe(0);
});
});
// =================================================================
// Resync scenario (both sides have file, no snapshot entry)
// =================================================================
describe("Resync scenario", () => {
test("both sides have file but no snapshot entry, same content", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
// Write same content to both sides without syncing
await primary.writeFile("resync.md", encode("Same content"));
await secondary.writeFile("resync.md", encode("Same content"));
await sleep(10);
// No snapshot entry exists — this is a resync scenario (sync.ts:454-458)
const ops = await doSync(sync, snapshot);
// Should go through conflict resolver, detect same content, no conflict copy
const files = await primary.fetchFileList();
const conflictFiles = files.filter((f) => f.name.includes(".conflicted:"));
expect(conflictFiles.length).toBe(0);
// Snapshot should now have the file
expect(snapshot.files.has("resync.md")).toBe(true);
expect(ops).toBe(0); // Same content = 0 ops from conflict resolver
});
test("both sides have file but no snapshot entry, different content", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
await primary.writeFile("resync.md", encode("Primary version"));
await secondary.writeFile("resync.md", encode("Secondary version"));
await sleep(10);
await doSync(sync, snapshot);
// Primary should win
expect(decode((await primary.readFile("resync.md")).data)).toBe(
"Primary version",
);
// Conflict copy should exist
const files = await primary.fetchFileList();
const conflictFile = files.find((f) => f.name.includes(".conflicted:"));
expect(conflictFile).toBeDefined();
});
});
// =================================================================
// Size mismatch conflict
// =================================================================
describe("Size mismatch conflict", () => {
test("matching timestamps but different sizes triggers conflict resolution", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
// Initial sync
await primary.writeFile("data.bin", encode("original"));
await doSync(sync, snapshot);
// Manually manipulate the snapshot to simulate matching timestamps
// but the underlying files have different sizes
// Write different-sized content directly (bypass normal sync)
await primary.writeFile("data.bin", encode("short"));
await secondary.writeFile("data.bin", encode("much longer content here"));
// Set snapshot timestamps to match current files' timestamps
const primaryMeta = await primary.getFileMeta("data.bin");
const secondaryMeta = await secondary.getFileMeta("data.bin");
snapshot.files.set("data.bin", [
primaryMeta.lastModified,
secondaryMeta.lastModified,
]);
// Now sync — sizes differ despite timestamps matching in snapshot
// This should trigger the size mismatch conflict path
await doSync(sync, snapshot);
// Primary should win (primaryConflictResolver)
expect(decode((await primary.readFile("data.bin")).data)).toBe("short");
expect(decode((await secondary.readFile("data.bin")).data)).toBe("short");
// Conflict copy should exist
const files = await primary.fetchFileList();
const conflictFile = files.find((f) => f.name.includes(".conflicted:"));
expect(conflictFile).toBeDefined();
});
});
// =================================================================
// Empty file sync
// =================================================================
describe("Empty file sync", () => {
test("zero-byte files sync correctly in both directions", async () => {
const { primary, secondary, snapshot, sync } = createSyncSetup();
// Empty file from primary to secondary
await primary.writeFile("empty.md", encode(""));
await doSync(sync, snapshot);
const secondaryData = await secondary.readFile("empty.md");
expect(secondaryData.data.byteLength).toBe(0);
// Empty file from secondary to primary
await secondary.writeFile("empty2.md", encode(""));
await doSync(sync, snapshot);
const primaryData = await primary.readFile("empty2.md");
expect(primaryData.data.byteLength).toBe(0);
// Update empty file to have content
await primary.writeFile("empty.md", encode("now has content"));
await doSync(sync, snapshot);
expect(decode((await secondary.readFile("empty.md")).data)).toBe(
"now has content",
);
// Update back to empty
await primary.writeFile("empty.md", encode(""));
await doSync(sync, snapshot);
expect((await secondary.readFile("empty.md")).data.byteLength).toBe(0);
});
});
// =================================================================
// syncProgress event
// =================================================================
describe("syncProgress event", () => {
test("fires during syncFiles with correct counts", async () => {
const { secondary, snapshot, sync } = createSyncSetup();
const progressEvents: { filesProcessed: number; totalFiles: number }[] = [];
sync.on({
snapshotUpdated: () => {},
syncProgress: (status) => {
progressEvents.push({ ...status });
},
});
await secondary.writeFile("a.md", encode("a"));
await secondary.writeFile("b.md", encode("b"));
await secondary.writeFile("c.md", encode("c"));
await sleep(10);
await sync.syncFiles(snapshot);
// syncProgress only fires when fileOperations > 0
expect(progressEvents.length).toBeGreaterThan(0);
// Each event should have valid counts
for (const event of progressEvents) {
expect(event.filesProcessed).toBeGreaterThan(0);
expect(event.totalFiles).toBe(3);
expect(event.filesProcessed).toBeLessThanOrEqual(event.totalFiles);
}
});
});