Files
AgentHub/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx
T
nntrivi2001 59533b997b chore: v2.8.73 — Artifacts folder bar restyled; hidden live profile gets a stand-in
- Artifacts: folders are rounded chips with a count badge; rename/delete are
  icons beside the picked folder (double-click renames); New folder is a
  dashed chip; the empty-folder hint mentions drag and drop.
- GET /api/pricing/quota: every Claude row carries its accountUuid.
- Settings → Profiles and the status bar's Usage panel: a profile hidden in
  this browser is left out of the Usage panel too; when the live login is
  hidden, the first visible profile is shown in its place (Logged in now,
  "in use", the % used chip with that profile's own usage). Display only.
2026-11-04 16:14:27 +07:00

221 lines
8.5 KiB
TypeScript

/**
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
* failing account costs the others nothing), and its right-click delete asks
* first and never offers to delete the only copy of the live account; Codex's
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
* switched; and in server mode a profile links into the account pool.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { ToolProfilesSettings } from "../ToolProfilesSettings";
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../hooks/useAiTools", () => ({
useAiTools: () => ({
byId: (id: string) => ({ id, installed: true, enabled: true }),
}),
}));
const m = vi.hoisted(() => {
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
return {
window,
cc: {
profiles: vi.fn(),
profileUsage: vi.fn(),
deleteProfile: vi.fn(),
activateProfile: vi.fn(),
saveProfile: vi.fn(),
renameProfile: vi.fn(),
},
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
pool: { list: vi.fn() },
};
});
vi.mock("../../../lib/api", () => ({
api: {
ccConfig: m.cc,
toolProfiles: m.tp,
accountPool: m.pool,
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
},
}));
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function show() {
render(
<MemoryRouter>
<ToolProfilesSettings />
</MemoryRouter>
);
await settle();
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
perms.local = true;
m.cc.profiles.mockResolvedValue({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
});
m.cc.profileUsage.mockImplementation(async (name: string) => {
if (name === "spare") throw new Error("this profile's saved login has been superseded");
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
});
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
m.tp.list.mockResolvedValue({
tools: [
{
tool: "codex",
supported: true,
items: [
{
name: "a",
label: "Alice",
active: true,
kind: "chatgpt",
email: "a@x",
detail: "plus",
},
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
],
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
},
{ tool: "omp", supported: true, items: [], active: null },
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
],
});
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
});
describe("ToolProfilesSettings — Claude", () => {
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
await show();
await settle();
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
});
it("deletes from the right-click menu only after confirming", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
});
it("leaves a profile this browser hid out of the list and asks nothing about it", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(screen.queryByText("d@e.f")).toBeNull();
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
});
it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => {
const user = userEvent.setup();
await show();
await user.keyboard("{Alt>}{Shift>}");
await user.click(screen.getByTestId("profile-row-spare"));
await user.keyboard("{/Shift}{/Alt}");
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]);
await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}");
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
it("shows the first visible profile as the live login while the live one is hidden", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-work")).toBeNull();
expect(screen.queryByText("a@b.c")).toBeNull();
expect(screen.queryByText("—")).toBeNull();
const spare = screen.getByTestId("profile-row-spare");
expect(spare).toHaveTextContent(/active/);
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
expect(screen.getAllByText("d@e.f").length).toBe(2);
});
it("does not hide anything in server mode", async () => {
perms.local = false;
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
await show();
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
});
describe("ToolProfilesSettings — other tools", () => {
it("switches a Codex login through /api/tool-profiles", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /codex/i }));
const bob = screen.getByTestId("profile-row-codex-b");
expect(bob).toHaveTextContent("…1234");
await user.click(screen.getByRole("button", { name: "Switch to" }));
await settle();
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
expect(m.tp.list).toHaveBeenCalledTimes(2);
});
it("says why Antigravity cannot be switched", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
});
});
describe("ToolProfilesSettings — account pool", () => {
it("is not shown in local mode", async () => {
await show();
expect(m.pool.list).not.toHaveBeenCalled();
expect(screen.queryByText(/Move to pool/)).toBeNull();
});
it("in server mode shows the pool's size and links a profile into it", async () => {
perms.local = false;
await show();
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
});
});