diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx index 842ecdce64a..c37a935976b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-hub/page.tsx @@ -1,6 +1,6 @@ "use client"; -import ModelHubTable from "@/components/AIHub/model_hub_table"; +import ModelHubTable from "@/components/AIHub/ModelHubTable"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; const ModelHubPage = () => { diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index df454eb48e5..1df7019ad25 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -import ModelHubTable from "@/components/AIHub/model_hub_table"; +import ModelHubTable from "@/components/AIHub/ModelHubTable"; export default function PublicModelHubTable() { const searchParams = useSearchParams()!; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 6d5384969ca..598266df9a2 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -15,7 +15,7 @@ import GeneralSettings from "@/components/general_settings"; import GuardrailsPanel from "@/components/guardrails"; import { Team } from "@/components/key_team_helpers/key_list"; import { MCPServers } from "@/components/mcp_tools"; -import ModelHubTable from "@/components/AIHub/model_hub_table"; +import ModelHubTable from "@/components/AIHub/ModelHubTable"; import Navbar from "@/components/navbar"; import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking"; import NewUsagePage from "@/components/UsagePage/components/UsagePageView"; diff --git a/ui/litellm-dashboard/src/components/AIHub/agent_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx similarity index 93% rename from ui/litellm-dashboard/src/components/AIHub/agent_hub_table_columns.tsx rename to ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx index 026165c0eb9..c6a8c0b9daa 100644 --- a/ui/litellm-dashboard/src/components/AIHub/agent_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx @@ -28,7 +28,7 @@ export interface AgentHubData { [key: string]: any; } -export const agentHubColumns = ( +export const getAgentHubTableColumns = ( showModal: (agent: AgentHubData) => void, copyToClipboard: (text: string) => void, publicPage: boolean = false, @@ -69,11 +69,7 @@ export const agentHubColumns = ( cell: ({ row }) => { const agent = row.original; - return ( - - {agent.description || "-"} - - ); + return {agent.description || "-"}; }, meta: { className: "hidden md:table-cell", @@ -105,11 +101,7 @@ export const agentHubColumns = ( cell: ({ row }) => { const agent = row.original; - return ( - - {agent.protocolVersion || "-"} - - ); + return {agent.protocolVersion || "-"}; }, meta: { className: "hidden lg:table-cell", @@ -135,9 +127,7 @@ export const agentHubColumns = ( {skill.name} ))} - {skills.length > 2 && ( - +{skills.length - 2} - )} + {skills.length > 2 && +{skills.length - 2}} )} @@ -240,4 +230,3 @@ export const agentHubColumns = ( return allColumns; }; - diff --git a/ui/litellm-dashboard/src/components/AIHub/model_hub_table.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx similarity index 88% rename from ui/litellm-dashboard/src/components/AIHub/model_hub_table.test.tsx rename to ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index be0b9a113f4..a88ce0d7938 100644 --- a/ui/litellm-dashboard/src/components/AIHub/model_hub_table.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -1,7 +1,7 @@ import * as networking from "@/components/networking"; import { render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import ModelHubTable from "./model_hub_table"; +import ModelHubTable from "./ModelHubTable"; vi.mock("@/components/networking", () => ({ getUiConfig: vi.fn(), @@ -19,7 +19,7 @@ vi.mock("next/navigation", () => ({ }), })); -vi.mock("./public_model_hub", () => ({ +vi.mock("@/components/public_model_hub", () => ({ default: () =>
Public Model Hub
, })); @@ -51,7 +51,12 @@ describe("ModelHubTable", () => { const getUiConfigMock = vi.mocked(networking.getUiConfig); const modelHubPublicModelsCallMock = vi.mocked(networking.modelHubPublicModelsCall); - getUiConfigMock.mockResolvedValue({ server_root_path: "/", proxy_base_url: "http://localhost:4000" }); + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: "http://localhost:4000", + auto_redirect_to_sso: false, + admin_ui_disabled: false, + }); modelHubPublicModelsCallMock.mockResolvedValue([]); render(); diff --git a/ui/litellm-dashboard/src/components/AIHub/model_hub_table.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx similarity index 98% rename from ui/litellm-dashboard/src/components/AIHub/model_hub_table.tsx rename to ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index d73be781cb8..7c538b618f1 100644 --- a/ui/litellm-dashboard/src/components/AIHub/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -1,10 +1,10 @@ -import { agentHubColumns, AgentHubData } from "@/components/AIHub/agent_hub_table_columns"; -import MakeAgentPublicForm from "@/components/AIHub/forms/make_agent_public_form"; -import MakeMCPPublicForm from "@/components/AIHub/forms/make_mcp_public_form"; -import MakeModelPublicForm from "@/components/AIHub/forms/make_model_public_form"; +import { AgentHubData, getAgentHubTableColumns } from "@/components/AIHub/AgentHubTableColumns"; +import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm"; +import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm"; +import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm"; import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns"; import { modelHubColumns } from "@/components/model_hub_table_columns"; -import UsefulLinksManagement from "@/components/AIHub/useful_links_management"; +import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement"; import { ModelDataTable } from "@/components/model_dashboard/table"; import ModelFilters from "@/components/model_filters"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -423,7 +423,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Agent Table */} ({ +vi.mock("@/components/networking", () => ({ getPublicModelHubInfo: vi.fn(), updateUsefulLinksCall: vi.fn(), getProxyBaseUrl: vi.fn(), })); -vi.mock("./molecules/notifications_manager", () => ({ +vi.mock("@/components/molecules/notifications_manager", () => ({ __esModule: true, default: { success: vi.fn(), diff --git a/ui/litellm-dashboard/src/components/AIHub/useful_links_management.tsx b/ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/AIHub/useful_links_management.tsx rename to ui/litellm-dashboard/src/components/AIHub/UsefulLinksManagement.tsx diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx new file mode 100644 index 00000000000..67c6d7d6cc9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx @@ -0,0 +1,505 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import MakeAgentPublicForm from "./MakeAgentPublicForm"; +import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns"; + +// Mock the networking function +vi.mock("../../networking", () => ({ + makeAgentsPublicCall: vi.fn(), +})); + +// Import the mocked function +import { makeAgentsPublicCall } from "../../networking"; +const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall); + +// Mock antd components +vi.mock("antd", () => ({ + Modal: ({ open, title, children, onCancel, footer }: any) => + open ? ( +
+
{title}
+ {children} + {footer} +
+ ) : null, + Form: Object.assign(({ children, form }: any) =>
{children}
, { + useForm: () => [ + { + resetFields: vi.fn(), + validateFields: vi.fn(), + getFieldsValue: vi.fn(), + setFieldsValue: vi.fn(), + }, + vi.fn(), + ], + Item: ({ children }: any) =>
{children}
, + }), + Steps: Object.assign( + ({ children, current, className }: any) => ( +
+ {children} +
+ ), + { + Step: ({ title }: any) =>
{title}
, + }, + ), + Button: ({ children, onClick, disabled, loading, ...props }: any) => ( + + ), + Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( + + ), +})); + +// Mock @tremor/react components +vi.mock("@tremor/react", () => ({ + Text: ({ children, className }: any) => {children}, + Title: ({ children }: any) =>

