feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.test.tsx
|
||||
* @description Settings → Antigravity → Antigravity hooks: not installed until
|
||||
* the user clicks Install, then "Installed" with the note that agy asks no
|
||||
* trust and has no permission hook; a hook switched off in hooks.json is
|
||||
* reported; Refresh re-reads; Remove; a server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.gemini/config/hooks.json",
|
||||
script: "/home/u/.gemini/config/agenthub-hook.sh",
|
||||
installed,
|
||||
enabled,
|
||||
events: Object.fromEntries(
|
||||
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const hooks = api.settings.antigravityHooks;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
|
||||
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
|
||||
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
|
||||
});
|
||||
|
||||
describe("AntigravityHooksSettings", () => {
|
||||
it("installs on a click, and says agy has no permission hook", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(hooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PreToolUse")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/switched off/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(hooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
|
||||
render(<AntigravityHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: {
|
||||
info: vi.fn(),
|
||||
reinstallHooks: vi.fn(),
|
||||
resetPricing: vi.fn(),
|
||||
claudeHome: { get: vi.fn(), set: vi.fn() },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { SessionStart: true, Stop: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
vi.mocked(api.settings.claudeHome.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
||||
vi.mocked(api.settings.claudeHome.set)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
|
||||
vi.mocked(api.settings.resetPricing).mockReset();
|
||||
vi.mocked(api.settings.reinstallHooks)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
||||
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
await user.click(within(hooks).getByRole("button"));
|
||||
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
|
||||
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
|
||||
ok: true,
|
||||
claude_home: "/srv/claude",
|
||||
routers: { missing: ["7 (api): my-router"] },
|
||||
});
|
||||
const onHomeChanged = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
|
||||
expect(onHomeChanged).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.test.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
|
||||
* the user clicks Install, then "waiting for your trust" with the note on how
|
||||
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
|
||||
* server refusal shown inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { CodexHooksSettings } from "../CodexHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
|
||||
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.codex/hooks.json",
|
||||
script: "/home/u/.codex/agenthub-hook.sh",
|
||||
installed,
|
||||
trusted: installed && trusted,
|
||||
events: Object.fromEntries(
|
||||
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
|
||||
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
|
||||
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
|
||||
});
|
||||
|
||||
describe("CodexHooksSettings", () => {
|
||||
it("installs on a click, then says Codex still has to trust them", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(api.settings.codexHooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trusted hooks, and removes them", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers Remove when only some of the hooks are there", async () => {
|
||||
const partial = status(true, false);
|
||||
partial.installed = false;
|
||||
partial.events.Stop = { installed: false, trusted: false };
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
// The chip, plus the missing event's screen-reader label.
|
||||
expect(screen.getAllByText("Not installed")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
|
||||
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
|
||||
);
|
||||
render(<CodexHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file HomeDirSetting.test.tsx
|
||||
* @description The shared editable home directory: loads the value in effect,
|
||||
* Save is disabled until the path changes, a server refusal shows inline and
|
||||
* changes nothing, an env-owned variable warns that a save will not survive a
|
||||
* restart, and a partial-success warning from `save` is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
|
||||
const saveFn = vi.fn(save);
|
||||
const view = render(
|
||||
<HomeDirSetting
|
||||
id="x-home"
|
||||
title="X_HOME"
|
||||
description="desc"
|
||||
placeholder="/path"
|
||||
load={() => Promise.resolve(load)}
|
||||
save={saveFn}
|
||||
/>
|
||||
);
|
||||
return { saveFn, ...view };
|
||||
}
|
||||
|
||||
describe("HomeDirSetting", () => {
|
||||
it("saves a changed path and shows the value now in effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: false })
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(button);
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
expect(await screen.findByText("/b", { selector: "code" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a refusal inline and keeps the old value", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: false }, () =>
|
||||
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the environment owns the variable, and shows a save's warning", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: true }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
|
||||
);
|
||||
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
|
||||
const input = screen.getByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file PricingSettings.test.tsx
|
||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||
* offered only where asked for (the Claude tab).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { PricingSettings } from "../PricingSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: { resetPricing: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rule = (model_pattern: string, display_name: string) =>
|
||||
({
|
||||
model_pattern,
|
||||
display_name,
|
||||
input_per_mtok: 1,
|
||||
output_per_mtok: 2,
|
||||
cache_read_per_mtok: 0.1,
|
||||
cache_write_per_mtok: 0,
|
||||
cache_write_1h_per_mtok: 0,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-09-29 10:00:00",
|
||||
}) as ModelPricing;
|
||||
|
||||
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
||||
vi.mocked(api.pricing.upsert)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PricingSettings", () => {
|
||||
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
||||
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
||||
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says where OpenAI prices come from when there are none", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("turns Show all on when a row outside the filter is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
||||
const [pattern, name] = screen.getAllByRole("textbox");
|
||||
await user.type(pattern!, "deepseek-v4%");
|
||||
await user.type(name!, "DeepSeek V4");
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
||||
});
|
||||
await user.click(screen.getByTitle(/save/i));
|
||||
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
||||
render(<PricingSettings vendor="claude" allowReset />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user