Files
fusion/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx
Fusion 74989993a3 feat(FN-2964): merge fusion/fn-2964
Commits merged:
- test(FN-2964): complete Step 2 — cover custom providers disclosure behavior
- feat(FN-2964): complete Step 1 — embed custom providers disclosure in onboarding
- fix(FN-2963): reuse shared onboarding disclosure for custom providers
- fix(FN-2963): address disclosure focus ring and mobile touch target
- fix(FN-2963): align custom providers disclosure with shared pattern
- feat(FN-2963): apply inline edit UX and tokenized custom provider styles
- fix(FN-2963): align custom providers UI with auth patterns
- fix(FN-2963): complete Step 5 — resolve lint gate in providers section
- test(FN-2963): complete Step 4 — add custom providers section tests
- feat(FN-2963): complete Step 3 — integrate custom providers section
- feat(FN-2963): complete Step 2 — add custom providers section styles
- feat(FN-2963): complete Step 1 — create custom providers section component
- feat(FN-2961): complete Step 6 — document custom provider settings
- test(FN-2961): complete Step 4 — cover custom provider routes
- feat(FN-2961): complete Step 3 — add custom provider API client
- feat(FN-2961): complete Step 2 — add custom provider CRUD routes
- feat(FN-2961): complete Step 1 — add custom provider core types

Files changed:
.../app/components/CustomProvidersSection.css      | 138 +++++++
 .../app/components/CustomProvidersSection.tsx      | 425 +++++++++++++++++++++
 .../app/components/ModelOnboardingModal.css        |  60 ---
 .../app/components/OnboardingDisclosure.css        |  67 ++++
 .../app/components/OnboardingDisclosure.tsx        |  38 ++
 .../__tests__/CustomProvidersSection.test.tsx      | 237 ++++++++++++
 .../__tests__/ModelOnboardingModal.test.tsx        |  67 +++-
 7 files changed, 967 insertions(+), 65 deletions(-)

Fusion-Task-Id: FN-2964
2026-04-29 23:39:18 -07:00

