Implement tests for LiteLLM component and enhance API configuration handling

- Added comprehensive tests for the LiteLLM component to validate API key and base URL handling during model refresh.
- Updated the LiteLLMProps type to be exported for better accessibility.
- Enhanced the SettingsView tests to ensure correct rendering and default values for LiteLLM configuration.
- Mocked ApiOptions in tests to inspect props and validate API configuration behavior.
This commit is contained in:
slytechnical 2025-05-19 18:44:24 -05:00
parent f92e4b9233
commit 3053678903
4 changed files with 266 additions and 4 deletions

View file

@ -41,8 +41,8 @@ jest.mock("vscrui", () => ({
// Mock @shadcn/ui components
jest.mock("@/components/ui", () => ({
Select: ({ children, value, onValueChange }: any) => (
<div className="select-mock">
Select: ({ children, value, onValueChange, ...rest }: any) => (
<div className="select-mock" data-testid={rest["data-testid"]}>
<select value={value} onChange={(e) => onValueChange && onValueChange(e.target.value)}>
{children}
</select>
@ -152,11 +152,13 @@ jest.mock("@src/components/ui/hooks/useSelectedModel", () => ({
if (apiConfiguration.apiModelId?.includes("thinking")) {
return {
provider: apiConfiguration.apiProvider,
id: apiConfiguration.apiModelId,
info: { thinking: true, contextWindow: 4000, maxTokens: 128000 },
}
} else {
return {
provider: apiConfiguration.apiProvider,
id: apiConfiguration.apiModelId,
info: { contextWindow: 4000 },
}
}

View file

@ -134,6 +134,14 @@ jest.mock("@/components/ui", () => ({
),
}))
// Mock ApiOptions to inspect its props
jest.mock("../ApiOptions", () => ({
__esModule: true,
default: jest.fn((props) => (
<div data-testid="api-options-mock" data-apiconfiguration={JSON.stringify(props.apiConfiguration)} />
)),
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage(
@ -369,13 +377,87 @@ describe("SettingsView - Sound Settings", () => {
describe("SettingsView - API Configuration", () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset ApiOptions mock calls before each test if needed
require("../ApiOptions").default.mockClear()
})
it("renders ApiConfigManagement with correct props", () => {
it("renders ApiConfigManager with correct props", () => {
renderSettingsView()
expect(screen.getByTestId("api-config-management")).toBeInTheDocument()
})
it("defaults LiteLLM fields in apiConfiguration if provider is litellm and fields are missing", async () => {
const initialExtensionState = {
apiConfiguration: {
apiProvider: "litellm",
// litellmBaseUrl, litellmApiKey, litellmModelId are missing
},
// Add other necessary state fields for useExtensionState mock
currentApiConfigName: "default",
listApiConfigMeta: [],
uriScheme: "vscode",
version: "1.0.0",
settingsImportedAt: null,
}
// Mock useExtensionState to return our initial state
const mockUseExtensionState = jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState")
mockUseExtensionState.mockReturnValue(initialExtensionState)
const { activateTab } = renderSettingsView() // onDone is part of the return, but we don't need it here
// Ensure providers tab is active (it should be by default, but explicit doesn't hurt)
activateTab("providers")
// Wait for effects to run. Finding the mocked ApiOptions is a good way to ensure it has rendered with updated props.
const apiOptionsMock = await screen.findByTestId("api-options-mock")
const passedApiConfigString = apiOptionsMock.getAttribute("data-apiconfiguration")
const passedApiConfig = JSON.parse(passedApiConfigString!)
expect(passedApiConfig.apiProvider).toBe("litellm")
expect(passedApiConfig.litellmBaseUrl).toBe("http://localhost:4000")
expect(passedApiConfig.litellmApiKey).toBe("sk-1234")
expect(passedApiConfig.litellmModelId).toBeDefined() // Check it's defined (actual value is litellmDefaultModelId)
mockUseExtensionState.mockRestore()
})
it("preserves existing LiteLLM fields in apiConfiguration if provider is litellm", async () => {
const myCustomKey = "my-custom-key"
const myCustomUrl = "http://my-custom-url.com"
const myCustomModel = "custom-model/my-model"
const initialExtensionState = {
apiConfiguration: {
apiProvider: "litellm",
litellmBaseUrl: myCustomUrl,
litellmApiKey: myCustomKey,
litellmModelId: myCustomModel,
},
currentApiConfigName: "default",
listApiConfigMeta: [],
uriScheme: "vscode",
version: "1.0.0",
settingsImportedAt: null,
}
const mockUseExtensionState = jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState")
mockUseExtensionState.mockReturnValue(initialExtensionState)
const { activateTab } = renderSettingsView()
activateTab("providers")
const apiOptionsMock = await screen.findByTestId("api-options-mock")
const passedApiConfigString = apiOptionsMock.getAttribute("data-apiconfiguration")
const passedApiConfig = JSON.parse(passedApiConfigString!)
expect(passedApiConfig.apiProvider).toBe("litellm")
expect(passedApiConfig.litellmBaseUrl).toBe(myCustomUrl)
expect(passedApiConfig.litellmApiKey).toBe(myCustomKey)
expect(passedApiConfig.litellmModelId).toBe(myCustomModel)
mockUseExtensionState.mockRestore()
})
})
describe("SettingsView - Allowed Commands", () => {

View file

@ -12,7 +12,7 @@ import { ModelPicker } from "../ModelPicker"
import { WebviewMessage } from "@roo/shared/WebviewMessage"
import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
type LiteLLMProps = {
export type LiteLLMProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
// routerModels prop might need to be updated by parent if we want to show new models immediately.

View file

@ -0,0 +1,178 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import { I18nextProvider } from "react-i18next"
import i18next from "i18next"
import { LiteLLM, LiteLLMProps } from "../LiteLLM"
import { vscode } from "@/utils/vscode"
// Minimal i18n instance for testing
const testI18n = i18next.createInstance()
testI18n.init({
fallbackLng: "en",
debug: false,
resources: {
en: {
translation: {
"settings:providers.refreshModels.label": "Refresh Models",
"settings:providers.refreshModels.missingConfig": "API key or base URL missing.",
},
},
},
interpolation: {
escapeValue: false, // Not needed for React
},
})
// Mock vscode API
jest.mock("@/utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock VSCodeTextField
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({ children, value, onInput, type }: any) => (
<div>
{children}
<input
type={type || "text"}
value={value}
onChange={(e) => onInput && onInput({ target: { value: e.target.value } })}
/>
</div>
),
}))
// Mock ModelPicker
jest.mock("../../ModelPicker", () => ({
ModelPicker: () => <div data-testid="model-picker-mock">ModelPicker</div>,
}))
const mockT = jest.fn((key) => key) // Simple t mock
jest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: mockT,
}),
}))
const defaultProps: LiteLLMProps = {
apiConfiguration: { litellmApiKey: "", litellmBaseUrl: "" },
setApiConfigurationField: jest.fn(),
routerModels: {
litellm: {},
glama: {},
openrouter: {},
unbound: {},
requesty: {},
},
}
const renderLiteLLM = (props?: Partial<LiteLLMProps>) => {
return render(
<I18nextProvider i18n={testI18n}>
<LiteLLM {...defaultProps} {...props} />
</I18nextProvider>,
)
}
describe("LiteLLM Component", () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset the ref module-level if needed, but usually refs are instance-based.
// For this test, we rely on fresh mounts giving fresh refs.
})
it("does not attempt initial model refresh if API key is missing", () => {
renderLiteLLM({
apiConfiguration: {
...defaultProps.apiConfiguration,
litellmBaseUrl: "http://localhost:4000",
litellmApiKey: "",
},
})
expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "requestProviderModels" }))
})
it("does not attempt initial model refresh if base URL is missing", () => {
renderLiteLLM({
apiConfiguration: { ...defaultProps.apiConfiguration, litellmApiKey: "test-key", litellmBaseUrl: "" },
})
expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "requestProviderModels" }))
})
it("attempts initial model refresh once if API key and base URL are present on mount", () => {
renderLiteLLM({
apiConfiguration: {
...defaultProps.apiConfiguration,
litellmApiKey: "test-key",
litellmBaseUrl: "http://localhost:4000",
},
})
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "requestProviderModels",
payload: {
provider: "litellm",
apiKey: "test-key",
baseUrl: "http://localhost:4000",
},
})
})
it("does not re-attempt initial refresh if props change but refresh was already done", () => {
const { rerender } = renderLiteLLM({
apiConfiguration: {
...defaultProps.apiConfiguration,
litellmApiKey: "test-key",
litellmBaseUrl: "http://localhost:4000",
},
})
expect(vscode.postMessage).toHaveBeenCalledTimes(1) // Initial call
// Re-render with different routerModels (a prop that might change)
rerender(
<I18nextProvider i18n={testI18n}>
<LiteLLM
{...defaultProps}
apiConfiguration={{
...defaultProps.apiConfiguration,
litellmApiKey: "test-key", // Same key
litellmBaseUrl: "http://localhost:4000", // Same URL
}}
routerModels={{
litellm: { "new-model": { contextWindow: 4096, supportsPromptCache: false } },
glama: {},
openrouter: {},
unbound: {},
requesty: {},
}}
/>
</I18nextProvider>,
)
// Should still only be 1 call from the initial refresh
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
})
it("manual refresh button is disabled if API key is missing", () => {
renderLiteLLM({
apiConfiguration: {
...defaultProps.apiConfiguration,
litellmBaseUrl: "http://localhost:4000",
litellmApiKey: "",
},
})
const refreshButton = screen.getByText("settings:providers.refreshModels.label").closest("button")
expect(refreshButton).toBeDisabled()
})
it("manual refresh button is disabled if base URL is missing", () => {
renderLiteLLM({
apiConfiguration: { ...defaultProps.apiConfiguration, litellmApiKey: "test-key", litellmBaseUrl: "" },
})
const refreshButton = screen.getByText("settings:providers.refreshModels.label").closest("button")
expect(refreshButton).toBeDisabled()
})
})