/** * @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ĩ */ 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( ); await settle(); await settle(); } beforeEach(() => { vi.clearAllMocks(); 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(); }); }); 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"); }); });