mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
const resolveQaNodeExecPathMock = vi.hoisted(() => vi.fn(async () => "/usr/bin/node"));
|
|
const waitForGatewayHealthyMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
const waitForTransportReadyMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: spawnMock,
|
|
}));
|
|
|
|
vi.mock("./node-exec.js", () => ({
|
|
resolveQaNodeExecPath: resolveQaNodeExecPathMock,
|
|
}));
|
|
|
|
vi.mock("./suite-runtime-gateway.js", () => ({
|
|
waitForGatewayHealthy: waitForGatewayHealthyMock,
|
|
waitForTransportReady: waitForTransportReadyMock,
|
|
}));
|
|
|
|
import {
|
|
findManagedDreamingCronJob,
|
|
isManagedDreamingCronJob,
|
|
listCronJobs,
|
|
readDoctorMemoryStatus,
|
|
runAgentPrompt,
|
|
runQaCli,
|
|
startAgentRun,
|
|
waitForAgentRun,
|
|
waitForMemorySearchMatch,
|
|
} from "./suite-runtime-agent-process.js";
|
|
|
|
type MockEmitter = {
|
|
emit: (eventName: string | symbol, ...args: unknown[]) => boolean;
|
|
on: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter;
|
|
once: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter;
|
|
};
|
|
|
|
type MockChildProcess = MockEmitter & {
|
|
stdout: MockEmitter;
|
|
stderr: MockEmitter;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function createMockEmitter() {
|
|
return new EventEmitter() as unknown as MockEmitter;
|
|
}
|
|
|
|
function createSpawnedProcess() {
|
|
const child = createMockEmitter() as MockChildProcess;
|
|
child.stdout = createMockEmitter();
|
|
child.stderr = createMockEmitter();
|
|
child.kill = vi.fn();
|
|
return child;
|
|
}
|
|
|
|
async function waitForSpawnCount(count: number) {
|
|
while (spawnMock.mock.calls.length < count) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
describe("qa suite runtime agent process helpers", () => {
|
|
beforeEach(() => {
|
|
spawnMock.mockReset();
|
|
resolveQaNodeExecPathMock.mockClear();
|
|
waitForGatewayHealthyMock.mockClear();
|
|
waitForTransportReadyMock.mockClear();
|
|
});
|
|
|
|
it("runs the qa cli through the resolved node executable", async () => {
|
|
const child = createSpawnedProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const pending = runQaCli(
|
|
{
|
|
repoRoot: "/repo",
|
|
gateway: {
|
|
tempRoot: "/tmp/runtime",
|
|
runtimeEnv: { PATH: "/usr/bin" },
|
|
},
|
|
primaryModel: "openai/gpt-5.5",
|
|
alternateModel: "openai/gpt-5.5-mini",
|
|
providerMode: "mock-openai",
|
|
} as never,
|
|
["qa", "suite"],
|
|
);
|
|
|
|
await waitForSpawnCount(1);
|
|
child.stdout.emit("data", Buffer.from("ok\n"));
|
|
child.emit("exit", 0);
|
|
|
|
await expect(pending).resolves.toBe("ok");
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
"/usr/bin/node",
|
|
["/repo/dist/index.js", "qa", "suite"],
|
|
expect.objectContaining({
|
|
cwd: "/tmp/runtime",
|
|
env: { PATH: "/usr/bin" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("merges isolated env overrides into qa cli runs", async () => {
|
|
const child = createSpawnedProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const pending = runQaCli(
|
|
{
|
|
repoRoot: "/repo",
|
|
gateway: {
|
|
tempRoot: "/tmp/runtime",
|
|
runtimeEnv: { PATH: "/usr/bin", OPENCLAW_STATE_DIR: "/tmp/default-state" },
|
|
},
|
|
primaryModel: "openai/gpt-5.5",
|
|
alternateModel: "openai/gpt-5.5-mini",
|
|
providerMode: "mock-openai",
|
|
} as never,
|
|
["crestodian", "-m", "overview"],
|
|
{
|
|
env: {
|
|
OPENCLAW_STATE_DIR: "/tmp/isolated-state",
|
|
OPENCLAW_CONFIG_PATH: "/tmp/isolated-state/openclaw.json",
|
|
},
|
|
},
|
|
);
|
|
|
|
await waitForSpawnCount(1);
|
|
child.stdout.emit("data", Buffer.from("ok\n"));
|
|
child.emit("exit", 0);
|
|
|
|
await expect(pending).resolves.toBe("ok");
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
"/usr/bin/node",
|
|
["/repo/dist/index.js", "crestodian", "-m", "overview"],
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({
|
|
PATH: "/usr/bin",
|
|
OPENCLAW_STATE_DIR: "/tmp/isolated-state",
|
|
OPENCLAW_CONFIG_PATH: "/tmp/isolated-state/openclaw.json",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("parses json qa cli output when requested", async () => {
|
|
const child = createSpawnedProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const pending = runQaCli(
|
|
{
|
|
repoRoot: "/repo",
|
|
gateway: {
|
|
tempRoot: "/tmp/runtime",
|
|
runtimeEnv: {},
|
|
},
|
|
primaryModel: "openai/gpt-5.5",
|
|
alternateModel: "openai/gpt-5.5-mini",
|
|
providerMode: "mock-openai",
|
|
} as never,
|
|
["memory", "search"],
|
|
{ json: true },
|
|
);
|
|
|
|
await waitForSpawnCount(1);
|
|
child.stdout.emit("data", Buffer.from('{"ok":true}\n'));
|
|
child.emit("exit", 0);
|
|
|
|
await expect(pending).resolves.toEqual({ ok: true });
|
|
});
|
|
|
|
it("parses json qa cli output after colored startup logs", async () => {
|
|
const child = createSpawnedProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const pending = runQaCli(
|
|
{
|
|
repoRoot: "/repo",
|
|
gateway: {
|
|
tempRoot: "/tmp/runtime",
|
|
runtimeEnv: {},
|
|
},
|
|
primaryModel: "openai/gpt-5.5",
|
|
alternateModel: "openai/gpt-5.5-mini",
|
|
providerMode: "mock-openai",
|
|
} as never,
|
|
["memory", "search", "--json"],
|
|
{ json: true },
|
|
);
|
|
|
|
await waitForSpawnCount(1);
|
|
child.stdout.emit(
|
|
"data",
|
|
Buffer.from(
|
|
'\u001b[35m[plugins]\u001b[39m \u001b[36mcodex installed bundled runtime deps\u001b[39m\n{"results":[{"text":"ORBIT-10"}]}\n',
|
|
),
|
|
);
|
|
child.emit("exit", 0);
|
|
|
|
await expect(pending).resolves.toEqual({ results: [{ text: "ORBIT-10" }] });
|
|
});
|
|
|
|
it("parses pretty json qa cli output after startup logs", async () => {
|
|
const child = createSpawnedProcess();
|
|
spawnMock.mockReturnValue(child);
|
|
|
|
const pending = runQaCli(
|
|
{
|
|
repoRoot: "/repo",
|
|
gateway: {
|
|
tempRoot: "/tmp/runtime",
|
|
runtimeEnv: {},
|
|
},
|
|
primaryModel: "openai/gpt-5.5",
|
|
alternateModel: "openai/gpt-5.5-mini",
|
|
providerMode: "mock-openai",
|
|
} as never,
|
|
["memory", "search", "--json"],
|
|
{ json: true },
|
|
);
|
|
|
|
await waitForSpawnCount(1);
|
|
child.stdout.emit(
|
|
"data",
|
|
Buffer.from(
|
|
'[plugins] memory-core installed bundled runtime deps\n{\n "results": [\n {\n "text": "ORBIT-10"\n }\n ]\n}\n',
|
|
),
|
|
);
|
|
child.emit("exit", 0);
|
|
|
|
await expect(pending).resolves.toEqual({ results: [{ text: "ORBIT-10" }] });
|
|
});
|
|
|
|
it("starts an agent run with transport-derived delivery metadata", async () => {
|
|
const gatewayCall = vi.fn(async () => ({ runId: "run-1" }));
|
|
const env = {
|
|
gateway: { call: gatewayCall },
|
|
transport: {
|
|
buildAgentDelivery: vi.fn(() => ({
|
|
channel: "qa-channel",
|
|
replyChannel: "reply-channel",
|
|
replyTo: "reply-target",
|
|
})),
|
|
},
|
|
} as never;
|
|
|
|
await expect(
|
|
startAgentRun(env, {
|
|
sessionKey: "session-1",
|
|
message: "hello",
|
|
}),
|
|
).resolves.toEqual({ runId: "run-1" });
|
|
expect(gatewayCall).toHaveBeenCalledWith(
|
|
"agent",
|
|
expect.objectContaining({
|
|
sessionKey: "session-1",
|
|
message: "hello",
|
|
channel: "qa-channel",
|
|
replyChannel: "reply-channel",
|
|
replyTo: "reply-target",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("finds managed dreaming cron jobs across legacy and current payload contracts", () => {
|
|
const legacy = {
|
|
id: "legacy",
|
|
name: "Memory Dreaming Promotion",
|
|
payload: {
|
|
kind: "systemEvent",
|
|
text: "__openclaw_memory_core_short_term_promotion_dream__",
|
|
},
|
|
};
|
|
const current = {
|
|
id: "current",
|
|
name: "Memory Dreaming Promotion",
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: "__openclaw_memory_core_short_term_promotion_dream__",
|
|
lightContext: true,
|
|
},
|
|
sessionTarget: "isolated",
|
|
delivery: { mode: "none" },
|
|
};
|
|
|
|
expect(isManagedDreamingCronJob(legacy)).toBe(true);
|
|
expect(isManagedDreamingCronJob(current)).toBe(true);
|
|
expect(findManagedDreamingCronJob([{ id: "other", name: "Other" }, current])).toBe(current);
|
|
});
|
|
|
|
it("waits for an agent run and fails when the run does not finish ok", async () => {
|
|
const gatewayCall = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ runId: "run-2" })
|
|
.mockResolvedValueOnce({ status: "error", error: "boom" });
|
|
const env = {
|
|
gateway: { call: gatewayCall },
|
|
transport: {
|
|
buildAgentDelivery: vi.fn(() => ({
|
|
channel: "qa-channel",
|
|
replyChannel: "reply-channel",
|
|
replyTo: "reply-target",
|
|
})),
|
|
},
|
|
} as never;
|
|
|
|
await expect(
|
|
runAgentPrompt(env, {
|
|
sessionKey: "session-2",
|
|
message: "hello",
|
|
}),
|
|
).rejects.toThrow("agent.wait returned error: boom");
|
|
});
|
|
|
|
it("waits for a specific agent run id", async () => {
|
|
const gatewayCall = vi.fn(async () => ({ status: "ok" }));
|
|
|
|
await expect(
|
|
waitForAgentRun({ gateway: { call: gatewayCall } } as never, "run-3"),
|
|
).resolves.toEqual({ status: "ok" });
|
|
expect(gatewayCall).toHaveBeenCalledWith(
|
|
"agent.wait",
|
|
{ runId: "run-3", timeoutMs: 30_000 },
|
|
{ timeoutMs: 35_000 },
|
|
);
|
|
});
|
|
|
|
it("lists cron jobs and doctor memory status through the gateway", async () => {
|
|
const gatewayCall = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
jobs: [{ id: "job-1", name: "dreaming" }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
dreaming: { enabled: true, shortTermCount: 3 },
|
|
});
|
|
const env = { gateway: { call: gatewayCall } } as never;
|
|
|
|
await expect(listCronJobs(env)).resolves.toEqual([{ id: "job-1", name: "dreaming" }]);
|
|
await expect(readDoctorMemoryStatus(env)).resolves.toEqual({
|
|
dreaming: { enabled: true, shortTermCount: 3 },
|
|
});
|
|
});
|
|
|
|
it("polls memory search results until the expected needle appears", async () => {
|
|
const search = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
results: [{ path: "memory/2020-01-01.md", text: "ORBIT-9" }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
results: [{ path: "memory/2020-01-01.md", text: "ORBIT-10" }],
|
|
});
|
|
|
|
await expect(
|
|
waitForMemorySearchMatch({
|
|
search,
|
|
expectedNeedle: "ORBIT-10",
|
|
timeoutMs: 2_000,
|
|
}),
|
|
).resolves.toEqual({
|
|
results: [{ path: "memory/2020-01-01.md", text: "ORBIT-10" }],
|
|
});
|
|
expect(search).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|