Files
openclaw/src/cli/skills-cli.workshop.test.ts

227 lines
7.1 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { registerSkillsCli } from "./skills-cli.js";
const tempDirs = createTrackedTempDirs();
let envSnapshot: ReturnType<typeof captureEnv>;
let stateDir = "";
const mocks = vi.hoisted(() => {
const runtimeStdout: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: vi.fn((message: string) => {
runtimeStdout.push(message);
}),
error: vi.fn((message: string) => {
runtimeErrors.push(message);
}),
writeStdout: vi.fn((value: string) => {
runtimeStdout.push(value.endsWith("\n") ? value.slice(0, -1) : value);
}),
writeJson: vi.fn((value: unknown, space = 2) => {
runtimeStdout.push(JSON.stringify(value, null, space > 0 ? space : undefined));
}),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return {
defaultRuntime,
runtimeStdout,
runtimeErrors,
workspaceDir: "",
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
vi.mock("../terminal/links.js", () => ({
formatDocsLink: () => "docs.openclaw.ai/cli/skills",
}));
vi.mock("../terminal/theme.js", () => ({
theme: {
command: (value: string) => value,
error: (value: string) => value,
heading: (value: string) => value,
muted: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => ({}),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentIdByWorkspacePath: () => undefined,
resolveDefaultAgentId: () => "main",
resolveAgentWorkspaceDir: () => mocks.workspaceDir,
}));
describe("skills workshop cli", () => {
const createProgram = () => {
const program = new Command();
program.exitOverride();
registerSkillsCli(program);
return program;
};
const runCommand = async (argv: string[]) => {
try {
await createProgram().parseAsync(argv, { from: "user" });
} catch (error) {
if (error instanceof Error && error.message === "__exit__:0") {
return;
}
throw error;
}
};
beforeEach(async () => {
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-");
stateDir = await tempDirs.make("openclaw-skills-cli-workshop-state-");
process.env.OPENCLAW_STATE_DIR = stateDir;
mocks.runtimeStdout.length = 0;
mocks.runtimeErrors.length = 0;
mocks.defaultRuntime.log.mockClear();
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.writeStdout.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.exit.mockClear();
});
afterEach(async () => {
envSnapshot.restore();
await tempDirs.cleanup();
});
it("creates, lists, inspects, and applies a skill proposal", async () => {
const draftPath = path.join(mocks.workspaceDir, "proposal-draft");
await fs.mkdir(path.join(draftPath, "references"), { recursive: true });
await fs.writeFile(
path.join(draftPath, "PROPOSAL.md"),
"# Paris Weather\n\nCheck current weather before advice.\n",
"utf8",
);
await fs.writeFile(
path.join(draftPath, "references", "weather.md"),
"Use current conditions before recommendations.\n",
"utf8",
);
await runCommand([
"skills",
"workshop",
"propose-create",
"--name",
"Paris Weather",
"--description",
"Weather lookup workflow",
"--proposal-dir",
draftPath,
]);
const proposalId = mocks.runtimeStdout.at(-1);
expect(proposalId).toMatch(/^paris-weather-/);
await runCommand(["skills", "workshop", "list"]);
expect(mocks.runtimeStdout.at(-1)).toContain(`${proposalId} pending create`);
await runCommand(["skills", "workshop", "inspect", proposalId!]);
expect(mocks.runtimeStdout.at(-1)).toContain("status: proposal");
expect(mocks.runtimeStdout.at(-1)).toContain("--- references/weather.md ---");
expect(mocks.runtimeStdout.at(-1)).toContain("Use current conditions before recommendations.");
const revisedPath = path.join(mocks.workspaceDir, "revised-proposal.md");
await fs.writeFile(
revisedPath,
"# Paris Weather\n\nCheck current weather and alerts before advice.\n",
"utf8",
);
await runCommand([
"skills",
"workshop",
"revise",
proposalId!,
"--description",
"Revised weather lookup workflow",
"--proposal",
revisedPath,
]);
expect(mocks.runtimeStdout.at(-1)).toContain(`Revised ${proposalId} v2`);
await runCommand(["skills", "workshop", "apply", proposalId!]);
expect(mocks.runtimeStdout.at(-1)).toContain("Applied");
await expect(
fs.readFile(path.join(mocks.workspaceDir, "skills", "paris-weather", "SKILL.md"), "utf8"),
).resolves.toContain("Check current weather and alerts");
await expect(
fs.readFile(
path.join(mocks.workspaceDir, "skills", "paris-weather", "references", "weather.md"),
"utf8",
),
).resolves.toContain("Use current conditions");
});
it("scopes list and inspect to the selected workspace", async () => {
const firstWorkspaceDir = mocks.workspaceDir;
const draftPath = path.join(firstWorkspaceDir, "proposal-draft.md");
await fs.writeFile(draftPath, "# First CLI Skill\n", "utf8");
await runCommand([
"skills",
"workshop",
"propose-create",
"--name",
"First CLI Skill",
"--description",
"First workspace proposal",
"--proposal",
draftPath,
]);
const proposalId = mocks.runtimeStdout.at(-1);
expect(proposalId).toMatch(/^first-cli-skill-/);
mocks.workspaceDir = await tempDirs.make("openclaw-skills-cli-workshop-second-");
await runCommand(["skills", "workshop", "list"]);
expect(mocks.runtimeStdout.at(-1)).toBe("No skill proposals.");
await expect(runCommand(["skills", "workshop", "inspect", proposalId!])).rejects.toThrow(
"__exit__:1",
);
expect(mocks.runtimeErrors).toContain(`Skill proposal not found: ${proposalId}`);
mocks.workspaceDir = firstWorkspaceDir;
await runCommand(["skills", "workshop", "inspect", proposalId!]);
expect(mocks.runtimeStdout.at(-1)).toContain("status: proposal");
});
it("rejects missing proposal drafts before creating workshop state", async () => {
await expect(
runCommand([
"skills",
"workshop",
"propose-create",
"--name",
"Missing Draft",
"--description",
"Missing draft",
"--proposal",
path.join(mocks.workspaceDir, "missing.md"),
]),
).rejects.toThrow("__exit__:1");
expect(mocks.runtimeErrors[0]).toContain("file not found");
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
});
});