import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ToolWorkshopPage } from "./tool-workshop"; import * as toolTypesApi from "../api/tool_types"; import * as toolConfigsApi from "../api/tool_configs"; import * as configFoldersApi from "../api/config_folders"; const mockToolTypes = [ { id: "type-1", name: "code-server", display_name: "VS Code Server", description: "VS Code in browser", category: "editor", interface_type: "web", requires_port: true, default_port: 8443, definition_type: "compose", compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", dockerfile_template: null, build_context: null, readiness_probe: null, required_variables: ["REPO_PATH"], is_builtin: true, created_by_id: null, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, { id: "type-2", name: "custom-tool", display_name: "Custom Tool", description: "My custom tool", category: "utility", interface_type: "terminal", requires_port: false, default_port: 8080, definition_type: "dockerfile", compose_template: null, dockerfile_template: "FROM python:3.11", build_context: null, readiness_probe: { command: "python --version", timeout: 30, interval: 2, }, required_variables: [], is_builtin: false, created_by_id: "user-1", created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, ]; const mockConfigs = [ { id: "config-1", tool_type_id: "type-1", project_id: null, key: "OPENAI_API_KEY", value: "sk-test123", config_type: "env", file_path: null, port_override: null, start_command: null, working_directory: null, environment_variables: {}, volumes: [], }, { id: "config-2", tool_type_id: "type-2", project_id: null, key: "advanced-config", value: "test-value", config_type: "env", file_path: null, port_override: 9090, start_command: "python app.py", working_directory: "/app", environment_variables: { DEBUG: "true" }, volumes: [{ source: "data", target: "/data", type: "bind" }], }, ]; const mockFolders = [ { id: "folder-1", user_id: "user-1", name: "my-dotfiles", description: "My personal config files", mount_path: "/home/user", files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }, project_overrides: {}, is_active: true, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, { id: "folder-2", user_id: "user-1", name: "project-configs", description: "Project specific configs", mount_path: "/workspace", files: { ".env": "API_URL=http://localhost:8080" }, project_overrides: { "proj-1": { mount_path: "/app", files: { ".env": "API_URL=http://prod.api" }, }, }, is_active: false, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }, ]; afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("ToolWorkshopPage", () => { it("renders loading state initially", () => { vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {})); vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {})); vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {})); render(); expect(screen.getByText(/loading/i)).toBeInTheDocument(); }); it("renders tool types tab by default", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); expect(screen.getByText("Custom Tool")).toBeInTheDocument(); }); it("switches to configs tab", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /configs/i })); await waitFor(() => { expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /add config/i })); expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument(); expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument(); }); it("creates config with advanced fields", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /configs/i })); await waitFor(() => { expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /add config/i })); fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), { target: { value: "MY_CONFIG" }, }); fireEvent.change(screen.getByPlaceholderText(/Enter value/i), { target: { value: "my-value" }, }); fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), { target: { value: "9090" }, }); fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), { target: { value: "python app.py" }, }); fireEvent.click(screen.getByRole("button", { name: /add$/i })); await waitFor(() => { expect(createMock).toHaveBeenCalledWith( expect.objectContaining({ key: "MY_CONFIG", value: "my-value", port_override: 9090, start_command: "python app.py", }) ); }); expect(configsListMock).toHaveBeenCalledTimes(2); }); it("opens folder creation form", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /create folder/i })); expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument(); }); it("creates config folder successfully", async () => { const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder); vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /create folder/i })); fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), { target: { value: "new-folder" }, }); fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), { target: { value: "/home/dev" }, }); fireEvent.click(screen.getByRole("button", { name: /create$/i })); await waitFor(() => { expect(createMock).toHaveBeenCalledWith( expect.objectContaining({ name: "new-folder", mount_path: "/home/dev", }) ); }); expect(foldersListMock).toHaveBeenCalledTimes(2); }); it("shows folder active/inactive status", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); fireEvent.click(screen.getByText("VS Code Server")); await waitFor(() => { expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /folders/i })); await waitFor(() => { expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); }); // Check that active folder shows Active badge expect(screen.getByText("Active")).toBeInTheDocument(); }); it("handles error state gracefully", async () => { vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error")); vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error")); vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error")); render(); await waitFor(() => { expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); }); expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); it("retries loading after error", async () => { const listMock = vi.spyOn(toolTypesApi, "listToolTypes") .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]); vi.spyOn(toolConfigsApi, "listToolConfigs") .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders") .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: /retry/i })); await waitFor(() => { expect(screen.getByText("VS Code Server")).toBeInTheDocument(); }); expect(listMock).toHaveBeenCalledTimes(2); }); it("deletes tool type successfully", async () => { const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]); const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined); vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]); vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]); render(); await waitFor(() => { expect(screen.getByText("Custom Tool")).toBeInTheDocument(); }); // Find and click delete button for custom tool (not built-in) const customToolCard = screen.getByText("Custom Tool").closest(".card") || screen.getByText("Custom Tool").parentElement; if (customToolCard) { const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i }); if (deleteButton) { vi.spyOn(window, "confirm").mockReturnValue(true); fireEvent.click(deleteButton); await waitFor(() => { expect(deleteMock).toHaveBeenCalledWith("type-2"); }); expect(listMock).toHaveBeenCalledTimes(2); } } }); });