diff --git a/.env.example b/.env.example deleted file mode 100644 index d312d6c..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_BASE_URL=http://localhost:8010 -VITE_WS_BASE_URL=ws://localhost:8010 diff --git a/.gitignore b/.gitignore index 4550653..23af014 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ coverage .env.local .env.*.local .scannerwork + +# Config +public/config.json + + +trivy-report.json diff --git a/README.md b/README.md index 0dffaac..c63f1c6 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,22 @@ bun install ## Configuration -| Variable | Default | Description | +Configuration is loaded at runtime from `public/config.json`. Copy the example file to get started: + +```bash +cp public/config.example.json public/config.json +``` + +**`public/config.json`** - Application configuration: + +| Field | Type | Description | |---|---|---| -| `VITE_API_BASE_URL` | `http://localhost:8010` | composable-agents API URL | -| `VITE_WS_BASE_URL` | `ws://localhost:8010` | WebSocket URL for streaming | +| `apiBaseUrl` | `string` | composable-agents API URL (e.g., `http://localhost:8010`) | +| `wsBaseUrl` | `string` | WebSocket URL for streaming (e.g., `ws://localhost:8010`) | + +The config is validated with Zod on startup. Invalid configuration will show an error toast. -The Vite dev server proxies `/api` requests to the API automatically (see `vite.config.ts`). +**Note:** `config.json` is gitignored. Use `config.example.json` as a template. ## Running @@ -103,15 +113,19 @@ src/ entities/ agent/ # AgentConfig, AgentConfigMetadata, McpServerConfig chat/ # Message, Thread, ChatRequest + config/ # AppConfig (Zod-validated) ports/ agent/agentPort.ts # Agent repository interface chat/chatPort.ts # Chat repository interface + config/configRepository.ts # Config repository interface infrastructure/ # External adapters (API clients, config) api/ agent/agentApi.ts # Agent API adapter (axios) chat/chatApi.ts # Chat API adapter (axios + SSE) axiosInstance.ts # Shared axios instance - config/envConfig.ts # Environment variables + config/ + configRepositoryInstance.ts # Singleton config repository + fileConfigRepository.ts # File-based config implementation application/ # React UI layer components/ agent/ # AgentCard, AgentGrid, CreateAgentDialog, AgentConfigViewer @@ -122,11 +136,15 @@ src/ hooks/ agent/ # useAgents, useCreateAgent, useDeleteAgent, useUpdateAgent, useAgentConfig chat/ # useThreads, useCreateThread, useDeleteThread, useMessages, useSendMessage, useStreamChat + config/ # useConfig pages/ AgentsPage.tsx # /agents route ChatPage.tsx # /chat/:threadId? route stores/ useChatStore.ts # Zustand store for chat state +public/ + config.example.json # Example config (committed) + config.json # Runtime config (gitignored) tests/ unit/ # Mirrors src/ structure fixtures/ # Test data diff --git a/public/config.example.json b/public/config.example.json new file mode 100644 index 0000000..068d207 --- /dev/null +++ b/public/config.example.json @@ -0,0 +1,4 @@ +{ + "apiBaseUrl": "http://localhost:8010", + "wsBaseUrl": "ws://localhost:8010" +} \ No newline at end of file diff --git a/src/application/components/agent/AgentCard.tsx b/src/application/components/agent/AgentCard.tsx index 0f0c603..f673b52 100644 --- a/src/application/components/agent/AgentCard.tsx +++ b/src/application/components/agent/AgentCard.tsx @@ -40,7 +40,10 @@ function getAgentIcon(name: string): string { return AGENT_ICONS[firstLetter] ?? "smart_toy"; } -export default function AgentCard({ agent, onConfigure }: Readonly) { +export default function AgentCard({ + agent, + onConfigure, +}: Readonly) { return (
{/* Header: icon + status */} diff --git a/src/application/components/agent/AgentConfigViewer.tsx b/src/application/components/agent/AgentConfigViewer.tsx index fbe4661..45b5dbe 100644 --- a/src/application/components/agent/AgentConfigViewer.tsx +++ b/src/application/components/agent/AgentConfigViewer.tsx @@ -49,205 +49,220 @@ export default function AgentConfigViewer({ } return ( - { if (e.key === "Escape") onOpenChange(false); }} + onKeyDown={(e) => { + if (e.key === "Escape") onOpenChange(false); + }} + role="dialog" + aria-modal="true" + aria-labelledby="agent-viewer-title" > -
- {/* Header */} -
-

- {agentName} -

- -
- - {isLoading && ( -

- Loading configuration... -

- )} + e.stopPropagation()} + > +
+ {/* Header */} +
+

+ {agentName} +

