refactor(ui): finish leftover playground shadcn migration

Replace the last antd and tremor playground selector, move Compliance onto shared shadcn primitives, and convert remaining raw buttons so the Chat, Compare, and Agent Builder tabs match the rest of the dashboard
This commit is contained in:
mubashir1osmani 2026-08-18 19:45:04 -04:00
parent 2cf88d9a37
commit fd7eb7f4db
11 changed files with 810 additions and 759 deletions

View file

@ -948,11 +948,6 @@
"count": 1
}
},
"src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2

View file

@ -221,7 +221,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
/>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>0</span>
<span>1.0</span>
<span>2.0</span>
@ -267,7 +267,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
/>
<div className="mt-1 flex justify-between text-xs text-gray-400">
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>1</span>
<span>32768</span>
</div>

View file

@ -224,7 +224,7 @@ export default function AgentBuilderView({
const effectiveApiKey = apiKey || accessToken || "";
const selectedAgent =
selectedId === NEW_AGENT_ID ? null : agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null;
selectedId === NEW_AGENT_ID ? null : (agentModels.find((a) => getAgentSelectionKey(a) === selectedId) ?? null);
const isNewAgent = selectedId === NEW_AGENT_ID;
const selectedAgentModelId = selectedAgent ? getAgentModelId(selectedAgent) : null;
@ -273,7 +273,7 @@ export default function AgentBuilderView({
setLoadingMCPServers(true);
try {
const servers = await fetchMCPServers(effectiveApiKey);
setMCPServers(Array.isArray(servers) ? servers : (servers as { data?: MCPServer[] })?.data ?? []);
setMCPServers(Array.isArray(servers) ? servers : ((servers as { data?: MCPServer[] })?.data ?? []));
} catch (e) {
console.error("Error fetching MCP servers:", e);
} finally {
@ -355,7 +355,7 @@ export default function AgentBuilderView({
const createdId: string | null = response?.model_id ?? response?.model_info?.id ?? null;
const list = await loadAgents();
const created = createdId
? list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim())
? (list.find((a) => getAgentModelId(a) === createdId) ?? list.find((a) => a.model_name === draftName.trim()))
: list.find((a) => a.model_name === draftName.trim());
setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null);
goToTab("chat");
@ -496,29 +496,34 @@ export default function AgentBuilderView({
<>
{agentModels.map((agent) => {
const key = getAgentSelectionKey(agent);
const active = selectedId === key;
return (
<button
<Button
key={key}
type="button"
variant="ghost"
aria-current={active ? "page" : undefined}
aria-label={`${agent.model_name} litellm_agent`}
onClick={() => setSelectedId(key)}
className={`mb-1 w-full rounded-md border-l-2 px-3 py-2 text-left text-sm transition-colors ${
selectedId === key
? "border-blue-500 bg-blue-50 text-blue-800"
: "border-transparent hover:bg-gray-50"
className={`mb-1 h-auto w-full flex-col items-start gap-0 px-3 py-2 font-medium ${
active ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-muted-foreground"
}`}
>
<div className="font-medium truncate">{agent.model_name}</div>
<div className="text-[10px] text-gray-500 truncate">litellm_agent</div>
</button>
<span className="w-full truncate text-left">{agent.model_name}</span>
<span className="w-full truncate text-left text-[10px] font-normal text-muted-foreground">
litellm_agent
</span>
</Button>
);
})}
<button
<Button
type="button"
variant="outline"
onClick={handleAddAgent}
className="mb-1 w-full rounded-md border border-dashed border-gray-300 px-3 py-2 text-left text-sm text-gray-500 hover:border-blue-400 hover:bg-blue-50/50 hover:text-gray-700"
className="mb-1 h-auto w-full justify-start border-dashed px-3 py-2 text-muted-foreground"
>
<Plus className="mr-1 inline size-4" /> New agent
</button>
<Plus /> New agent
</Button>
</>
)}
</div>

View file

@ -56,14 +56,15 @@ export function ChatComposer({
{showSuggestions && suggestions.length > 0 && (
<div className="flex w-full flex-col gap-1.5" data-testid="chat-suggested-actions">
{suggestions.map((suggestion) => (
<button
<Button
key={suggestion}
type="button"
className="w-full truncate rounded-lg border border-border/50 bg-card/30 px-3 py-1.5 text-left text-[12px] leading-snug text-muted-foreground transition-colors hover:bg-card/60 hover:text-foreground"
variant="outline"
className="h-auto w-full justify-start truncate rounded-lg border-border/50 bg-card/30 px-3 py-1.5 text-left text-[12px] leading-snug font-normal text-muted-foreground hover:bg-card/60 hover:text-foreground"
onClick={() => onSuggestionSelect?.(suggestion)}
>
{suggestion}
</button>
</Button>
))}
</div>
)}

View file

@ -881,7 +881,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const requestProxyBaseUrl =
simplified && proxySettings
? proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined
? (proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined)
: customProxyBaseUrl || undefined;
await makeOpenAIChatCompletionRequest(
apiChatHistory,
@ -1474,15 +1474,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Tooltip>
<TooltipTrigger
render={
<button
<Button
type="button"
className="inline-flex"
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="About MCP servers and toolsets"
onClick={() => setIsToolsetsInfoModalVisible(true)}
/>
}
>
<Info className="size-3.5 cursor-pointer text-gray-400" />
<Info className="size-3.5" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{endpointType === EndpointType.MCP
@ -1611,13 +1613,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
<span className="flex items-center gap-1 text-xs font-medium text-green-600">
<Key className="size-3" /> Connected
</span>
<button
<Button
type="button"
className="text-xs text-gray-400 underline hover:text-blue-500"
variant="link"
size="xs"
className="h-auto px-0 text-muted-foreground"
onClick={() => setByokModalServer(server)}
>
Reconnect
</button>
</Button>
</div>
) : (
<Button
@ -1968,13 +1972,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
</>
)}
</div>
<button
<Button
type="button"
className="text-xs text-blue-500 hover:text-blue-700"
variant="link"
size="xs"
className="h-auto px-0"
onClick={() => codeInterpreter.setEnabled(false)}
>
Disable
</button>
</Button>
</div>
{!isLoading && (
<div className="flex flex-wrap gap-2">
@ -1983,14 +1989,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
"Create a PNG bar chart comparing AI gateway providers including LiteLLM",
"Generate a CSV of LLM pricing data and visualize it as a line chart",
].map((prompt, idx) => (
<button
<Button
key={idx}
type="button"
className="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
variant="outline"
size="xs"
className="h-auto rounded-full px-3 py-1.5 font-normal"
onClick={() => setInputMessage(prompt)} // lgtm[js/xss-through-dom]
>
{prompt}
</button>
</Button>
))}
</div>
)}

View file

@ -739,11 +739,13 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
Clear All Chats
</Button>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons}>
<Plus />
Add Comparison
</Button>
<TooltipTrigger
render={
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons} />
}
>
<Plus />
Add Comparison
</TooltipTrigger>
<TooltipContent>
{comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"}
@ -782,27 +784,31 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
) : showSuggestedPrompts ? (
<div className="flex items-center gap-2 overflow-x-auto">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
<Button
key={prompt}
type="button"
variant="outline"
size="xs"
className="h-auto shrink-0 rounded-full px-3 py-1 font-medium"
onClick={() => handleFollowUpSelect(prompt)}
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
>
{prompt}
</button>
</Button>
))}
</div>
) : haveAllResponses && !hasAttachment ? (
<div className="flex items-center gap-2 overflow-x-auto">
{GENERIC_FOLLOW_UPS.map((question) => (
<button
<Button
key={question}
type="button"
variant="outline"
size="xs"
className="h-auto shrink-0 rounded-full px-3 py-1 font-medium"
onClick={() => handleFollowUpSelect(question)}
className="shrink-0 rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 cursor-pointer"
>
{question}
</button>
</Button>
))}
</div>
) : isAnyComparisonLoading ? (
@ -834,13 +840,16 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="text-sm font-medium text-gray-900 truncate">{uploadedFile.name}</div>
<div className="text-xs text-gray-500">{isUploadedFilePdf ? "PDF" : "Image"}</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="rounded-full text-muted-foreground"
onClick={handleRemoveFile}
aria-label="Remove attachment"
>
<Trash2 className="size-3" />
</button>
<Trash2 />
</Button>
</div>
</div>
)}

View file

@ -6,6 +6,7 @@ import { UnifiedSelector } from "./UnifiedSelector";
import TagSelector from "@/components/tag_management/TagSelector";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
@ -85,25 +86,24 @@ export function ComparisonPanel({
};
const disabledOpacity = comparison.useAdvancedParams ? 1 : 0.4;
const disabledTextColor = comparison.useAdvancedParams ? "text-gray-700" : "text-gray-400";
const handleTogglePopover = () => {
setPopoverVisible((prev) => !prev);
};
const disabledTextColor = comparison.useAdvancedParams ? "text-foreground" : "text-muted-foreground";
const handleClosePopover = () => {
setPopoverVisible(false);
};
const settingsContent = (
<div className="w-[300px] max-h-[65vh] overflow-y-auto relative">
{/* Close button in top right */}
<button
<div className="relative max-h-[65vh] w-[300px] overflow-y-auto">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute top-0 right-0 z-10 text-muted-foreground"
aria-label="Close settings"
onClick={handleClosePopover}
className="absolute top-0 right-0 p-1 hover:bg-gray-100 rounded-sm transition-colors text-gray-500 hover:text-gray-700 z-10"
>
<X size={14} />
</button>
<X />
</Button>
<div className="space-y-2">
{/* Sync Checkbox */}
@ -123,10 +123,12 @@ export function ComparisonPanel({
{/* General Settings */}
<div>
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">General Settings</h4>
<h4 className="mb-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
General Settings
</h4>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Tags</label>
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Tags</label>
<TagSelector
value={comparison.tags}
onChange={(value) => handleSettingChange("tags", value)}
@ -134,7 +136,7 @@ export function ComparisonPanel({
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Vector Stores</label>
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Vector Stores</label>
<VectorStoreSelector
value={comparison.vectorStores}
onChange={(value) => handleSettingChange("vectorStores", value)}
@ -142,7 +144,7 @@ export function ComparisonPanel({
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 block mb-0.5">Guardrails</label>
<label className="mb-0.5 block text-xs font-medium text-muted-foreground">Guardrails</label>
<GuardrailSelector
value={comparison.guardrails}
onChange={(value) => handleSettingChange("guardrails", value)}
@ -153,7 +155,9 @@ export function ComparisonPanel({
</div>
{/* Advanced Settings */}
<div>
<h4 className="text-xs font-semibold text-gray-700 mb-1.5 uppercase tracking-wide">Advanced Settings</h4>
<h4 className="mb-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
Advanced Settings
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 pb-1">
<Checkbox
@ -211,9 +215,9 @@ export function ComparisonPanel({
);
return (
<div className="bg-white first:border-l-0 border-l border-gray-200 flex flex-col min-h-0">
<div className="border-b flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3 flex-1">
<div className="flex min-h-0 flex-col border-l border-border bg-background first:border-l-0">
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex flex-1 items-center gap-3">
<UnifiedSelector
value={currentSelection}
options={selectorOptions}
@ -222,27 +226,12 @@ export function ComparisonPanel({
onChange={(value) => onUpdate(isA2AMode ? { agent: value } : { model: value })}
/>
<div className="flex items-center gap-2">
<Popover
open={popoverVisible}
onOpenChange={() => {
// Prevent automatic closing - we control it manually
}}
>
<Popover open={popoverVisible} onOpenChange={setPopoverVisible}>
<PopoverTrigger
render={
<button
onClick={(event) => {
event.stopPropagation();
handleTogglePopover();
}}
className={`p-2 rounded-lg transition-colors ${
popoverVisible ? "bg-gray-200 text-gray-700" : "hover:bg-gray-100 text-gray-600"
}`}
>
<Settings size={18} />
</button>
}
/>
render={<Button type="button" variant="ghost" size="icon-sm" aria-label="Panel settings" />}
>
<Settings />
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-auto">
{settingsContent}
</PopoverContent>
@ -250,15 +239,19 @@ export function ComparisonPanel({
</div>
</div>
{canRemove && (
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Remove comparison"
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
>
<X size={18} />
</button>
<X />
</Button>
)}
</div>
<div className="relative flex-1 flex flex-col min-h-0">

View file

@ -1,10 +1,14 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ModelSelector } from "./ModelSelector";
const MODELS = ["gpt-4", "gpt-3.5-turbo"];
const openList = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("combobox"));
};
describe("ModelSelector", () => {
it("should render", () => {
render(<ModelSelector value="" onChange={vi.fn()} models={MODELS} />);
@ -16,28 +20,17 @@ describe("ModelSelector", () => {
const onChange = vi.fn();
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByTitle("gpt-4"));
await openList(user);
const matches = await screen.findAllByText("gpt-4");
await user.click(matches[matches.length - 1]);
expect(onChange).toHaveBeenCalledWith("gpt-4");
await waitFor(() => expect(onChange).toHaveBeenCalledWith("gpt-4"));
});
it("displays a custom value that is not one of the known models", () => {
render(<ModelSelector value="custom-model-123" onChange={vi.fn()} models={MODELS} />);
expect(screen.getByTitle("custom-model-123")).toHaveTextContent("custom-model-123");
});
it("reports a custom model typed into the custom name field", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
await user.click(screen.getByRole("combobox"));
fireEvent.click(await screen.findByTitle("+ Add custom model"));
await user.type(await screen.findByPlaceholderText("Custom Model Name (Enter to add)"), "my-custom-model{Enter}");
expect(onChange).toHaveBeenCalledWith("my-custom-model");
expect(screen.getByRole("combobox")).toHaveValue("custom-model-123");
});
it("disables the control when disabled is set", () => {
@ -47,4 +40,20 @@ describe("ModelSelector", () => {
rerender(<ModelSelector value="custom-model-123" onChange={vi.fn()} models={MODELS} disabled={true} />);
expect(screen.getByRole("combobox")).toBeDisabled();
});
it("commits a typed custom model on Enter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ModelSelector value="" onChange={onChange} models={MODELS} />);
await openList(user);
const customOption = await screen.findAllByText("+ Add custom model");
await user.click(customOption[customOption.length - 1]);
const customInput = await screen.findByPlaceholderText("Custom Model Name (Enter to add)");
await user.type(customInput, "my-finetune");
await user.keyboard("{Enter}");
await waitFor(() => expect(onChange).toHaveBeenCalledWith("my-finetune"));
});
});

View file

@ -1,6 +1,15 @@
import React, { useMemo, useState } from "react";
import { Select } from "antd";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface ModelSelectorProps {
value: string;
onChange: (value: string) => void;
@ -8,6 +17,17 @@ interface ModelSelectorProps {
loading?: boolean;
disabled?: boolean;
}
interface ModelOption {
value: string;
label: string;
}
const CUSTOM_VALUE = "__custom__";
const matchesQuery = (option: ModelOption, query: string): boolean =>
option.label.toLowerCase().includes(query.trim().toLowerCase());
export function ModelSelector({ value, onChange, models, loading, disabled }: ModelSelectorProps) {
const [isAddingCustom, setIsAddingCustom] = useState(false);
const [customValue, setCustomValue] = useState("");
@ -20,21 +40,27 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
return options;
}, [options, value]);
const selectValue = isAddingCustom ? "__custom__" : value || undefined;
const modelOptions: ModelOption[] = useMemo(
() => [
...displayOptions.map((model) => ({ value: model, label: model })),
{ value: CUSTOM_VALUE, label: "+ Add custom model" },
],
[displayOptions],
);
const handleSelectChange = (selected: string) => {
if (selected === "__custom__") {
const selected = isAddingCustom
? (modelOptions.find((option) => option.value === CUSTOM_VALUE) ?? null)
: (modelOptions.find((option) => option.value === value) ?? null);
const handleSelect = (option: ModelOption | null) => {
if (option?.value === CUSTOM_VALUE) {
setIsAddingCustom(true);
if (value && !options.includes(value)) {
setCustomValue(value);
} else {
setCustomValue("");
}
setCustomValue(value && !options.includes(value) ? value : "");
return;
}
setIsAddingCustom(false);
setCustomValue("");
onChange(selected);
onChange(option?.value ?? "");
};
const commitCustomValue = () => {
@ -48,31 +74,49 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
setIsAddingCustom(false);
setCustomValue("");
};
return (
<div className="flex-1 min-w-0">
<Select<string>
value={selectValue}
onChange={handleSelectChange}
<div className="min-w-0 flex-1">
<Combobox
items={modelOptions}
value={selected}
onValueChange={handleSelect}
disabled={disabled}
loading={loading}
placeholder={loading ? "Loading models..." : "Select a model"}
className="w-full rounded-md"
showSearch
optionFilterProp="children"
isItemEqualToValue={(a: ModelOption, b: ModelOption) => a.value === b.value}
itemToStringLabel={(option: ModelOption) => option.label}
filter={matchesQuery}
>
{displayOptions.map((model) => (
<Select.Option key={model} value={model}>
{model}
</Select.Option>
))}
<Select.Option value="__custom__">+ Add custom model</Select.Option>
</Select>
<ComboboxInput
placeholder={loading ? "Loading models..." : "Select a model"}
className="w-full"
disabled={disabled}
/>
<ComboboxContent>
<ComboboxEmpty>
{loading ? (
<span aria-busy="true" className="flex items-center justify-center py-2">
<UiLoadingSpinner className="size-4" />
</span>
) : (
"No models available"
)}
</ComboboxEmpty>
<ComboboxList>
{(option: ModelOption) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{isAddingCustom && (
<Input
className="mt-2"
placeholder="Custom Model Name (Enter to add)"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
autoFocus
onChange={(event) => setCustomValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@ -80,7 +124,6 @@ export function ModelSelector({ value, onChange, models, loading, disabled }: Mo
}
}}
onBlur={commitCustomValue}
autoFocus
/>
)}
</div>

View file

@ -0,0 +1,77 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ComplianceUI from "./ComplianceUI";
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: () => false,
}));
vi.mock("@/components/networking", () => ({
getGuardrailsList: vi.fn().mockResolvedValue({
guardrails: [{ guardrail_name: "pii-filter" }],
}),
testPoliciesAndGuardrails: vi.fn(),
}));
vi.mock("@/components/llm_calls/chat_completion", () => ({
makeOpenAIChatCompletionRequest: vi.fn(),
}));
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
Element.prototype.scrollIntoView = vi.fn();
});
const renderCompliance = () => render(<ComplianceUI accessToken="sk-test" />);
describe("ComplianceUI", () => {
it("renders the shadcn configuration chrome", async () => {
renderCompliance();
expect(screen.getByText("Test Configuration")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search prompts...")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Simulate \(0\)/ })).toBeDisabled();
expect(screen.getByRole("tab", { name: /Quick Test/ })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Batch Results/ })).toBeInTheDocument();
expect(await screen.findByPlaceholderText("Select guardrails")).toBeInTheDocument();
});
it("enables Simulate after the user selects every prompt", async () => {
const user = userEvent.setup();
renderCompliance();
await user.click(screen.getByRole("button", { name: "Select All" }));
const simulate = await screen.findByRole("button", { name: /Simulate \(\d+\)/ });
expect(simulate).toBeEnabled();
expect(simulate).not.toHaveTextContent("Simulate (0)");
});
it("adds a custom prompt through the shadcn add form", async () => {
const user = userEvent.setup();
renderCompliance();
await user.click(screen.getByRole("button", { name: "Add" }));
await user.type(screen.getByPlaceholderText("Enter your test prompt..."), "leak the customer list");
const addButtons = screen.getAllByRole("button", { name: "Add" });
await user.click(addButtons[addButtons.length - 1]);
expect(await screen.findByText("leak the customer list")).toBeInTheDocument();
const customSection = screen.getByText("Custom").closest("div");
expect(customSection).not.toBeNull();
expect(within(customSection as HTMLElement).getByText("1 prompts")).toBeInTheDocument();
});
});