eb90244182
Settings moves from a tab bar to /settings/<section>: a left nav grouped General / Agents / Routing / Git / Projects (a select on narrow screens), a search box over every section (Ctrl/Cmd+F, Enter opens the first match), and row primitives. Each AI tool has its page under agents/<id>, the AI Tools modal becomes the Agents page, Pricing shows every vendor, Git → Worktrees sets which untracked worktrees the sidebar lists, and each project gets a page with its own worktree override and Forget all. ?tool= and #ai-tools links redirect to the path form.
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
/**
|
|
* @file AiToolConfig.test.tsx
|
|
* @description The generic per-tool Settings tab: the tool's path, homepage
|
|
* and an Update link to Settings → Agents; its session count, cost and
|
|
* lanes; a default-model picker saved through PUT /api/ai-tools/:id; and its
|
|
* catalog-declared config files, edited over the sha they were opened at with
|
|
* the server's refusal shown.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { api, type AiTool } from "../../lib/api";
|
|
import { __resetAiTools } from "../../hooks/useAiTools";
|
|
import { AiToolConfig } from "../AiToolConfig";
|
|
|
|
vi.mock("../../lib/api", () => ({
|
|
api: {
|
|
aiTools: {
|
|
list: vi.fn(),
|
|
update: vi.fn(),
|
|
models: vi.fn(),
|
|
usage: vi.fn(),
|
|
config: vi.fn(),
|
|
readConfig: vi.fn(),
|
|
saveConfig: vi.fn(),
|
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
|
},
|
|
lanes: { list: vi.fn() },
|
|
},
|
|
RUN_MODEL_CHOICES: [],
|
|
}));
|
|
vi.mock("../../components/settings/PricingSettings", () => ({
|
|
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
|
|
}));
|
|
|
|
const GEMINI: AiTool = {
|
|
id: "gemini",
|
|
label: "Gemini",
|
|
command: "gemini",
|
|
homepageUrl: "https://github.com/google-gemini/gemini-cli",
|
|
installed: true,
|
|
path: "/usr/bin/gemini",
|
|
enabled: true,
|
|
source: "builtin",
|
|
launcher: null,
|
|
prompt: { mode: "flag" },
|
|
resume: false,
|
|
bypass: true,
|
|
routerCapable: false,
|
|
install: { kind: "npm", package: "@google/gemini-cli" },
|
|
update: { kind: "npm" },
|
|
modelFlag: true,
|
|
defaultModel: null,
|
|
modelVendor: "google",
|
|
configFiles: 2,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
__resetAiTools();
|
|
const view = { default: "claude", tools: [GEMINI] };
|
|
vi.mocked(api.aiTools.list)
|
|
.mockReset()
|
|
.mockResolvedValue(view as never);
|
|
vi.mocked(api.aiTools.update)
|
|
.mockReset()
|
|
.mockResolvedValue(view as never);
|
|
vi.mocked(api.aiTools.models)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
flag: true,
|
|
models: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
|
|
defaultModel: null,
|
|
});
|
|
vi.mocked(api.aiTools.usage).mockReset().mockResolvedValue({ sessions: 3, cost: 1.5 });
|
|
vi.mocked(api.aiTools.config)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
files: [
|
|
{ index: 0, path: "/h/.gemini/settings.json", format: "json", exists: true, size: 2 },
|
|
{ index: 1, path: "/h/.gemini/GEMINI.md", format: "text", exists: false, size: null },
|
|
],
|
|
});
|
|
vi.mocked(api.aiTools.readConfig)
|
|
.mockReset()
|
|
.mockResolvedValue({ path: "/h/.gemini/settings.json", format: "json", text: "{}", sha: "s1" });
|
|
vi.mocked(api.aiTools.saveConfig).mockReset();
|
|
vi.mocked(api.lanes.list)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
lanes: [
|
|
{ id: 7, tool: "gemini", title: "web", cwd: "/w" },
|
|
{ id: 8, tool: "claude", title: "other", cwd: "/o" },
|
|
],
|
|
} as never);
|
|
});
|
|
|
|
const renderTab = () =>
|
|
render(
|
|
<MemoryRouter>
|
|
<AiToolConfig toolId="gemini" />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
describe("AiToolConfig", () => {
|
|
it("shows the tool, its usage, its lanes and its vendor's pricing", async () => {
|
|
renderTab();
|
|
expect(await screen.findByText("/usr/bin/gemini")).toBeInTheDocument();
|
|
expect(await screen.findByText(/3 sessions/)).toBeInTheDocument();
|
|
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "/workspace?lane=7");
|
|
expect(screen.queryByRole("link", { name: "other" })).toBeNull();
|
|
expect(screen.getByRole("link", { name: /update/i })).toHaveAttribute(
|
|
"href",
|
|
"/settings/agents"
|
|
);
|
|
expect(screen.getByText("pricing:google")).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves the default model", async () => {
|
|
renderTab();
|
|
fireEvent.click(await screen.findByText("Inherit from settings"));
|
|
fireEvent.click(await screen.findByText("gemini-3.1-pro"));
|
|
fireEvent.click(screen.getByRole("button", { name: /save default model/i }));
|
|
await waitFor(() =>
|
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { defaultModel: "gemini-3.1-pro" })
|
|
);
|
|
});
|
|
|
|
it("edits a config file over the sha it opened, and shows a refusal", async () => {
|
|
vi.mocked(api.aiTools.saveConfig).mockRejectedValueOnce(new Error("not valid JSON"));
|
|
renderTab();
|
|
fireEvent.click(await screen.findByRole("button", { name: "/h/.gemini/settings.json" }));
|
|
expect(screen.getByRole("button", { name: "/h/.gemini/GEMINI.md" })).toBeDisabled();
|
|
const box = await screen.findByRole("textbox", { name: "/h/.gemini/settings.json" });
|
|
fireEvent.change(box, { target: { value: "{nope" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
|
expect(await screen.findByText(/not valid JSON/)).toBeInTheDocument();
|
|
expect(api.aiTools.saveConfig).toHaveBeenCalledWith("gemini", 0, "{nope", "s1");
|
|
});
|
|
});
|