refactor(agent): dedupe harness and command workflows

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:09 +00:00
parent 04892ee230
commit f717a13039
204 changed files with 7366 additions and 11540 deletions

View File

@@ -49,6 +49,18 @@ function buildSnapshot(params: {
};
}
function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) {
mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
}
async function runConfigCommand(args: string[]) {
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(args, { from: "user" });
}
describe("config cli", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -77,16 +89,9 @@ describe("config cli", () => {
} as never,
} as never,
};
mockReadConfigFileSnapshot.mockResolvedValueOnce(
buildSnapshot({ resolved, config: runtimeMerged }),
);
setSnapshot(resolved, runtimeMerged);
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], { from: "user" });
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
@@ -114,16 +119,9 @@ describe("config cli", () => {
messages: { ackReaction: "✅" } as never,
sessions: { persistence: { enabled: true } } as never,
};
mockReadConfigFileSnapshot.mockResolvedValueOnce(
buildSnapshot({ resolved, config: runtimeMerged }),
);
setSnapshot(resolved, runtimeMerged);
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "set", "gateway.auth.mode", "token"], { from: "user" });
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
@@ -157,16 +155,9 @@ describe("config cli", () => {
},
} as never,
};
mockReadConfigFileSnapshot.mockResolvedValueOnce(
buildSnapshot({ resolved, config: runtimeMerged }),
);
setSnapshot(resolved, runtimeMerged);
const { registerConfigCli } = await import("./config-cli.js");
const program = new Command();
program.exitOverride();
registerConfigCli(program);
await program.parseAsync(["config", "unset", "tools.alsoAllow"], { from: "user" });
await runConfigCommand(["config", "unset", "tools.alsoAllow"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];

View File

@@ -29,6 +29,13 @@ vi.mock("../runtime.js", () => ({
const { registerCronCli } = await import("./cron-cli.js");
type CronUpdatePatch = {
patch?: {
payload?: { message?: string };
delivery?: { mode?: string; channel?: string; to?: string; bestEffort?: boolean };
};
};
function buildProgram() {
const program = new Command();
program.exitOverride();
@@ -36,6 +43,14 @@ function buildProgram() {
return program;
}
async function runCronEditAndGetPatch(editArgs: string[]): Promise<CronUpdatePatch> {
callGatewayFromCli.mockClear();
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", ...editArgs], { from: "user" });
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
return (updateCall?.[2] ?? {}) as CronUpdatePatch;
}
describe("cron cli", () => {
it("trims model and thinking on cron add", { timeout: 60_000 }, async () => {
callGatewayFromCli.mockClear();
@@ -347,34 +362,15 @@ describe("cron cli", () => {
});
it("includes delivery fields when explicitly provided with message", async () => {
callGatewayFromCli.mockClear();
const program = buildProgram();
// Update message AND delivery - should include both
await program.parseAsync(
[
"cron",
"edit",
"job-1",
"--message",
"Updated message",
"--deliver",
"--channel",
"telegram",
"--to",
"19098680",
],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: { message?: string };
delivery?: { mode?: string; channel?: string; to?: string };
};
};
const patch = await runCronEditAndGetPatch([
"--message",
"Updated message",
"--deliver",
"--channel",
"telegram",
"--to",
"19098680",
]);
// Should include everything
expect(patch?.patch?.payload?.message).toBe("Updated message");
@@ -384,22 +380,11 @@ describe("cron cli", () => {
});
it("includes best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const program = buildProgram();
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: { message?: string };
delivery?: { bestEffort?: boolean; mode?: string };
};
};
const patch = await runCronEditAndGetPatch([
"--message",
"Updated message",
"--best-effort-deliver",
]);
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.delivery?.mode).toBe("announce");
@@ -407,22 +392,11 @@ describe("cron cli", () => {
});
it("includes no-best-effort delivery when provided with message", async () => {
callGatewayFromCli.mockClear();
const program = buildProgram();
await program.parseAsync(
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as {
patch?: {
payload?: { message?: string };
delivery?: { bestEffort?: boolean; mode?: string };
};
};
const patch = await runCronEditAndGetPatch([
"--message",
"Updated message",
"--no-best-effort-deliver",
]);
expect(patch?.patch?.payload?.message).toBe("Updated message");
expect(patch?.patch?.delivery?.mode).toBe("announce");

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
import { withEnvOverride } from "../config/test-helpers.js";
const callGateway = vi.fn(async () => ({ ok: true }));
const startGatewayServer = vi.fn(async () => ({
@@ -25,32 +26,6 @@ const defaultRuntime = {
},
};
async function withEnvOverride<T>(
overrides: Record<string, string | undefined>,
fn: () => Promise<T>,
): Promise<T> {
const saved: Record<string, string | undefined> = {};
for (const key of Object.keys(overrides)) {
saved[key] = process.env[key];
if (overrides[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = overrides[key];
}
}
try {
return await fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = saved[key];
}
}
}
}
vi.mock(
new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href,
() => ({

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { HookStatusReport } from "../hooks/hooks-status.js";
import { formatHooksCheck, formatHooksList } from "./hooks-cli.js";
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
const report: HookStatusReport = {
workspaceDir: "/tmp/workspace",
@@ -22,22 +23,7 @@ const report: HookStatusReport = {
disabled: false,
eligible: true,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
...createEmptyInstallChecks(),
},
],
};
@@ -75,22 +61,7 @@ describe("hooks cli formatting", () => {
disabled: false,
eligible: true,
managedByPlugin: true,
requirements: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
...createEmptyInstallChecks(),
},
],
};

View File

@@ -12,6 +12,14 @@ vi.mock("./gateway-rpc.js", async () => {
};
});
async function runLogsCli(argv: string[]) {
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(argv, { from: "user" });
}
describe("logs cli", () => {
afterEach(() => {
callGatewayFromCli.mockReset();
@@ -38,12 +46,7 @@ describe("logs cli", () => {
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
await runLogsCli(["logs"]);
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
@@ -72,12 +75,7 @@ describe("logs cli", () => {
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs", "--local-time", "--plain"], { from: "user" });
await runLogsCli(["logs", "--local-time", "--plain"]);
stdoutSpy.mockRestore();
@@ -105,12 +103,7 @@ describe("logs cli", () => {
return true;
});
const { registerLogsCli } = await import("./logs-cli.js");
const program = new Command();
program.exitOverride();
registerLogsCli(program);
await program.parseAsync(["logs"], { from: "user" });
await runLogsCli(["logs"]);
stdoutSpy.mockRestore();
stderrSpy.mockRestore();

View File

@@ -29,6 +29,12 @@ afterEach(async () => {
});
describe("memory cli", () => {
function expectCliSync(sync: ReturnType<typeof vi.fn>) {
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
}
it("prints vector status when available", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const { defaultRuntime } = await import("../runtime.js");
@@ -244,9 +250,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "status", "--index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expectCliSync(sync);
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
@@ -269,9 +273,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expectCliSync(sync);
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
});
@@ -298,9 +300,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expectCliSync(sync);
expect(log).toHaveBeenCalledWith(expect.stringContaining("QMD index: "));
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
expect(close).toHaveBeenCalled();
@@ -329,9 +329,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expectCliSync(sync);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory index failed (main): QMD index file is empty"),
);
@@ -360,9 +358,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expectCliSync(sync);
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),

View File

@@ -13,6 +13,23 @@ function getFirstRuntimeLogLine(): string {
return first;
}
async function expectLoggedSingleMediaFile(params?: {
expectedContent?: string;
expectedPathPattern?: RegExp;
}): Promise<string> {
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
if (params?.expectedPathPattern) {
expect(mediaPath).toMatch(params.expectedPathPattern);
}
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe(params?.expectedContent ?? "hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
return mediaPath;
}
const IOS_NODE = {
nodeId: "ios-node",
displayName: "iOS Node",
@@ -20,10 +37,7 @@ const IOS_NODE = {
connected: true,
} as const;
function mockCameraGateway(
command: "camera.snap" | "camera.clip",
payload: Record<string, unknown>,
) {
function mockNodeGateway(command?: string, payload?: Record<string, unknown>) {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
@@ -31,7 +45,7 @@ function mockCameraGateway(
nodes: [IOS_NODE],
};
}
if (opts.method === "node.invoke") {
if (opts.method === "node.invoke" && command) {
return {
ok: true,
nodeId: IOS_NODE.nodeId,
@@ -52,7 +66,7 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera snap and prints two MEDIA paths", async () => {
mockCameraGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
const program = buildProgram();
runtime.log.mockClear();
@@ -85,34 +99,11 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera clip and prints one MEDIA path", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
},
};
}
return { ok: true };
mockNodeGateway("camera.clip", {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
});
const program = buildProgram();
@@ -140,19 +131,13 @@ describe("cli program (nodes media)", () => {
}),
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-camera-clip-front-.*\.mp4$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/,
});
});
it("runs nodes camera snap with facing front and passes params", async () => {
mockCameraGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
mockNodeGateway("camera.snap", { format: "jpg", base64: "aGk=", width: 1, height: 1 });
const program = buildProgram();
runtime.log.mockClear();
@@ -196,45 +181,15 @@ describe("cli program (nodes media)", () => {
}),
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile();
});
it("runs nodes camera clip with --no-audio", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
},
};
}
return { ok: true };
mockNodeGateway("camera.clip", {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
});
const program = buildProgram();
@@ -271,45 +226,15 @@ describe("cli program (nodes media)", () => {
}),
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile();
});
it("runs nodes camera clip with human duration (10s)", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
},
};
}
return { ok: true };
mockNodeGateway("camera.clip", {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
});
const program = buildProgram();
@@ -332,30 +257,7 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes canvas snapshot and prints MEDIA path", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "canvas.snapshot",
payload: { format: "png", base64: "aGk=" },
};
}
return { ok: true };
});
mockNodeGateway("canvas.snapshot", { format: "png", base64: "aGk=" });
const program = buildProgram();
runtime.log.mockClear();
@@ -364,34 +266,13 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-canvas-snapshot-.*\.png$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("hi");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-canvas-snapshot-.*\.png$/,
});
});
it("fails nodes camera snap on invalid facing", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
return { ok: true };
});
mockNodeGateway();
const program = buildProgram();
runtime.error.mockClear();
@@ -426,34 +307,11 @@ describe("cli program (nodes media)", () => {
});
it("runs nodes camera snap with url payload", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.snap",
payload: {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
},
};
}
return { ok: true };
mockNodeGateway("camera.snap", {
format: "jpg",
url: "https://example.com/photo.jpg",
width: 640,
height: 480,
});
const program = buildProgram();
@@ -463,46 +321,18 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-camera-snap-front-.*\.jpg$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-camera-snap-front-.*\.jpg$/,
expectedContent: "url-content",
});
});
it("runs nodes camera clip with url payload", async () => {
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: Date.now(),
nodes: [
{
nodeId: "ios-node",
displayName: "iOS Node",
remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 5000,
hasAudio: true,
},
};
}
return { ok: true };
mockNodeGateway("camera.clip", {
format: "mp4",
url: "https://example.com/clip.mp4",
durationMs: 5000,
hasAudio: true,
});
const program = buildProgram();
@@ -512,15 +342,10 @@ describe("cli program (nodes media)", () => {
{ from: "user" },
);
const out = getFirstRuntimeLogLine();
const mediaPath = out.replace(/^MEDIA:/, "").trim();
expect(mediaPath).toMatch(/openclaw-camera-clip-front-.*\.mp4$/);
try {
await expect(fs.readFile(mediaPath, "utf8")).resolves.toBe("url-content");
} finally {
await fs.unlink(mediaPath).catch(() => {});
}
await expectLoggedSingleMediaFile({
expectedPathPattern: /openclaw-camera-clip-front-.*\.mp4$/,
expectedContent: "url-content",
});
});
});