238 lines
9.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomProvidersSection } from "../CustomProvidersSection";
import type { CustomProvider } from "../../api";
vi.mock("../../api", () => ({
fetchCustomProviders: vi.fn(),
addCustomProvider: vi.fn(),
updateCustomProvider: vi.fn(),
deleteCustomProvider: vi.fn(),
}));
vi.mock("lucide-react", () => {
const ForwardRef = (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />;
return {
ChevronRight: ForwardRef,
Plus: ForwardRef,
Pencil: ForwardRef,
Trash2: ForwardRef,
Loader2: ForwardRef,
AlertCircle: ForwardRef,
};
});
import { addCustomProvider, deleteCustomProvider, fetchCustomProviders, updateCustomProvider } from "../../api";
const mockedFetchCustomProviders = vi.mocked(fetchCustomProviders);
const mockedAddCustomProvider = vi.mocked(addCustomProvider);
const mockedUpdateCustomProvider = vi.mocked(updateCustomProvider);
const mockedDeleteCustomProvider = vi.mocked(deleteCustomProvider);
const baseProvider: CustomProvider = {
id: "proxy-1",
name: "Proxy One",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
models: [{ id: "gpt-4", name: "gpt-4" }],
};
const secondProvider: CustomProvider = {
id: "proxy-2",
name: "Proxy Two",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic-proxy.example.com/v1",
models: [{ id: "claude-3-7-sonnet", name: "claude-3-7-sonnet" }],
};
async function openDisclosure(user = userEvent.setup()) {
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
return user;
}
describe("CustomProvidersSection", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedFetchCustomProviders.mockResolvedValue([] as CustomProvider[] & { providers: CustomProvider[] });
mockedAddCustomProvider.mockResolvedValue(baseProvider);
mockedUpdateCustomProvider.mockResolvedValue(baseProvider);
mockedDeleteCustomProvider.mockResolvedValue({ success: true });
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders disclosure trigger", () => {
render(<CustomProvidersSection />);
expect(screen.getByRole("button", { name: "Advanced: Custom Providers" })).toBeInTheDocument();
});
it("fetches providers on first open and shows empty state", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await waitFor(() => expect(mockedFetchCustomProviders).toHaveBeenCalledTimes(1));
expect(screen.getByText("No custom providers configured.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Add Custom Provider/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
await user.click(screen.getByRole("button", { name: "Advanced: Custom Providers" }));
expect(mockedFetchCustomProviders).toHaveBeenCalledTimes(1);
});
it("shows form fields when add is clicked", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
expect(screen.getByLabelText("Provider name")).toBeInTheDocument();
expect(screen.getByLabelText("API type")).toBeInTheDocument();
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("API key")).toBeInTheDocument();
expect(screen.getByLabelText("Available models")).toBeInTheDocument();
});
it("validates required fields", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Provider name is required.")).toBeInTheDocument();
expect(mockedAddCustomProvider).not.toHaveBeenCalled();
});
it("validates base url protocol", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy");
await user.type(screen.getByLabelText("Base URL"), "ftp://proxy");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Base URL must be a valid http/https URL.")).toBeInTheDocument();
await user.clear(screen.getByLabelText("Base URL"));
await user.type(screen.getByLabelText("Base URL"), "not-a-url");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(screen.getByText("Base URL must be a valid http/https URL.")).toBeInTheDocument();
});
it("adds provider with parsed models payload", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy Two");
await user.selectOptions(screen.getByLabelText("API type"), "openai-compatible");
await user.type(screen.getByLabelText("Base URL"), "https://proxy2.example.com/v1");
await user.type(screen.getByLabelText("API key"), "secret");
await user.type(screen.getByLabelText("Available models"), "gpt-4, gpt-4o-mini, ");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
await waitFor(() => {
expect(mockedAddCustomProvider).toHaveBeenCalledWith({
name: "Proxy Two",
apiType: "openai-compatible",
baseUrl: "https://proxy2.example.com/v1",
apiKey: "secret",
models: [
{ id: "gpt-4", name: "gpt-4" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
],
});
});
});
it("renders existing providers", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
render(<CustomProvidersSection />);
await openDisclosure();
expect(await screen.findByText("Proxy One")).toBeInTheDocument();
expect(screen.getByText("openai-compatible")).toBeInTheDocument();
expect(screen.getByText(/https:\/\/proxy\.example\.com\/v1/)).toBeInTheDocument();
});
it("edits provider inline and pre-populates models", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce(
[baseProvider, secondProvider] as CustomProvider[] & { providers: CustomProvider[] },
);
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(await screen.findByRole("button", { name: "Edit Proxy One" }));
const updatedField = screen.getByLabelText("Provider name");
expect(updatedField).toHaveValue("Proxy One");
expect(screen.getByLabelText("Available models")).toHaveValue("gpt-4");
expect(screen.getByText("Proxy Two")).toBeInTheDocument();
await user.clear(updatedField);
await user.type(updatedField, "Proxy Updated");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockedUpdateCustomProvider).toHaveBeenCalledWith("proxy-1", {
name: "Proxy Updated",
apiType: "openai-compatible",
baseUrl: "https://proxy.example.com/v1",
models: [{ id: "gpt-4", name: "gpt-4" }],
});
});
});
it("deletes provider when confirmed", async () => {
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
expect(window.confirm).toHaveBeenCalled();
await waitFor(() => expect(mockedDeleteCustomProvider).toHaveBeenCalledWith("proxy-1"));
});
it("does not delete provider when confirm is canceled", async () => {
vi.mocked(window.confirm).mockReturnValue(false);
mockedFetchCustomProviders.mockResolvedValueOnce([baseProvider] as CustomProvider[] & { providers: CustomProvider[] });
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(await screen.findByRole("button", { name: "Delete Proxy One" }));
expect(mockedDeleteCustomProvider).not.toHaveBeenCalled();
});
it("cancel closes form without API calls", async () => {
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByLabelText("Provider name")).not.toBeInTheDocument();
expect(mockedAddCustomProvider).not.toHaveBeenCalled();
expect(mockedUpdateCustomProvider).not.toHaveBeenCalled();
});
it("shows API error state on fetch failure", async () => {
mockedFetchCustomProviders.mockRejectedValueOnce(new Error("fetch failed"));
render(<CustomProvidersSection />);
await openDisclosure();
expect(await screen.findByText("fetch failed")).toBeInTheDocument();
});
it("shows API error state on add failure", async () => {
mockedAddCustomProvider.mockRejectedValueOnce(new Error("save failed"));
render(<CustomProvidersSection />);
const user = await openDisclosure();
await user.click(screen.getByRole("button", { name: /Add Custom Provider/ }));
await user.type(screen.getByLabelText("Provider name"), "Proxy Two");
await user.type(screen.getByLabelText("Base URL"), "https://proxy2.example.com/v1");
await user.click(screen.getByRole("button", { name: "Save Provider" }));
expect(await screen.findByText("save failed")).toBeInTheDocument();
});
});