Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, expect, it, vi, beforeEach, type Mock } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpError } from "@openhands/typescript-client";
import { renderWithProviders } from "test-utils";
import { MetaLlmSettingsView } from "#/components/features/settings/meta-llm-profiles";
import * as useMetaProfilesHook from "#/hooks/query/use-meta-profiles";
import * as useLlmProfilesHook from "#/hooks/query/use-llm-profiles";
import * as useSaveMetaProfileHook from "#/hooks/mutation/use-save-meta-profile";
import * as useActivateMetaProfileHook from "#/hooks/mutation/use-activate-meta-profile";
import * as useDeleteMetaProfileHook from "#/hooks/mutation/use-delete-meta-profile";
import MetaProfilesService from "#/api/meta-profiles-service/meta-profiles-service.api";

vi.mock("#/hooks/query/use-meta-profiles");
vi.mock("#/hooks/query/use-llm-profiles");
vi.mock("#/hooks/mutation/use-save-meta-profile");
vi.mock("#/hooks/mutation/use-activate-meta-profile");
vi.mock("#/hooks/mutation/use-delete-meta-profile");
vi.mock("#/api/meta-profiles-service/meta-profiles-service.api");
vi.mock("#/utils/custom-toast-handlers");

const mockMetaProfiles = [
{
name: "balanced",
classifier_model: "minimax",
default_model: "gpt",
num_classes: 2,
},
{
name: "cheap",
classifier_model: "minimax",
default_model: "deepseek",
num_classes: 0,
},
];

const mockLlmProfiles = [
{ name: "minimax", model: "m", base_url: null, api_key_set: true },
{ name: "gpt", model: "g", base_url: null, api_key_set: true },
{ name: "deepseek", model: "d", base_url: null, api_key_set: true },
];

function mockMutation<T>(mutateAsync: Mock, overrides: Partial<T> = {}): T {
return {
mutateAsync,
mutate: vi.fn(),
isPending: false,
isError: false,
isSuccess: false,
error: null,
data: undefined,
reset: vi.fn(),
status: "idle",
isIdle: true,
...overrides,
} as T;
}

