b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
333 lines
13 KiB
TypeScript
333 lines
13 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 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ĩ <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("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
|
|
vi.mock("../GitCredentialsConfig", () => ({
|
|
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
|
|
}));
|
|
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
|
|
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
|
|
}));
|
|
vi.mock("../../components/settings/SnapshotSettings", () => ({
|
|
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
|
|
}));
|
|
vi.mock("../../components/settings/ReportSettings", () => ({
|
|
ReportSettings: () => <div data-testid="report-settings" />,
|
|
}));
|
|
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"],
|
|
["/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<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("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();
|
|
});
|
|
});
|