Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234import assert from "node:assert/strict";import { readdir, rename, rm, utimes } from "node:fs/promises";import { join } from "node:path";import { setFlagsFromString } from "node:v8";import { runInNewContext } from "node:vm";import { afterEach, beforeEach, test } from "node:test";import { syncFolder } from "../src/sync/engine.ts";import { RemoteSide } from "../src/sync/remote.ts";import { Harness } from "./helpers.ts";
let h: Harness;beforeEach(async () => { h = await Harness.start();});afterEach(async () => { await h.stop();});
/** Brings both sides to a synced starting point. */async function seed(files: Record<string, string>): Promise<void> { for (const [path, content] of Object.entries(files)) await h.writeLocal(path, content); await h.syncActions(); assert.deepEqual(await h.syncActions(), [], "a second sync has nothing to do");}
test("first sync uploads local files, and a second has nothing to do", async () => { await h.writeLocal("a.txt", "alpha"); await h.writeLocal("docs/b.txt", "beta"); const actions = await h.syncActions(); assert.deepEqual(actions.sort(), ["mkdir remote docs", "upload a.txt", "upload docs/b.txt"]); assert.equal(await h.readRemote("a.txt"), "alpha"); assert.equal(await h.readRemote("docs/b.txt"), "beta"); assert.deepEqual(await h.syncActions(), []);});
test("files created remotely are downloaded", async () => { await seed({ "keep.txt": "x" }); await h.writeRemote("new/c.txt", "gamma"); const actions = await h.syncActions(); assert.deepEqual(actions, ["mkdir local new", "download new/c.txt"]); assert.equal(await h.readLocal("new/c.txt"), "gamma"); assert.deepEqual(await h.syncActions(), []);});
test("a local edit is uploaded, a remote edit downloaded", async () => { await seed({ "a.txt": "one", "b.txt": "two" }); await h.writeLocal("a.txt", "one, edited"); await h.writeRemote("b.txt", "two, edited"); assert.deepEqual((await h.syncActions()).sort(), ["download b.txt", "upload a.txt"]); assert.equal(await h.readRemote("a.txt"), "one, edited"); assert.equal(await h.readLocal("b.txt"), "two, edited"); assert.deepEqual(await h.syncActions(), []);});
test("touching a file without changing it transfers nothing", async () => { await seed({ "a.txt": "same" }); const later = new Date(Date.now() + 60_000); await utimes(h.localPath("a.txt"), later, later); assert.deepEqual(await h.syncActions(), ["record a.txt"]); assert.deepEqual(await h.syncActions(), []);});
test("a local rename becomes a remote MOVE, not an upload", async () => { await seed({ "old.txt": "content that moves" }); await rename(h.localPath("old.txt"), h.localPath("new.txt")); assert.deepEqual(await h.syncActions(), ["move remote old.txt -> new.txt", "record new.txt"]); assert.equal(await h.readRemote("new.txt"), "content that moves"); assert.equal(await h.readRemote("old.txt"), null); assert.deepEqual(await h.syncActions(), []);});
test("a renamed directory moves as one", async () => { await seed({ "photos/a.jpg": "A", "photos/b.jpg": "B", "photos/2026/c.jpg": "C" }); await rename(h.localPath("photos"), h.localPath("pictures")); const actions = await h.syncActions(); assert.deepEqual(actions.filter((action) => !action.startsWith("record")), ["move remote photos -> pictures"]); assert.deepEqual(await h.remoteTree(), ["pictures/", "pictures/2026/", "pictures/2026/c.jpg", "pictures/a.jpg", "pictures/b.jpg"]); assert.deepEqual(await h.syncActions(), []);});
test("a remote rename is followed locally", async () => { await seed({ "docs/report.txt": "quarterly numbers" }); await h.dav.move("docs/report.txt", "docs/report-final.txt"); assert.deepEqual(await h.syncActions(), ["move local docs/report.txt -> docs/report-final.txt", "record docs/report-final.txt"]); assert.equal(await h.readLocal("docs/report-final.txt"), "quarterly numbers"); assert.deepEqual(await h.syncActions(), []);});
test("a rename plus an edit on the other side keeps both", async () => { await seed({ "a.txt": "first" }); await rename(h.localPath("a.txt"), h.localPath("b.txt")); await h.writeRemote("a.txt", "first, edited remotely"); assert.deepEqual(await h.syncActions(), ["move remote a.txt -> b.txt", "download b.txt"]); assert.equal(await h.readLocal("b.txt"), "first, edited remotely"); assert.equal(await h.readRemote("a.txt"), null); assert.deepEqual(await h.syncActions(), []);});
test("edits on both sides keep both versions", async () => { await seed({ "notes.txt": "base" }); await h.writeLocal("notes.txt", "local version"); await h.writeRemote("notes.txt", "remote version"); const result = await h.sync(); assert.equal(result.status, "ok"); assert.match(result.outcomes[0]!.detail!, /kept as notes \(conflict .*\)\.txt/); assert.equal(await h.readLocal("notes.txt"), "remote version"); const files = (await readdir(h.folder.localPath)).filter((name) => name.startsWith("notes (conflict")); assert.equal(files.length, 1); assert.equal(await h.readLocal(files[0]!), "local version"); assert.equal(await h.readRemote(files[0]!), "local version"); assert.deepEqual(await h.syncActions(), []);});
test("identical edits on both sides are adopted without a conflict", async () => { await seed({ "same.txt": "base" }); await h.writeLocal("same.txt", "converged"); await h.writeRemote("same.txt", "converged"); const result = await h.sync(); assert.equal(result.outcomes[0]!.detail, "same content on both sides"); assert.deepEqual((await readdir(h.folder.localPath)).filter((name) => name.includes("conflict")), []);});
test("deletions propagate both ways; local ones land in the trash", async () => { await seed({ "gone-locally.txt": "1", "gone-remotely.txt": "2", "dir/x.txt": "3" }); await rm(h.localPath("gone-locally.txt")); await rm(h.localPath("dir"), { recursive: true }); await h.dav.delete("gone-remotely.txt"); assert.deepEqual((await h.syncActions()).sort(), ["delete local gone-remotely.txt", "delete remote dir", "delete remote gone-locally.txt"]); assert.deepEqual(await h.remoteTree(), []); assert.deepEqual(await readdir(h.folder.localPath), [".davsync"]); assert.deepEqual(await h.syncActions(), []);});
test("a deletion loses against an edit on the other side", async () => { await seed({ "precious.txt": "v1", "other.txt": "keeps the folder from being empty" }); await rm(h.localPath("precious.txt")); await h.writeRemote("precious.txt", "v2"); assert.deepEqual(await h.syncActions(), ["download precious.txt"]); assert.equal(await h.readLocal("precious.txt"), "v2");});
test("a side that suddenly comes back empty is refused, not mirrored", async () => { await seed({ "a.txt": "1", "b.txt": "2" }); await rm(h.folder.localPath, { recursive: true }); await (await import("node:fs/promises")).mkdir(h.folder.localPath); const result = await h.sync(); assert.equal(result.status, "failed"); assert.match(result.error!, /is empty, but 2 entries were synced before/); assert.equal(await h.readRemote("a.txt"), "1");});
test("ignored paths are never synced", async () => { await h.writeLocal(".DS_Store", "junk"); await h.writeLocal("build/out.bin", "artefact"); h.folder.ignore.push("build"); await h.writeLocal("real.txt", "keep"); assert.deepEqual(await h.syncActions(), ["upload real.txt"]);});
test("a dry run plans but changes nothing", async () => { await h.writeLocal("a.txt", "alpha"); const result = await h.sync(true); assert.equal(result.status, "planned"); assert.equal(result.plan!.actions.length, 1); assert.equal(await h.readRemote("a.txt"), null); assert.equal(h.store.actions(result.run)[0]!.status, "planned");});
test("large files stream both ways intact", async () => { const big = "0123456789abcdef".repeat(400_000); // 6.4 MB await h.writeLocal("big.bin", big); assert.deepEqual(await h.syncActions(), ["upload big.bin"]); assert.equal(await h.readRemote("big.bin"), big); await h.writeRemote("big.bin", big.toUpperCase()); assert.deepEqual(await h.syncActions(), ["download big.bin"]); assert.equal(await h.readLocal("big.bin"), big.toUpperCase()); assert.deepEqual(await h.syncActions(), []);});
test("a download is read in full even when garbage collection runs before it is read", async () => { // fetch cancels a body once its Response is collected; the download must keep it alive. setFlagsFromString("--expose-gc"); const gc = runInNewContext("gc") as () => void; await h.writeRemote("kept.txt", "x".repeat(5000)); const remote = new RemoteSide({ remoteUrl: h.folder.remoteUrl, username: "", password: "" }); const incoming = await remote.download("kept.txt"); for (let round = 0; round < 3; round++) { gc(); await new Promise((resolve) => setTimeout(resolve, 10)); } let size = 0; for await (const chunk of incoming.body) size += chunk.byteLength; assert.equal(size, 5000);});
test("a download that delivers less than the server declared is refused, not stored", async () => { await seed({ "other.txt": "keeps the folder from being empty" }); await h.writeRemote("short.txt", "complete content, all of it"); const truncating = async (url: string, init: RequestInit) => { const response = await fetch(url, init); if (init.method !== "GET") return response; const bytes = new Uint8Array(await response.arrayBuffer()); return new Response(bytes.subarray(0, 5), { status: response.status, headers: response.headers }); }; const result = await syncFolder(h.folder, h.store, { lockDir: join(h.root, "locks"), fetch: truncating }); const index = result.plan!.actions.findIndex((action) => action.type === "download" && action.path === "short.txt"); assert.equal(result.outcomes[index]!.status, "failed"); assert.match(result.outcomes[index]!.detail!, /incomplete: 5 of 27 bytes/); await assert.rejects(h.readLocal("short.txt"), /ENOENT/); assert.deepEqual(await h.syncActions(), ["download short.txt"], "it is fetched again, properly, next time"); assert.equal(await h.readLocal("short.txt"), "complete content, all of it");});
test("names the server spells decomposed (NFD) are fetched, updated and moved by that spelling", async () => { // mailbox.org holds "ö" composed in one name and decomposed in the next. const nfd = "Auflösung"; const nfc = "Auflösung"; await h.writeRemote(`Köln/${nfd}Formular.pdf`, "decomposed"); await h.writeRemote(`Köln/${nfc}Brief.pdf`, "composed"); assert.deepEqual((await h.syncActions()).sort(), [`download Köln/${nfc}Brief.pdf`, `download Köln/${nfc}Formular.pdf`, "mkdir local Köln"]); assert.equal(await h.readLocal(`Köln/${nfc}Formular.pdf`), "decomposed"); assert.deepEqual(await h.syncActions(), []);
await h.writeLocal(`Köln/${nfc}Formular.pdf`, "edited here"); assert.deepEqual(await h.syncActions(), [`upload Köln/${nfc}Formular.pdf`]); assert.equal(await h.readRemote(`Köln/${nfd}Formular.pdf`), "edited here"); // Updated in place: no second, composed copy beside it. assert.equal((await h.remoteTree()).filter((path) => path.normalize("NFC").includes("Formular")).length, 1);
await rename(h.localPath(`Köln/${nfc}Formular.pdf`), h.localPath(`Köln/${nfc}Formular-alt.pdf`)); assert.deepEqual(await h.syncActions(), [`move remote Köln/${nfc}Formular.pdf -> Köln/${nfc}Formular-alt.pdf`, `record Köln/${nfc}Formular-alt.pdf`]); assert.deepEqual(await h.syncActions(), []);});