View File

@@ -0,0 +1,18 @@
export function createEmptyRequirements() {
return {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
};
}
export function createEmptyInstallChecks() {
return {
requirements: createEmptyRequirements(),
missing: createEmptyRequirements(),
configChecks: [],
install: [],
};
}

View File

@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import type { SkillEntry } from "../agents/skills.js";
import { captureEnv } from "../test-utils/env.js";
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js";
// Unit tests: don't pay the runtime cost of loading/parsing the real skills loader.
@@ -23,22 +29,7 @@ function createMockSkill(overrides: Partial<SkillStatusEntry> = {}): SkillStatus
disabled: false,
blockedByAllowlist: false,
eligible: true,
requirements: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
configChecks: [],
install: [],
...createEmptyInstallChecks(),
...overrides,
};
}
@@ -211,4 +202,87 @@ describe("skills-cli", () => {
expect(parsed.summary.total).toBe(2);
});
});
describe("integration: loads real skills from bundled directory", () => {
let tempWorkspaceDir = "";
let tempBundledDir = "";
let envSnapshot: ReturnType<typeof captureEnv>;
let buildWorkspaceSkillStatus: typeof import("../agents/skills-status.js").buildWorkspaceSkillStatus;
beforeAll(async () => {
envSnapshot = captureEnv(["OPENCLAW_BUNDLED_SKILLS_DIR"]);
tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-"));
tempBundledDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-skills-test-"));
process.env.OPENCLAW_BUNDLED_SKILLS_DIR = tempBundledDir;
({ buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"));
});
afterAll(() => {
if (tempWorkspaceDir) {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
}
if (tempBundledDir) {
fs.rmSync(tempBundledDir, { recursive: true, force: true });
}
envSnapshot.restore();
});
const createEntries = (): SkillEntry[] => {
const baseDir = path.join(tempWorkspaceDir, "peekaboo");
return [
{
skill: {
name: "peekaboo",
description: "Capture UI screenshots",
source: "openclaw-bundled",
filePath: path.join(baseDir, "SKILL.md"),
baseDir,
} as SkillEntry["skill"],
frontmatter: {},
metadata: { emoji: "📸" },
},
];
};
it("loads bundled skills and formats them", async () => {
const entries = createEntries();
const report = buildWorkspaceSkillStatus(tempWorkspaceDir, {
managedSkillsDir: "/nonexistent",
entries,
});
// Should have loaded some skills
expect(report.skills.length).toBeGreaterThan(0);
// Format should work without errors
const listOutput = formatSkillsList(report, {});
expect(listOutput).toContain("Skills");
const checkOutput = formatSkillsCheck(report, {});
expect(checkOutput).toContain("Total:");
// JSON output should be valid
const jsonOutput = formatSkillsList(report, { json: true });
const parsed = JSON.parse(jsonOutput);
expect(parsed.skills).toBeInstanceOf(Array);
});
it("formats info for a real bundled skill (peekaboo)", async () => {
const entries = createEntries();
const report = buildWorkspaceSkillStatus(tempWorkspaceDir, {
managedSkillsDir: "/nonexistent",
entries,
});
// peekaboo is a bundled skill that should always exist
const peekaboo = report.skills.find((s) => s.name === "peekaboo");
if (!peekaboo) {
throw new Error("peekaboo fixture skill missing");
}
const output = formatSkillInfo(report, "peekaboo", {});
expect(output).toContain("peekaboo");
expect(output).toContain("Details:");
});
});
});