+ +
- {config && ( -
- {/* Model & Debug */} -
-
- Model -

{config.model}

-
-
- Debug - -
-
+ {isLoading && ( +

+ Loading configuration... +

+ )} - {/* System Prompt */} - {config.system_prompt && ( -
- System Prompt -
- {promptExpanded - ? config.system_prompt - : config.system_prompt.slice(0, 200)} - {config.system_prompt.length > 200 && ( - - )} + {config && ( +
+ {/* Model & Debug */} +
+
+ Model +

{config.model}

+
+
+ Debug +
- )} - {/* Tools */} - {config.tools.length > 0 && ( -
- Tools ({config.tools.length}) -
- {config.tools.map((tool) => ( - - ))} + {/* System Prompt */} + {config.system_prompt && ( +
+ System Prompt +
+ {promptExpanded + ? config.system_prompt + : config.system_prompt.slice(0, 200)} + {config.system_prompt.length > 200 && ( + + )} +
-
- )} + )} - {/* Middleware */} - {config.middleware.length > 0 && ( -
- - Middleware ({config.middleware.length}) - -
- {config.middleware.map((mw) => ( - - ))} + {/* Tools */} + {config.tools.length > 0 && ( +
+ Tools ({config.tools.length}) +
+ {config.tools.map((tool) => ( + + ))} +
-
- )} + )} - {/* Backend */} -
- Backend -

- Type: {config.backend.type} - {config.backend.root_dir && ( - <> - {" "} - · Root:{" "} - {config.backend.root_dir} - - )} -

-
+ {/* Middleware */} + {config.middleware.length > 0 && ( +
+ + Middleware ({config.middleware.length}) + +
+ {config.middleware.map((mw) => ( + + ))} +
+
+ )} - {/* HITL Rules */} - {Object.keys(config.hitl.rules).length > 0 && ( + {/* Backend */}
- HITL Rules -
- {Object.entries(config.hitl.rules).map(([key, value]) => ( -
- {key} - - {(() => { - if (typeof value === "boolean") return value ? "Enabled" : "Disabled"; - return JSON.stringify(value); - })()} - -
- ))} -
+ Backend +

+ Type: {config.backend.type} + {config.backend.root_dir && ( + <> + {" "} + · Root:{" "} + {config.backend.root_dir} + + )} +

- )} - {/* MCP Servers */} - {config.mcp_servers.length > 0 && ( -
- - MCP Servers ({config.mcp_servers.length}) - -
- {config.mcp_servers.map((server) => ( -
- - {server.name} - - -
- ))} + {/* HITL Rules */} + {Object.keys(config.hitl.rules).length > 0 && ( +
+ HITL Rules +
+ {Object.entries(config.hitl.rules).map(([key, value]) => ( +
+ {key} + + {(() => { + if (typeof value === "boolean") + return value ? "Enabled" : "Disabled"; + return JSON.stringify(value); + })()} + +
+ ))} +
-
- )} + )} - {/* Subagents */} - {config.subagents.length > 0 && ( -
- - Subagents ({config.subagents.length}) - -
- {config.subagents.map((sub) => ( -
-

- {sub.name} -

-

- {sub.description} -

-
- ))} + {/* MCP Servers */} + {config.mcp_servers.length > 0 && ( +
+ + MCP Servers ({config.mcp_servers.length}) + +
+ {config.mcp_servers.map((server) => ( +
+ + {server.name} + + +
+ ))} +
-
- )} -
- )} + )} - {/* Actions */} -
- - + {/* Subagents */} + {config.subagents.length > 0 && ( +
+ + Subagents ({config.subagents.length}) + +
+ {config.subagents.map((sub) => ( +
+

+ {sub.name} +

+

+ {sub.description} +

+
+ ))} +
+
+ )} +
+ )} + + {/* Actions */} +
+ + +
-
-
+
+
); } @@ -257,4 +272,4 @@ function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) { {children} ); -} +} \ No newline at end of file diff --git a/src/application/components/agent/CreateAgentDialog.tsx b/src/application/components/agent/CreateAgentDialog.tsx index b7510bd..7df5b35 100644 --- a/src/application/components/agent/CreateAgentDialog.tsx +++ b/src/application/components/agent/CreateAgentDialog.tsx @@ -53,82 +53,96 @@ export default function CreateAgentDialog({ } return ( - { if (e.key === "Escape") onOpenChange(false); }} + onKeyDown={(e) => { + if (e.key === "Escape") onOpenChange(false); + }} + role="dialog" + aria-modal="true" + aria-labelledby="create-agent-title" > -
- {/* Header */} -
-

- Create Agent -

- -
- - {/* Form */} -
-
- - setName(e.target.value)} - placeholder="my-agent" - className="w-full px-4 py-3 rounded-xl bg-surface-container-low border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-secondary-brand/40 transition-shadow" - /> -
- -
-
+ + {/* Form */} +
+
+ + setName(e.target.value)} + placeholder="my-agent" + className="w-full px-4 py-3 rounded-xl bg-surface-container-low border border-outline-variant/30 text-on-surface text-sm focus:outline-none focus:ring-2 focus:ring-secondary-brand/40 transition-shadow" + /> +
+ +
+ + +
+ +
+ + +
+
+ + + ); -} +} \ No newline at end of file diff --git a/src/application/components/chat/MessageList.tsx b/src/application/components/chat/MessageList.tsx index 90b27d2..bfcfcba 100644 --- a/src/application/components/chat/MessageList.tsx +++ b/src/application/components/chat/MessageList.tsx @@ -11,7 +11,10 @@ interface MessageListProps { agentName: string; } -export default function MessageList({ threadId, agentName }: Readonly) { +export default function MessageList({ + threadId, + agentName, +}: Readonly) { const { data: messages, isLoading } = useMessages(threadId); const { streamingContent, isStreaming, pendingUserMessage } = useChatStore(); const scrollRef = useRef(null); diff --git a/src/application/components/layout/ThreadSidebar.tsx b/src/application/components/layout/ThreadSidebar.tsx index 3e1b297..bb4d7d2 100644 --- a/src/application/components/layout/ThreadSidebar.tsx +++ b/src/application/components/layout/ThreadSidebar.tsx @@ -21,7 +21,9 @@ function formatDate(dateStr: string): string { }); } -export default function ThreadSidebar({ activeThreadId }: Readonly) { +export default function ThreadSidebar({ + activeThreadId, +}: Readonly) { const { data: threads, isLoading } = useThreads(); const { data: agents, isLoading: agentsLoading } = useAgents(); const createThread = useCreateThread(); @@ -68,8 +70,8 @@ export default function ThreadSidebar({ activeThreadId }: Readonly - add - {" "}New Conversation + add New + Conversation @@ -129,46 +131,60 @@ export default function ThreadSidebar({ activeThreadId }: Readonly setShowAgentDialog(false)} - onKeyDown={(e) => { if (e.key === "Escape") setShowAgentDialog(false); }} + onKeyDown={(e) => { + if (e.key === "Escape") setShowAgentDialog(false); + }} + role="dialog" + aria-modal="true" + aria-labelledby="agent-dialog-title" > -
e.stopPropagation()} > -

- Choose an Agent -

- {agentsLoading ? ( -

- Loading agents... -

- ) : ( -
- {agents?.map((agent) => ( - - ))} -
- )} -
- +
+

+ Choose an Agent +

+ {agentsLoading ? ( +

+ Loading agents... +

+ ) : ( +
+ {agents?.map((agent) => ( + + ))} +
+ )} +
+ + )} ); -} +} \ No newline at end of file diff --git a/src/application/hooks/config/index.ts b/src/application/hooks/config/index.ts new file mode 100644 index 0000000..a43d5e4 --- /dev/null +++ b/src/application/hooks/config/index.ts @@ -0,0 +1 @@ +export { useConfig } from "./useConfig"; diff --git a/src/application/hooks/config/useConfig.ts b/src/application/hooks/config/useConfig.ts new file mode 100644 index 0000000..3190b4d --- /dev/null +++ b/src/application/hooks/config/useConfig.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { configRepository } from "@/infrastructure/config/configRepositoryInstance"; + +export function useConfig() { + return useQuery({ + queryKey: ["config"], + queryFn: () => configRepository.getConfig(), + staleTime: Infinity, + }); +} diff --git a/src/application/pages/AgentsPage.tsx b/src/application/pages/AgentsPage.tsx index 8e3aba1..793a02f 100644 --- a/src/application/pages/AgentsPage.tsx +++ b/src/application/pages/AgentsPage.tsx @@ -30,8 +30,8 @@ export default function AgentsPage() { > add_circle - - {" "}Create Agent (YAML) + {" "} + Create Agent (YAML) diff --git a/src/domain/entities/config/appConfig.ts b/src/domain/entities/config/appConfig.ts new file mode 100644 index 0000000..c265376 --- /dev/null +++ b/src/domain/entities/config/appConfig.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const AppConfigSchema = z.object({ + apiBaseUrl: z.string().url(), + wsBaseUrl: z.string().url(), +}); + +export type AppConfig = z.infer; diff --git a/src/domain/ports/config/configRepository.ts b/src/domain/ports/config/configRepository.ts new file mode 100644 index 0000000..0f13ef0 --- /dev/null +++ b/src/domain/ports/config/configRepository.ts @@ -0,0 +1,6 @@ +import { AppConfig } from "@/domain/entities/config/appConfig"; + +export interface IConfigRepository { + getConfig(): Promise; + isLoaded(): boolean; +} diff --git a/src/infrastructure/api/axiosInstance.ts b/src/infrastructure/api/axiosInstance.ts index 37e18cd..fb122c7 100644 --- a/src/infrastructure/api/axiosInstance.ts +++ b/src/infrastructure/api/axiosInstance.ts @@ -1,14 +1,24 @@ import axios from "axios"; -import { envConfig } from "@/infrastructure/config/envConfig"; +import { configRepository } from "@/infrastructure/config/configRepositoryInstance"; + +let cachedBaseURL: string | null = null; export const apiClient = axios.create({ - baseURL: envConfig.apiBaseUrl, timeout: 30000, headers: { "Content-Type": "application/json", }, }); +apiClient.interceptors.request.use(async (config) => { + if (!cachedBaseURL) { + const appConfig = await configRepository.getConfig(); + cachedBaseURL = appConfig.apiBaseUrl; + } + config.baseURL = cachedBaseURL; + return config; +}); + apiClient.interceptors.response.use( (response) => response, (error) => { diff --git a/src/infrastructure/api/chat/chatApi.ts b/src/infrastructure/api/chat/chatApi.ts index c87350c..4da7ee4 100644 --- a/src/infrastructure/api/chat/chatApi.ts +++ b/src/infrastructure/api/chat/chatApi.ts @@ -3,8 +3,8 @@ import type { Message } from "@/domain/entities/chat/message"; import type { Thread } from "@/domain/entities/chat/thread"; import type { IChatPort } from "@/domain/ports/chat/chatPort"; import { apiClient } from "@/infrastructure/api/axiosInstance"; +import { configRepository } from "@/infrastructure/config/configRepositoryInstance"; import { fetchEventSource } from "@microsoft/fetch-event-source"; -import { envConfig } from "@/infrastructure/config/envConfig"; export const chatApi: IChatPort = { async createThread(agentName: string): Promise { @@ -52,23 +52,30 @@ export const chatApi: IChatPort = { ): AbortController { const ctrl = new AbortController(); - fetchEventSource(`${envConfig.apiBaseUrl}/api/v1/chat/${threadId}/stream`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(request), - signal: ctrl.signal, - onmessage(ev) { - if (ev.data) { - onChunk(ev.data); - } - }, - onclose() { - onComplete(); - }, - onerror(err) { - onError(err instanceof Error ? err : new Error(String(err))); - throw err; - }, + const streamUrl = async () => { + const config = await configRepository.getConfig(); + return `${config.apiBaseUrl}/api/v1/chat/${threadId}/stream`; + }; + + streamUrl().then((url) => { + fetchEventSource(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + signal: ctrl.signal, + onmessage(ev) { + if (ev.data) { + onChunk(ev.data); + } + }, + onclose() { + onComplete(); + }, + onerror(err) { + onError(err instanceof Error ? err : new Error(String(err))); + throw err; + }, + }); }); return ctrl; diff --git a/src/infrastructure/config/configRepositoryInstance.ts b/src/infrastructure/config/configRepositoryInstance.ts new file mode 100644 index 0000000..0152a68 --- /dev/null +++ b/src/infrastructure/config/configRepositoryInstance.ts @@ -0,0 +1,3 @@ +import { FileConfigRepository } from "./fileConfigRepository"; + +export const configRepository = new FileConfigRepository(); diff --git a/src/infrastructure/config/envConfig.ts b/src/infrastructure/config/envConfig.ts deleted file mode 100644 index d338056..0000000 --- a/src/infrastructure/config/envConfig.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const envConfig = { - apiBaseUrl: import.meta.env.VITE_API_BASE_URL || "http://localhost:8010", - wsBaseUrl: import.meta.env.VITE_WS_BASE_URL || "ws://localhost:8010", -}; diff --git a/src/infrastructure/config/fileConfigRepository.ts b/src/infrastructure/config/fileConfigRepository.ts new file mode 100644 index 0000000..af827a4 --- /dev/null +++ b/src/infrastructure/config/fileConfigRepository.ts @@ -0,0 +1,46 @@ +import { toast } from "sonner"; +import { AppConfig, AppConfigSchema } from "@/domain/entities/config/appConfig"; +import { IConfigRepository } from "@/domain/ports/config/configRepository"; + +export class FileConfigRepository implements IConfigRepository { + private config: AppConfig | null = null; + private configPromise: Promise | null = null; + + async getConfig(): Promise { + if (this.config) { + return this.config; + } + + if (this.configPromise !== null) { + return this.configPromise; + } + + this.configPromise = this.fetchConfig(); + return this.configPromise; + } + + private async fetchConfig(): Promise { + try { + const response = await fetch("/config.json"); + if (!response.ok) { + throw new Error(`Failed to load config: ${response.status}`); + } + + const rawConfig = await response.json(); + const config = AppConfigSchema.parse(rawConfig); + this.config = config; + return config; + } catch (error) { + this.configPromise = null; + console.error("Config loading failed:", error); + toast.error("Configuration Error", { + description: "App is not configured.", + }); + throw error; + } + } + + isLoaded(): boolean { + return this.config !== null; + } +} diff --git a/tests/unit/components/agent/AgentConfigViewer.test.tsx b/tests/unit/components/agent/AgentConfigViewer.test.tsx index d4c1130..b5e659a 100644 --- a/tests/unit/components/agent/AgentConfigViewer.test.tsx +++ b/tests/unit/components/agent/AgentConfigViewer.test.tsx @@ -4,7 +4,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderWithProviders } from "../../../utils/render"; import AgentConfigViewer from "@/application/components/agent/AgentConfigViewer"; import type { AgentConfig } from "@/domain/entities/agent/agentConfig"; -import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig"; +import { + BackendType, + MiddlewareType, +} from "@/domain/entities/agent/agentConfig"; const { mockAgentConfigData, mockDeleteMutate } = vi.hoisted(() => { return { @@ -37,7 +40,8 @@ vi.mock("sonner", () => ({ const fullConfig: AgentConfig = { name: "test-agent", model: "openai:gpt-4o", - system_prompt: "You are a helpful assistant that provides accurate information.", + system_prompt: + "You are a helpful assistant that provides accurate information.", tools: ["search", "calculator"], middleware: [], backend: { type: "state" as BackendType }, @@ -246,7 +250,13 @@ describe("AgentConfigViewer", () => { mockAgentConfigData.data = { ...fullConfig, mcp_servers: [ - { name: "filesystem-server", transport: "stdio" as any, args: [], headers: {}, env: {} }, + { + name: "filesystem-server", + transport: "stdio" as any, + args: [], + headers: {}, + env: {}, + }, ], }; @@ -351,7 +361,10 @@ describe("AgentConfigViewer", () => { expect(mockDeleteMutate).toHaveBeenCalledWith( "test-agent", - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), ); }); @@ -394,9 +407,7 @@ describe("AgentConfigViewer", () => { // The footer "Close" button const closeButtons = screen.getAllByRole("button"); - const footerClose = closeButtons.find( - (btn) => btn.textContent === "Close", - ); + const footerClose = closeButtons.find((btn) => btn.textContent === "Close"); expect(footerClose).toBeDefined(); await user.click(footerClose!); diff --git a/tests/unit/components/agent/CreateAgentDialog.test.tsx b/tests/unit/components/agent/CreateAgentDialog.test.tsx index 11dae3c..ea1ae39 100644 --- a/tests/unit/components/agent/CreateAgentDialog.test.tsx +++ b/tests/unit/components/agent/CreateAgentDialog.test.tsx @@ -118,7 +118,10 @@ describe("CreateAgentDialog", () => { expect(mockCreateAgentMutate).toHaveBeenCalledWith( { name: "my-agent", yamlFile: file }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), ); }); diff --git a/tests/unit/components/chat/ChatInput.test.tsx b/tests/unit/components/chat/ChatInput.test.tsx index 3dbdb53..70f8672 100644 --- a/tests/unit/components/chat/ChatInput.test.tsx +++ b/tests/unit/components/chat/ChatInput.test.tsx @@ -132,7 +132,10 @@ describe("ChatInput", () => { }); expect(mockSendMessageMutate).toHaveBeenCalledWith( { message: "Standard message" }, - expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), ); // Restore diff --git a/tests/unit/components/chat/HITLReviewPanel.test.tsx b/tests/unit/components/chat/HITLReviewPanel.test.tsx index 4803364..0a607f0 100644 --- a/tests/unit/components/chat/HITLReviewPanel.test.tsx +++ b/tests/unit/components/chat/HITLReviewPanel.test.tsx @@ -67,7 +67,9 @@ describe("HITLReviewPanel", () => { await user.click(screen.getByRole("button", { name: /review data/i })); // In reviewing state, Approve and Reject buttons should appear - expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /approve/i }), + ).toBeInTheDocument(); expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); }); diff --git a/tests/unit/components/layout/MainLayout.test.tsx b/tests/unit/components/layout/MainLayout.test.tsx index b9af002..ba7e5f7 100644 --- a/tests/unit/components/layout/MainLayout.test.tsx +++ b/tests/unit/components/layout/MainLayout.test.tsx @@ -2,23 +2,32 @@ import { screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { renderWithProviders } from "../../../utils/render"; import MainLayout from "@/application/components/layout/MainLayout"; -import { createThread, createAgentConfigMetadata } from "../../../fixtures/external"; +import { + createThread, + createAgentConfigMetadata, +} from "../../../fixtures/external"; -const { mockThreadsData, mockAgentsData, mockCreateThreadMutate, mockSetActiveThread } = - vi.hoisted(() => { - return { - mockThreadsData: { - data: undefined as ReturnType[] | undefined, - isLoading: false, - }, - mockAgentsData: { - data: undefined as ReturnType[] | undefined, - isLoading: false, - }, - mockCreateThreadMutate: vi.fn(), - mockSetActiveThread: vi.fn(), - }; - }); +const { + mockThreadsData, + mockAgentsData, + mockCreateThreadMutate, + mockSetActiveThread, +} = vi.hoisted(() => { + return { + mockThreadsData: { + data: undefined as ReturnType[] | undefined, + isLoading: false, + }, + mockAgentsData: { + data: undefined as + | ReturnType[] + | undefined, + isLoading: false, + }, + mockCreateThreadMutate: vi.fn(), + mockSetActiveThread: vi.fn(), + }; +}); // Mock the hooks used by ThreadSidebar (child component) vi.mock("@/application/hooks/chat/useThreads", () => ({ @@ -37,7 +46,11 @@ vi.mock("@/application/hooks/chat/useCreateThread", () => ({ })); vi.mock("@/application/stores/useChatStore", () => { - const fn = (selector: (state: { setActiveThread: typeof mockSetActiveThread }) => unknown) => { + const fn = ( + selector: (state: { + setActiveThread: typeof mockSetActiveThread; + }) => unknown, + ) => { return selector({ setActiveThread: mockSetActiveThread }); }; fn.getState = () => ({ setActiveThread: mockSetActiveThread }); diff --git a/tests/unit/components/layout/ThreadSidebar.test.tsx b/tests/unit/components/layout/ThreadSidebar.test.tsx index 7f1151b..e33c59d 100644 --- a/tests/unit/components/layout/ThreadSidebar.test.tsx +++ b/tests/unit/components/layout/ThreadSidebar.test.tsx @@ -8,21 +8,27 @@ import { createAgentConfigMetadata, } from "../../../fixtures/external"; -const { mockThreadsData, mockAgentsData, mockCreateThreadMutate, mockSetActiveThread } = - vi.hoisted(() => { - return { - mockThreadsData: { - data: undefined as ReturnType[] | undefined, - isLoading: false, - }, - mockAgentsData: { - data: undefined as ReturnType[] | undefined, - isLoading: false, - }, - mockCreateThreadMutate: vi.fn(), - mockSetActiveThread: vi.fn(), - }; - }); +const { + mockThreadsData, + mockAgentsData, + mockCreateThreadMutate, + mockSetActiveThread, +} = vi.hoisted(() => { + return { + mockThreadsData: { + data: undefined as ReturnType[] | undefined, + isLoading: false, + }, + mockAgentsData: { + data: undefined as + | ReturnType[] + | undefined, + isLoading: false, + }, + mockCreateThreadMutate: vi.fn(), + mockSetActiveThread: vi.fn(), + }; +}); vi.mock("@/application/hooks/chat/useThreads", () => ({ useThreads: () => mockThreadsData, @@ -40,7 +46,11 @@ vi.mock("@/application/hooks/chat/useCreateThread", () => ({ })); vi.mock("@/application/stores/useChatStore", () => { - const fn = (selector: (state: { setActiveThread: typeof mockSetActiveThread }) => unknown) => { + const fn = ( + selector: (state: { + setActiveThread: typeof mockSetActiveThread; + }) => unknown, + ) => { return selector({ setActiveThread: mockSetActiveThread }); }; fn.getState = () => ({ setActiveThread: mockSetActiveThread }); diff --git a/tests/unit/hooks/agent/useAgentConfig.test.tsx b/tests/unit/hooks/agent/useAgentConfig.test.tsx index 24fc0b1..03f5409 100644 --- a/tests/unit/hooks/agent/useAgentConfig.test.tsx +++ b/tests/unit/hooks/agent/useAgentConfig.test.tsx @@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { vi, describe, it, expect, beforeEach } from "vitest"; import { useAgentConfig } from "@/application/hooks/agent/useAgentConfig"; import { agentApi } from "@/infrastructure/api/agent/agentApi"; -import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig"; +import type { + AgentConfig, + BackendType, +} from "@/domain/entities/agent/agentConfig"; import type { ReactNode } from "react"; vi.mock("@/infrastructure/api/agent/agentApi", () => ({ diff --git a/tests/unit/hooks/agent/useCreateAgent.test.tsx b/tests/unit/hooks/agent/useCreateAgent.test.tsx index c5663bd..65eeb0f 100644 --- a/tests/unit/hooks/agent/useCreateAgent.test.tsx +++ b/tests/unit/hooks/agent/useCreateAgent.test.tsx @@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { vi, describe, it, expect, beforeEach } from "vitest"; import { useCreateAgent } from "@/application/hooks/agent/useCreateAgent"; import { agentApi } from "@/infrastructure/api/agent/agentApi"; -import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig"; +import type { + AgentConfig, + BackendType, +} from "@/domain/entities/agent/agentConfig"; import type { ReactNode } from "react"; vi.mock("@/infrastructure/api/agent/agentApi", () => ({ diff --git a/tests/unit/hooks/agent/useDeleteAgent.test.tsx b/tests/unit/hooks/agent/useDeleteAgent.test.tsx index dff5547..9e57636 100644 --- a/tests/unit/hooks/agent/useDeleteAgent.test.tsx +++ b/tests/unit/hooks/agent/useDeleteAgent.test.tsx @@ -65,9 +65,7 @@ describe("useDeleteAgent", () => { }); it("returns error state when deletion fails", async () => { - vi.mocked(agentApi.deleteAgent).mockRejectedValue( - new Error("Forbidden"), - ); + vi.mocked(agentApi.deleteAgent).mockRejectedValue(new Error("Forbidden")); const { wrapper } = createWrapper(); const { result } = renderHook(() => useDeleteAgent(), { wrapper }); diff --git a/tests/unit/hooks/agent/useUpdateAgent.test.tsx b/tests/unit/hooks/agent/useUpdateAgent.test.tsx index a6a31bb..b614384 100644 --- a/tests/unit/hooks/agent/useUpdateAgent.test.tsx +++ b/tests/unit/hooks/agent/useUpdateAgent.test.tsx @@ -3,7 +3,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { vi, describe, it, expect, beforeEach } from "vitest"; import { useUpdateAgent } from "@/application/hooks/agent/useUpdateAgent"; import { agentApi } from "@/infrastructure/api/agent/agentApi"; -import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig"; +import type { + AgentConfig, + BackendType, +} from "@/domain/entities/agent/agentConfig"; import type { ReactNode } from "react"; vi.mock("@/infrastructure/api/agent/agentApi", () => ({ diff --git a/tests/unit/hooks/chat/useCreateThread.test.tsx b/tests/unit/hooks/chat/useCreateThread.test.tsx index 4c40580..852a517 100644 --- a/tests/unit/hooks/chat/useCreateThread.test.tsx +++ b/tests/unit/hooks/chat/useCreateThread.test.tsx @@ -39,7 +39,10 @@ describe("useCreateThread", () => { }); it("calls chatApi.createThread on mutate", async () => { - const mockThread = createThread({ id: "new-thread", agent_name: "my-agent" }); + const mockThread = createThread({ + id: "new-thread", + agent_name: "my-agent", + }); vi.mocked(chatApi.createThread).mockResolvedValue(mockThread); const { wrapper } = createWrapper(); @@ -55,7 +58,10 @@ describe("useCreateThread", () => { }); it("invalidates threads query on success", async () => { - const mockThread = createThread({ id: "new-thread", agent_name: "my-agent" }); + const mockThread = createThread({ + id: "new-thread", + agent_name: "my-agent", + }); vi.mocked(chatApi.createThread).mockResolvedValue(mockThread); const { wrapper, queryClient } = createWrapper(); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); diff --git a/tests/unit/hooks/config/useConfig.test.tsx b/tests/unit/hooks/config/useConfig.test.tsx new file mode 100644 index 0000000..b9b6169 --- /dev/null +++ b/tests/unit/hooks/config/useConfig.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useConfig } from "@/application/hooks/config/useConfig"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode } from "react"; + +vi.mock("@/infrastructure/config/configRepositoryInstance", () => ({ + configRepository: { + getConfig: vi.fn().mockResolvedValue({ + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }), + isLoaded: vi.fn().mockReturnValue(true), + }, +})); + +describe("useConfig", () => { + let queryClient: QueryClient; + + const mockConfig = { + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("should fetch and return config", async () => { + const { result } = renderHook(() => useConfig(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockConfig); + }); + + it("should cache config with infinite stale time", async () => { + const { result } = renderHook(() => useConfig(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const fetchCount = queryClient.getQueryData(["config"]); + expect(fetchCount).toEqual(mockConfig); + }); + + it("should have correct query key", async () => { + const { result } = renderHook(() => useConfig(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeDefined(); + }); +}); diff --git a/tests/unit/infrastructure/axiosInstance.test.ts b/tests/unit/infrastructure/axiosInstance.test.ts index ff825b9..6de73f7 100644 --- a/tests/unit/infrastructure/axiosInstance.test.ts +++ b/tests/unit/infrastructure/axiosInstance.test.ts @@ -1,17 +1,36 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("@/infrastructure/config/envConfig", () => ({ - envConfig: { +const { MockFileConfigRepository } = vi.hoisted(() => { + const mockConfig = { apiBaseUrl: "http://test-api:8010", wsBaseUrl: "ws://test-api:8010", - }, + }; + const mockGetConfig = vi.fn().mockResolvedValue(mockConfig); + class MockFileConfigRepository { + getConfig = mockGetConfig; + isLoaded = vi.fn().mockReturnValue(false); + } + return { MockFileConfigRepository }; +}); + +vi.mock("@/infrastructure/config/configRepositoryInstance", () => ({ + configRepository: new MockFileConfigRepository(), })); describe("axiosInstance", () => { - it("apiClient has correct baseURL from envConfig", async () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("apiClient fetches config and sets baseURL", async () => { const { apiClient } = await import("@/infrastructure/api/axiosInstance"); - expect(apiClient.defaults.baseURL).toBe("http://test-api:8010"); + const requestConfig = + await apiClient.interceptors.request.handlers[0].fulfilled({ + headers: {}, + }); + + expect(requestConfig.baseURL).toBe("http://test-api:8010"); }); it("apiClient has 30 second timeout", async () => { @@ -29,7 +48,6 @@ describe("axiosInstance", () => { it("error interceptor extracts detail from response", async () => { const { apiClient } = await import("@/infrastructure/api/axiosInstance"); - // Simulate an axios error with response.data.detail const axiosError = { response: { status: 400, @@ -38,7 +56,6 @@ describe("axiosInstance", () => { message: "Request failed with status code 400", }; - // Get the error interceptor (second argument of the response interceptor) const interceptors = (apiClient.interceptors.response as any).handlers; const errorHandler = interceptors[0]?.rejected; diff --git a/tests/unit/infrastructure/config/fileConfigRepository.test.ts b/tests/unit/infrastructure/config/fileConfigRepository.test.ts new file mode 100644 index 0000000..5693b0a --- /dev/null +++ b/tests/unit/infrastructure/config/fileConfigRepository.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { FileConfigRepository } from "@/infrastructure/config/fileConfigRepository"; + +const { mockToast } = vi.hoisted(() => ({ + mockToast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("sonner", () => ({ + toast: mockToast, +})); + +describe("FileConfigRepository", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("getConfig", () => { + it("should fetch config from /config.json", async () => { + const mockConfig = { + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockConfig), + } as Response); + + const repo = new FileConfigRepository(); + const config = await repo.getConfig(); + + expect(fetch).toHaveBeenCalledWith("/config.json"); + expect(config).toEqual(mockConfig); + }); + + it("should cache config after first load", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }), + } as Response); + + const repo = new FileConfigRepository(); + await repo.getConfig(); + await repo.getConfig(); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("should report isLoaded correctly", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }), + } as Response); + + const repo = new FileConfigRepository(); + expect(repo.isLoaded()).toBe(false); + + await repo.getConfig(); + expect(repo.isLoaded()).toBe(true); + }); + + it("should show toast on fetch failure", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const repo = new FileConfigRepository(); + await expect(repo.getConfig()).rejects.toThrow( + "Failed to load config: 500", + ); + + expect(mockToast.error).toHaveBeenCalledWith("Configuration Error", { + description: "App is not configured.", + }); + }); + + it("should show toast on network error", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const repo = new FileConfigRepository(); + await expect(repo.getConfig()).rejects.toThrow("Network error"); + + expect(mockToast.error).toHaveBeenCalledWith("Configuration Error", { + description: "App is not configured.", + }); + }); + + it("should allow retry after fetch failure", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error")); + + const repo = new FileConfigRepository(); + await expect(repo.getConfig()).rejects.toThrow("Network error"); + + const mockConfig = { + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockConfig), + } as Response); + + const config = await repo.getConfig(); + expect(config).toEqual(mockConfig); + }); + + it("should handle concurrent getConfig calls", async () => { + let resolvePromise: (value: Response) => void; + vi.mocked(fetch).mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const repo = new FileConfigRepository(); + const p1 = repo.getConfig(); + const p2 = repo.getConfig(); + + resolvePromise!({ + ok: true, + json: () => + Promise.resolve({ + apiBaseUrl: "http://api.test.com", + wsBaseUrl: "ws://api.test.com", + }), + } as Response); + + const [config1, config2] = await Promise.all([p1, p2]); + expect(config1).toEqual(config2); + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/pages/AgentsPage.test.tsx b/tests/unit/pages/AgentsPage.test.tsx index 396191b..a2c55c1 100644 --- a/tests/unit/pages/AgentsPage.test.tsx +++ b/tests/unit/pages/AgentsPage.test.tsx @@ -69,9 +69,7 @@ describe("AgentsPage", () => { renderWithProviders(, { initialEntries: ["/agents"] }); - await user.click( - screen.getByRole("button", { name: /create agent/i }), - ); + await user.click(screen.getByRole("button", { name: /create agent/i })); expect(screen.getByText("Create Agent")).toBeInTheDocument(); expect(screen.getByLabelText(/agent name/i)).toBeInTheDocument(); @@ -82,12 +80,12 @@ describe("AgentsPage", () => { renderWithProviders(, { initialEntries: ["/agents"] }); - await user.click( - screen.getByRole("button", { name: /configure/i }), - ); + await user.click(screen.getByRole("button", { name: /configure/i })); // The AgentConfigViewer should be rendered with Delete button (unique to viewer) - expect(screen.getByRole("button", { name: /^delete$/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^delete$/i }), + ).toBeInTheDocument(); // The dialog should be present expect(screen.getByRole("dialog")).toBeInTheDocument(); });