/** * @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 Appearance, a disabled tool has no page while an * installed, enabled catalog tool gets one — as a tab on AI tools, not a nav * item —, 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, the nav has three groups (General, Agents, Data) with no * one-page group, Git and Snapshots & reports are one page each (their old * paths redirect there), and an unknown section toasts. * @author Nguyễn Ngọc Trí Vĩ */ 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 }) => (
), })); vi.mock("../../components/settings/PetSettings", () => ({ PetSettings: () =>
, })); vi.mock("../CcConfig", () => ({ CcConfig: () =>
})); vi.mock("../CodexConfig", () => ({ CodexConfig: () =>
})); vi.mock("../AiToolConfig", () => ({ AiToolConfig: ({ toolId }: { toolId: string }) =>
, })); vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null })); vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () =>
, })); vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({ WorktreeVisibilitySettings: () =>
, })); vi.mock("../../components/settings/SnapshotSettings", () => ({ SnapshotSettings: () =>
, })); vi.mock("../../components/settings/ReportSettings", () => ({ ReportSettings: () =>
, })); vi.mock("../../components/settings/AiToolsSettings", () => ({ AiToolsSettings: () =>
, })); vi.mock("../../components/settings/PricingSettings", () => ({ PricingSettings: ({ vendor }: { vendor: string }) =>
, })); const tool = (id: string, over: Partial = {}) => ({ 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>(); 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 {loc.pathname + loc.search}; } function renderAt(at: string) { return render( } /> ); } 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"], ["/settings/about", "/settings/system"], ["/settings/snapshots", "/settings/reports"], ["/settings/worktrees", "/settings/git"], ["/settings/git-credentials", "/settings/git"], ])("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 tabs = within(screen.getByRole("tablist", { name: "AI tool settings" })) .getAllByRole("tab") .map((a) => a.textContent); expect(tabs).toEqual(["All tools", "Claude Code", "Gemini"]); }); it("shows tool pages as tabs on AI tools, not as nav items", async () => { renderAt("/settings/agents/codex"); expect(await screen.findByTestId("codex-config")).toBeInTheDocument(); const links = within(nav()) .getAllByRole("link") .map((a) => a.textContent); expect(links).not.toContain("Codex"); expect(within(nav()).getByRole("link", { name: "AI tools" })).toHaveAttribute( "aria-current", "page" ); expect(screen.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true"); fireEvent.click(screen.getByRole("tab", { name: "All tools" })); expect(screen.getByTestId("where").textContent).toBe("/settings/agents"); expect(screen.getByTestId("ai-tools")).toBeInTheDocument(); }); 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(["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("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("groups the nav into General, Agents and Data, with no one-page group", async () => { renderAt("/settings/data"); expect(await screen.findByTestId("settings-data")).toBeInTheDocument(); const groups = within(nav()) .getAllByRole("group") .map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]); expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]); for (const [, count] of groups) expect(count).toBeGreaterThan(1); const links = within(nav()) .getAllByRole("link") .map((a) => a.textContent); expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]); expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"])); expect(links).not.toContain("About"); expect(links).not.toContain("Worktrees"); expect(links).not.toContain("Pet"); }); it("shows git logins and worktree suggestions on one Git page", async () => { renderAt("/settings/git"); expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument(); expect(screen.getByTestId("git-credentials")).toBeInTheDocument(); expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument(); }); it("shows snapshots and scheduled reports on one page", async () => { renderAt("/settings/reports"); await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" }); expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument(); expect(screen.getByTestId("report-settings")).toBeInTheDocument(); }); 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"); const msg = seen.find((x) => x.message.includes("nope"))?.message ?? ""; expect(msg).toContain("Appearance"); expect(msg).not.toContain("General"); off(); }); });