365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import { afterEach, describe, expect, test } from "vitest";
|
|
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import {
|
|
buildPostSyncCommands,
|
|
buildCollectionPlans,
|
|
buildRsyncArgs,
|
|
getDefaultSyncOptions,
|
|
detectConflicts,
|
|
includePatternsForCollection,
|
|
parseConfigYaml,
|
|
parseRsyncItemized,
|
|
remoteRsyncPath,
|
|
runQmdSync,
|
|
shellQuote,
|
|
type CommandRunner,
|
|
} from "../src/sync.js";
|
|
|
|
const originalEnv = { ...process.env };
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
describe("qmd sync config and collection planning", () => {
|
|
test("parses empty or missing collection config", () => {
|
|
expect(parseConfigYaml("", "empty")).toEqual({ collections: {} });
|
|
expect(parseConfigYaml("global_context: hello\n", "ctx")).toEqual({
|
|
global_context: "hello",
|
|
collections: {},
|
|
});
|
|
});
|
|
|
|
test("builds bidirectional and one-sided mirror plans", () => {
|
|
const plans = buildCollectionPlans({
|
|
host: "root@example.com",
|
|
remoteHome: "/home/ubuntu",
|
|
localConfig: {
|
|
collections: {
|
|
docs: { path: "/local/docs", pattern: "**/*.md" },
|
|
localOnly: { path: "/local/only", pattern: "**/*.md" },
|
|
},
|
|
},
|
|
remoteConfig: {
|
|
collections: {
|
|
docs: { path: "/remote/docs", pattern: "**/*.md" },
|
|
remoteOnly: { path: "/remote/only", pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(plans.find(p => p.name === "docs")).toMatchObject({
|
|
direction: "bidirectional",
|
|
localPath: "/local/docs",
|
|
remotePath: "/remote/docs",
|
|
pattern: "**/*.md",
|
|
localConfigured: true,
|
|
remoteConfigured: true,
|
|
});
|
|
expect(plans.find(p => p.name === "remoteOnly")).toMatchObject({
|
|
direction: "download-mirror",
|
|
remotePath: "/remote/only",
|
|
localConfigured: false,
|
|
remoteConfigured: true,
|
|
});
|
|
expect(plans.find(p => p.name === "localOnly")).toMatchObject({
|
|
direction: "upload-mirror",
|
|
localPath: "/local/only",
|
|
localConfigured: true,
|
|
remoteConfigured: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("qmd sync collection masks", () => {
|
|
test("maps markdown collection masks to rsync includes", () => {
|
|
expect(includePatternsForCollection("**/*.md")).toEqual(["*/", "*.md"]);
|
|
expect(includePatternsForCollection("**/*.txt")).toEqual([]);
|
|
});
|
|
|
|
test("uses include rules before exclude-all for markdown collections", () => {
|
|
const args = buildRsyncArgs({
|
|
source: "/local/docs",
|
|
destination: "root@example.com:/remote/docs",
|
|
remoteQmdUser: "ubuntu",
|
|
includes: includePatternsForCollection("**/*.md"),
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(args).toContain("--include");
|
|
expect(args).toContain("*/");
|
|
expect(args).toContain("*.md");
|
|
const excludeAllIndex = args.findIndex((arg, index) => arg === "--exclude" && args[index + 1] === "*");
|
|
const includeMdIndex = args.findIndex((arg) => arg === "*.md");
|
|
expect(includeMdIndex).toBeGreaterThan(-1);
|
|
expect(excludeAllIndex).toBeGreaterThan(includeMdIndex);
|
|
});
|
|
});
|
|
|
|
describe("qmd sync rsync command generation", () => {
|
|
test("quotes remote rsync path under the QMD user", () => {
|
|
expect(remoteRsyncPath("ubuntu")).toBe("sudo -u 'ubuntu' rsync");
|
|
expect(shellQuote("a'b")).toBe("'a'\\''b'");
|
|
});
|
|
|
|
test("uses resumable rsync options and remote user switching in dry-run", () => {
|
|
const args = buildRsyncArgs({
|
|
source: "/local/docs",
|
|
destination: "root@example.com:/remote/docs",
|
|
remoteQmdUser: "ubuntu",
|
|
dryRun: true,
|
|
delete: true,
|
|
excludeFrom: "/tmp/conflicts",
|
|
});
|
|
|
|
expect(args).toContain("--dry-run");
|
|
expect(args).toContain("--delete");
|
|
expect(args).toContain("--partial");
|
|
expect(args).toContain("--partial-dir=.qmd-rsync-partial");
|
|
expect(args).toContain("--delay-updates");
|
|
expect(args).not.toContain("--temp-dir");
|
|
expect(args).toContain("--rsync-path");
|
|
expect(args).toContain("sudo -u 'ubuntu' rsync");
|
|
expect(args).toContain("--exclude-from");
|
|
expect(args).toContain("/tmp/conflicts");
|
|
expect(args.at(-2)).toBe("/local/docs/");
|
|
expect(args.at(-1)).toBe("root@example.com:/remote/docs/");
|
|
});
|
|
|
|
test("uses an explicit temp directory for apply mode", () => {
|
|
const args = buildRsyncArgs({
|
|
source: "/local/docs",
|
|
destination: "root@example.com:/remote/docs",
|
|
remoteQmdUser: "ubuntu",
|
|
tempDir: "/remote/docs/.qmd-rsync-tmp",
|
|
});
|
|
|
|
expect(args).toContain("--temp-dir");
|
|
expect(args).toContain("/remote/docs/.qmd-rsync-tmp");
|
|
});
|
|
|
|
test("preserves exact file paths for conflict copies", () => {
|
|
const args = buildRsyncArgs({
|
|
source: "/local/docs/file.md",
|
|
destination: "root@example.com:/remote/docs/file.md.conflict.local.20260525Z",
|
|
remoteQmdUser: "ubuntu",
|
|
preserveFilePath: true,
|
|
});
|
|
|
|
expect(args.at(-2)).toBe("/local/docs/file.md");
|
|
expect(args.at(-1)).toBe("root@example.com:/remote/docs/file.md.conflict.local.20260525Z");
|
|
});
|
|
|
|
test("shell-quotes remote endpoints with spaces without requiring modern rsync -s", () => {
|
|
const args = buildRsyncArgs({
|
|
source: "/local/Obsidian Vault",
|
|
destination: "root@example.com:/remote/Obsidian Vault",
|
|
remoteQmdUser: "ubuntu",
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(args).not.toContain("-s");
|
|
expect(args.at(-2)).toBe("/local/Obsidian Vault/");
|
|
expect(args.at(-1)).toBe("root@example.com:'/remote/Obsidian Vault/'");
|
|
});
|
|
});
|
|
|
|
describe("qmd sync dry-run parsing and conflicts", () => {
|
|
test("parses rsync itemize output into relative paths", () => {
|
|
const output = [
|
|
">f.st...... notes/a.md",
|
|
"cd+++++++++ new-dir/",
|
|
">f+++++++++ new-dir/b.md",
|
|
">f.st...... .qmd-rsync-partial/tmp",
|
|
"",
|
|
].join("\n");
|
|
|
|
expect(parseRsyncItemized(output)).toEqual(["notes/a.md", "new-dir/b.md"]);
|
|
});
|
|
|
|
test("detects two-way modified paths and names conflict copies", () => {
|
|
const conflicts = detectConflicts(
|
|
"docs",
|
|
["a.md", "same.md"],
|
|
["same.md", "b.md"],
|
|
"20260525T010203Z",
|
|
);
|
|
|
|
expect(conflicts).toEqual([{
|
|
collection: "docs",
|
|
path: "same.md",
|
|
localConflictPath: "same.md.conflict.remote.20260525T010203Z",
|
|
remoteConflictPath: "same.md.conflict.local.20260525T010203Z",
|
|
}]);
|
|
});
|
|
});
|
|
|
|
describe("qmd sync update freshness", () => {
|
|
test("builds local and remote post-sync commands with sudo remote user", () => {
|
|
const opts = getDefaultSyncOptions({
|
|
host: "root@example.com",
|
|
remoteQmdUser: "ubuntu",
|
|
update: true,
|
|
embed: true,
|
|
localQmdCommand: ["bun", "src/cli/qmd.ts"],
|
|
});
|
|
|
|
expect(buildPostSyncCommands(opts).map(step => ({
|
|
side: step.side,
|
|
action: step.action,
|
|
command: step.command,
|
|
skipped: step.skipped,
|
|
}))).toEqual([
|
|
{ side: "local", action: "update", command: ["bun", "src/cli/qmd.ts", "update"], skipped: false },
|
|
{ side: "remote", action: "update", command: ["ssh", "root@example.com", "sudo -u 'ubuntu' sh -lc 'qmd update'"], skipped: false },
|
|
{ side: "local", action: "embed", command: ["bun", "src/cli/qmd.ts", "embed"], skipped: false },
|
|
{ side: "remote", action: "embed", command: ["ssh", "root@example.com", "sudo -u 'ubuntu' sh -lc 'qmd embed'"], skipped: false },
|
|
]);
|
|
});
|
|
|
|
test("dry-run --update plans update commands without executing them", async () => {
|
|
const env = await createSyncTestEnv();
|
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
const summary = await runQmdSync({
|
|
host: "root@example.com",
|
|
remoteQmdUser: "ubuntu",
|
|
remoteHome: "/home/ubuntu",
|
|
dryRun: true,
|
|
update: true,
|
|
localQmdCommand: ["qmd-test"],
|
|
runCommand: fakeRunner(calls),
|
|
});
|
|
|
|
expect(summary.failed).toBe(false);
|
|
expect(summary.postSync).toHaveLength(2);
|
|
expect(summary.postSync.every(step => step.skipped && step.reason === "dry-run")).toBe(true);
|
|
expect(calls.some(call => call.command === "qmd-test")).toBe(false);
|
|
expect(calls.some(call => call.command === "ssh" && call.args.join(" ").includes("qmd update"))).toBe(false);
|
|
await env.cleanup();
|
|
});
|
|
|
|
test("--collection limits sync to collection paths and skips config apply", async () => {
|
|
const env = await createSyncTestEnv();
|
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
const summary = await runQmdSync({
|
|
host: "root@example.com",
|
|
remoteQmdUser: "ubuntu",
|
|
remoteHome: "/home/ubuntu",
|
|
collection: ["docs"],
|
|
runCommand: fakeRunner(calls),
|
|
});
|
|
|
|
expect(summary.rsync.map(result => result.label)).toEqual(["docs", "docs", "docs", "docs"]);
|
|
expect(calls
|
|
.filter(call => call.command === "rsync")
|
|
.some(call => call.args.join(" ").includes(".config/qmd"))).toBe(false);
|
|
await env.cleanup();
|
|
});
|
|
|
|
test("apply rsync failure marks sync failed and skips update/embed", async () => {
|
|
const env = await createSyncTestEnv();
|
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
const summary = await runQmdSync({
|
|
host: "root@example.com",
|
|
remoteQmdUser: "ubuntu",
|
|
remoteHome: "/home/ubuntu",
|
|
update: true,
|
|
embed: true,
|
|
localQmdCommand: ["qmd-test"],
|
|
runCommand: fakeRunner(calls, { failApplyRsync: true }),
|
|
});
|
|
|
|
expect(summary.failed).toBe(true);
|
|
expect(summary.postSync).toHaveLength(4);
|
|
expect(summary.postSync.every(step => step.skipped && step.reason === "sync failed; update/embed not run")).toBe(true);
|
|
expect(calls.some(call => call.command === "qmd-test")).toBe(false);
|
|
await env.cleanup();
|
|
});
|
|
|
|
test("successful apply runs local and remote update before embed", async () => {
|
|
const env = await createSyncTestEnv();
|
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
const summary = await runQmdSync({
|
|
host: "root@example.com",
|
|
remoteQmdUser: "ubuntu",
|
|
remoteHome: "/home/ubuntu",
|
|
update: true,
|
|
embed: true,
|
|
localQmdCommand: ["qmd-test"],
|
|
runCommand: fakeRunner(calls),
|
|
});
|
|
|
|
expect(summary.failed).toBe(false);
|
|
expect(summary.postSync.map(step => `${step.side}:${step.action}:${step.exitCode}`)).toEqual([
|
|
"local:update:0",
|
|
"remote:update:0",
|
|
"local:embed:0",
|
|
"remote:embed:0",
|
|
]);
|
|
const executed = calls
|
|
.filter(call => call.command === "qmd-test" || call.args.join(" ").includes("qmd update") || call.args.join(" ").includes("qmd embed"))
|
|
.map(call => [call.command, ...call.args].join(" "));
|
|
expect(executed).toEqual([
|
|
"qmd-test update",
|
|
"ssh root@example.com sudo -u 'ubuntu' sh -lc 'qmd update'",
|
|
"qmd-test embed",
|
|
"ssh root@example.com sudo -u 'ubuntu' sh -lc 'qmd embed'",
|
|
]);
|
|
await env.cleanup();
|
|
});
|
|
});
|
|
|
|
async function createSyncTestEnv(): Promise<{ cleanup: () => Promise<void> }> {
|
|
const root = await mkdtemp(join(tmpdir(), "qmd-sync-test-"));
|
|
const configDir = join(root, "config");
|
|
const cacheDir = join(root, "cache");
|
|
const dataDir = join(root, "data");
|
|
const docsDir = join(root, "docs");
|
|
await mkdir(configDir, { recursive: true });
|
|
await mkdir(cacheDir, { recursive: true });
|
|
await mkdir(dataDir, { recursive: true });
|
|
await mkdir(docsDir, { recursive: true });
|
|
await writeFile(join(docsDir, "local.md"), "# Local\n");
|
|
await writeFile(join(configDir, "index.yml"), `collections:\n docs:\n path: ${JSON.stringify(docsDir)}\n pattern: "**/*.md"\n`);
|
|
process.env.QMD_CONFIG_DIR = configDir;
|
|
process.env.XDG_CACHE_HOME = cacheDir;
|
|
process.env.XDG_DATA_HOME = dataDir;
|
|
return {
|
|
cleanup: async () => {
|
|
await rm(root, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeRunner(
|
|
calls: Array<{ command: string; args: string[] }>,
|
|
options: { failApplyRsync?: boolean } = {},
|
|
): CommandRunner {
|
|
return async (command, args) => {
|
|
calls.push({ command, args });
|
|
if (command === "ssh") {
|
|
const remoteCommand = args.join(" ");
|
|
if (remoteCommand.includes("command -v rsync")) {
|
|
return { exitCode: 0, stdout: "rsync=1\nflock=1\nqmd 2.1.0\n", stderr: "" };
|
|
}
|
|
if (remoteCommand.includes("cat") && remoteCommand.includes("index.yml")) {
|
|
return { exitCode: 0, stdout: "collections:\n docs:\n path: /remote/docs\n pattern: \"**/*.md\"\n", stderr: "" };
|
|
}
|
|
return { exitCode: 0, stdout: "remote ok\n", stderr: "" };
|
|
}
|
|
if (command === "rsync") {
|
|
const isDryRun = args.includes("--dry-run");
|
|
if (!isDryRun && options.failApplyRsync) {
|
|
return { exitCode: 23, stdout: "", stderr: "rsync failed" };
|
|
}
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
}
|
|
if (command === "qmd-test") {
|
|
return { exitCode: 0, stdout: `${args[0]} ok\n`, stderr: "" };
|
|
}
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
};
|
|
}
|