59533b997b
- 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.
221 lines
8.5 KiB
TypeScript
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");
|
|
});
|
|
});
|