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