describe("MetaLlmSettingsView", () => {
const activateMutateAsync = vi.fn();
const saveMutateAsync = vi.fn();

beforeEach(() => {
vi.clearAllMocks();

vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({
data: {
meta_profiles: mockMetaProfiles,
active_meta_profile: "balanced",
},
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useMetaProfilesHook.useMetaProfiles>);

vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue({
data: { profiles: mockLlmProfiles, active_profile: "minimax" },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useLlmProfilesHook.useLlmProfiles>);

vi.mocked(useSaveMetaProfileHook.useSaveMetaProfile).mockReturnValue(
mockMutation(saveMutateAsync),
);
vi.mocked(
useActivateMetaProfileHook.useActivateMetaProfile,
).mockReturnValue(mockMutation(activateMutateAsync));
// The delete hook is consumed by the modal that is always mounted.
vi.mocked(useDeleteMetaProfileHook.useDeleteMetaProfile).mockReturnValue(
mockMutation(vi.fn()),
);
});

it("renders the list of meta-profiles with an active badge", () => {
renderWithProviders(<MetaLlmSettingsView />);

expect(screen.getByTestId("meta-profile-row-balanced")).toBeInTheDocument();
expect(screen.getByTestId("meta-profile-row-cheap")).toBeInTheDocument();
// Only the active one shows the badge
expect(screen.getAllByTestId("meta-profile-active-badge")).toHaveLength(1);
});

it("shows the empty state when there are no meta-profiles", () => {
vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({
data: { meta_profiles: [], active_meta_profile: null },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useMetaProfilesHook.useMetaProfiles>);

renderWithProviders(<MetaLlmSettingsView />);

expect(screen.getByTestId("meta-profile-empty")).toBeInTheDocument();
});

it("hints when there are no LLM profiles to route between", () => {
vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue({
data: { profiles: [], active_profile: null },
isLoading: false,
error: null,
} as unknown as ReturnType<typeof useLlmProfilesHook.useLlmProfiles>);

renderWithProviders(<MetaLlmSettingsView />);

expect(
screen.getByTestId("meta-profile-no-llm-profiles"),
).toBeInTheDocument();
});

it("opens the editor when clicking Add meta-profile", async () => {
const user = userEvent.setup();
renderWithProviders(<MetaLlmSettingsView />);

await user.click(screen.getByTestId("add-meta-profile"));

expect(screen.getByTestId("meta-profile-editor")).toBeInTheDocument();
expect(screen.getByTestId("meta-profile-name-input")).toBeInTheDocument();
});

it("activates a meta-profile via the actions menu", async () => {
const user = userEvent.setup();
activateMutateAsync.mockResolvedValue({ name: "cheap" });
renderWithProviders(<MetaLlmSettingsView />);

await user.click(screen.getByTestId("meta-profile-menu-trigger-cheap"));
await user.click(screen.getByTestId("meta-profile-set-active"));

await waitFor(() =>
expect(activateMutateAsync).toHaveBeenCalledWith("cheap"),
);
});

it("loads the config and opens the editor via the actions menu", async () => {
const user = userEvent.setup();
vi.mocked(MetaProfilesService.getMetaProfile).mockResolvedValue({
name: "balanced",
config: {
classifier_model: "minimax",
default_model: "gpt",
classes: [{ description: "UI", model: "deepseek" }],
},
});
renderWithProviders(<MetaLlmSettingsView />);

await user.click(screen.getByTestId("meta-profile-menu-trigger-balanced"));
await user.click(screen.getByTestId("meta-profile-edit"));

await waitFor(() =>
expect(screen.getByTestId("meta-profile-editor")).toBeInTheDocument(),
);
expect(MetaProfilesService.getMetaProfile).toHaveBeenCalledWith("balanced");
});

it("shows an explicit unsupported-backend message when the API is missing (404)", () => {
// Older backends (pre software-agent-sdk #3744) have no /api/meta-profiles
// endpoint and return 404; the page must explain that instead of a dead
// generic error, and must not offer Add.
vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({
data: undefined,
isLoading: false,
error: new HttpError(404, "Not Found"),
} as unknown as ReturnType<typeof useMetaProfilesHook.useMetaProfiles>);

renderWithProviders(<MetaLlmSettingsView />);

expect(screen.getByTestId("meta-profile-unsupported")).toBeInTheDocument();
expect(screen.queryByTestId("add-meta-profile")).not.toBeInTheDocument();
});

it("shows the generic error for non-404 failures", () => {
vi.mocked(useMetaProfilesHook.useMetaProfiles).mockReturnValue({
data: undefined,
isLoading: false,
error: new HttpError(500, "Internal Server Error"),
} as unknown as ReturnType<typeof useMetaProfilesHook.useMetaProfiles>);

renderWithProviders(<MetaLlmSettingsView />);

expect(
screen.queryByTestId("meta-profile-unsupported"),
).not.toBeInTheDocument();
// The Add affordance remains for transient/server errors.
expect(screen.getByTestId("add-meta-profile")).toBeInTheDocument();
});

it("disables Set active in the menu for the already-active profile", async () => {
const user = userEvent.setup();
renderWithProviders(<MetaLlmSettingsView />);

await user.click(screen.getByTestId("meta-profile-menu-trigger-balanced"));

expect(screen.getByTestId("meta-profile-set-active")).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { MetaProfileEditor } from "#/components/features/settings/meta-llm-profiles";
import type { MetaProfile } from "#/api/meta-profiles-service/meta-profiles-service.api";

const AVAILABLE = ["minimax", "gpt", "deepseek"];

const FILLED: MetaProfile = {
classifier_model: "minimax",
default_model: "gpt",
classes: [{ description: "UI tasks", model: "deepseek" }],
};

describe("MetaProfileEditor", () => {
it("disables Save in create mode until required fields are set", () => {
renderWithProviders(
<MetaProfileEditor
mode="create"
availableProfiles={AVAILABLE}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(screen.getByTestId("meta-profile-save")).toBeDisabled();
});

it("enables Save in edit mode with a complete config and saves it", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
renderWithProviders(
<MetaProfileEditor
mode="edit"
initialName="balanced"
initialConfig={FILLED}
availableProfiles={AVAILABLE}
isSaving={false}
onSave={onSave}
onCancel={vi.fn()}
/>,
);

const save = screen.getByTestId("meta-profile-save");
expect(save).toBeEnabled();

await user.click(save);

expect(onSave).toHaveBeenCalledWith("balanced", FILLED);
});

it("disables the name field in edit mode", () => {
renderWithProviders(
<MetaProfileEditor
mode="edit"
initialName="balanced"
initialConfig={FILLED}
availableProfiles={AVAILABLE}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(screen.getByTestId("meta-profile-name-input")).toBeDisabled();
});

it("adds and removes task class rows", async () => {
const user = userEvent.setup();
renderWithProviders(
<MetaProfileEditor
mode="create"
availableProfiles={AVAILABLE}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(
screen.getByTestId("meta-profile-classes-empty"),
).toBeInTheDocument();

await user.click(screen.getByTestId("meta-profile-add-class"));
expect(
screen.getByTestId("meta-profile-class-description-0"),
).toBeInTheDocument();

await user.click(screen.getByTestId("meta-profile-remove-class-0"));
expect(
screen.queryByTestId("meta-profile-class-description-0"),
).not.toBeInTheDocument();
});

it("rejects a duplicate name in create mode and blocks Save", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
renderWithProviders(
<MetaProfileEditor
mode="create"
initialConfig={FILLED}
availableProfiles={AVAILABLE}
existingNames={["balanced"]}
isSaving={false}
onSave={onSave}
onCancel={vi.fn()}
/>,
);

await user.type(screen.getByTestId("meta-profile-name-input"), "balanced");

expect(screen.getByTestId("meta-profile-name-taken")).toBeInTheDocument();
const save = screen.getByTestId("meta-profile-save");
expect(save).toBeDisabled();

await user.click(save);
expect(onSave).not.toHaveBeenCalled();
});

it("accepts a unique name in create mode", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
renderWithProviders(
<MetaProfileEditor
mode="create"
initialConfig={FILLED}
availableProfiles={AVAILABLE}
existingNames={["balanced"]}
isSaving={false}
onSave={onSave}
onCancel={vi.fn()}
/>,
);

await user.type(screen.getByTestId("meta-profile-name-input"), "fast");

expect(
screen.queryByTestId("meta-profile-name-taken"),
).not.toBeInTheDocument();
const save = screen.getByTestId("meta-profile-save");
expect(save).toBeEnabled();

await user.click(save);
expect(onSave).toHaveBeenCalledWith("fast", FILLED);
});

it("allows the existing name in edit mode (no duplicate warning)", () => {
renderWithProviders(
<MetaProfileEditor
mode="edit"
initialName="balanced"
initialConfig={FILLED}
availableProfiles={AVAILABLE}
existingNames={["balanced"]}
isSaving={false}
onSave={vi.fn()}
onCancel={vi.fn()}
/>,
);

expect(
screen.queryByTestId("meta-profile-name-taken"),
).not.toBeInTheDocument();
expect(screen.getByTestId("meta-profile-save")).toBeEnabled();
});

it("calls onCancel when Cancel is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
renderWithProviders(
<MetaProfileEditor
mode="create"
availableProfiles={AVAILABLE}
isSaving={false}
onSave={vi.fn()}
onCancel={onCancel}
/>,
);

await user.click(screen.getByTestId("meta-profile-cancel"));
expect(onCancel).toHaveBeenCalled();
});
});
Loading
Loading