diff options
Diffstat (limited to 'packages/tool-shell/src/shell.test.ts')
| -rw-r--r-- | packages/tool-shell/src/shell.test.ts | 298 |
1 files changed, 123 insertions, 175 deletions
diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts index 07e0af4..4a579b0 100644 --- a/packages/tool-shell/src/shell.test.ts +++ b/packages/tool-shell/src/shell.test.ts @@ -1,12 +1,7 @@ +import { type ExecBackend, localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; -import { - buildResult, - createRunShellTool, - type SpawnShell, - truncateOutput, - validateArgs, -} from "./shell.js"; +import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { return { @@ -22,12 +17,21 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } -function fakeSpawn(result: { +/** A fake backend whose `spawn` resolves a fixed result (no real I/O). */ +function fakeBackend(result: { exitCode: number | null; timedOut: boolean; aborted?: boolean; -}): SpawnShell { - return async () => ({ aborted: false, ...result }); +}): ExecBackend { + return { + ...localExecBackend, + spawn: async () => ({ aborted: false, ...result }), + }; +} + +/** Wrap a fake backend in the resolver the factory expects (computerId-agnostic). */ +function resolverFor(backend: ExecBackend) { + return () => backend; } describe("validateArgs", () => { @@ -173,7 +177,7 @@ describe("createRunShellTool", () => { it("has correct name and parameters shape", () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); expect(tool.name).toBe("run_shell"); expect(tool.parameters.type).toBe("object"); @@ -185,7 +189,7 @@ describe("createRunShellTool", () => { it("concurrencySafe is false", () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); expect(tool.concurrencySafe).toBe(false); }); @@ -193,7 +197,7 @@ describe("createRunShellTool", () => { it("rejects missing or empty command", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); const result = await tool.execute({}, stubCtx()); expect(result.isError).toBe(true); @@ -203,10 +207,13 @@ describe("createRunShellTool", () => { it("maps a zero exit code to a success result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("hello\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("hello\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "echo hello" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -216,10 +223,13 @@ describe("createRunShellTool", () => { it("maps a non-zero exit code to an isError result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("error output\n", "stderr"); - return { exitCode: 1, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("error output\n", "stderr"); + return { exitCode: 1, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "false" }, stubCtx()); expect(result.isError).toBe(true); @@ -229,10 +239,13 @@ describe("createRunShellTool", () => { it("reports a timeout as an isError result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("partial\n", "stdout"); - return { exitCode: null, timedOut: true, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("partial\n", "stdout"); + return { exitCode: null, timedOut: true, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "sleep 999" }, stubCtx()); expect(result.isError).toBe(true); @@ -244,10 +257,13 @@ describe("createRunShellTool", () => { const tool = createRunShellTool({ workdir: "/tmp", outputCap: cap, - spawn: async (_params) => { - _params.onOutput("a".repeat(200), "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("a".repeat(200), "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "gen" }, stubCtx()); expect(result.content).toContain("[Output truncated"); @@ -258,12 +274,15 @@ describe("createRunShellTool", () => { const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (params) => { - params.onOutput("line1\n", "stdout"); - params.onOutput("err1\n", "stderr"); - params.onOutput("line2\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("line1\n", "stdout"); + params.onOutput("err1\n", "stderr"); + params.onOutput("line2\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute( { command: "test" }, @@ -282,10 +301,13 @@ describe("createRunShellTool", () => { let receivedCwd = ""; const tool = createRunShellTool({ workdir: "/baked", - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" })); expect(receivedCwd).toBe("/custom"); @@ -295,10 +317,13 @@ describe("createRunShellTool", () => { let receivedCwd = ""; const tool = createRunShellTool({ workdir: "/baked", - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "pwd" }, stubCtx()); expect(receivedCwd).toBe("/baked"); @@ -307,9 +332,12 @@ describe("createRunShellTool", () => { it("returns error for spawn failure", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async () => { - throw new Error("spawn failed"); - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async () => { + throw new Error("spawn failed"); + }, + }), }); const result = await tool.execute({ command: "bad" }, stubCtx()); expect(result.isError).toBe(true); @@ -321,7 +349,7 @@ describe("createRunShellTool", () => { controller.abort(); const tool = createRunShellTool({ workdir: "/tmp", - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); expect(result.isError).toBe(true); @@ -331,20 +359,51 @@ describe("createRunShellTool", () => { let receivedTimeout = 0; const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (params) => { - receivedTimeout = params.timeout; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedTimeout = params.timeout; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "test", timeout: 5000 }, stubCtx()); expect(receivedTimeout).toBe(5000); }); + + it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" })); + expect(receivedComputerId).toBe("my-host"); + }); + + it("resolves the local backend when ctx.computerId is undefined", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx()); + expect(receivedComputerId).toBeUndefined(); + }); }); describe("createRunShellTool (integration)", () => { - it("runs a real echo command and captures stdout + cwd", async () => { - const { realSpawn } = await import("./spawn.js"); - const tool = createRunShellTool({ workdir: "/tmp", spawn: realSpawn }); + it("runs a real echo command through the local backend and captures stdout + cwd", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, + }); let streamed = ""; const result = await tool.execute( { command: "echo hello-from-shell" }, @@ -358,134 +417,23 @@ describe("createRunShellTool (integration)", () => { expect(result.content).toContain("hello-from-shell"); expect(streamed).toContain("hello-from-shell"); }); -}); -describe("realSpawn — process-group kill on abort/timeout", () => { - it("aborts a command with a grandchild holding the pipes and resolves immediately", async () => { - const { realSpawn } = await import("./spawn.js"); + it("aborts a real long-running command through the local backend and resolves with aborted", async () => { const controller = new AbortController(); - - // "sleep 30 & wait" spawns a grandchild (sleep) that inherits the stdio - // pipes. Killing just the sh parent does NOT close the pipes → close never - // fires. With detached:true + process-group kill, the grandchild dies too. - const promise = realSpawn({ - command: "sleep 30 & wait", - cwd: "/tmp", - signal: controller.signal, - timeout: 60_000, - onOutput: () => {}, - }); - - // Give the shell time to actually spawn the grandchild. - await new Promise((r) => setTimeout(r, 500)); - - controller.abort(); - - // Must resolve promptly (not wait 30s for the grandchild's sleep). - const result = await promise; - expect(result.aborted).toBe(true); - expect(result.timedOut).toBe(false); - - // Give the OS a moment to reap the killed processes. - await new Promise((r) => setTimeout(r, 200)); - - // The grandchild sleep process should be gone. Check via pgrep. - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - // pgrep returns non-zero when no processes match → all gone. - sleeping = []; - } - expect(sleeping.length).toBe(0); - }); - - it("times out a command with a grandchild holding the pipes and resolves promptly", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - - const promise = realSpawn({ - command: "sleep 30 & wait", - cwd: "/tmp", - signal: controller.signal, - timeout: 500, - onOutput: () => {}, - }); - - // Must resolve within a short window (not 30s). - const start = Date.now(); - const result = await promise; - const elapsed = Date.now() - start; - - expect(result.timedOut).toBe(true); - expect(result.aborted).toBe(false); - // Should resolve shortly after the 500ms timeout, well under 30s. - expect(elapsed).toBeLessThan(10_000); - - // Grandchild should be dead. - await new Promise((r) => setTimeout(r, 200)); - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - sleeping = []; - } - expect(sleeping.length).toBe(0); - }); - - it("captures stdout on normal completion (regression guard)", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - let output = ""; - - const result = await realSpawn({ - command: "echo hi", - cwd: "/tmp", - signal: controller.signal, - timeout: 5_000, - onOutput: (data) => { - output += data; - }, - }); - - expect(result.aborted).toBe(false); - expect(result.timedOut).toBe(false); - expect(result.exitCode).toBe(0); - expect(output).toContain("hi"); - }); - - it("aborts a simple single-process command and resolves with aborted: true", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - - const promise = realSpawn({ - command: "sleep 30", - cwd: "/tmp", - signal: controller.signal, - timeout: 60_000, - onOutput: () => {}, + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, }); - + const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal })); // Let the sleep actually start. await new Promise((r) => setTimeout(r, 300)); - controller.abort(); - const result = await promise; - expect(result.aborted).toBe(true); - expect(result.timedOut).toBe(false); - - // The sleep process should be gone. + expect(result.isError).toBe(true); + // The detailed process-group-kill semantics (grandchild holding the pipes, + // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts — + // realSpawn was ported there byte-for-byte. Here we only confirm the tool + // wires the backend's aborted result through to an isError result. await new Promise((r) => setTimeout(r, 200)); - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - sleeping = []; - } - expect(sleeping.length).toBe(0); }); }); |
