Files
openclaw/src/commands/backup.test.ts
shichangs 0ecfd37b44 feat: add local backup CLI (#40163)
Merged via squash.

Prepared head SHA: ed46625ae20c71e5f46d51aa801cefe4ef1c92e3
Co-authored-by: shichangs <46870204+shichangs@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 16:21:20 -04:00

435 lines
15 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import {
buildBackupArchiveRoot,
encodeAbsolutePathForBackupArchive,
resolveBackupPlanFromDisk,
} from "./backup-shared.js";
import { backupCreateCommand } from "./backup.js";
const backupVerifyCommandMock = vi.hoisted(() => vi.fn());
vi.mock("./backup-verify.js", () => ({
backupVerifyCommand: backupVerifyCommandMock,
}));
describe("backup commands", () => {
let tempHome: TempHomeEnv;
let previousCwd: string;
beforeEach(async () => {
tempHome = await createTempHomeEnv("openclaw-backup-test-");
previousCwd = process.cwd();
backupVerifyCommandMock.mockReset();
backupVerifyCommandMock.mockResolvedValue({
ok: true,
archivePath: "/tmp/fake.tar.gz",
archiveRoot: "fake",
createdAt: new Date().toISOString(),
runtimeVersion: "test",
assetCount: 1,
entryCount: 2,
});
});
afterEach(async () => {
process.chdir(previousCwd);
await tempHome.restore();
});
it("collapses default config, credentials, and workspace into the state backup root", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true });
await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(path.join(stateDir, "workspace", "SOUL.md"), "# soul\n", "utf8");
const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 });
expect(plan.included).toHaveLength(1);
expect(plan.included[0]?.kind).toBe("state");
expect(plan.skipped).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]),
);
});
it("orders coverage checks by canonical path so symlinked workspaces do not duplicate state", async () => {
if (process.platform === "win32") {
return;
}
const stateDir = path.join(tempHome.home, ".openclaw");
const workspaceDir = path.join(stateDir, "workspace");
const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-link-"));
const workspaceLink = path.join(symlinkDir, "ws-link");
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8");
await fs.symlink(workspaceDir, workspaceLink);
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
agents: {
defaults: {
workspace: workspaceLink,
},
},
}),
"utf8",
);
const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 });
expect(plan.included).toHaveLength(1);
expect(plan.included[0]?.kind).toBe("state");
expect(plan.skipped).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "workspace", reason: "covered" })]),
);
} finally {
await fs.rm(symlinkDir, { recursive: true, force: true });
}
});
it("creates an archive with a manifest and external workspace payload", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
const configPath = path.join(tempHome.home, "custom-config.json");
const backupDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backups-"));
try {
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(
configPath,
JSON.stringify({
agents: {
defaults: {
workspace: externalWorkspace,
},
},
}),
"utf8",
);
await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8");
await fs.writeFile(path.join(externalWorkspace, "SOUL.md"), "# external\n", "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const nowMs = Date.UTC(2026, 2, 9, 0, 0, 0);
const result = await backupCreateCommand(runtime, {
output: backupDir,
includeWorkspace: true,
nowMs,
});
expect(result.archivePath).toBe(
path.join(backupDir, `${buildBackupArchiveRoot(nowMs)}.tar.gz`),
);
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-extract-"));
try {
await tar.x({ file: result.archivePath, cwd: extractDir, gzip: true });
const archiveRoot = path.join(extractDir, buildBackupArchiveRoot(nowMs));
const manifest = JSON.parse(
await fs.readFile(path.join(archiveRoot, "manifest.json"), "utf8"),
) as {
assets: Array<{ kind: string; archivePath: string }>;
};
expect(manifest.assets).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "state" }),
expect.objectContaining({ kind: "config" }),
expect.objectContaining({ kind: "workspace" }),
]),
);
const stateAsset = result.assets.find((asset) => asset.kind === "state");
const workspaceAsset = result.assets.find((asset) => asset.kind === "workspace");
expect(stateAsset).toBeDefined();
expect(workspaceAsset).toBeDefined();
const encodedStatePath = path.join(
archiveRoot,
"payload",
encodeAbsolutePathForBackupArchive(stateAsset!.sourcePath),
"state.txt",
);
const encodedWorkspacePath = path.join(
archiveRoot,
"payload",
encodeAbsolutePathForBackupArchive(workspaceAsset!.sourcePath),
"SOUL.md",
);
expect(await fs.readFile(encodedStatePath, "utf8")).toBe("state\n");
expect(await fs.readFile(encodedWorkspacePath, "utf8")).toBe("# external\n");
} finally {
await fs.rm(extractDir, { recursive: true, force: true });
}
} finally {
delete process.env.OPENCLAW_CONFIG_PATH;
await fs.rm(externalWorkspace, { recursive: true, force: true });
await fs.rm(backupDir, { recursive: true, force: true });
}
});
it("optionally verifies the archive after writing it", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const archiveDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-backup-verify-on-create-"),
);
try {
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await backupCreateCommand(runtime, {
output: archiveDir,
verify: true,
});
expect(result.verified).toBe(true);
expect(backupVerifyCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ log: expect.any(Function) }),
expect.objectContaining({ archive: result.archivePath, json: false }),
);
} finally {
await fs.rm(archiveDir, { recursive: true, force: true });
}
});
it("rejects output paths that would be created inside a backed-up directory", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(
backupCreateCommand(runtime, {
output: path.join(stateDir, "backups"),
}),
).rejects.toThrow(/must not be written inside a source path/i);
});
it("rejects symlinked output paths even when intermediate directories do not exist yet", async () => {
if (process.platform === "win32") {
return;
}
const stateDir = path.join(tempHome.home, ".openclaw");
const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-link-"));
const symlinkPath = path.join(symlinkDir, "linked-state");
try {
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.symlink(stateDir, symlinkPath);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(
backupCreateCommand(runtime, {
output: path.join(symlinkPath, "new", "subdir", "backup.tar.gz"),
}),
).rejects.toThrow(/must not be written inside a source path/i);
} finally {
await fs.rm(symlinkDir, { recursive: true, force: true });
}
});
it("falls back to the home directory when cwd is inside a backed-up source tree", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const workspaceDir = path.join(stateDir, "workspace");
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8");
process.chdir(workspaceDir);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const nowMs = Date.UTC(2026, 2, 9, 1, 2, 3);
const result = await backupCreateCommand(runtime, { nowMs });
expect(result.archivePath).toBe(
path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`),
);
await fs.rm(result.archivePath, { force: true });
});
it("falls back to the home directory when cwd is a symlink into a backed-up source tree", async () => {
if (process.platform === "win32") {
return;
}
const stateDir = path.join(tempHome.home, ".openclaw");
const workspaceDir = path.join(stateDir, "workspace");
const linkParent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-cwd-link-"));
const workspaceLink = path.join(linkParent, "workspace-link");
try {
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8");
await fs.symlink(workspaceDir, workspaceLink);
process.chdir(workspaceLink);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const nowMs = Date.UTC(2026, 2, 9, 1, 3, 4);
const result = await backupCreateCommand(runtime, { nowMs });
expect(result.archivePath).toBe(
path.join(tempHome.home, `${buildBackupArchiveRoot(nowMs)}.tar.gz`),
);
await fs.rm(result.archivePath, { force: true });
} finally {
await fs.rm(linkParent, { recursive: true, force: true });
}
});
it("allows dry-run preview even when the target archive already exists", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const existingArchive = path.join(tempHome.home, "existing-backup.tar.gz");
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(existingArchive, "already here", "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await backupCreateCommand(runtime, {
output: existingArchive,
dryRun: true,
});
expect(result.dryRun).toBe(true);
expect(result.verified).toBe(false);
expect(result.archivePath).toBe(existingArchive);
expect(await fs.readFile(existingArchive, "utf8")).toBe("already here");
});
it("fails fast when config is invalid and workspace backup is enabled", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const configPath = path.join(tempHome.home, "custom-config.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
try {
await expect(backupCreateCommand(runtime, { dryRun: true })).rejects.toThrow(
/--no-include-workspace/i,
);
} finally {
delete process.env.OPENCLAW_CONFIG_PATH;
}
});
it("allows explicit partial backups when config is invalid", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const configPath = path.join(tempHome.home, "custom-config.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
try {
const result = await backupCreateCommand(runtime, {
dryRun: true,
includeWorkspace: false,
});
expect(result.includeWorkspace).toBe(false);
expect(result.assets.some((asset) => asset.kind === "workspace")).toBe(false);
} finally {
delete process.env.OPENCLAW_CONFIG_PATH;
}
});
it("backs up only the active config file when --only-config is requested", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true });
await fs.writeFile(configPath, JSON.stringify({ theme: "config-only" }), "utf8");
await fs.writeFile(path.join(stateDir, "state.txt"), "state\n", "utf8");
await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await backupCreateCommand(runtime, {
dryRun: true,
onlyConfig: true,
});
expect(result.onlyConfig).toBe(true);
expect(result.includeWorkspace).toBe(false);
expect(result.assets).toHaveLength(1);
expect(result.assets[0]?.kind).toBe("config");
});
it("allows config-only backups even when the config file is invalid", async () => {
const configPath = path.join(tempHome.home, "custom-config.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
try {
const result = await backupCreateCommand(runtime, {
dryRun: true,
onlyConfig: true,
});
expect(result.assets).toHaveLength(1);
expect(result.assets[0]?.kind).toBe("config");
} finally {
delete process.env.OPENCLAW_CONFIG_PATH;
}
});
});