diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..00b6e26 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run linter + run: bunx oxlint + + - name: Run type check + run: bun run type-check + + - name: Run tests + run: bun test diff --git a/bun.lock b/bun.lock index 21f5fd6..0ab5bf2 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "marked": "^16.3.0", - "react": "^19.1.1", + "react": "^19.2.0", }, "devDependencies": { "@types/bun": "latest", @@ -23,6 +23,7 @@ "@types/express": "^5.0.3", "@types/react": "^19.1.16", "husky": "^9.1.7", + "ink-testing-library": "^4.0.0", "lint-staged": "^16.2.3", "prettier": "^3.6.2", }, @@ -246,6 +247,8 @@ "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -346,7 +349,7 @@ "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], - "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], diff --git a/package.json b/package.json index 908a3c0..1f4cc43 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@types/express": "^5.0.3", "@types/react": "^19.1.16", "husky": "^9.1.7", + "ink-testing-library": "^4.0.0", "lint-staged": "^16.2.3", "prettier": "^3.6.2" }, @@ -35,7 +36,7 @@ "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "marked": "^16.3.0", - "react": "^19.1.1" + "react": "^19.2.0" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ diff --git a/src/__tests__/store.test.tsx b/src/__tests__/store.test.tsx new file mode 100644 index 0000000..68c4cce --- /dev/null +++ b/src/__tests__/store.test.tsx @@ -0,0 +1,417 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { AgentStore } from "../store" +import type { Message, McpServerStatus, ToolUse } from "../store" +import { MessageQueue } from "../utils/MessageQueue" + +describe("Store", () => { + const setup = (initialState = {}) => { + let state: any + let actions: any + + const TestComponent = () => { + state = AgentStore.useStoreState((state) => state) + actions = AgentStore.useStoreActions((actions) => actions) + return null + } + + const { rerender } = render( + + + + ) + + return { + getState: () => { + rerender( + + + + ) + return state + }, + actions, + } + } + + describe("initialization", () => { + test("should initialize with default state", () => { + const { getState } = setup() + + expect(getState().chatHistory).toEqual([]) + expect(getState().currentAssistantMessage).toBe("") + expect(getState().currentToolUses).toEqual([]) + expect(getState().input).toBe("") + expect(getState().isProcessing).toBe(false) + expect(getState().mcpServers).toEqual([]) + expect(getState().sessionId).toBeUndefined() + expect(getState().stats).toBeUndefined() + expect(getState().pendingToolPermission).toBeUndefined() + expect(getState().abortController).toBeUndefined() + }) + + test("should have MessageQueue instance", () => { + const { getState } = setup() + + expect(getState().messageQueue).toBeInstanceOf(MessageQueue) + }) + + test("should expose all expected actions", () => { + const { actions } = setup() + + expect(Object.keys(actions)).toEqual( + expect.arrayContaining([ + "abortRequest", + "addChatHistoryEntry", + "addToolUse", + "appendCurrentAssistantMessage", + "clearCurrentAssistantMessage", + "clearToolUses", + "reset", + "sendMessage", + "setPendingToolPermission", + "setAbortController", + "setConfig", + "setcurrentAssistantMessage", + "setCurrentToolUses", + "setInput", + "setIsProcessing", + "setMcpServers", + "setSessionId", + "setStats", + ]) + ) + }) + }) + + describe("computed properties", () => { + test("isBooted should be false when config is null", () => { + const { getState } = setup() + + expect(getState().isBooted).toBe(false) + }) + + test("isBooted should be true when config is set", () => { + const { getState, actions } = setup() + + actions.setConfig({ + mcpServers: {}, + }) + + expect(getState().isBooted).toBe(true) + }) + }) + + describe("chat history actions", () => { + test("addChatHistoryEntry should add message to chat history", () => { + const { getState, actions } = setup() + + const message: Message = { + type: "message", + role: "user", + content: "Hello", + } + + actions.addChatHistoryEntry(message) + + expect(getState().chatHistory).toHaveLength(1) + expect(getState().chatHistory[0]).toEqual(message) + }) + + test("addChatHistoryEntry should append to existing history", () => { + const { getState, actions } = setup() + + const message1: Message = { + type: "message", + role: "user", + content: "Hello", + } + + const message2: Message = { + type: "message", + role: "assistant", + content: "Hi there", + } + + actions.addChatHistoryEntry(message1) + actions.addChatHistoryEntry(message2) + + expect(getState().chatHistory).toHaveLength(2) + expect(getState().chatHistory).toEqual([message1, message2]) + }) + }) + + describe("assistant message actions", () => { + test("setcurrentAssistantMessage should set message", () => { + const { getState, actions } = setup() + + actions.setcurrentAssistantMessage("Hello") + + expect(getState().currentAssistantMessage).toBe("Hello") + }) + + test("appendCurrentAssistantMessage should append to current message", () => { + const { getState, actions } = setup() + + actions.setcurrentAssistantMessage("Hello") + actions.appendCurrentAssistantMessage(" world") + + expect(getState().currentAssistantMessage).toBe("Hello world") + }) + + test("clearCurrentAssistantMessage should clear message", () => { + const { getState, actions } = setup() + + actions.setcurrentAssistantMessage("Hello") + actions.clearCurrentAssistantMessage() + + expect(getState().currentAssistantMessage).toBe("") + }) + }) + + describe("tool use actions", () => { + test("addToolUse should add tool to current tool uses", () => { + const { getState, actions } = setup() + + const toolUse: ToolUse = { + type: "tool_use", + name: "test_tool", + input: { param: "value" }, + } + + actions.addToolUse(toolUse) + + expect(getState().currentToolUses).toHaveLength(1) + expect(getState().currentToolUses[0]).toEqual(toolUse) + }) + + test("setCurrentToolUses should replace all tool uses", () => { + const { getState, actions } = setup() + + const toolUse1: ToolUse = { + type: "tool_use", + name: "tool1", + input: {}, + } + + const toolUse2: ToolUse = { + type: "tool_use", + name: "tool2", + input: {}, + } + + actions.addToolUse(toolUse1) + actions.setCurrentToolUses([toolUse2]) + + expect(getState().currentToolUses).toHaveLength(1) + expect(getState().currentToolUses[0]).toEqual(toolUse2) + }) + + test("clearToolUses should clear all tool uses", () => { + const { getState, actions } = setup() + + const toolUse: ToolUse = { + type: "tool_use", + name: "test_tool", + input: {}, + } + + actions.addToolUse(toolUse) + actions.clearToolUses() + + expect(getState().currentToolUses).toEqual([]) + }) + }) + + describe("message sending actions", () => { + test("sendMessage should set processing state and clear input", () => { + const { getState, actions } = setup() + + actions.setInput("Hello") + actions.sendMessage("Hello") + + expect(getState().isProcessing).toBe(true) + expect(getState().input).toBe("") + expect(getState().stats).toBeNull() + }) + }) + + describe("MCP server actions", () => { + test("setMcpServers should update server status", () => { + const { getState, actions } = setup() + + const servers: McpServerStatus[] = [ + { name: "server1", status: "connected" }, + { name: "server2", status: "error" }, + ] + + actions.setMcpServers(servers) + + expect(getState().mcpServers).toEqual(servers) + }) + }) + + describe("session actions", () => { + test("setSessionId should update session ID", () => { + const { getState, actions } = setup() + + actions.setSessionId("session-123") + + expect(getState().sessionId).toBe("session-123") + }) + }) + + describe("processing state actions", () => { + test("setIsProcessing should update processing state", () => { + const { getState, actions } = setup() + + actions.setIsProcessing(true) + + expect(getState().isProcessing).toBe(true) + + actions.setIsProcessing(false) + + expect(getState().isProcessing).toBe(false) + }) + }) + + describe("input actions", () => { + test("setInput should update input value", () => { + const { getState, actions } = setup() + + actions.setInput("test input") + + expect(getState().input).toBe("test input") + }) + }) + + describe("stats actions", () => { + test("setStats should update stats", () => { + const { getState, actions } = setup() + + const statsString = "Cost: $0.01 | Duration: 2.5s" + + actions.setStats(statsString) + + expect(getState().stats).toBe(statsString) + }) + + test("setStats should accept null", () => { + const { getState, actions } = setup() + + actions.setStats("some stats") + actions.setStats(null) + + expect(getState().stats).toBeNull() + }) + }) + + describe("config actions", () => { + test("setConfig should update configuration", () => { + const { getState, actions } = setup() + + const config = { + mcpServers: { + server1: { + command: "node", + args: ["server.js"], + }, + }, + stream: true, + } + + actions.setConfig(config) + + expect(getState().config).toEqual(config) + }) + }) + + describe("tool permission actions", () => { + test("setPendingToolPermission should set pending permission", () => { + const { getState, actions } = setup() + + const permission = { + toolName: "test_tool", + input: { param: "value" }, + } + + actions.setPendingToolPermission(permission) + + expect(getState().pendingToolPermission).toEqual(permission) + }) + + test("setPendingToolPermission should accept undefined", () => { + const { getState, actions } = setup() + + actions.setPendingToolPermission({ + toolName: "test", + input: {}, + }) + actions.setPendingToolPermission(undefined) + + expect(getState().pendingToolPermission).toBeUndefined() + }) + }) + + describe("abort controller actions", () => { + test("setAbortController should set abort controller", () => { + const { getState, actions } = setup() + + const controller = new AbortController() + + actions.setAbortController(controller) + + expect(getState().abortController).toBe(controller) + }) + + test("abortRequest should call abort and set processing to false", () => { + const { getState, actions } = setup() + + const controller = new AbortController() + + actions.setAbortController(controller) + actions.setIsProcessing(true) + actions.abortRequest() + + expect(controller.signal.aborted).toBe(true) + expect(getState().isProcessing).toBe(false) + }) + + test("abortRequest should handle undefined controller", () => { + const { getState, actions } = setup() + + actions.setIsProcessing(true) + actions.abortRequest() + + expect(getState().isProcessing).toBe(false) + }) + }) + + describe("reset action", () => { + test("reset should clear all state except config and session", () => { + const { getState, actions } = setup() + + actions.addChatHistoryEntry({ + type: "message", + role: "user", + content: "Hello", + }) + actions.setcurrentAssistantMessage("Assistant message") + actions.addToolUse({ + type: "tool_use", + name: "tool", + input: {}, + }) + actions.setInput("input text") + actions.setStats("stats") + actions.reset() + + expect(getState().chatHistory).toEqual([]) + expect(getState().currentAssistantMessage).toBe("") + expect(getState().currentToolUses).toEqual([]) + expect(getState().input).toBe("") + expect(getState().stats).toBeNull() + }) + }) +}) diff --git a/src/components/__tests__/BlinkCaret.test.tsx b/src/components/__tests__/BlinkCaret.test.tsx new file mode 100644 index 0000000..6c1a9c5 --- /dev/null +++ b/src/components/__tests__/BlinkCaret.test.tsx @@ -0,0 +1,30 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { BlinkCaret } from "../BlinkCaret" + +describe("BlinkCaret", () => { + test("should render caret symbol", () => { + const { lastFrame } = render() + + expect(lastFrame()).toContain("▷") + }) + + test("should not blink when disabled", () => { + const { lastFrame } = render() + + expect(lastFrame()).toContain("▷") + }) + + test("should render when enabled", () => { + const { lastFrame } = render() + + expect(lastFrame()).toContain("▷") + }) + + test("should cleanup on unmount", () => { + const { unmount } = render() + + unmount() + }) +}) diff --git a/src/components/__tests__/ChatHeader.test.tsx b/src/components/__tests__/ChatHeader.test.tsx new file mode 100644 index 0000000..489b187 --- /dev/null +++ b/src/components/__tests__/ChatHeader.test.tsx @@ -0,0 +1,103 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { ChatHeader } from "../ChatHeader" +import { AgentStore } from "../../store" + +describe("ChatHeader", () => { + test("should render title", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toContain("@ Agent CLI") + }) + + test("should render instructions", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toContain("Type your message and press Enter") + expect(lastFrame()).toContain("Type 'exit' to quit") + }) + + test("should show connecting message when no servers", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toContain("Connecting to MCP servers...") + }) + + test("should display connected servers", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("MCP Servers:") + expect(lastFrame()).toContain("github") + expect(lastFrame()).toContain("gitlab") + }) + + test("should show server status with colors", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("github") + expect(lastFrame()).toContain("gitlab") + }) +}) + +const TestWrapper = ({ + servers, +}: { + servers: Array<{ name: string; status: string }> +}) => { + const actions = AgentStore.useStoreActions((actions) => actions) + actions.setMcpServers(servers) + return +} diff --git a/src/components/__tests__/Markdown.test.tsx b/src/components/__tests__/Markdown.test.tsx new file mode 100644 index 0000000..191d4e3 --- /dev/null +++ b/src/components/__tests__/Markdown.test.tsx @@ -0,0 +1,93 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { Markdown } from "../Markdown" + +describe("Markdown", () => { + test("should render plain text", () => { + const { lastFrame } = render(Hello world) + + expect(lastFrame()).toContain("Hello world") + }) + + test("should render bold text", () => { + const { lastFrame } = render(**bold text**) + + expect(lastFrame()).toContain("bold text") + }) + + test("should render italic text", () => { + const { lastFrame } = render(*italic text*) + + expect(lastFrame()).toContain("italic text") + }) + + test("should render inline code", () => { + const { lastFrame } = render(`code`) + + expect(lastFrame()).toContain("code") + }) + + test("should render code blocks", () => { + const { lastFrame } = render( + {`\`\`\`\nconst x = 1;\n\`\`\``} + ) + + expect(lastFrame()).toContain("const x = 1;") + }) + + test("should render headers", () => { + const { lastFrame } = render(# Heading) + + expect(lastFrame()).toContain("Heading") + }) + + test("should render lists", () => { + const { lastFrame } = render( + {`- Item 1\n- Item 2\n- Item 3`} + ) + + expect(lastFrame()).toContain("Item 1") + expect(lastFrame()).toContain("Item 2") + expect(lastFrame()).toContain("Item 3") + expect(lastFrame()).toContain("•") + }) + + test("should render links", () => { + const { lastFrame } = render( + [link text](https://example.com) + ) + + expect(lastFrame()).toContain("link text") + }) + + test("should render strikethrough", () => { + const { lastFrame } = render(~~strikethrough~~) + + expect(lastFrame()).toContain("strikethrough") + }) + + test("should render blockquotes", () => { + const content = "> Quote text" + const { lastFrame } = render({content}) + + expect(lastFrame()).toContain("Quote text") + }) + + test("should render horizontal rules", () => { + const { lastFrame } = render(---) + + expect(lastFrame()).toContain("─") + }) + + test("should render mixed content", () => { + const { lastFrame } = render( + {`# Title\n\nSome **bold** and *italic* text with \`code\`.`} + ) + + expect(lastFrame()).toContain("Title") + expect(lastFrame()).toContain("bold") + expect(lastFrame()).toContain("italic") + expect(lastFrame()).toContain("code") + }) +}) diff --git a/src/components/__tests__/Stats.test.tsx b/src/components/__tests__/Stats.test.tsx new file mode 100644 index 0000000..ac86960 --- /dev/null +++ b/src/components/__tests__/Stats.test.tsx @@ -0,0 +1,51 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { Stats } from "../Stats" +import { AgentStore } from "../../store" + +describe("Stats", () => { + test("should display stats string", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("Cost: $0.01") + expect(lastFrame()).toContain("Duration: 2.5s") + expect(lastFrame()).toContain("Turns: 3") + }) + + test("should not render when stats are null", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toBe("") + }) + + test("should not render when stats are undefined", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toBe("") + }) +}) + +const TestWrapper = ({ statsValue }: { statsValue: string }) => { + const actions = AgentStore.useStoreActions((actions) => actions) + actions.setStats(statsValue) + return +} diff --git a/src/components/__tests__/ToolPermissionPrompt.test.tsx b/src/components/__tests__/ToolPermissionPrompt.test.tsx new file mode 100644 index 0000000..5a6f2c6 --- /dev/null +++ b/src/components/__tests__/ToolPermissionPrompt.test.tsx @@ -0,0 +1,137 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { ToolPermissionPrompt } from "../ToolPermissionPrompt" +import { AgentStore } from "../../store" + +describe("ToolPermissionPrompt", () => { + test("should not render when no pending permission", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toBe("") + }) + + test("should render permission request with MCP tool", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("[Tool Permission Request]") + expect(lastFrame()).toContain("Tool:") + expect(lastFrame()).toContain("[github]") + expect(lastFrame()).toContain("search_repositories") + }) + + test("should render permission request with regular tool", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("[Tool Permission Request]") + expect(lastFrame()).toContain("regular_tool") + }) + + test("should show prompt text", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("Allow?") + expect(lastFrame()).toContain("Enter=yes") + expect(lastFrame()).toContain("ESC=no") + }) + + test("should show blink caret", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("▷") + }) +}) + +const TestWrapper = ({ + permission, +}: { + permission: { toolName: string; input: any } +}) => { + const actions = AgentStore.useStoreActions((actions) => actions) + actions.setPendingToolPermission(permission) + return +} diff --git a/src/components/__tests__/ToolUses.test.tsx b/src/components/__tests__/ToolUses.test.tsx new file mode 100644 index 0000000..728edc6 --- /dev/null +++ b/src/components/__tests__/ToolUses.test.tsx @@ -0,0 +1,224 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { ToolUses } from "../ToolUses" +import { AgentStore } from "../../store" +import type { ToolUse } from "../../store" + +describe("ToolUses", () => { + test("should display MCP tool with server name", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "mcp__github__search_repositories", + input: { query: "test" }, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("[github]") + expect(lastFrame()).toContain("search_repositories") + }) + + test("should display regular tool without server name", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "regular_tool", + input: { param: "value" }, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("[tool] regular_tool") + }) + + test("should display formatted tool input", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "test_tool", + input: { param1: "value1", param2: "value2" }, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("param1") + expect(lastFrame()).toContain("value1") + expect(lastFrame()).toContain("param2") + expect(lastFrame()).toContain("value2") + }) + + test("should show denied tool indicator", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "mcp__github__search", + input: {}, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("✖ Tool denied by configuration") + }) + + test("should not show denied indicator for allowed tools", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "mcp__github__create_issue", + input: {}, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).not.toContain("✖ Tool denied by configuration") + }) + + test("should handle empty input", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "test_tool", + input: {}, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("test_tool") + }) + + test("should handle multiline input", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "test_tool", + input: { query: "line1\nline2\nline3" }, + } + + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("line1") + expect(lastFrame()).toContain("line2") + expect(lastFrame()).toContain("line3") + }) +}) + +const TestWrapper = ({ + toolUse, + config, +}: { + toolUse: ToolUse + config: any +}) => { + const actions = AgentStore.useStoreActions((actions) => actions) + actions.setConfig(config) + return +} diff --git a/src/components/__tests__/UserInput.test.tsx b/src/components/__tests__/UserInput.test.tsx new file mode 100644 index 0000000..f1f3776 --- /dev/null +++ b/src/components/__tests__/UserInput.test.tsx @@ -0,0 +1,53 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { UserInput } from "../UserInput" +import { AgentStore } from "../../store" + +describe("UserInput", () => { + test("should render input field", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toContain("▷") + }) + + test("should show blink caret when servers are connected", () => { + const { lastFrame, rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(lastFrame()).toContain("▷") + }) + + test("should not show blink caret when no servers", () => { + const { lastFrame } = render( + + + + ) + + expect(lastFrame()).toContain("▷") + }) +}) + +const TestWrapper = ({ + servers, +}: { + servers: Array<{ name: string; status: string }> +}) => { + const actions = AgentStore.useStoreActions((actions) => actions) + actions.setMcpServers(servers) + return +} diff --git a/src/hooks/__tests__/useCycleMessages.test.tsx b/src/hooks/__tests__/useCycleMessages.test.tsx new file mode 100644 index 0000000..15c178a --- /dev/null +++ b/src/hooks/__tests__/useCycleMessages.test.tsx @@ -0,0 +1,230 @@ +import React from "react" +import { render } from "ink-testing-library" +import { test, expect, describe } from "bun:test" +import { useCycleMessages } from "../useCycleMessages" +import { AgentStore } from "../../store" + +describe("useCycleMessages", () => { + test("should initialize with no messages", () => { + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(true).toBe(true) + }) + + test("should filter user messages from chat history", () => { + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(true).toBe(true) + }) + + test("should expose reset function", () => { + let resetFn: (() => void) | undefined + + const TestComponentWithReset = () => { + const { reset } = useCycleMessages() + resetFn = reset + return null + } + + render( + + + + ) + + expect(typeof resetFn).toBe("function") + }) + + test("should handle empty chat history", () => { + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(true).toBe(true) + }) + + test("should handle chat history with only assistant messages", () => { + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(true).toBe(true) + }) + + test("should handle multiple user messages", () => { + const { rerender } = render( + + + + ) + + rerender( + + + + ) + + expect(true).toBe(true) + }) +}) + +const TestComponent = () => { + useCycleMessages() + return null +} + +const TestComponentWithMessages = ({ + messages, +}: { + messages: Array<{ + type: "message" + role: "user" | "assistant" + content: string + }> +}) => { + const actions = AgentStore.useStoreActions((actions) => actions) + const store = AgentStore.useStoreState((state) => state) + + if (store.chatHistory.length === 0 && messages.length > 0) { + messages.forEach((message) => { + actions.addChatHistoryEntry(message) + }) + } + + useCycleMessages() + return null +} diff --git a/src/utils/__tests__/MessageQueue.test.ts b/src/utils/__tests__/MessageQueue.test.ts new file mode 100644 index 0000000..f5281bb --- /dev/null +++ b/src/utils/__tests__/MessageQueue.test.ts @@ -0,0 +1,116 @@ +import { test, expect, describe } from "bun:test" +import { MessageQueue } from "../MessageQueue" + +describe("MessageQueue", () => { + test("should create an empty queue", () => { + const queue = new MessageQueue() + + expect(queue.hasPendingRequests()).toBe(false) + }) + + test("should send and receive messages", () => { + const queue = new MessageQueue() + + queue.sendMessage("hello") + const promise = queue.waitForMessage() + + expect(promise).toBeInstanceOf(Promise) + }) + + test("should dequeue messages in FIFO order", async () => { + const queue = new MessageQueue() + + queue.sendMessage("first") + queue.sendMessage("second") + queue.sendMessage("third") + + const first = await queue.waitForMessage() + const second = await queue.waitForMessage() + const third = await queue.waitForMessage() + + expect(first).toBe("first") + expect(second).toBe("second") + expect(third).toBe("third") + }) + + test("should wait for messages when queue is empty", async () => { + const queue = new MessageQueue() + + const promise = queue.waitForMessage() + queue.sendMessage("delayed") + + const message = await promise + + expect(message).toBe("delayed") + }) + + test("should handle hasPendingRequests correctly", async () => { + const queue = new MessageQueue() + + expect(queue.hasPendingRequests()).toBe(false) + + const promise = queue.waitForMessage() + expect(queue.hasPendingRequests()).toBe(true) + + queue.sendMessage("message") + await promise + + expect(queue.hasPendingRequests()).toBe(false) + }) + + test("should clear queue and listeners", async () => { + const queue = new MessageQueue() + + queue.sendMessage("first") + queue.sendMessage("second") + queue.waitForMessage() + + queue.clear() + + expect(queue.hasPendingRequests()).toBe(false) + + queue.sendMessage("after-clear") + const message = await queue.waitForMessage() + expect(message).toBe("after-clear") + }) + + test("should handle multiple waiting consumers", async () => { + const queue = new MessageQueue() + + const promise1 = queue.waitForMessage() + + queue.sendMessage("first") + const msg1 = await promise1 + + const promise2 = queue.waitForMessage() + queue.sendMessage("second") + const msg2 = await promise2 + + expect(msg1).toBe("first") + expect(msg2).toBe("second") + }) + + test("should buffer messages when no listeners", async () => { + const queue = new MessageQueue() + + queue.sendMessage("buffered1") + queue.sendMessage("buffered2") + + const msg1 = await queue.waitForMessage() + const msg2 = await queue.waitForMessage() + + expect(msg1).toBe("buffered1") + expect(msg2).toBe("buffered2") + }) + + test("should emit directly when listeners exist", async () => { + const queue = new MessageQueue() + + const promise = queue.waitForMessage() + queue.sendMessage("direct") + + const message = await promise + + expect(message).toBe("direct") + }) +}) diff --git a/src/utils/__tests__/canUseTool.test.ts b/src/utils/__tests__/canUseTool.test.ts new file mode 100644 index 0000000..9f5f4b7 --- /dev/null +++ b/src/utils/__tests__/canUseTool.test.ts @@ -0,0 +1,284 @@ +import { test, expect, describe, mock } from "bun:test" +import { createCanUseTool } from "../canUseTool" +import { MessageQueue } from "../MessageQueue" +import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk" + +describe("createCanUseTool", () => { + test("should allow tool when user responds with 'yes'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + { param: "value" }, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("yes") + const result = await promise + + expect(result.behavior).toBe("allow") + if (result.behavior === "allow") { + expect(result.updatedInput).toEqual({ param: "value" }) + } + }) + + test("should allow tool when user responds with 'y'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("y") + const result = await promise + + expect(result.behavior).toBe("allow") + }) + + test("should allow tool when user responds with 'allow'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("allow") + const result = await promise + + expect(result.behavior).toBe("allow") + }) + + test("should deny tool when user responds with 'no'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("no") + const result = await promise + + expect(result.behavior).toBe("deny") + if (result.behavior === "deny") { + expect(result.message).toBe("User denied permission") + expect(result.interrupt).toBe(true) + } + }) + + test("should deny tool when user responds with 'n'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("n") + const result = await promise + + expect(result.behavior).toBe("deny") + }) + + test("should deny tool when user responds with 'deny'", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("deny") + const result = await promise + + expect(result.behavior).toBe("deny") + }) + + test("should handle case-insensitive responses", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("YES") + const result = await promise + + expect(result.behavior).toBe("allow") + }) + + test("should handle whitespace in responses", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage(" yes ") + const result = await promise + + expect(result.behavior).toBe("allow") + }) + + test("should treat other input as new message and deny", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("do something else") + const result = await promise + + expect(result.behavior).toBe("deny") + if (result.behavior === "deny") { + expect(result.message).toBe("do something else") + expect(result.interrupt).toBe(true) + } + }) + + test("should call onToolPermissionRequest callback", async () => { + const queue = new MessageQueue() + const callback = mock(() => {}) + const canUseTool = createCanUseTool({ + messageQueue: queue, + onToolPermissionRequest: callback, + }) + + const promise = canUseTool( + "test_tool", + { param: "value" }, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("yes") + await promise + + expect(callback).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith("test_tool", { param: "value" }) + }) + + test("should add MCP tool rules when allowing MCP tools", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const promise = canUseTool( + "mcp__github__search", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("yes") + const result = await promise + + expect(result.behavior).toBe("allow") + if (result.behavior === "allow") { + expect(result.updatedPermissions).toEqual([ + { + type: "addRules", + rules: [{ toolName: "mcp__github__search" }], + behavior: "allow", + destination: "session", + }, + ]) + } + }) + + test("should use suggestions for non-MCP tools", async () => { + const queue = new MessageQueue() + const canUseTool = createCanUseTool({ messageQueue: queue }) + + const suggestions: PermissionUpdate[] = [ + { + type: "addRules", + rules: [{ toolName: "regular_tool" }], + behavior: "allow", + destination: "session", + }, + ] + + const promise = canUseTool( + "regular_tool", + {}, + { + signal: new AbortController().signal, + suggestions, + } + ) + + queue.sendMessage("yes") + const result = await promise + + expect(result.behavior).toBe("allow") + if (result.behavior === "allow") { + expect(result.updatedPermissions).toEqual(suggestions) + } + }) + + test("should call setIsProcessing on deny", async () => { + const queue = new MessageQueue() + const setIsProcessing = mock(() => {}) + const canUseTool = createCanUseTool({ + messageQueue: queue, + setIsProcessing, + }) + + const promise = canUseTool( + "test_tool", + {}, + { + signal: new AbortController().signal, + } + ) + + queue.sendMessage("no") + await promise + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(setIsProcessing).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/utils/__tests__/formatToolInput.test.ts b/src/utils/__tests__/formatToolInput.test.ts new file mode 100644 index 0000000..d1ec7e5 --- /dev/null +++ b/src/utils/__tests__/formatToolInput.test.ts @@ -0,0 +1,89 @@ +import { test, expect, describe } from "bun:test" +import { formatToolInput } from "../formatToolInput" + +describe("formatToolInput", () => { + test("should format simple object input", () => { + const input = { param: "value" } + const result = formatToolInput(input) + + expect(result).toBe('{\n "param": "value"\n}') + }) + + test("should format nested object input", () => { + const input = { + outer: { + inner: "value", + }, + } + const result = formatToolInput(input) + + expect(result).toContain('"outer"') + expect(result).toContain('"inner"') + expect(result).toContain('"value"') + }) + + test("should format array input", () => { + const input = { items: ["one", "two", "three"] } + const result = formatToolInput(input) + + expect(result).toContain('"items"') + expect(result).toContain('"one"') + expect(result).toContain('"two"') + expect(result).toContain('"three"') + }) + + test("should handle empty input", () => { + const input = {} + const result = formatToolInput(input) + + expect(result).toBe("{}") + }) + + test("should format GraphQL query input", () => { + const input = { + query: "query { user { name } }", + } + const result = formatToolInput(input) + + expect(result).toBe("query { user { name } }") + }) + + test("should replace escaped newlines with actual newlines", () => { + const jsonString = JSON.stringify({ text: "line1\nline2" }) + const input = JSON.parse(jsonString) + const result = formatToolInput(input) + + expect(result).toContain("line1\nline2") + }) + + test("should replace escaped tabs with spaces", () => { + const jsonString = JSON.stringify({ text: "tab\there" }) + const input = JSON.parse(jsonString) + const result = formatToolInput(input) + + expect(result).toContain("tab here") + }) + + test("should handle null values", () => { + const input = { value: null } + const result = formatToolInput(input) + + expect(result).toContain('"value": null') + }) + + test("should handle number values", () => { + const input = { count: 42, price: 19.99 } + const result = formatToolInput(input) + + expect(result).toContain('"count": 42') + expect(result).toContain('"price": 19.99') + }) + + test("should handle boolean values", () => { + const input = { enabled: true, disabled: false } + const result = formatToolInput(input) + + expect(result).toContain('"enabled": true') + expect(result).toContain('"disabled": false') + }) +}) diff --git a/src/utils/__tests__/getPrompt.test.ts b/src/utils/__tests__/getPrompt.test.ts new file mode 100644 index 0000000..ab2f8e1 --- /dev/null +++ b/src/utils/__tests__/getPrompt.test.ts @@ -0,0 +1,134 @@ +import { test, expect, describe } from "bun:test" +import { getPrompt, buildSystemPrompt } from "../getPrompt" +import type { AgentChatConfig } from "../../store" + +describe("getPrompt", () => { + test("should load system prompt from file", () => { + const prompt = getPrompt("system.md") + + expect(typeof prompt).toBe("string") + expect(prompt.length).toBeGreaterThan(0) + }) + + test("should trim whitespace from prompt", () => { + const prompt = getPrompt("system.md") + + expect(prompt).toBe(prompt.trim()) + }) +}) + +describe("buildSystemPrompt", () => { + test("should include current date", () => { + const config: AgentChatConfig = { + mcpServers: {}, + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("Current date:") + }) + + test("should use default prompt when systemPrompt is not provided", () => { + const config: AgentChatConfig = { + mcpServers: {}, + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("You are a helpful agent.") + }) + + test("should use custom systemPrompt when provided", () => { + const config: AgentChatConfig = { + mcpServers: {}, + systemPrompt: "You are a custom agent.", + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("You are a custom agent.") + expect(prompt).not.toContain("You are a helpful agent.") + }) + + test("should include MCP server prompts", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + prompt: "GitHub server instructions", + }, + }, + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("# github MCP Server") + expect(prompt).toContain("GitHub server instructions") + }) + + test("should combine multiple MCP server prompts", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + prompt: "GitHub instructions", + }, + gitlab: { + command: "node", + args: [], + prompt: "GitLab instructions", + }, + }, + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("# github MCP Server") + expect(prompt).toContain("GitHub instructions") + expect(prompt).toContain("# gitlab MCP Server") + expect(prompt).toContain("GitLab instructions") + }) + + test("should skip MCP servers without prompts", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + prompt: "GitHub instructions", + }, + gitlab: { + command: "node", + args: [], + }, + }, + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("# github MCP Server") + expect(prompt).not.toContain("# gitlab MCP Server") + }) + + test("should build prompt with custom system prompt and MCP prompts", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + prompt: "GitHub instructions", + }, + }, + systemPrompt: "Custom base prompt", + } + + const prompt = buildSystemPrompt(config) + + expect(prompt).toContain("Current date:") + expect(prompt).toContain("Custom base prompt") + expect(prompt).toContain("# github MCP Server") + expect(prompt).toContain("GitHub instructions") + }) +}) diff --git a/src/utils/__tests__/getToolInfo.test.ts b/src/utils/__tests__/getToolInfo.test.ts new file mode 100644 index 0000000..46c3629 --- /dev/null +++ b/src/utils/__tests__/getToolInfo.test.ts @@ -0,0 +1,198 @@ +import { test, expect, describe } from "bun:test" +import { + getToolInfo, + getDisallowedTools, + isToolDisallowed, +} from "../getToolInfo" +import type { AgentChatConfig } from "../../store" + +describe("getToolInfo", () => { + test("should extract server name and tool name from MCP format", () => { + const result = getToolInfo("mcp__github__search_repositories") + + expect(result.serverName).toBe("github") + expect(result.toolName).toBe("search_repositories") + }) + + test("should handle tool name without MCP prefix", () => { + const result = getToolInfo("regular_tool") + + expect(result.serverName).toBeNull() + expect(result.toolName).toBe("regular_tool") + }) + + test("should handle tool names with multiple underscores", () => { + const result = getToolInfo("mcp__server__tool__with__underscores") + + expect(result.serverName).toBe("server") + expect(result.toolName).toBe("tool__with__underscores") + }) + + test("should handle malformed tool names", () => { + const result = getToolInfo("mcp__only_one_part") + + expect(result.serverName).toBeNull() + expect(result.toolName).toBe("mcp__only_one_part") + }) + + test("should handle empty string", () => { + const result = getToolInfo("") + + expect(result.serverName).toBeNull() + expect(result.toolName).toBe("") + }) +}) + +describe("getDisallowedTools", () => { + test("should convert short names to full MCP format", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search_repositories", "create_issue"], + }, + }, + } + + const result = getDisallowedTools(config) + + expect(result).toEqual([ + "mcp__github__search_repositories", + "mcp__github__create_issue", + ]) + }) + + test("should handle empty deny list", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + }, + }, + } + + const result = getDisallowedTools(config) + + expect(result).toEqual([]) + }) + + test("should handle multiple servers", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search"], + }, + gitlab: { + command: "node", + args: [], + denyTools: ["merge"], + }, + }, + } + + const result = getDisallowedTools(config) + + expect(result).toEqual(["mcp__github__search", "mcp__gitlab__merge"]) + }) + + test("should handle servers with no denyTools", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search"], + }, + gitlab: { + command: "node", + args: [], + }, + }, + } + + const result = getDisallowedTools(config) + + expect(result).toEqual(["mcp__github__search"]) + }) +}) + +describe("isToolDisallowed", () => { + test("should return true for disallowed tools", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search_repositories"], + }, + }, + } + + const result = isToolDisallowed({ + toolName: "mcp__github__search_repositories", + config, + }) + + expect(result).toBe(true) + }) + + test("should return false for allowed tools", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search_repositories"], + }, + }, + } + + const result = isToolDisallowed({ + toolName: "mcp__github__create_issue", + config, + }) + + expect(result).toBe(false) + }) + + test("should handle empty disallowed list", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + }, + }, + } + + const result = isToolDisallowed({ + toolName: "mcp__github__any_tool", + config, + }) + + expect(result).toBe(false) + }) + + test("should handle non-MCP tool names", () => { + const config: AgentChatConfig = { + mcpServers: { + github: { + command: "node", + args: [], + denyTools: ["search"], + }, + }, + } + + const result = isToolDisallowed({ + toolName: "regular_tool", + config, + }) + + expect(result).toBe(false) + }) +}) diff --git a/src/utils/__tests__/validateEnv.test.ts b/src/utils/__tests__/validateEnv.test.ts new file mode 100644 index 0000000..0ee60a5 --- /dev/null +++ b/src/utils/__tests__/validateEnv.test.ts @@ -0,0 +1,106 @@ +import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test" +import { validateEnv } from "../validateEnv" + +describe("validateEnv", () => { + const originalEnv = { ...process.env } + let consoleErrorSpy: any + let processExitSpy: any + + beforeEach(() => { + consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}) + processExitSpy = spyOn(process, "exit").mockImplementation( + (() => {}) as never + ) + }) + + afterEach(() => { + process.env = { ...originalEnv } + consoleErrorSpy.mockRestore() + processExitSpy.mockRestore() + }) + + test("should pass when all required environment variables are present", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token" + process.env.ARTSY_MCP_X_USER_ID = "test-user" + process.env.GITHUB_ACCESS_TOKEN = "test-github" + + validateEnv() + + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(processExitSpy).not.toHaveBeenCalled() + }) + + test("should exit when ANTHROPIC_API_KEY is missing", () => { + delete process.env.ANTHROPIC_API_KEY + process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token" + process.env.ARTSY_MCP_X_USER_ID = "test-user" + process.env.GITHUB_ACCESS_TOKEN = "test-github" + + validateEnv() + + expect(consoleErrorSpy).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("should exit when ARTSY_MCP_X_ACCESS_TOKEN is missing", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + delete process.env.ARTSY_MCP_X_ACCESS_TOKEN + process.env.ARTSY_MCP_X_USER_ID = "test-user" + process.env.GITHUB_ACCESS_TOKEN = "test-github" + + validateEnv() + + expect(consoleErrorSpy).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("should exit when ARTSY_MCP_X_USER_ID is missing", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token" + delete process.env.ARTSY_MCP_X_USER_ID + process.env.GITHUB_ACCESS_TOKEN = "test-github" + + validateEnv() + + expect(consoleErrorSpy).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("should exit when GITHUB_ACCESS_TOKEN is missing", () => { + process.env.ANTHROPIC_API_KEY = "test-key" + process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token" + process.env.ARTSY_MCP_X_USER_ID = "test-user" + delete process.env.GITHUB_ACCESS_TOKEN + + validateEnv() + + expect(consoleErrorSpy).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("should exit when all environment variables are missing", () => { + delete process.env.ANTHROPIC_API_KEY + delete process.env.ARTSY_MCP_X_ACCESS_TOKEN + delete process.env.ARTSY_MCP_X_USER_ID + delete process.env.GITHUB_ACCESS_TOKEN + + validateEnv() + + expect(consoleErrorSpy).toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + test("should display helpful error message with missing variables", () => { + delete process.env.ANTHROPIC_API_KEY + delete process.env.GITHUB_ACCESS_TOKEN + process.env.ARTSY_MCP_X_ACCESS_TOKEN = "test-token" + process.env.ARTSY_MCP_X_USER_ID = "test-user" + + validateEnv() + + const errorMessage = consoleErrorSpy.mock.calls[0][0] + expect(errorMessage).toContain("ANTHROPIC_API_KEY") + expect(errorMessage).toContain("GITHUB_ACCESS_TOKEN") + }) +})