Files
AgentHub/client/src/pages/__tests__/SettingsHub.test.tsx
T
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00

279 lines
11 KiB
TypeScript

/**
* @file SettingsHub.test.tsx
* @description The Settings page shell: a path picks its section, `/settings`
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
* unknown section falls back to General, a disabled tool has no page while an
* installed, enabled catalog tool gets one, the mobile
* select navigates, and the search filters the nav, opens the first match on
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
),
}));
vi.mock("../../components/settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
vi.mock("../AiToolConfig", () => ({
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
}));
vi.mock("../AiRouterConfig", () => ({ AiRouterConfig: () => null }));
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
vi.mock("../../components/settings/AiToolsSettings", () => ({
AiToolsSettings: () => <div data-testid="ai-tools" />,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
const tool = (id: string, over: Partial<AiTool> = {}) =>
({
id,
label: id[0]!.toUpperCase() + id.slice(1),
installed: true,
enabled: true,
launcher: null,
...over,
}) as AiTool;
let view: AiToolsView;
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
},
};
});
import { SettingsHub, backDelta } from "../SettingsHub";
import { __resetAiTools } from "../../hooks/useAiTools";
import { subscribeToasts, type ToastItem } from "../../lib/toast";
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname + loc.search}</span>;
}
function renderAt(at: string) {
return render(
<MemoryRouter initialEntries={[at]}>
<Routes>
<Route
path="/settings/*"
element={
<>
<SettingsHub />
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
}
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => {
__resetAiTools();
view = {
default: "claude",
tools: [
tool("claude", { launcher: "claude" }),
tool("codex", { launcher: "codex" }),
tool("gemini"),
tool("qwen", { enabled: false }),
tool("aider", { installed: false }),
],
};
});
describe("SettingsHub", () => {
it("renders the section its path names, marked current in the nav", async () => {
renderAt("/settings/appearance");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(within(nav()).getByRole("link", { name: "Appearance" })).toHaveAttribute(
"aria-current",
"page"
);
});
it.each([
["/settings", "/settings/appearance"],
["/settings/general", "/settings/appearance"],
["/settings/pet", "/settings/appearance"],
["/settings?tool=codex", "/settings/agents/codex"],
["/settings?tool=claude-code", "/settings/agents/claude-code"],
["/settings?tool=gemini", "/settings/agents/gemini"],
["/settings#ai-tools", "/settings/agents"],
])("redirects %s to %s", async (from, to) => {
renderAt(from);
await screen.findByRole("heading", { level: 1 });
expect(screen.getByTestId("where").textContent).toBe(to);
});
it("sends an unknown section to Appearance once the lists have loaded", async () => {
renderAt("/settings/nope");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
});
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
view.tools[1] = tool("codex", { launcher: "codex", enabled: false });
renderAt("/settings/agents/gemini");
expect(await screen.findByTestId("tool-gemini")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toContain("Claude Code");
expect(links).not.toContain("Codex");
expect(links).not.toContain("Qwen");
expect(links).not.toContain("Aider");
});
it("navigates from the mobile select", async () => {
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
target: { value: "pricing" },
});
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
expect(screen.getByTestId("pricing-openai")).toBeInTheDocument();
});
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
const user = userEvent.setup();
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole("searchbox", { name: "Search settings" });
await user.type(box, "pricing");
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
"data-first-match",
"true"
);
await user.keyboard("{Enter}");
expect(screen.getByTestId("where").textContent).toBe("/settings/pricing");
await user.keyboard("{Escape}");
expect(box).toHaveValue("");
expect(within(nav()).getAllByRole("link").length).toBeGreaterThan(3);
});
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
const user = userEvent.setup();
renderAt("/settings/data");
await screen.findByTestId("settings-data");
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
const box = screen.getByRole("searchbox", { name: "Search settings" });
expect(box).toHaveFocus();
await user.type(box, "zzzz");
expect(within(nav()).getByText("No setting matches “zzzz”.")).toBeInTheDocument();
});
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const field = document.createElement("input");
dialog.appendChild(field);
document.body.appendChild(dialog);
field.focus();
const ev = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(ev).toBe(true); // not prevented
expect(field).toHaveFocus();
dialog.remove();
});
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
const user = userEvent.setup();
renderAt("/settings/data");
await screen.findByTestId("settings-data");
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
await user.type(box, "pet");
const second = fireEvent.keyDown(window, { key: "f", ctrlKey: true });
expect(second).toBe(false); // default prevented
expect(box).toHaveFocus();
expect([box.selectionStart, box.selectionEnd]).toEqual([0, 3]);
});
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
const user = userEvent.setup();
renderAt("/settings/data");
await screen.findByTestId("settings-data");
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
const list = screen.getByRole("list", { name: "Search results" });
await user.click(within(list).getAllByRole("link")[0]!);
expect(screen.getByTestId("where").textContent).toBe(
"/settings/agents/claude-code?tab=marketplaces"
);
});
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
renderAt("/settings/data");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
const links = within(nav())
.getAllByRole("link")
.map((a) => a.textContent);
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
expect(links).not.toContain("General");
expect(links).not.toContain("Pet");
});
it("scrolls to and highlights the anchor the URL names", async () => {
renderAt("/settings/data#data-row");
const row = await screen.findByTestId("settings-data");
await vi.waitFor(() => expect(row.className).toContain("ring-2"));
});
it("a malformed #anchor is ignored instead of crashing the page", async () => {
renderAt("/settings/data#%E0%A4");
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
});
it("Back always goes back: one step past the entry, at least one step", () => {
// Entered at index 2, now two pages further on: back to index 1.
expect(backDelta(4, 2)).toBe(-3);
expect(backDelta(2, 2)).toBe(-1);
// Browser Back moved below the entry with the hub still mounted: one step,
// never history.go(0) (a reload) or a step forward.
expect(backDelta(1, 2)).toBe(-1);
expect(backDelta(0, 2)).toBe(-1);
});
it("toasts when an unknown section falls back to Appearance", async () => {
let seen: ToastItem[] = [];
const off = subscribeToasts((i) => (seen = i));
renderAt("/settings/nope");
await screen.findByTestId("pet-settings");
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
off();
});
});