/** * @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ĩ */ 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("../AiRouterConfig", () => ({ AiRouterConfig: () => null })); vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null })); 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"], ])("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("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(); }); });