{children}

, + Badge: ({ children, color, size }: any) => ( + + {children} + + ), +})); + +describe("MakeAgentPublicForm", () => { + const mockProps = { + visible: true, + onClose: vi.fn(), + accessToken: "test-token", + agentHubData: [ + { + agent_id: "agent-1", + name: "Test Agent 1", + description: "Description 1", + version: "1.0", + is_public: false, + skills: [ + { id: "skill-1", name: "Skill 1", description: "Skill desc" }, + { id: "skill-2", name: "Skill 2", description: "Skill desc" }, + ], + protocolVersion: "1.0", + }, + { + agent_id: "agent-2", + name: "Test Agent 2", + description: "Description 2", + version: "2.0", + is_public: true, + skills: [], + protocolVersion: "1.0", + }, + ] as AgentHubData[], + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("should render the component", () => { + render(); + + expect(screen.getByText("Make Agents Public")).toBeInTheDocument(); + expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); + }); + + it("should initialize with correct state", () => { + render(); + + // Check that the component renders with the correct title and content + expect(screen.getByText("Make Agents Public")).toBeInTheDocument(); + expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); + + // Check that all agent checkboxes are present + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 agents + + // Check that the Next button is enabled (agents are preselected) + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should handle agent selection and navigation", async () => { + render(); + + // Initially on step 1 + expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); + + // Select all agents using the select all checkbox + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Verify Next button is enabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + + // Click Next + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should move to step 2 + await waitFor(() => { + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); + }); + }); + + it("should submit selected agents successfully", async () => { + mockMakeAgentsPublicCall.mockResolvedValueOnce({}); + + render(); + + // Select all agents + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Wait for navigation to complete + await waitFor(() => { + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockMakeAgentsPublicCall).toHaveBeenCalledWith("test-token", ["agent-1", "agent-2"]); + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should handle select all functionality", async () => { + render(); + + const checkboxes = screen.getAllByRole("checkbox"); + const selectAllCheckbox = checkboxes[0]; + + // Select all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be checked + checkboxes.forEach((checkbox) => { + expect(checkbox).toBeChecked(); + }); + + // Deselect all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be unchecked except the indeterminate state + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it("should show error when no agents selected", async () => { + render(); + + // Deselect all agents first + const checkboxes = screen.getAllByRole("checkbox"); + await act(async () => { + fireEvent.click(checkboxes[0]); // Click select all to select all + fireEvent.click(checkboxes[0]); // Click select all again to deselect all + }); + + // Try to go to next step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should stay on same step + expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); + }); + + it("should display empty state when no agents are available", () => { + const emptyProps = { + ...mockProps, + agentHubData: [] as AgentHubData[], + }; + + render(); + + expect(screen.getByText("No agents available.")).toBeInTheDocument(); + + // Select All checkbox should be disabled + const selectAllCheckbox = screen.getByLabelText("Select All"); + expect(selectAllCheckbox).toBeDisabled(); + + // Next button should be disabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toBeDisabled(); + }); + + it("should handle Cancel button functionality", async () => { + render(); + + // Click Cancel button + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await act(async () => { + fireEvent.click(cancelButton); + }); + + // Should call onClose + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + it("should handle Previous button functionality", async () => { + render(); + + // Navigate to step 1 + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Verify we're on step 1 + await waitFor(() => { + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); + }); + + // Click Previous button + const previousButton = screen.getByRole("button", { name: "Previous" }); + await act(async () => { + fireEvent.click(previousButton); + }); + + // Should go back to step 0 + expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); + }); + + it("should handle individual agent selection", async () => { + render(); + + // Get all checkboxes (select all + individual agents) + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 agents + + // Initially, agent-2 should be selected (it's already public) + const agent1Checkbox = checkboxes[1]; // First agent checkbox + const agent2Checkbox = checkboxes[2]; // Second agent checkbox + + expect(agent2Checkbox).toBeChecked(); // agent-2 is already public + + // Select agent-1 + await act(async () => { + fireEvent.click(agent1Checkbox); + }); + + expect(agent1Checkbox).toBeChecked(); + expect(agent2Checkbox).toBeChecked(); + + // Deselect agent-2 + await act(async () => { + fireEvent.click(agent2Checkbox); + }); + + expect(agent1Checkbox).toBeChecked(); + expect(agent2Checkbox).not.toBeChecked(); + + // Select all should be indeterminate now + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); + + it("should display skills overflow text when agent has more than 3 skills", () => { + const agentWithManySkills = { + ...mockProps.agentHubData[0], + skills: [ + { id: "skill-1", name: "Skill 1", description: "Skill desc" }, + { id: "skill-2", name: "Skill 2", description: "Skill desc" }, + { id: "skill-3", name: "Skill 3", description: "Skill desc" }, + { id: "skill-4", name: "Skill 4", description: "Skill desc" }, + { id: "skill-5", name: "Skill 5", description: "Skill desc" }, + ], + }; + + const propsWithManySkills = { + ...mockProps, + agentHubData: [agentWithManySkills], + }; + + render(); + + // Should show first 3 skills as badges + expect(screen.getByText("Skill 1")).toBeInTheDocument(); + expect(screen.getByText("Skill 2")).toBeInTheDocument(); + expect(screen.getByText("Skill 3")).toBeInTheDocument(); + + // Should show "+2 more" text for the remaining skills + expect(screen.getByText("+2 more")).toBeInTheDocument(); + }); + + it("should handle submit error properly", async () => { + const errorMessage = "Network error"; + mockMakeAgentsPublicCall.mockRejectedValueOnce(new Error(errorMessage)); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Should handle error and show error notification + await waitFor(() => { + expect(mockMakeAgentsPublicCall).toHaveBeenCalledWith("test-token", ["agent-2"]); + }); + + // Should not call onSuccess or onClose on error + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + }); + + it("should show loading state during submit", async () => { + let resolvePromise: (value: any) => void = () => {}; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockMakeAgentsPublicCall.mockReturnValueOnce(pendingPromise); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Check loading state + expect(submitButton).toHaveAttribute("data-loading", "true"); + expect(submitButton).toBeDisabled(); + + // Resolve the promise + resolvePromise({}); + await waitFor(() => { + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should not render modal when visible is false", () => { + const invisibleProps = { + ...mockProps, + visible: false, + }; + + render(); + + // Modal should not be rendered + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument(); + }); + + it("should preselect already public agents when modal opens", () => { + // Test data where one agent is public and one is not + const mixedPublicProps = { + ...mockProps, + agentHubData: [ + { + agent_id: "agent-1", + name: "Test Agent 1", + description: "Description 1", + url: "http://example.com/agent1", + version: "1.0", + is_public: false, // Not public + skills: [], + protocolVersion: "1.0", + }, + { + agent_id: "agent-2", + name: "Test Agent 2", + description: "Description 2", + url: "http://example.com/agent2", + version: "2.0", + is_public: true, // Already public + skills: [], + protocolVersion: "1.0", + }, + { + agent_id: "agent-3", + name: "Test Agent 3", + description: "Description 3", + url: "http://example.com/agent3", + version: "3.0", + is_public: true, // Already public + skills: [], + protocolVersion: "1.0", + }, + ] as AgentHubData[], + }; + + render(); + + // Check that the correct checkboxes are selected + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(4); // Select all + 3 agents + + // agent-2 and agent-3 should be checked (they're already public) + const agent1Checkbox = checkboxes[1]; + const agent2Checkbox = checkboxes[2]; + const agent3Checkbox = checkboxes[3]; + + expect(agent1Checkbox).not.toBeChecked(); // agent-1 is not public + expect(agent2Checkbox).toBeChecked(); // agent-2 is public + expect(agent3Checkbox).toBeChecked(); // agent-3 is public + + // Select all should be indeterminate + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/make_agent_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/AIHub/forms/make_agent_public_form.tsx rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx index bcb23b99974..a38950b8fb7 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/make_agent_public_form.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Steps, Button, Checkbox } from "antd"; import { Text, Title, Badge } from "@tremor/react"; import { makeAgentsPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; -import { AgentHubData } from "@/components/AIHub/agent_hub_table_columns"; +import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns"; const { Step } = Steps; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx new file mode 100644 index 00000000000..b0228e9e868 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -0,0 +1,562 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import MakeMCPPublicForm from "./MakeMCPPublicForm"; +import { MCPServerData } from "../../mcp_hub_table_columns"; + +// Mock the networking function +vi.mock("../../networking", () => ({ + makeMCPPublicCall: vi.fn(), +})); + +// Import the mocked function +import { makeMCPPublicCall } from "../../networking"; +const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall); + +// Mock antd components +vi.mock("antd", () => ({ + Modal: ({ open, title, children, onCancel, footer }: any) => + open ? ( +
+
{title}
+ {children} + {footer} +
+ ) : null, + Form: Object.assign(({ children, form }: any) =>
{children}
, { + useForm: () => [ + { + resetFields: vi.fn(), + validateFields: vi.fn(), + getFieldsValue: vi.fn(), + setFieldsValue: vi.fn(), + }, + vi.fn(), + ], + Item: ({ children }: any) =>
{children}
, + }), + Steps: Object.assign( + ({ children, current, className }: any) => ( +
+ {children} +
+ ), + { + Step: ({ title }: any) =>
{title}
, + }, + ), + Button: ({ children, onClick, disabled, loading, ...props }: any) => ( + + ), + Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( + + ), +})); + +// Additional @tremor/react mocks (Button is already mocked globally) +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Text: ({ children, className }: any) => {children}, + Title: ({ children }: any) =>

