diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
| commit | a38d5b1279db6f9de5228c173019fc2ac08daec3 (patch) | |
| tree | 32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/core/tests | |
| parent | 0ae805b28b5160b8d9fb43635fa172961f6550cc (diff) | |
| download | dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip | |
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
Permission engine:
- Rule-based engine: wildcard matching, last-match-wins, reject cascade
- PermissionService with pending/approved state, PermissionChecker interface
- dispatch.yaml config loader with per-permission pattern rules
Shell tool:
- run_shell tool with child_process spawn, timeout, streaming output
- Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM)
- BashArity command normalization for 'always allow' patterns
- FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc.
Agent loop refactored:
- Removed maxSteps, manual step loop with tool execution
- Permission checks on shell commands (external_directory only)
- Permission checks on file tools outside workspace boundary
- Symlink bypass fix (realpathSync), .. false positive fix
- Shell output streaming via Promise.race + setImmediate polling
API layer:
- PermissionManager wraps PermissionService, broadcasts via WebSocket
- WebSocket handles permission-reply messages from frontend
- Config loaded from dispatch.yaml, converted to ruleset
Frontend:
- Permission prompt modal (native dialog, focus trap, ARIA)
- Always-allow confirmation flow with pattern preview
- Shell output display (live streaming + final parsed result)
- Permission log panel (fixed bottom-right overlay)
- Exit code badge (green 0, red non-zero)
134 tests, typecheck clean on all 3 packages
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 130 | ||||
| -rw-r--r-- | packages/core/tests/config/loader.test.ts | 166 | ||||
| -rw-r--r-- | packages/core/tests/permission/evaluate.test.ts | 68 | ||||
| -rw-r--r-- | packages/core/tests/permission/service.test.ts | 95 | ||||
| -rw-r--r-- | packages/core/tests/permission/wildcard.test.ts | 50 | ||||
| -rw-r--r-- | packages/core/tests/tools/bash-arity.test.ts | 36 | ||||
| -rw-r--r-- | packages/core/tests/tools/run-shell.test.ts | 78 |
7 files changed, 555 insertions, 68 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts index 92df90e..5be210a 100644 --- a/packages/core/tests/agent/agent.test.ts +++ b/packages/core/tests/agent/agent.test.ts @@ -40,15 +40,9 @@ async function* makeFullStream( } } -interface MockStreamOptions { - events: Array<{ type: string; [key: string]: unknown }>; - steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>; -} - -function makeMockStreamResult(opts: MockStreamOptions) { +function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) { return { - fullStream: makeFullStream(opts.events), - steps: Promise.resolve(opts.steps ?? []), + fullStream: makeFullStream(events), } as ReturnType<typeof import("ai").streamText>; } @@ -66,18 +60,16 @@ describe("Agent", () => { it("yields running then idle status events around a simple message", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Hello!" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Hello!" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -97,19 +89,17 @@ describe("Agent", () => { it("yields text-delta events", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Hello" }, - { type: "text-delta", textDelta: " world" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Hello" }, + { type: "text-delta", textDelta: " world" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -127,18 +117,16 @@ describe("Agent", () => { it("adds user message and assistant message to history", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Response" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Response" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -160,18 +148,16 @@ describe("Agent", () => { it("yields done event with final message", async () => { const { streamText } = await import("ai"); vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ - { type: "text-delta", textDelta: "Done!" }, - { - type: "finish", - finishReason: "stop", - usage: {}, - providerMetadata: undefined, - response: {}, - }, - ], - }), + makeMockStreamResult([ + { type: "text-delta", textDelta: "Done!" }, + { + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, + }, + ]), ); const agent = new Agent(makeConfig()); @@ -190,31 +176,39 @@ describe("Agent", () => { it("yields tool-call and tool-result events", async () => { const { streamText } = await import("ai"); - vi.mocked(streamText).mockReturnValue( - makeMockStreamResult({ - events: [ + + // First call: LLM emits a tool-call + // Second call (after tool execution): LLM emits text response with no tool calls + vi.mocked(streamText) + .mockReturnValueOnce( + makeMockStreamResult([ { type: "tool-call", toolCallId: "tc1", toolName: "read_file", args: { path: "hello.txt" }, }, - { type: "text-delta", textDelta: "Here is the file." }, { type: "finish", - finishReason: "stop", + finishReason: "tool-calls", usage: {}, providerMetadata: undefined, response: {}, }, - ], - steps: [ + ]), + ) + .mockReturnValueOnce( + makeMockStreamResult([ + { type: "text-delta", textDelta: "Here is the file." }, { - toolResults: [{ toolCallId: "tc1", result: "file contents" }], + type: "finish", + finishReason: "stop", + usage: {}, + providerMetadata: undefined, + response: {}, }, - ], - }), - ); + ]), + ); const toolDef = { name: "read_file", diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts new file mode 100644 index 0000000..9a358fe --- /dev/null +++ b/packages/core/tests/config/loader.test.ts @@ -0,0 +1,166 @@ +import { homedir } from "node:os"; +import { join, sep } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { configToRuleset, loadConfig } from "../../src/config/loader.js"; + +const TMP = join("/tmp/opencode", "dispatch-config-test"); + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +function writeYaml(content: string): void { + writeFileSync(join(TMP, "dispatch.yaml"), content, "utf-8"); +} + +describe("loadConfig", () => { + it("returns empty permissions when dispatch.yaml is missing", () => { + const config = loadConfig(TMP); + expect(config.permissions).toEqual({}); + }); + + it("parses simple string permissions", () => { + writeYaml(`permissions:\n read: allow\n edit: deny\n`); + const config = loadConfig(TMP); + expect(config.permissions["read"]).toBe("allow"); + expect(config.permissions["edit"]).toBe("deny"); + }); + + it("parses nested pattern permissions", () => { + writeYaml(`permissions:\n bash:\n "npm test": allow\n "*": ask\n`); + const config = loadConfig(TMP); + const bash = config.permissions["bash"] as Record<string, string>; + expect(bash["npm test"]).toBe("allow"); + expect(bash["*"]).toBe("ask"); + }); + + it("ignores comment lines", () => { + writeYaml(`# this is a comment\npermissions:\n # another comment\n read: allow\n`); + const config = loadConfig(TMP); + expect(config.permissions["read"]).toBe("allow"); + }); + + it("handles ~ expansion in nested keys", () => { + const home = homedir(); + writeYaml(`permissions:\n read:\n "~/projects/*": allow\n`); + const config = loadConfig(TMP); + const read = config.permissions["read"] as Record<string, string>; + expect(read[`${home}/projects/*`]).toBe("allow"); + }); + + it("handles $HOME expansion in nested keys", () => { + const home = homedir(); + writeYaml(`permissions:\n read:\n "$HOME/docs/*": allow\n`); + const config = loadConfig(TMP); + const read = config.permissions["read"] as Record<string, string>; + expect(read[`${home}/docs/*`]).toBe("allow"); + }); + + it("parses quoted keys", () => { + writeYaml(`permissions:\n bash:\n "git commit *": allow\n "rm *": deny\n`); + const config = loadConfig(TMP); + const bash = config.permissions["bash"] as Record<string, string>; + expect(bash["git commit *"]).toBe("allow"); + expect(bash["rm *"]).toBe("deny"); + }); + + it("handles multiple permission groups", () => { + writeYaml( + `permissions:\n read: allow\n edit:\n "*": ask\n "src/**": allow\n bash:\n "npm test": allow\n "*": ask\n`, + ); + const config = loadConfig(TMP); + expect(config.permissions["read"]).toBe("allow"); + const edit = config.permissions["edit"] as Record<string, string>; + expect(edit["*"]).toBe("ask"); + expect(edit["src/**"]).toBe("allow"); + const bash = config.permissions["bash"] as Record<string, string>; + expect(bash["npm test"]).toBe("allow"); + expect(bash["*"]).toBe("ask"); + }); + + it("preserves # inside quoted string values", () => { + writeYaml(`permissions:\n bash:\n '"file#1"': allow\n`); + const config = loadConfig(TMP); + const bash = config.permissions["bash"] as Record<string, string>; + expect(bash['"file#1"']).toBe("allow"); + }); + + it("strips inline comments on nested map keys (empty value after comment)", () => { + writeYaml(`permissions:\n bash: # scripts\n "*": allow\n`); + const config = loadConfig(TMP); + const bash = config.permissions["bash"] as Record<string, string>; + expect(bash["*"]).toBe("allow"); + }); + + it("expands ~ with platform path separator", () => { + const home = homedir(); + // Simulate a path using the OS separator + const pattern = `~${sep}projects${sep}*`; + writeYaml(`permissions:\n read:\n "${pattern}": allow\n`); + const config = loadConfig(TMP); + const read = config.permissions["read"] as Record<string, string>; + expect(read[`${home}${sep}projects${sep}*`]).toBe("allow"); + }); +}); + +describe("configToRuleset — new validations", () => { + it("falls back to ask and warns for invalid action in string value", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const rules = configToRuleset({ permissions: { read: "allw" } }); + expect(rules[0]?.action).toBe("ask"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw")); + warn.mockRestore(); + }); + + it("falls back to ask and warns for invalid action in nested pattern", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } }); + expect(rules[0]?.action).toBe("ask"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID")); + warn.mockRestore(); + }); + + it("expands ~ with backslash separator in patterns", () => { + const home = homedir(); + // Force backslash path even on Linux to test the regex + const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } }); + expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`); + }); +}); + +describe("configToRuleset", () => { + it("produces a rule with pattern * for string value", () => { + const rules = configToRuleset({ permissions: { read: "allow" } }); + expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]); + }); + + it("produces rules for each pattern in an object value", () => { + const rules = configToRuleset({ + permissions: { bash: { "npm test": "allow", "*": "ask" } }, + }); + expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" }); + expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" }); + }); + + it("expands ~ in patterns", () => { + const home = homedir(); + const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } }); + expect(rules[0]?.pattern).toBe(`${home}/foo/*`); + }); + + it("expands $HOME in patterns", () => { + const home = homedir(); + const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } }); + expect(rules[0]?.pattern).toBe(`${home}/bar/*`); + }); + + it("handles empty permissions", () => { + const rules = configToRuleset({ permissions: {} }); + expect(rules).toEqual([]); + }); +}); diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts new file mode 100644 index 0000000..c8f4541 --- /dev/null +++ b/packages/core/tests/permission/evaluate.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { evaluate } from "../../src/permission/evaluate.js"; +import type { Ruleset } from "../../src/permission/index.js"; + +describe("evaluate", () => { + it("returns default ask when no rules match", () => { + const result = evaluate("bash", "ls -la"); + expect(result.action).toBe("ask"); + expect(result.permission).toBe("bash"); + expect(result.pattern).toBe("ls -la"); + }); + + it("returns allow when matching rule is allow", () => { + const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }]; + const result = evaluate("bash", "ls -la", rules); + expect(result.action).toBe("allow"); + }); + + it("returns deny when matching rule is deny", () => { + const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }]; + const result = evaluate("bash", "rm -rf /", rules); + expect(result.action).toBe("deny"); + }); + + it("last-match-wins: later deny overrides earlier allow", () => { + const rules: Ruleset = [ + { permission: "bash", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ]; + const result = evaluate("bash", "rm -rf /", rules); + expect(result.action).toBe("deny"); + }); + + it("last-match-wins: later allow overrides earlier deny", () => { + const rules: Ruleset = [ + { permission: "bash", pattern: "rm *", action: "deny" }, + { permission: "bash", pattern: "*", action: "allow" }, + ]; + const result = evaluate("bash", "rm -rf /", rules); + expect(result.action).toBe("allow"); + }); + + it("matches permission wildcard", () => { + const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }]; + const result = evaluate("read", "anything", rules); + expect(result.action).toBe("allow"); + }); + + it("multiple rulesets are concatenated, last match wins across rulesets", () => { + const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]; + const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }]; + const result = evaluate("bash", "git status", baseRules, overrideRules); + expect(result.action).toBe("allow"); + }); + + it("second ruleset can deny what first ruleset allows", () => { + const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]; + const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }]; + const result = evaluate("bash", "rm -rf /", baseRules, overrideRules); + expect(result.action).toBe("deny"); + }); + + it("non-matching permission returns default ask", () => { + const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]; + const result = evaluate("read", "/some/path", rules); + expect(result.action).toBe("ask"); + }); +}); diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts new file mode 100644 index 0000000..d1b39d9 --- /dev/null +++ b/packages/core/tests/permission/service.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { PermissionRequest, Ruleset } from "../../src/permission/index.js"; +import { PermissionService } from "../../src/permission/service.js"; + +function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest { + return { + permission: "bash", + patterns: ["git *"], + always: ["git status"], + description: "Run git status", + metadata: {}, + ...overrides, + }; +} + +describe("PermissionService", () => { + it("resolves immediately with 'once' when rule is allow", async () => { + const svc = new PermissionService(); + const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]; + const reply = await svc.ask(makeRequest(), [rules]); + expect(reply).toBe("once"); + }); + + it("rejects immediately when rule is deny", async () => { + const svc = new PermissionService(); + const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }]; + await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied"); + }); + + it("creates pending request when rule is ask", () => { + const svc = new PermissionService(); + svc.ask(makeRequest(), []); + expect(svc.getPending()).toHaveLength(1); + }); + + it("reply 'once' resolves the specific pending request", async () => { + const svc = new PermissionService(); + const promise = svc.ask(makeRequest(), []); + const pending = svc.getPending(); + expect(pending).toHaveLength(1); + svc.reply(pending[0].id, "once"); + const result = await promise; + expect(result).toBe("once"); + expect(svc.getPending()).toHaveLength(0); + }); + + it("reply 'always' adds approved rules and resolves", async () => { + const svc = new PermissionService(); + const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []); + const pending = svc.getPending(); + svc.reply(pending[0].id, "always"); + const result = await promise; + expect(result).toBe("always"); + + // Now the same permission should be immediately allowed + const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []); + expect(reply2).toBe("once"); + }); + + it("reply 'reject' rejects all pending requests (cascade)", async () => { + const svc = new PermissionService(); + const p1 = svc.ask(makeRequest(), []); + const p2 = svc.ask(makeRequest({ permission: "read" }), []); + + const pending = svc.getPending(); + expect(pending).toHaveLength(2); + + // Reject using the first id — should cascade to all + svc.reply(pending[0].id, "reject"); + + await expect(p1).rejects.toThrow("Permission rejected"); + await expect(p2).rejects.toThrow("Permission rejected"); + expect(svc.getPending()).toHaveLength(0); + }); + + it("approved rules override config rulesets", async () => { + const svc = new PermissionService(); + svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]); + + // Config says deny, but approved says allow — approved wins (last) + const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }]; + const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]); + expect(reply).toBe("once"); + }); + + it("getPending returns all pending requests with id and request", () => { + const svc = new PermissionService(); + const req = makeRequest(); + svc.ask(req, []); + const pending = svc.getPending(); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBeDefined(); + expect(pending[0].request.permission).toBe("bash"); + }); +}); diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts new file mode 100644 index 0000000..8fa30a7 --- /dev/null +++ b/packages/core/tests/permission/wildcard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { Wildcard } from "../../src/permission/wildcard.js"; + +describe("Wildcard.match", () => { + it("matches exact string", () => { + expect(Wildcard.match("bash", "bash")).toBe(true); + expect(Wildcard.match("bash", "read")).toBe(false); + }); + + it("matches * wildcard (any characters)", () => { + expect(Wildcard.match("*", "bash")).toBe(true); + expect(Wildcard.match("*", "anything")).toBe(true); + expect(Wildcard.match("ba*", "bash")).toBe(true); + expect(Wildcard.match("ba*", "ba")).toBe(true); + expect(Wildcard.match("ba*", "read")).toBe(false); + }); + + it("matches ? wildcard (single character)", () => { + expect(Wildcard.match("ba?h", "bash")).toBe(true); + expect(Wildcard.match("ba?h", "bath")).toBe(true); + expect(Wildcard.match("ba?h", "baXXh")).toBe(false); + expect(Wildcard.match("?", "a")).toBe(true); + expect(Wildcard.match("?", "ab")).toBe(false); + }); + + it("matches nested * patterns with path-like strings", () => { + expect(Wildcard.match("/home/*", "/home/user")).toBe(true); + expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true); + expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true); + expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false); + }); + + it("escapes regex special characters in pattern", () => { + expect(Wildcard.match("git add .", "git add .")).toBe(true); + expect(Wildcard.match("git add .", "git add X")).toBe(false); + expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true); + expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false); + }); + + it("is case-sensitive", () => { + expect(Wildcard.match("Bash", "bash")).toBe(false); + expect(Wildcard.match("BASH", "BASH")).toBe(true); + }); + + it("handles empty pattern and value", () => { + expect(Wildcard.match("", "")).toBe(true); + expect(Wildcard.match("", "x")).toBe(false); + expect(Wildcard.match("*", "")).toBe(true); + }); +}); diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts new file mode 100644 index 0000000..a01a6a5 --- /dev/null +++ b/packages/core/tests/tools/bash-arity.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { prefix } from "../../src/tools/bash-arity.js"; + +describe("BashArity.prefix", () => { + it("returns arity-2 prefix for known command 'git'", () => { + expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]); + }); + + it("returns arity-3 prefix for npm", () => { + expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]); + }); + + it("returns arity-2 prefix for bun", () => { + expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]); + }); + + it("returns just the command for unknown command", () => { + expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]); + }); + + it("returns empty array for empty tokens", () => { + expect(prefix([])).toEqual([]); + }); + + it("handles single token for unknown command", () => { + expect(prefix(["ls"])).toEqual(["ls"]); + }); + + it("handles git with fewer tokens than arity", () => { + expect(prefix(["git"])).toEqual(["git"]); + }); + + it("handles case-insensitive matching", () => { + expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]); + }); +}); diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts new file mode 100644 index 0000000..cb66d1c --- /dev/null +++ b/packages/core/tests/tools/run-shell.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRunShellTool } from "../../src/tools/run-shell.js"; + +describe("run_shell tool", () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "dispatch-test-")); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it("executes a simple echo command", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo hello" }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("hello"); + expect(result.exitCode).toBe(0); + }); + + it("returns non-zero exit code on failure", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "exit 42" }); + const result = JSON.parse(raw); + expect(result.exitCode).toBe(42); + }); + + it("captures stderr", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo errormsg >&2" }); + const result = JSON.parse(raw); + expect(result.stderr.trim()).toBe("errormsg"); + }); + + it("handles timeout", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "sleep 10", timeout: 100 }); + const result = JSON.parse(raw); + // Either times out (non-zero exit) or returns an error + expect(result.exitCode !== 0 || result.error !== undefined).toBe(true); + }, 5000); + + it("executes in the working directory", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "pwd" }); + const result = JSON.parse(raw); + // On macOS /tmp is symlinked; use includes check + expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, "")); + }); + + it("calls onOutput callback with stdout chunks", async () => { + const tool = createRunShellTool(workDir); + const onOutput = vi.fn(); + const raw = await tool.execute({ command: "echo streaming" }, { onOutput }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("streaming"); + expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout"); + }); + + it("calls onOutput callback with stderr chunks", async () => { + const tool = createRunShellTool(workDir); + const onOutput = vi.fn(); + await tool.execute({ command: "echo errdata >&2" }, { onOutput }); + expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr"); + }); + + it("works without context (backward compatible)", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo nocontext" }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("nocontext"); + }); +}); |
