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
435 lines
15 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|