{children}

, + Badge: ({ children, color, size }: any) => ( + + {children} + + ), + }; +}); + +describe("MakeMCPPublicForm", () => { + const mockProps = { + visible: true, + onClose: vi.fn(), + accessToken: "test-token", + mcpHubData: [ + { + server_id: "server-1", + server_name: "Test Server 1", + description: "Description 1", + url: "http://example.com/server1", + transport: "http", + status: "active", + mcp_info: { is_public: false }, + allowed_tools: ["tool-1", "tool-2"], + auth_type: "bearer", + credentials: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user1", + teams: [], + mcp_access_groups: [], + extra_headers: [], + static_headers: {}, + args: [], + env: {}, + }, + { + server_id: "server-2", + server_name: "Test Server 2", + description: "Description 2", + url: "http://example.com/server2", + transport: "websocket", + status: "inactive", + mcp_info: { is_public: true }, + allowed_tools: [], + auth_type: "none", + credentials: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user2", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user2", + teams: [], + mcp_access_groups: [], + extra_headers: [], + static_headers: {}, + args: [], + env: {}, + }, + ] as MCPServerData[], + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("should render the component", () => { + render(); + + expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument(); + expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); + }); + + it("should initialize with correct state", () => { + render(); + + // Check that the component renders with the correct title and content + expect(screen.getByText("Make MCP Servers Public")).toBeInTheDocument(); + expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); + + // Check that all server checkboxes are present + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 servers + + // Check that the Next button is enabled (servers are preselected) + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should handle server selection and navigation", async () => { + render(); + + // Initially on step 1 + expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); + + // Select all servers using the select all checkbox + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Verify Next button is enabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + + // Click Next + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should move to step 2 + await waitFor(() => { + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); + }); + }); + + it("should submit selected servers successfully", async () => { + mockMakeMCPPublicCall.mockResolvedValueOnce({}); + + render(); + + // Select all servers + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Wait for navigation to complete + await waitFor(() => { + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", ["server-1", "server-2"]); + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should handle select all functionality", async () => { + render(); + + const checkboxes = screen.getAllByRole("checkbox"); + const selectAllCheckbox = checkboxes[0]; + + // Select all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be checked + checkboxes.forEach((checkbox) => { + expect(checkbox).toBeChecked(); + }); + + // Deselect all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be unchecked except the indeterminate state + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it("should show error when no servers selected", async () => { + render(); + + // Deselect all servers first + const checkboxes = screen.getAllByRole("checkbox"); + await act(async () => { + fireEvent.click(checkboxes[0]); // Click select all to select all + fireEvent.click(checkboxes[0]); // Click select all again to deselect all + }); + + // Try to go to next step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should stay on same step + expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); + }); + + it("should display empty state when no servers are available", () => { + const emptyProps = { + ...mockProps, + mcpHubData: [] as MCPServerData[], + }; + + render(); + + expect(screen.getByText("No MCP servers available.")).toBeInTheDocument(); + + // Select All checkbox should be disabled + const selectAllCheckbox = screen.getByLabelText("Select All"); + expect(selectAllCheckbox).toBeDisabled(); + + // Next button should be disabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toBeDisabled(); + }); + + it("should handle Cancel button functionality", async () => { + render(); + + // Click Cancel button + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await act(async () => { + fireEvent.click(cancelButton); + }); + + // Should call onClose + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + it("should handle Previous button functionality", async () => { + render(); + + // Navigate to step 1 + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Verify we're on step 1 + await waitFor(() => { + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); + }); + + // Click Previous button + const previousButton = screen.getByRole("button", { name: "Previous" }); + await act(async () => { + fireEvent.click(previousButton); + }); + + // Should go back to step 0 + expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); + }); + + it("should handle individual server selection", async () => { + render(); + + // Get all checkboxes (select all + individual servers) + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 servers + + // Initially, server-2 should be selected (it's already public) + const server1Checkbox = checkboxes[1]; // First server checkbox + const server2Checkbox = checkboxes[2]; // Second server checkbox + + expect(server2Checkbox).toBeChecked(); // server-2 is already public + + // Select server-1 + await act(async () => { + fireEvent.click(server1Checkbox); + }); + + expect(server1Checkbox).toBeChecked(); + expect(server2Checkbox).toBeChecked(); + + // Deselect server-2 + await act(async () => { + fireEvent.click(server2Checkbox); + }); + + expect(server1Checkbox).toBeChecked(); + expect(server2Checkbox).not.toBeChecked(); + + // Select all should be indeterminate now + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); + + it("should display tools overflow text when server has more than 3 tools", () => { + const serverWithManyTools = { + ...mockProps.mcpHubData[0], + allowed_tools: ["tool-1", "tool-2", "tool-3", "tool-4", "tool-5"], + }; + + const propsWithManyTools = { + ...mockProps, + mcpHubData: [serverWithManyTools], + }; + + render(); + + // Should show first 3 tools as badges + expect(screen.getByText("tool-1")).toBeInTheDocument(); + expect(screen.getByText("tool-2")).toBeInTheDocument(); + expect(screen.getByText("tool-3")).toBeInTheDocument(); + + // Should show "+2 more" text for the remaining tools + expect(screen.getByText("+2 more")).toBeInTheDocument(); + }); + + it("should handle submit error properly", async () => { + const errorMessage = "Network error"; + mockMakeMCPPublicCall.mockRejectedValueOnce(new Error(errorMessage)); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Should handle error and show error notification + await waitFor(() => { + expect(mockMakeMCPPublicCall).toHaveBeenCalledWith("test-token", ["server-2"]); + }); + + // Should not call onSuccess or onClose on error + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + }); + + it("should show loading state during submit", async () => { + let resolvePromise: (value: any) => void = () => {}; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockMakeMCPPublicCall.mockReturnValueOnce(pendingPromise); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Check loading state + expect(submitButton).toHaveAttribute("data-loading", "true"); + expect(submitButton).toBeDisabled(); + + // Resolve the promise + resolvePromise({}); + await waitFor(() => { + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should not render modal when visible is false", () => { + const invisibleProps = { + ...mockProps, + visible: false, + }; + + render(); + + // Modal should not be rendered + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument(); + }); + + it("should preselect already public servers when modal opens", () => { + // Test data where one server is public and one is not + const mixedPublicProps = { + ...mockProps, + mcpHubData: [ + { + server_id: "server-1", + server_name: "Test Server 1", + description: "Description 1", + url: "http://example.com/server1", + transport: "http", + status: "active", + mcp_info: { is_public: false }, // Not public + allowed_tools: [], + auth_type: "bearer", + credentials: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user1", + teams: [], + mcp_access_groups: [], + extra_headers: [], + static_headers: {}, + args: [], + env: {}, + }, + { + server_id: "server-2", + server_name: "Test Server 2", + description: "Description 2", + url: "http://example.com/server2", + transport: "websocket", + status: "inactive", + mcp_info: { is_public: true }, // Already public + allowed_tools: [], + auth_type: "none", + credentials: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user2", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user2", + teams: [], + mcp_access_groups: [], + extra_headers: [], + static_headers: {}, + args: [], + env: {}, + }, + { + server_id: "server-3", + server_name: "Test Server 3", + description: "Description 3", + url: "http://example.com/server3", + transport: "sse", + status: "healthy", + mcp_info: { is_public: true }, // Already public + allowed_tools: [], + auth_type: "oauth", + credentials: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: "user3", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user3", + teams: [], + mcp_access_groups: [], + extra_headers: [], + static_headers: {}, + args: [], + env: {}, + }, + ] as MCPServerData[], + }; + + render(); + + // Check that the correct checkboxes are selected + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(4); // Select all + 3 servers + + // server-2 and server-3 should be checked (they're already public) + const server1Checkbox = checkboxes[1]; + const server2Checkbox = checkboxes[2]; + const server3Checkbox = checkboxes[3]; + + expect(server1Checkbox).not.toBeChecked(); // server-1 is not public + expect(server2Checkbox).toBeChecked(); // server-2 is public + expect(server3Checkbox).toBeChecked(); // server-3 is public + + // Select all should be indeterminate + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/make_mcp_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/AIHub/forms/make_mcp_public_form.tsx rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx new file mode 100644 index 00000000000..2b57535f3ad --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -0,0 +1,557 @@ +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import MakeModelPublicForm from "./MakeModelPublicForm"; + +interface ModelGroupInfo { + model_group: string; + providers: string[]; + max_input_tokens?: number; + max_output_tokens?: number; + input_cost_per_token?: number; + output_cost_per_token?: number; + mode?: string; + tpm?: number; + rpm?: number; + supports_parallel_function_calling: boolean; + supports_vision: boolean; + supports_function_calling: boolean; + supported_openai_params?: string[]; + is_public_model_group: boolean; + [key: string]: any; +} + +// Mock the networking function +vi.mock("../../networking", () => ({ + makeModelGroupPublic: vi.fn(), +})); + +// Import the mocked function +import { makeModelGroupPublic } from "../../networking"; +const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic); + +// Mock antd components +vi.mock("antd", () => ({ + Modal: ({ open, title, children, onCancel, footer }: any) => + open ? ( +
+
{title}
+ {children} + {footer} +
+ ) : null, + Form: Object.assign(({ children, form }: any) =>
{children}
, { + useForm: () => [ + { + resetFields: vi.fn(), + validateFields: vi.fn(), + getFieldsValue: vi.fn(), + setFieldsValue: vi.fn(), + }, + vi.fn(), + ], + Item: ({ children }: any) =>
{children}
, + }), + Steps: Object.assign( + ({ children, current, className }: any) => ( +
+ {children} +
+ ), + { + Step: ({ title }: any) =>
{title}
, + }, + ), + Button: ({ children, onClick, disabled, loading, ...props }: any) => ( + + ), + Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( + + ), +})); + +// Mock @tremor/react components +vi.mock("@tremor/react", () => ({ + Text: ({ children, className }: any) => {children}, + Title: ({ children }: any) =>

{children}

, + Badge: ({ children, color, size }: any) => ( + + {children} + + ), +})); + +// Mock ModelFilters component +vi.mock("../../model_filters", () => ({ + default: ({ onFilteredDataChange, modelHubData }: any) => ( +
+ +
+ ), +})); + +// Mock NotificationsManager +vi.mock("../../molecules/notifications_manager", () => ({ + default: { + fromBackend: vi.fn(), + success: vi.fn(), + }, +})); + +describe("MakeModelPublicForm", () => { + const mockProps = { + visible: true, + onClose: vi.fn(), + accessToken: "test-token", + modelHubData: [ + { + model_group: "gpt-4", + providers: ["openai"], + max_input_tokens: 8192, + max_output_tokens: 4096, + input_cost_per_token: 0.03, + output_cost_per_token: 0.06, + mode: "chat", + tpm: 10000, + rpm: 200, + supports_parallel_function_calling: true, + supports_vision: false, + supports_function_calling: true, + supported_openai_params: ["temperature", "max_tokens"], + is_public_model_group: false, + }, + { + model_group: "gpt-3.5-turbo", + providers: ["openai"], + max_input_tokens: 4096, + max_output_tokens: 2048, + input_cost_per_token: 0.0015, + output_cost_per_token: 0.002, + mode: "chat", + tpm: 60000, + rpm: 3500, + supports_parallel_function_calling: false, + supports_vision: false, + supports_function_calling: true, + supported_openai_params: ["temperature", "max_tokens"], + is_public_model_group: true, + }, + ] as ModelGroupInfo[], + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("should render the component", () => { + render(); + + expect(screen.getByText("Make Models Public")).toBeInTheDocument(); + expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); + }); + + it("should initialize with correct state", () => { + render(); + + // Check that the component renders with the correct title and content + expect(screen.getByText("Make Models Public")).toBeInTheDocument(); + expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); + + // Check that all model checkboxes are present + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 models + + // Check that the Next button is enabled (models are preselected) + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + }); + + it("should handle model selection and navigation", async () => { + render(); + + // Initially on step 1 + expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); + + // Select all models using the select all checkbox + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Verify Next button is enabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).not.toBeDisabled(); + + // Click Next + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should move to step 2 + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + }); + + it("should submit selected models successfully", async () => { + mockMakeModelGroupPublic.mockResolvedValueOnce({}); + + render(); + + // Select all models + const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Wait for navigation to complete + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockMakeModelGroupPublic).toHaveBeenCalledWith("test-token", ["gpt-4", "gpt-3.5-turbo"]); + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should handle select all functionality", async () => { + render(); + + const checkboxes = screen.getAllByRole("checkbox"); + const selectAllCheckbox = checkboxes[0]; + + // Select all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be checked + checkboxes.forEach((checkbox) => { + expect(checkbox).toBeChecked(); + }); + + // Deselect all + await act(async () => { + fireEvent.click(selectAllCheckbox); + }); + + // All checkboxes should be unchecked except the indeterminate state + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it("should show error when no models selected", async () => { + render(); + + // Deselect all models first + const checkboxes = screen.getAllByRole("checkbox"); + await act(async () => { + fireEvent.click(checkboxes[0]); // Click select all to select all + fireEvent.click(checkboxes[0]); // Click select all again to deselect all + }); + + // Try to go to next step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Should stay on same step + expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); + }); + + it("should display empty state when no models are available", () => { + const emptyProps = { + ...mockProps, + modelHubData: [] as ModelGroupInfo[], + }; + + render(); + + expect(screen.getByText("No models match the current filters.")).toBeInTheDocument(); + + // Select All checkbox should be disabled + const selectAllCheckbox = screen.getByLabelText("Select All"); + expect(selectAllCheckbox).toBeDisabled(); + + // Next button should be disabled + const nextButton = screen.getByRole("button", { name: "Next" }); + expect(nextButton).toBeDisabled(); + }); + + it("should handle Cancel button functionality", async () => { + render(); + + // Click Cancel button + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await act(async () => { + fireEvent.click(cancelButton); + }); + + // Should call onClose + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + it("should handle Previous button functionality", async () => { + render(); + + // Navigate to step 1 + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + // Verify we're on step 1 + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + + // Click Previous button + const previousButton = screen.getByRole("button", { name: "Previous" }); + await act(async () => { + fireEvent.click(previousButton); + }); + + // Should go back to step 0 + expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); + }); + + it("should handle individual model selection", async () => { + render(); + + // Get all checkboxes (select all + individual models) + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); // Select all + 2 models + + // Initially, gpt-3.5-turbo should be selected (it's already public) + const gpt4Checkbox = checkboxes[1]; // First model checkbox + const gpt35Checkbox = checkboxes[2]; // Second model checkbox + + expect(gpt35Checkbox).toBeChecked(); // gpt-3.5-turbo is already public + + // Select gpt-4 + await act(async () => { + fireEvent.click(gpt4Checkbox); + }); + + expect(gpt4Checkbox).toBeChecked(); + expect(gpt35Checkbox).toBeChecked(); + + // Deselect gpt-3.5-turbo + await act(async () => { + fireEvent.click(gpt35Checkbox); + }); + + expect(gpt4Checkbox).toBeChecked(); + expect(gpt35Checkbox).not.toBeChecked(); + + // Select all should be indeterminate now + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); + + it("should display model badges and information", () => { + render(); + + // Should show model names + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + + // Should show mode badges + expect(screen.getAllByText("chat")).toHaveLength(2); + + // Should show provider badges + expect(screen.getAllByText("openai")).toHaveLength(2); + }); + + it("should handle submit error properly", async () => { + const errorMessage = "Network error"; + mockMakeModelGroupPublic.mockRejectedValueOnce(new Error(errorMessage)); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Should handle error and show error notification + await waitFor(() => { + expect(mockMakeModelGroupPublic).toHaveBeenCalledWith("test-token", ["gpt-3.5-turbo"]); + }); + + // Should not call onSuccess or onClose on error + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + }); + + it("should show loading state during submit", async () => { + let resolvePromise: (value: any) => void = () => {}; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockMakeModelGroupPublic.mockReturnValueOnce(pendingPromise); + + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + + // Submit + const submitButton = screen.getByRole("button", { name: "Make Public" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + // Check loading state + expect(submitButton).toHaveAttribute("data-loading", "true"); + expect(submitButton).toBeDisabled(); + + // Resolve the promise + resolvePromise({}); + await waitFor(() => { + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + }); + + it("should not render modal when visible is false", () => { + const invisibleProps = { + ...mockProps, + visible: false, + }; + + render(); + + // Modal should not be rendered + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument(); + }); + + it("should preselect already public models when modal opens", () => { + // Test data where one model is public and one is not + const mixedPublicProps = { + ...mockProps, + modelHubData: [ + { + model_group: "private-model", + providers: ["openai"], + is_public_model_group: false, + mode: "chat", + }, + { + model_group: "public-model", + providers: ["anthropic"], + is_public_model_group: true, + mode: "completion", + }, + { + model_group: "another-public-model", + providers: ["cohere"], + is_public_model_group: true, + mode: "chat", + }, + ] as ModelGroupInfo[], + }; + + render(); + + // Check that the correct checkboxes are selected + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(4); // Select all + 3 models + + // private-model should not be checked, public models should be checked + const privateModelCheckbox = checkboxes[1]; + const publicModelCheckbox = checkboxes[2]; + const anotherPublicModelCheckbox = checkboxes[3]; + + expect(privateModelCheckbox).not.toBeChecked(); // private-model is not public + expect(publicModelCheckbox).toBeChecked(); // public-model is public + expect(anotherPublicModelCheckbox).toBeChecked(); // another-public-model is public + + // Select all should be indeterminate + const selectAllCheckbox = checkboxes[0]; + expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + }); + + it("should show selected count", () => { + render(); + + // Should show that 1 model is selected (gpt-3.5-turbo is preselected) + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("model selected")).toBeInTheDocument(); + }); + + it("should show confirmation step with selected models", async () => { + render(); + + // Navigate to confirm step + const nextButton = screen.getByRole("button", { name: "Next" }); + await act(async () => { + fireEvent.click(nextButton); + }); + + await waitFor(() => { + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); + }); + + // Should show the selected model + expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument(); + + // Should show the warning message + expect(screen.getByText(/Warning:/)).toBeInTheDocument(); + expect(screen.getByText(/model_hub_table/)).toBeInTheDocument(); + + // Should show total count (already verified by checking the presence of the confirmation step) + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/make_model_public_form.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx similarity index 100% rename from ui/litellm-dashboard/src/components/AIHub/forms/make_model_public_form.tsx rename to ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx