389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
|
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
|
|
|
const spawnMock = vi.mocked(spawn);
|
|
|
|
/** Minimal mock ChildProcess for testing. */
|
|
function mockChildProcess() {
|
|
const events: Record<string, ((...args: unknown[]) => void)[]> = {};
|
|
const child = {
|
|
exitCode: null as number | null,
|
|
killed: false,
|
|
pid: 12345,
|
|
stdout: {
|
|
on: vi.fn(),
|
|
// Act as a minimal readable for createInterface
|
|
[Symbol.asyncIterator]: vi.fn(),
|
|
},
|
|
stderr: { on: vi.fn() },
|
|
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
events[event] = events[event] || [];
|
|
events[event].push(cb);
|
|
return child;
|
|
}),
|
|
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
events[event] = events[event] || [];
|
|
events[event].push(cb);
|
|
return child;
|
|
}),
|
|
kill: vi.fn(),
|
|
_emit(event: string, ...args: unknown[]) {
|
|
for (const cb of events[event] || []) {
|
|
cb(...args);
|
|
}
|
|
},
|
|
};
|
|
spawnMock.mockReturnValue(child as unknown as ChildProcess);
|
|
return child;
|
|
}
|
|
|
|
describe("agent-runner", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
process.env = { ...originalEnv };
|
|
// Re-wire mocks after resetModules
|
|
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
|
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── resolvePackageRoot ──────────────────────────────────────────────
|
|
|
|
describe("resolvePackageRoot", () => {
|
|
it("uses OPENCLAW_ROOT env var when set and valid", async () => {
|
|
process.env.OPENCLAW_ROOT = "/opt/ironclaw";
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
vi.mocked(mockExists).mockImplementation(
|
|
(p) => String(p) === "/opt/ironclaw",
|
|
);
|
|
|
|
const { resolvePackageRoot } = await import("./agent-runner.js");
|
|
expect(resolvePackageRoot()).toBe("/opt/ironclaw");
|
|
});
|
|
|
|
it("ignores OPENCLAW_ROOT when the path does not exist", async () => {
|
|
process.env.OPENCLAW_ROOT = "/nonexistent/path";
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
// OPENCLAW_ROOT doesn't exist, but we'll find openclaw.mjs by walking up
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
return String(p) === join("/pkg", "openclaw.mjs");
|
|
});
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue("/pkg/apps/web");
|
|
|
|
const { resolvePackageRoot } = await import("./agent-runner.js");
|
|
expect(resolvePackageRoot()).toBe("/pkg");
|
|
});
|
|
|
|
it("finds package root via openclaw.mjs in production (standalone cwd)", async () => {
|
|
delete process.env.OPENCLAW_ROOT;
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
// Only openclaw.mjs exists at the real package root
|
|
return String(p) === join("/pkg", "openclaw.mjs");
|
|
});
|
|
|
|
// Standalone mode: cwd is deep inside .next/standalone
|
|
vi.spyOn(process, "cwd").mockReturnValue(
|
|
"/pkg/apps/web/.next/standalone/apps/web",
|
|
);
|
|
|
|
const { resolvePackageRoot } = await import("./agent-runner.js");
|
|
expect(resolvePackageRoot()).toBe("/pkg");
|
|
});
|
|
|
|
it("finds package root via scripts/run-node.mjs in dev workspace", async () => {
|
|
delete process.env.OPENCLAW_ROOT;
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
return String(p) === join("/repo", "scripts", "run-node.mjs");
|
|
});
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
|
|
|
|
const { resolvePackageRoot } = await import("./agent-runner.js");
|
|
expect(resolvePackageRoot()).toBe("/repo");
|
|
});
|
|
|
|
it("falls back to legacy 2-levels-up heuristic", async () => {
|
|
delete process.env.OPENCLAW_ROOT;
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
vi.mocked(mockExists).mockReturnValue(false); // nothing found
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue("/unknown/apps/web");
|
|
|
|
const { resolvePackageRoot } = await import("./agent-runner.js");
|
|
expect(resolvePackageRoot()).toBe(
|
|
join("/unknown/apps/web", "..", ".."),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── spawnAgentProcess ──────────────────────────────────────────────
|
|
|
|
describe("spawnAgentProcess", () => {
|
|
it("uses scripts/run-node.mjs in dev when both scripts exist", async () => {
|
|
delete process.env.OPENCLAW_ROOT;
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
const s = String(p);
|
|
// Package root found via scripts/run-node.mjs
|
|
if (s === join("/repo", "scripts", "run-node.mjs")) {return true;}
|
|
// openclaw.mjs also exists in dev
|
|
if (s === join("/repo", "openclaw.mjs")) {return true;}
|
|
return false;
|
|
});
|
|
|
|
vi.spyOn(process, "cwd").mockReturnValue("/repo/apps/web");
|
|
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(
|
|
child as unknown as ChildProcess,
|
|
);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("hello");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"node",
|
|
expect.arrayContaining([
|
|
join("/repo", "scripts", "run-node.mjs"),
|
|
"agent",
|
|
"--agent",
|
|
"main",
|
|
"--message",
|
|
"hello",
|
|
"--stream-json",
|
|
]),
|
|
expect.objectContaining({
|
|
cwd: "/repo",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls back to openclaw.mjs in production (standalone)", async () => {
|
|
process.env.OPENCLAW_ROOT = "/pkg";
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
const s = String(p);
|
|
if (s === "/pkg") {return true;} // OPENCLAW_ROOT valid
|
|
if (s === join("/pkg", "openclaw.mjs")) {return true;} // prod script
|
|
// scripts/run-node.mjs does NOT exist (production install)
|
|
return false;
|
|
});
|
|
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(
|
|
child as unknown as ChildProcess,
|
|
);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("test message");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"node",
|
|
expect.arrayContaining([
|
|
join("/pkg", "openclaw.mjs"),
|
|
"agent",
|
|
"--agent",
|
|
"main",
|
|
"--message",
|
|
"test message",
|
|
"--stream-json",
|
|
]),
|
|
expect.objectContaining({
|
|
cwd: "/pkg",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("includes session-key and lane args when agentSessionId is set", async () => {
|
|
process.env.OPENCLAW_ROOT = "/pkg";
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
const s = String(p);
|
|
return s === "/pkg" || s === join("/pkg", "openclaw.mjs");
|
|
});
|
|
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(
|
|
child as unknown as ChildProcess,
|
|
);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("msg", "session-123");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"node",
|
|
expect.arrayContaining([
|
|
"--session-key",
|
|
"agent:main:web:session-123",
|
|
"--lane",
|
|
"web",
|
|
"--channel",
|
|
"webchat",
|
|
]),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── parseAgentErrorMessage ──────────────────────────────────────────
|
|
|
|
describe("parseAgentErrorMessage", () => {
|
|
it("extracts message from error field", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(
|
|
parseAgentErrorMessage({ error: "something went wrong" }),
|
|
).toBe("something went wrong");
|
|
});
|
|
|
|
it("extracts message from JSON error body", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const result = parseAgentErrorMessage({
|
|
errorMessage:
|
|
'402 {"error":{"message":"Insufficient funds"}}',
|
|
});
|
|
expect(result).toBe("Insufficient funds");
|
|
});
|
|
|
|
it("returns undefined for empty data", async () => {
|
|
const { parseAgentErrorMessage } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(parseAgentErrorMessage(undefined)).toBeUndefined();
|
|
expect(parseAgentErrorMessage({})).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── parseErrorFromStderr ───────────────────────────────────────────
|
|
|
|
describe("parseErrorFromStderr", () => {
|
|
it("extracts JSON error message from stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = `Some log line\n{"error":{"message":"Rate limit exceeded"}}\n`;
|
|
expect(parseErrorFromStderr(stderr)).toBe("Rate limit exceeded");
|
|
});
|
|
|
|
it("extracts error line containing 'error' keyword", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Module not found error: cannot resolve 'next'";
|
|
expect(parseErrorFromStderr(stderr)).toBeTruthy();
|
|
});
|
|
|
|
it("returns undefined for empty stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
expect(parseErrorFromStderr("")).toBeUndefined();
|
|
});
|
|
|
|
it("extracts first error line from multi-line stderr", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Info: starting up\nError: failed to connect\nInfo: shutting down";
|
|
expect(parseErrorFromStderr(stderr)).toBeTruthy();
|
|
});
|
|
|
|
it("returns undefined for non-error stderr content", async () => {
|
|
const { parseErrorFromStderr } = await import(
|
|
"./agent-runner.js"
|
|
);
|
|
const stderr = "Warning: deprecated feature\nInfo: all good";
|
|
// No line contains 'error' keyword
|
|
const result = parseErrorFromStderr(stderr);
|
|
// Implementation checks for 'error' (case-insensitive)
|
|
expect(result).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── parseErrorBody ──────────────────────────────────────────────
|
|
|
|
describe("parseErrorBody", () => {
|
|
it("extracts error message from JSON error body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
const body = '{"error":{"message":"Something failed"}}';
|
|
const result = parseErrorBody(body);
|
|
expect(result).toBe("Something failed");
|
|
});
|
|
|
|
it("returns raw string for non-JSON body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
expect(parseErrorBody("plain text error")).toBe("plain text error");
|
|
});
|
|
|
|
it("returns raw string for empty body", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
expect(parseErrorBody("")).toBe("");
|
|
});
|
|
|
|
it("extracts message from nested error object", async () => {
|
|
const { parseErrorBody } = await import("./agent-runner.js");
|
|
const body = '{"error":{"message":"Rate limit","type":"rate_limit_error"}}';
|
|
const result = parseErrorBody(body);
|
|
expect(result).toBe("Rate limit");
|
|
});
|
|
});
|
|
|
|
// ── spawnAgentProcess with file context ──────────────────────────
|
|
|
|
describe("spawnAgentProcess (additional)", () => {
|
|
it("includes file context flags when filePath is set", async () => {
|
|
process.env.OPENCLAW_ROOT = "/pkg";
|
|
|
|
const { existsSync: mockExists } = await import("node:fs");
|
|
const { spawn: mockSpawn } = await import("node:child_process");
|
|
|
|
vi.mocked(mockExists).mockImplementation((p) => {
|
|
const s = String(p);
|
|
return s === "/pkg" || s === join("/pkg", "openclaw.mjs");
|
|
});
|
|
|
|
const child = mockChildProcess();
|
|
vi.mocked(mockSpawn).mockReturnValue(child as unknown as ChildProcess);
|
|
|
|
const { spawnAgentProcess } = await import("./agent-runner.js");
|
|
spawnAgentProcess("analyze this file", "session-1", "knowledge/doc.md");
|
|
|
|
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
"node",
|
|
expect.arrayContaining(["--message"]),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
});
|
|
});
|