3113479cd2
Settings → AI tools → Profiles holds one tab per tool: Claude Code (the cc-profiles panel, moved out of the Claude Code page's modal — its button now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi (`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential tables are emptied first because import merges, and a failed import puts the outgoing set back). Antigravity is listed as not switchable: agy keeps its login in the OS keyring. server/lib/tool-profiles.js + /api/tool-profiles keep cc-profiles' rules (one login = one profile, the outgoing login is re-saved before a switch, an unsaved one refuses it, the live login's profile is not deletable) and never return a token; /tool-profiles joins ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES, writes are same-origin only. The account pool takes a saved Codex profile too (moved in like a Claude one, GET /api/accounts/profiles?tool=codex). Each Claude/Codex profile has a "Move to pool" link (/admin?poolAdd=&poolProfile= opens the pool's Add dialog on it), Settings shows the pool's size, and the pool card links back.
179 lines
6.5 KiB
TypeScript
179 lines
6.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();
|
|
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");
|
|
});
|
|
});
|