test: extract backup path plan coverage

This commit is contained in:
Peter Steinberger
2026-04-07 06:19:05 +01:00
parent 0cb162f05c
commit 86679ba84e
2 changed files with 110 additions and 71 deletions

View File

@@ -83,39 +83,26 @@ export function buildBackupArchivePath(archiveRoot: string, sourcePath: string):
return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath));
}
function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number {
const depthDelta = left.canonicalPath.length - right.canonicalPath.length;
if (depthDelta !== 0) {
return depthDelta;
}
const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind);
if (priorityDelta !== 0) {
return priorityDelta;
}
return left.canonicalPath.localeCompare(right.canonicalPath);
}
async function canonicalizeExistingPath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath);
} catch {
return path.resolve(targetPath);
}
}
export async function resolveBackupPlanFromDisk(
params: {
includeWorkspace?: boolean;
onlyConfig?: boolean;
nowMs?: number;
} = {},
): Promise<BackupPlan> {
export async function resolveBackupPlanFromPaths(params: {
stateDir: string;
configPath: string;
oauthDir: string;
workspaceDirs?: string[];
includeWorkspace?: boolean;
onlyConfig?: boolean;
configInsideState?: boolean;
oauthInsideState?: boolean;
nowMs?: number;
}): Promise<BackupPlan> {
const includeWorkspace = params.includeWorkspace ?? true;
const onlyConfig = params.onlyConfig ?? false;
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const oauthDir = resolveOAuthDir();
const stateDir = params.stateDir;
const configPath = params.configPath;
const oauthDir = params.oauthDir;
const archiveRoot = buildBackupArchiveRoot(params.nowMs);
const workspaceDirs = includeWorkspace ? (params.workspaceDirs ?? []) : [];
const configInsideState = params.configInsideState ?? false;
const oauthInsideState = params.oauthInsideState ?? false;
if (onlyConfig) {
const resolvedConfigPath = path.resolve(configPath);
@@ -155,34 +142,18 @@ export async function resolveBackupPlanFromDisk(
};
}
const configSnapshot = await readConfigFileSnapshot();
if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) {
throw new Error(
`Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`,
);
}
const cleanupPlan = buildCleanupPlan({
cfg: configSnapshot.config,
stateDir,
configPath,
oauthDir,
});
const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : [];
const rawCandidates: Array<Pick<BackupAssetCandidate, "kind" | "sourcePath">> = [
{ kind: "state", sourcePath: path.resolve(stateDir) },
...(cleanupPlan.configInsideState
...(configInsideState
? []
: [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]),
...(cleanupPlan.oauthInsideState
...(oauthInsideState
? []
: [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]),
...(includeWorkspace
? workspaceDirs.map((workspaceDir) => ({
kind: "workspace" as const,
sourcePath: path.resolve(workspaceDir),
}))
: []),
...workspaceDirs.map((workspaceDir) => ({
kind: "workspace" as const,
sourcePath: path.resolve(workspaceDir),
})),
];
const candidates: BackupAssetCandidate[] = await Promise.all(
@@ -252,3 +223,61 @@ export async function resolveBackupPlanFromDisk(
skipped,
};
}
function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number {
const depthDelta = left.canonicalPath.length - right.canonicalPath.length;
if (depthDelta !== 0) {
return depthDelta;
}
const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind);
if (priorityDelta !== 0) {
return priorityDelta;
}
return left.canonicalPath.localeCompare(right.canonicalPath);
}
async function canonicalizeExistingPath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath);
} catch {
return path.resolve(targetPath);
}
}
export async function resolveBackupPlanFromDisk(
params: {
includeWorkspace?: boolean;
onlyConfig?: boolean;
nowMs?: number;
} = {},
): Promise<BackupPlan> {
const includeWorkspace = params.includeWorkspace ?? true;
const onlyConfig = params.onlyConfig ?? false;
const stateDir = resolveStateDir();
const configPath = resolveConfigPath();
const oauthDir = resolveOAuthDir();
const configSnapshot = await readConfigFileSnapshot();
if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) {
throw new Error(
`Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`,
);
}
const cleanupPlan = buildCleanupPlan({
cfg: configSnapshot.config,
stateDir,
configPath,
oauthDir,
});
return await resolveBackupPlanFromPaths({
stateDir,
configPath,
oauthDir,
workspaceDirs: includeWorkspace ? cleanupPlan.workspaceDirs : [],
includeWorkspace,
onlyConfig,
configInsideState: cleanupPlan.configInsideState,
oauthInsideState: cleanupPlan.oauthInsideState,
nowMs: params.nowMs,
});
}

View File

@@ -8,6 +8,7 @@ import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js"
import {
buildBackupArchiveRoot,
encodeAbsolutePathForBackupArchive,
resolveBackupPlanFromPaths,
resolveBackupPlanFromDisk,
} from "./backup-shared.js";
import { backupCreateCommand } from "./backup.js";
@@ -88,13 +89,25 @@ describe("backup commands", () => {
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 configPath = path.join(stateDir, "openclaw.json");
const oauthDir = path.join(stateDir, "credentials");
const workspaceDir = path.join(stateDir, "workspace");
await fs.writeFile(configPath, JSON.stringify({}), "utf8");
await fs.mkdir(oauthDir, { recursive: true });
await fs.writeFile(path.join(oauthDir, "oauth.json"), "{}", "utf8");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8");
const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 });
const plan = await resolveBackupPlanFromPaths({
stateDir,
configPath,
oauthDir,
workspaceDirs: [workspaceDir],
includeWorkspace: true,
configInsideState: true,
oauthInsideState: true,
nowMs: 123,
});
expectWorkspaceCoveredByState(plan);
});
@@ -111,19 +124,16 @@ describe("backup commands", () => {
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 });
const plan = await resolveBackupPlanFromPaths({
stateDir,
configPath: path.join(stateDir, "openclaw.json"),
oauthDir: path.join(stateDir, "credentials"),
workspaceDirs: [workspaceLink],
includeWorkspace: true,
configInsideState: true,
oauthInsideState: true,
nowMs: 123,
});
expectWorkspaceCoveredByState(plan);
} finally {
await fs.rm(symlinkDir, { recursive: true, force: true });