View File

@@ -35,46 +35,10 @@ vi.mock("../config/config.js", () => ({
writeConfigFile: vi.fn(),
}));
vi.mock("../infra/update-check.js", () => {
const parseSemver = (
value: string | null,
): { major: number; minor: number; patch: number } | null => {
if (!value) {
return null;
}
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(value);
if (!m) {
return null;
}
const major = Number(m[1]);
const minor = Number(m[2]);
const patch = Number(m[3]);
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
return null;
}
return { major, minor, patch };
};
const compareSemverStrings = (a: string | null, b: string | null): number | null => {
const pa = parseSemver(a);
const pb = parseSemver(b);
if (!pa || !pb) {
return null;
}
if (pa.major !== pb.major) {
return pa.major < pb.major ? -1 : 1;
}
if (pa.minor !== pb.minor) {
return pa.minor < pb.minor ? -1 : 1;
}
if (pa.patch !== pb.patch) {
return pa.patch < pb.patch ? -1 : 1;
}
return 0;
};
vi.mock("../infra/update-check.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/update-check.js")>();
return {
compareSemverStrings,
...actual,
checkUpdateStatus: vi.fn(),
fetchNpmTagVersion: vi.fn(),
resolveNpmChannelTag: vi.fn(),

View File

@@ -114,21 +114,7 @@ function parseGmailSetupOptions(raw: Record<string, unknown>): GmailSetupOptions
return {
account,
project: stringOption(raw.project),
topic: common.topic,
subscription: common.subscription,
label: common.label,
hookUrl: common.hookUrl,
hookToken: common.hookToken,
pushToken: common.pushToken,
bind: common.bind,
port: common.port,
path: common.path,
includeBody: common.includeBody,
maxBytes: common.maxBytes,
renewEveryMinutes: common.renewEveryMinutes,
tailscale: common.tailscaleRaw as GmailSetupOptions["tailscale"],
tailscalePath: common.tailscalePath,
tailscaleTarget: common.tailscaleTarget,
...gmailOptionsFromCommon(common),
pushEndpoint: stringOption(raw.pushEndpoint),
json: Boolean(raw.json),
};
@@ -138,21 +124,7 @@ function parseGmailRunOptions(raw: Record<string, unknown>): GmailRunOptions {
const common = parseGmailCommonOptions(raw);
return {
account: stringOption(raw.account),
topic: common.topic,
subscription: common.subscription,
label: common.label,
hookUrl: common.hookUrl,
hookToken: common.hookToken,
pushToken: common.pushToken,
bind: common.bind,
port: common.port,
path: common.path,
includeBody: common.includeBody,
maxBytes: common.maxBytes,
renewEveryMinutes: common.renewEveryMinutes,
tailscale: common.tailscaleRaw as GmailRunOptions["tailscale"],
tailscalePath: common.tailscalePath,
tailscaleTarget: common.tailscaleTarget,
...gmailOptionsFromCommon(common),
};
}
@@ -176,6 +148,28 @@ function parseGmailCommonOptions(raw: Record<string, unknown>) {
};
}
function gmailOptionsFromCommon(
common: ReturnType<typeof parseGmailCommonOptions>,
): Omit<GmailRunOptions, "account"> {
return {
topic: common.topic,
subscription: common.subscription,
label: common.label,
hookUrl: common.hookUrl,
hookToken: common.hookToken,
pushToken: common.pushToken,
bind: common.bind,
port: common.port,
path: common.path,
includeBody: common.includeBody,
maxBytes: common.maxBytes,
renewEveryMinutes: common.renewEveryMinutes,
tailscale: common.tailscaleRaw as GmailRunOptions["tailscale"],
tailscalePath: common.tailscalePath,
tailscaleTarget: common.tailscaleTarget,
};
}
function stringOption(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;