mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
refactor(agent): dedupe harness and command workflows
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
() => ({
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
18
src/cli/requirements-test-fixtures.ts
Normal file
18
src/cli/requirements-test-fixtures.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function createEmptyRequirements() {
|
||||
return {
|
||||
bins: [],
|
||||
anyBins: [],
|
||||
env: [],
|
||||
config: [],
|
||||
os: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyInstallChecks() {
|
||||
return {
|
||||
requirements: createEmptyRequirements(),
|
||||
missing: createEmptyRequirements(),
|
||||
configChecks: [],
|
||||
install: [],
|
||||
};
|
||||
}
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user