diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 8aea32fc405..50971e48ba6 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -1,35 +1,21 @@ +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { PassThrough } from "node:stream"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; -import { createLobsterTool } from "./lobster-tool.js"; -async function writeFakeLobsterScript(scriptBody: string, prefix = "openclaw-lobster-plugin-") { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - const isWindows = process.platform === "win32"; +const spawnState = vi.hoisted(() => ({ + queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>, + spawn: vi.fn(), +})); - if (isWindows) { - const scriptPath = path.join(dir, "lobster.js"); - const cmdPath = path.join(dir, "lobster.cmd"); - await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" }); - const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`; - await fs.writeFile(cmdPath, cmd, { encoding: "utf8" }); - return { dir, binPath: cmdPath }; - } +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => spawnState.spawn(...args), +})); - const binPath = path.join(dir, "lobster"); - const file = `#!/usr/bin/env node\n${scriptBody}\n`; - await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); - return { dir, binPath }; -} - -async function writeFakeLobster(params: { payload: unknown }) { - const scriptBody = - `const payload = ${JSON.stringify(params.payload)};\n` + - `process.stdout.write(JSON.stringify(payload));\n`; - return await writeFakeLobsterScript(scriptBody); -} +let createLobsterTool: typeof import("./lobster-tool.js").createLobsterTool; function fakeApi(overrides: Partial = {}): OpenClawPluginApi { return { @@ -72,96 +58,115 @@ function fakeCtx(overrides: Partial = {}): OpenClawPl } describe("lobster plugin tool", () => { - it("runs lobster and returns parsed envelope in details", async () => { - const fake = await writeFakeLobster({ - payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, - }); + let tempDir = ""; + let lobsterBinPath = ""; - const originalPath = process.env.PATH; - process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + beforeAll(async () => { + ({ createLobsterTool } = await import("./lobster-tool.js")); - try { - const tool = createLobsterTool(fakeApi()); - const res = await tool.execute("call1", { - action: "run", - pipeline: "noop", - timeoutMs: 1000, + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-plugin-")); + lobsterBinPath = path.join(tempDir, process.platform === "win32" ? "lobster.cmd" : "lobster"); + await fs.writeFile(lobsterBinPath, "", { encoding: "utf8", mode: 0o755 }); + }); + + afterAll(async () => { + if (!tempDir) { + return; + } + if (process.platform === "win32") { + await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + spawnState.queue.length = 0; + spawnState.spawn.mockReset(); + spawnState.spawn.mockImplementation(() => { + const next = spawnState.queue.shift() ?? { stdout: "" }; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: (signal?: string) => boolean; + }; + child.stdout = stdout; + child.stderr = stderr; + child.kill = () => true; + + setImmediate(() => { + if (next.stderr) { + stderr.end(next.stderr); + } else { + stderr.end(); + } + stdout.end(next.stdout); + child.emit("exit", next.exitCode ?? 0); }); - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - } finally { - process.env.PATH = originalPath; - } + return child; + }); + }); + + it("runs lobster and returns parsed envelope in details", async () => { + spawnState.queue.push({ + stdout: JSON.stringify({ + ok: true, + status: "ok", + output: [{ hello: "world" }], + requiresApproval: null, + }), + }); + + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call1", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(spawnState.spawn).toHaveBeenCalled(); + expect(res.details).toMatchObject({ ok: true, status: "ok" }); }); it("tolerates noisy stdout before the JSON envelope", async () => { const payload = { ok: true, status: "ok", output: [], requiresApproval: null }; - const { dir } = await writeFakeLobsterScript( - `const payload = ${JSON.stringify(payload)};\n` + - `console.log("noise before json");\n` + - `process.stdout.write(JSON.stringify(payload));\n`, - "openclaw-lobster-plugin-noisy-", - ); + spawnState.queue.push({ + stdout: `noise before json\n${JSON.stringify(payload)}`, + }); - const originalPath = process.env.PATH; - process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call-noisy", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); - try { - const tool = createLobsterTool(fakeApi()); - const res = await tool.execute("call-noisy", { - action: "run", - pipeline: "noop", - timeoutMs: 1000, - }); - - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - } finally { - process.env.PATH = originalPath; - } + expect(res.details).toMatchObject({ ok: true, status: "ok" }); }); it("requires absolute lobsterPath when provided (even though it is ignored)", async () => { - const fake = await writeFakeLobster({ - payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, - }); - - const originalPath = process.env.PATH; - process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; - - try { - const tool = createLobsterTool(fakeApi()); - await expect( - tool.execute("call2", { - action: "run", - pipeline: "noop", - lobsterPath: "./lobster", - }), - ).rejects.toThrow(/absolute path/); - } finally { - process.env.PATH = originalPath; - } + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2", { + action: "run", + pipeline: "noop", + lobsterPath: "./lobster", + }), + ).rejects.toThrow(/absolute path/); }); it("rejects lobsterPath (deprecated) when invalid", async () => { - const fake = await writeFakeLobster({ - payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, - }); - - const originalPath = process.env.PATH; - process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; - - try { - const tool = createLobsterTool(fakeApi()); - await expect( - tool.execute("call2b", { - action: "run", - pipeline: "noop", - lobsterPath: "/bin/bash", - }), - ).rejects.toThrow(/lobster executable/); - } finally { - process.env.PATH = originalPath; - } + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2b", { + action: "run", + pipeline: "noop", + lobsterPath: "/bin/bash", + }), + ).rejects.toThrow(/lobster executable/); }); it("rejects absolute cwd", async () => { @@ -187,49 +192,38 @@ describe("lobster plugin tool", () => { }); it("uses pluginConfig.lobsterPath when provided", async () => { - const fake = await writeFakeLobster({ - payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + spawnState.queue.push({ + stdout: JSON.stringify({ + ok: true, + status: "ok", + output: [{ hello: "world" }], + requiresApproval: null, + }), }); - // Ensure `lobster` is NOT discoverable via PATH, while still allowing our - // fake lobster (a Node script with `#!/usr/bin/env node`) to run. - const originalPath = process.env.PATH; - process.env.PATH = path.dirname(process.execPath); + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: lobsterBinPath } })); + const res = await tool.execute("call-plugin-config", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); - try { - const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } })); - const res = await tool.execute("call-plugin-config", { - action: "run", - pipeline: "noop", - timeoutMs: 1000, - }); - - expect(res.details).toMatchObject({ ok: true, status: "ok" }); - } finally { - process.env.PATH = originalPath; - } + expect(spawnState.spawn).toHaveBeenCalled(); + const [execPath] = spawnState.spawn.mock.calls[0] ?? []; + expect(execPath).toBe(lobsterBinPath); + expect(res.details).toMatchObject({ ok: true, status: "ok" }); }); it("rejects invalid JSON from lobster", async () => { - const { dir } = await writeFakeLobsterScript( - `process.stdout.write("nope");\n`, - "openclaw-lobster-plugin-bad-", - ); + spawnState.queue.push({ stdout: "nope" }); - const originalPath = process.env.PATH; - process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; - - try { - const tool = createLobsterTool(fakeApi()); - await expect( - tool.execute("call3", { - action: "run", - pipeline: "noop", - }), - ).rejects.toThrow(/invalid JSON/); - } finally { - process.env.PATH = originalPath; - } + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call3", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/invalid JSON/); }); it("can be gated off in sandboxed contexts", async () => {