mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:31:34 +00:00
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
// Migrate Claude tests cover provider plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveHomePath } from "./helpers.js";
|
|
import { buildMemoryItems } from "./memory.js";
|
|
import { buildClaudeMigrationProvider } from "./provider.js";
|
|
import { CLAUDE_AUTO_MEMORY_MAX_FILES, type ClaudeSource, discoverClaudeSource } from "./source.js";
|
|
import {
|
|
cleanupTempRoots,
|
|
makeConfigRuntime,
|
|
makeContext,
|
|
makeTempRoot,
|
|
writeFile,
|
|
} from "./test/provider-helpers.js";
|
|
|
|
function planItemById(
|
|
items: readonly { id: string; kind?: string; action?: string }[],
|
|
id: string,
|
|
) {
|
|
const item = items.find((candidate) => candidate.id === id);
|
|
if (!item) {
|
|
throw new Error(`expected migration plan item ${id}`);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
describe("Claude migration provider", () => {
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
await cleanupTempRoots();
|
|
});
|
|
|
|
it("registers a Claude migration provider", () => {
|
|
const provider = buildClaudeMigrationProvider();
|
|
expect(provider.id).toBe("claude");
|
|
expect(provider.label).toBe("Claude");
|
|
});
|
|
|
|
it("resolves tilde source paths against the OS home when OPENCLAW_HOME is set", () => {
|
|
const previous = process.env.OPENCLAW_HOME;
|
|
process.env.OPENCLAW_HOME = path.join(path.sep, "tmp", "openclaw-home");
|
|
try {
|
|
expect(resolveHomePath("~/.claude")).toBe(path.join(os.homedir(), ".claude"));
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.OPENCLAW_HOME;
|
|
} else {
|
|
process.env.OPENCLAW_HOME = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects missing Claude sources before planning", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, "missing");
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir: root }),
|
|
),
|
|
).rejects.toThrow("Claude state was not found");
|
|
});
|
|
|
|
it("plans and imports only Claude Code auto-memory into the selected agent", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const defaultWorkspace = path.join(root, "workspace-main");
|
|
const targetWorkspace = path.join(root, "workspace-research");
|
|
const stateDir = path.join(root, "state");
|
|
const reportDir = path.join(root, "report");
|
|
const memoryDir = path.join(source, "projects", "-tmp-research", "memory");
|
|
await writeFile(path.join(memoryDir, "MEMORY.md"), "# Research memory\n");
|
|
await writeFile(path.join(memoryDir, "topics", "api.md"), "# API facts\n");
|
|
await writeFile(path.join(memoryDir, "ignored.txt"), "not memory\n");
|
|
await writeFile(path.join(source, "CLAUDE.md"), "# Global instructions\n");
|
|
const config = {
|
|
agents: {
|
|
defaults: { workspace: defaultWorkspace },
|
|
list: [
|
|
{ id: "main", default: true },
|
|
{ id: "research", workspace: targetWorkspace },
|
|
],
|
|
},
|
|
} as never;
|
|
const context = makeContext({
|
|
source,
|
|
stateDir,
|
|
workspaceDir: defaultWorkspace,
|
|
reportDir,
|
|
config,
|
|
targetAgentId: "research",
|
|
itemKinds: ["memory"],
|
|
});
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
const plan = await provider.plan(context);
|
|
|
|
expect(plan.items).toHaveLength(2);
|
|
expect(plan.items.every((item) => item.kind === "memory")).toBe(true);
|
|
expect(plan.items.some((item) => item.id === "workspace:.claude/CLAUDE.md")).toBe(false);
|
|
expect(plan.items.map((item) => item.details?.relativePath)).toEqual([
|
|
"MEMORY.md",
|
|
"topics/api.md",
|
|
]);
|
|
expect(plan.items.every((item) => item.target?.startsWith(targetWorkspace))).toBe(true);
|
|
|
|
const result = await provider.apply(context, plan);
|
|
|
|
expect(result.summary).toMatchObject({ migrated: 2, errors: 0, conflicts: 0 });
|
|
const imported = result.items.find((item) => item.details?.relativePath === "topics/api.md");
|
|
expect(imported?.target).toContain(path.join("memory", "imports", "claude-code"));
|
|
await expect(fs.readFile(imported?.target ?? "", "utf8")).resolves.toBe("# API facts\n");
|
|
await expect(fs.access(path.join(targetWorkspace, "USER.md"))).rejects.toThrow();
|
|
});
|
|
|
|
it("discovers a user-configured Claude Code auto-memory directory", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const customMemory = path.join(root, "custom-memory");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: customMemory }),
|
|
);
|
|
await writeFile(path.join(customMemory, "MEMORY.md"), "# Custom memory\n");
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
const plan = await provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
);
|
|
|
|
expect(plan.items).toHaveLength(1);
|
|
expect(plan.items[0]?.source).toBe(path.join(customMemory, "MEMORY.md"));
|
|
});
|
|
|
|
it("honors CLAUDE_CONFIG_DIR for a relocated Claude home", async () => {
|
|
const root = await makeTempRoot();
|
|
const relocatedHome = path.join(root, "relocated-claude");
|
|
const memoryDir = path.join(relocatedHome, "projects", "-tmp-project", "memory");
|
|
await writeFile(path.join(memoryDir, "MEMORY.md"), "# Relocated memory\n");
|
|
vi.stubEnv("CLAUDE_CONFIG_DIR", relocatedHome);
|
|
|
|
const source = await discoverClaudeSource();
|
|
|
|
expect(source.root).toBe(relocatedHome);
|
|
expect(source.homeDir).toBe(relocatedHome);
|
|
expect(source.autoMemorySources.map((entry) => entry.path)).toEqual([memoryDir]);
|
|
});
|
|
|
|
it("treats an explicit repo root with a top-level projects/ dir as a project, not a home", async () => {
|
|
const root = await makeTempRoot();
|
|
const projectRoot = path.join(root, "my-monorepo");
|
|
await writeFile(path.join(projectRoot, "projects", "svc-a", "readme.md"), "# svc\n");
|
|
await writeFile(path.join(projectRoot, "settings.json"), "{}\n");
|
|
|
|
const source = await discoverClaudeSource(projectRoot);
|
|
|
|
expect(source.projectDir).toBe(projectRoot);
|
|
expect(source.homeDir).toBeUndefined();
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"reports an unreadable configured Claude Code auto-memory directory",
|
|
async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const customMemory = path.join(root, "custom-memory");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: customMemory }),
|
|
);
|
|
await writeFile(path.join(customMemory, "MEMORY.md"), "# Custom memory\n");
|
|
await fs.chmod(customMemory, 0o000);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
try {
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("Unable to read Claude Code auto-memory directory");
|
|
} finally {
|
|
await fs.chmod(customMemory, 0o700);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)(
|
|
"reports an inaccessible configured Claude Code auto-memory directory",
|
|
async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const lockedParent = path.join(root, "locked-parent");
|
|
const customMemory = path.join(lockedParent, "custom-memory");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: customMemory }),
|
|
);
|
|
await writeFile(path.join(customMemory, "MEMORY.md"), "# Custom memory\n");
|
|
await fs.chmod(lockedParent, 0o000);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
try {
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow(customMemory);
|
|
} finally {
|
|
await fs.chmod(lockedParent, 0o700);
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"reports an unreadable standard Claude Code projects directory",
|
|
async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const projects = path.join(source, "projects");
|
|
await fs.mkdir(projects, { recursive: true });
|
|
await fs.chmod(projects, 0o000);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
try {
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("Unable to read Claude Code projects directory");
|
|
} finally {
|
|
await fs.chmod(projects, 0o700);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects relative Claude Code auto-memory settings", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: "relative-memory" }),
|
|
);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("autoMemoryDirectory must be absolute or start with ~/");
|
|
});
|
|
|
|
it('rejects bare "~" as a Claude Code auto-memory directory', async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: "~" }),
|
|
);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("autoMemoryDirectory must be absolute or start with ~/");
|
|
});
|
|
|
|
it("rejects Claude Code auto-memory that contains the import destination", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const workspaceDir = path.join(root, "workspace");
|
|
const customMemory = path.join(workspaceDir, "memory");
|
|
await writeFile(
|
|
path.join(source, "settings.json"),
|
|
JSON.stringify({ autoMemoryDirectory: customMemory }),
|
|
);
|
|
await writeFile(path.join(customMemory, "MEMORY.md"), "# Existing memory\n");
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir,
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("source and OpenClaw import destination must be separate");
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"rejects a symlinked import destination that resolves into Claude Code memory",
|
|
async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const memoryDir = path.join(source, "projects", "-tmp-linked", "memory");
|
|
const workspaceDir = path.join(root, "workspace");
|
|
await writeFile(path.join(memoryDir, "MEMORY.md"), "# Source memory\n");
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.symlink(memoryDir, path.join(workspaceDir, "memory"));
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir,
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("destination must stay in the selected workspace");
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"marks a dangling Claude Code memory destination symlink as a conflict",
|
|
async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const workspaceDir = path.join(root, "workspace");
|
|
await writeFile(
|
|
path.join(source, "projects", "-tmp-linked", "memory", "MEMORY.md"),
|
|
"# Source memory\n",
|
|
);
|
|
const provider = buildClaudeMigrationProvider();
|
|
const context = makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir,
|
|
itemKinds: ["memory"],
|
|
overwrite: true,
|
|
});
|
|
const initial = await provider.plan(context);
|
|
const target = initial.items[0]?.target;
|
|
if (!target) {
|
|
throw new Error("expected planned Claude memory target");
|
|
}
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
await fs.symlink(path.join(root, "missing-memory.md"), target);
|
|
|
|
const plan = await provider.plan(context);
|
|
|
|
expect(plan.items[0]).toMatchObject({
|
|
status: "conflict",
|
|
reason: "target is not a regular file",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("fails planning when a discovered Claude Code memory directory cannot be read", async () => {
|
|
const root = await makeTempRoot();
|
|
const missingMemory = path.join(root, "missing-memory");
|
|
await writeFile(missingMemory, "not a directory\n");
|
|
const source: ClaudeSource = {
|
|
root,
|
|
confidence: "medium",
|
|
autoMemorySources: [
|
|
{
|
|
id: "missing",
|
|
label: "missing",
|
|
path: missingMemory,
|
|
},
|
|
],
|
|
archivePaths: [],
|
|
};
|
|
|
|
await expect(
|
|
buildMemoryItems({
|
|
source,
|
|
targets: {
|
|
workspaceDir: path.join(root, "workspace"),
|
|
stateDir: path.join(root, "state"),
|
|
agentDir: path.join(root, "state", "agents", "main", "agent"),
|
|
},
|
|
includeInstructions: false,
|
|
}),
|
|
).rejects.toThrow("Unable to read Claude Code auto-memory directory");
|
|
});
|
|
|
|
it("rejects oversized Claude Code auto-memory instead of returning a partial plan", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, ".claude");
|
|
const memoryDir = path.join(source, "projects", "-tmp-large", "memory");
|
|
await fs.mkdir(memoryDir, { recursive: true });
|
|
await Promise.all(
|
|
Array.from({ length: CLAUDE_AUTO_MEMORY_MAX_FILES + 1 }, async (_, index) => {
|
|
await fs.writeFile(path.join(memoryDir, `memory-${index}.md`), "memory\n", "utf8");
|
|
}),
|
|
);
|
|
const provider = buildClaudeMigrationProvider();
|
|
|
|
await expect(
|
|
provider.plan(
|
|
makeContext({
|
|
source,
|
|
stateDir: path.join(root, "state"),
|
|
workspaceDir: path.join(root, "workspace"),
|
|
itemKinds: ["memory"],
|
|
}),
|
|
),
|
|
).rejects.toThrow("safe import limit of 2000 Markdown files");
|
|
});
|
|
|
|
it("plans project memory, MCP servers, commands, skills, and manual review items", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, "project");
|
|
const workspaceDir = path.join(root, "workspace");
|
|
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
|
|
await writeFile(path.join(source, "CLAUDE.local.md"), "local-only\n");
|
|
await writeFile(
|
|
path.join(source, ".mcp.json"),
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
filesystem: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
env: { ANTHROPIC_API_KEY: "short-dev-key" },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(source, ".claude", "settings.json"),
|
|
JSON.stringify({
|
|
hooks: { PreToolUse: [] },
|
|
permissions: { allow: ["Bash(*)"] },
|
|
env: { FOO: "bar" },
|
|
}),
|
|
);
|
|
await writeFile(path.join(source, ".claude", "commands", "commit.md"), "Commit $ARGUMENTS\n");
|
|
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
|
|
await writeFile(path.join(source, ".claude", "agents", "reviewer.md"), "# Reviewer\n");
|
|
|
|
const provider = buildClaudeMigrationProvider();
|
|
const plan = await provider.plan(
|
|
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir }),
|
|
);
|
|
|
|
expect(plan.summary.total).toBeGreaterThan(0);
|
|
expect(planItemById(plan.items, "workspace:CLAUDE.md").kind).toBe("workspace");
|
|
expect(planItemById(plan.items, "config:mcp-server:project-mcp:filesystem").kind).toBe(
|
|
"config",
|
|
);
|
|
expect(planItemById(plan.items, "skill:claude-command-commit").action).toBe("create");
|
|
expect(planItemById(plan.items, "skill:review").action).toBe("copy");
|
|
expect(planItemById(plan.items, "archive:CLAUDE.local.md").action).toBe("archive");
|
|
expect(planItemById(plan.items, "archive:project-agents").action).toBe("archive");
|
|
const manualHooksItem = plan.items.find((item) => item.id.startsWith("manual:hooks:"));
|
|
expect(manualHooksItem?.kind).toBe("manual");
|
|
|
|
const redacted = JSON.stringify(redactMigrationPlan(plan));
|
|
expect(redacted).not.toContain("short-dev-key");
|
|
expect(redacted).toContain("[redacted]");
|
|
});
|
|
|
|
it("applies project imports without reading global Claude state", async () => {
|
|
const root = await makeTempRoot();
|
|
const source = path.join(root, "project");
|
|
const workspaceDir = path.join(root, "workspace");
|
|
const stateDir = path.join(root, "state");
|
|
const reportDir = path.join(root, "report");
|
|
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
|
|
await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Existing agents\n");
|
|
await writeFile(
|
|
path.join(source, ".mcp.json"),
|
|
JSON.stringify({
|
|
mcpServers: {
|
|
filesystem: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const commandDescriptionPrefix = "a".repeat(179);
|
|
await writeFile(
|
|
path.join(source, ".claude", "commands", "ship.md"),
|
|
`${commandDescriptionPrefix}😀tail\n`,
|
|
);
|
|
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
|
|
|
|
const config = {
|
|
agents: {
|
|
defaults: {
|
|
workspace: workspaceDir,
|
|
},
|
|
},
|
|
} as never;
|
|
const provider = buildClaudeMigrationProvider();
|
|
const result = await provider.apply(
|
|
makeContext({
|
|
source,
|
|
stateDir,
|
|
workspaceDir,
|
|
reportDir,
|
|
runtime: makeConfigRuntime(config),
|
|
config,
|
|
}),
|
|
);
|
|
|
|
expect(result.summary.errors).toBe(0);
|
|
const mcpItem = result.items.find(
|
|
(item) => item.id === "config:mcp-server:project-mcp:filesystem",
|
|
);
|
|
expect(mcpItem?.status).toBe("migrated");
|
|
expect((config as { mcp?: { servers?: Record<string, unknown> } }).mcp?.servers).toEqual({
|
|
filesystem: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
},
|
|
});
|
|
expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toContain(
|
|
"Imported from Claude: project CLAUDE.md",
|
|
);
|
|
const generatedSkill = await fs.readFile(
|
|
path.join(workspaceDir, "skills", "claude-command-ship", "SKILL.md"),
|
|
"utf8",
|
|
);
|
|
expect(generatedSkill.split("\n").find((line) => line.startsWith("description: "))).toBe(
|
|
`description: ${JSON.stringify(commandDescriptionPrefix)}`,
|
|
);
|
|
await expect(
|
|
fs.access(path.join(workspaceDir, "skills", "review", "SKILL.md")),
|
|
).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(reportDir, "summary.md"))).resolves.toBeUndefined();
|
|
});
|
|
});
|