openclaw/extensions/qa-lab/src/suite-runtime-agent-process.test.ts
2026-04-25 18:05:28 +01:00

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);
});
});