Files
kandev/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx
T
João Salavisa 9de669558b feat!: move voice mode into task behavior (#2534)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-08-11 23:29:46 +02:00

539 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode";
const MAIN_WORKSPACE_ID = "ws-1";
const MAIN_WORKSPACE_NAME = "Main Workspace";
// The active workspace carries a badge, so its row's accessible name is the
// name plus that badge — match on the name rather than the whole string.
const WORKSPACE_ROW = new RegExp(MAIN_WORKSPACE_NAME);
const SEARCH_LABEL = "Search settings";
const VOICE_MODE_LABEL = "Voice Mode";
const WORKSPACES_ROW_KEY = "row:/settings/workspaces";
const AGENTS_ROW_KEY = "row:/settings/agents";
const WORKSPACE_KEY = `workspace:${MAIN_WORKSPACE_ID}`;
const AGENT_KEY = "agent:claude-code";
const AGENT_LABEL = "Claude Code";
// Anchored: the caret beside the row is labelled "Expand/Collapse Claude Code",
// and a badge can extend the row's own name past an exact match.
const AGENT_ROW = /^Claude Code/;
// Which integrations the probe reports as connected, per test.
const integrationsEnabled = new Set<string>();
const state = {
workspaces: {
activeId: MAIN_WORKSPACE_ID,
items: [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }],
},
settingsAgents: {
items: [
{
name: "claude-code",
profiles: [
{ id: "profile-1", name: "Default", agentDisplayName: AGENT_LABEL },
{ id: "profile-2", name: "Retired", agentDisplayName: AGENT_LABEL, enabled: false },
],
},
],
},
executors: {
items: [
{
id: "exec-1",
name: "Local Executor",
type: "local",
profiles: [{ id: "exec-profile-1", name: "Local" }],
},
],
},
// The branch orders agents the way the Agents page groups them, so it reads
// the CLI scan. Empty here: one agent, so there is no order to disturb.
agentDiscovery: {
items: [] as Array<{ name: string; available: boolean }>,
loaded: false,
},
settingsData: {
executorsLoaded: true,
agentsLoaded: true,
},
userSettings: {
loaded: true,
savedLayouts: [] as Array<{ id: string }>,
},
auth: {
mode: "disabled" as const,
authenticated: true,
user: null,
},
settingsMenu: {
mode: "flat" as SettingsMenuMode,
savedMode: "flat" as SettingsMenuMode,
expandedKeys: [] as string[],
},
setSettingsMenuExpandedKeys: (keys: string[]) => {
state.settingsMenu.expandedKeys = keys;
},
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
}));
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => false,
}));
vi.mock("@/hooks/domains/settings/use-secrets", () => ({
useSecrets: () => ({
items: [{ id: "secret-1" }, { id: "secret-2" }],
loaded: true,
loading: false,
}),
}));
vi.mock("@/hooks/domains/integrations/use-enabled-integrations", () => ({
useEnabledIntegrations: () => integrationsEnabled,
}));
vi.mock("@/hooks/domains/plugins/use-plugins", () => ({
usePlugins: () => ({ items: [{ id: "plugin-1" }], loaded: true, loading: false, error: null }),
}));
import { SettingsTree } from "./settings-tree";
function setMenuMode(mode: SettingsMenuMode, expandedKeys: string[] = []) {
state.settingsMenu.mode = mode;
state.settingsMenu.savedMode = mode;
state.settingsMenu.expandedKeys = expandedKeys;
}
const FONT_NORMAL = "font-normal";
const FONT_MEDIUM = "font-medium";
/** The glyph box every row opens, whether or not a glyph fills it. */
const GLYPH_BOX = "h-3.5 w-3.5";
/** The record mark — shared with the dot the Agents page renders per profile. */
const RECORD_DOT = "h-1.5 w-1.5";
/**
* `data-record` for a row, whether it is a leaf (which carries the attribute
* itself) or a branch (whose wrapper carries it, so an href-less agent row gets
* it too). Deliberately not `closest`: every row inside an open record branch
* has a marked ancestor, which would make the negative assertions vacuous.
*/
function recordFlag(row: HTMLElement): string | null {
return row.getAttribute("data-record") ?? row.parentElement?.getAttribute("data-record") ?? null;
}
/** Every row claiming to be the page you are on. Must always be exactly one. */
function activeRowNames(): string[] {
return Array.from(document.querySelectorAll("[data-active='true']")).map(
(element) => element.textContent ?? "",
);
}
describe("SettingsTree static menu", () => {
beforeEach(() => {
state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
setMenuMode("flat");
});
afterEach(() => cleanup());
it("renders section headers as static text, not links or buttons", () => {
render(<SettingsTree pathname="/settings" />);
for (const header of ["Preferences", "Workspaces & Access", "System"]) {
expect(screen.getByText(header)).toBeTruthy();
expect(screen.queryByRole("link", { name: header })).toBeNull();
expect(screen.queryByRole("button", { name: header })).toBeNull();
}
});
it("holds no rows for user-created data, so menu length is constant", () => {
render(<SettingsTree pathname="/settings" />);
// Store has a workspace, an agent profile and an executor profile — none
// of them may appear as menu rows.
expect(screen.queryByRole("link", { name: WORKSPACE_ROW })).toBeNull();
expect(screen.queryByRole("link", { name: new RegExp(AGENT_LABEL) })).toBeNull();
expect(screen.queryByRole("link", { name: "Local" })).toBeNull();
expect(screen.queryByRole("link", { name: "Repositories" })).toBeNull();
// One row per page.
expect(screen.getByRole("link", { name: /^Workspaces/ }).getAttribute("href")).toBe(
"/settings/workspaces",
);
expect(screen.getByRole("link", { name: /^Agents/ }).getAttribute("href")).toBe(
"/settings/agents",
);
expect(screen.getByRole("link", { name: /^Executors/ }).getAttribute("href")).toBe(
"/settings/executors",
);
});
it("marks the owning page row active for detail routes", () => {
render(<SettingsTree pathname="/settings/agents/claude-code/profiles/profile-1" />);
expect(screen.getByRole("link", { name: /^Agents/ }).getAttribute("data-active")).toBe("true");
expect(screen.getByRole("link", { name: /^Executors/ }).getAttribute("data-active")).toBeNull();
});
it("hides auth-gated rows and the Access Control section when auth is disabled", () => {
render(<SettingsTree pathname="/settings" />);
expect(screen.queryByText("Access Control")).toBeNull();
expect(screen.queryByRole("link", { name: "Users" })).toBeNull();
expect(screen.queryByRole("link", { name: "API Tokens" })).toBeNull();
});
it("keeps Voice Mode inside Task Behavior instead of as a page row", () => {
render(<SettingsTree pathname="/settings/preferences/task-behavior" />);
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
expect(screen.getByRole("link", { name: "Task Behavior" }).getAttribute("data-active")).toBe(
"true",
);
});
it("puts the count straight after the title, not out at the row's edge", () => {
render(<SettingsTree pathname="/settings" />);
const row = screen.getByRole("link", { name: "Workspaces 1" });
const title = within(row).getByText("Workspaces");
const count = title.nextElementSibling;
// Immediately after the title and sharing its parent — a right-aligned
// badge would sit outside the title's wrapper instead.
expect(count?.textContent).toBe("1");
expect(count?.parentElement).toBe(title.parentElement);
});
it("shows item counts on rows whose page owns a list", () => {
render(<SettingsTree pathname="/settings" />);
// One workspace, two agent profiles (one disabled — the badge does not
// change the count), one executor profile, two secrets, one plugin.
expect(screen.getByRole("link", { name: "Workspaces 1" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Agents 2" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Executors 1" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Global Secrets 2" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Plugins 1" })).toBeTruthy();
// Rows without a list never carry a badge.
expect(screen.getByRole("link", { name: "Appearance" }).textContent).toBe("Appearance");
});
});
describe("SettingsTree tree modes", () => {
beforeEach(() => {
state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
state.agentDiscovery.items = [];
state.agentDiscovery.loaded = false;
integrationsEnabled.clear();
});
afterEach(() => {
setMenuMode("flat");
cleanup();
});
it("grows the branched rows into their records and opens the route's path", () => {
setMenuMode("accordion");
render(
<SettingsTree pathname={`/settings/workspaces/${MAIN_WORKSPACE_ID}/integrations/github`} />,
);
// Workspaces › Main Workspace › Integrations › GitHub — the same chain the
// breadcrumb renders on that page.
expect(screen.getByRole("link", { name: /^Workspaces/ })).toBeTruthy();
expect(screen.getByRole("link", { name: WORKSPACE_ROW })).toBeTruthy();
expect(screen.getByRole("link", { name: "Integrations" })).toBeTruthy();
expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe(
`/settings/workspaces/${MAIN_WORKSPACE_ID}/integrations/github`,
);
});
it("marks only the deepest row active, never its ancestors", () => {
setMenuMode("accordion");
render(
<SettingsTree pathname={`/settings/workspaces/${MAIN_WORKSPACE_ID}/integrations/github`} />,
);
expect(activeRowNames()).toEqual(["GitHub"]);
});
it("falls back to the owning row when no branch node claims the route", () => {
setMenuMode("accordion");
// `/settings/agents/browse` is the install catalogue, not an agent, so no
// node holds it — the Agents row has to keep the active mark itself.
render(<SettingsTree pathname="/settings/agents/browse" />);
expect(activeRowNames()).toEqual(["Agents2"]);
});
it("collapses the other branches when one is opened in accordion mode", () => {
setMenuMode("accordion");
render(<SettingsTree pathname={`/settings/workspaces/${MAIN_WORKSPACE_ID}`} />);
expect(screen.getByRole("link", { name: WORKSPACE_ROW })).toBeTruthy();
expect(screen.queryByRole("button", { name: AGENT_LABEL })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Expand Agents" }));
expect(screen.getByRole("button", { name: AGENT_LABEL })).toBeTruthy();
expect(screen.queryByRole("link", { name: WORKSPACE_ROW })).toBeNull();
});
it("keeps several branches open at once in persistent mode", () => {
setMenuMode("persistent", [
WORKSPACES_ROW_KEY,
`workspace:${MAIN_WORKSPACE_ID}`,
AGENTS_ROW_KEY,
AGENT_KEY,
"row:/settings/executors",
"executor:exec-1",
]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
expect(screen.getByRole("link", { name: WORKSPACE_ROW })).toBeTruthy();
expect(screen.getByRole("link", { name: "Repositories" })).toBeTruthy();
expect(screen.getByRole("button", { name: AGENT_LABEL })).toBeTruthy();
expect(screen.getByRole("link", { name: "Default" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Local" })).toBeTruthy();
// A page outside every branch still leaves exactly one active row.
expect(activeRowNames()).toEqual(["Appearance"]);
});
it("links an agent's profile under the agent, which only discloses", () => {
setMenuMode("persistent", [AGENTS_ROW_KEY, AGENT_KEY]);
render(<SettingsTree pathname="/settings/agents/claude-code/profiles/profile-1" />);
// `/settings/agents/<name>` redirects back to the index for a saved agent,
// so the agent row must not be a link.
expect(screen.queryByRole("link", { name: new RegExp(AGENT_LABEL) })).toBeNull();
expect(screen.getByRole("link", { name: "Default" }).getAttribute("href")).toBe(
"/settings/agents/claude-code/profiles/profile-1",
);
expect(activeRowNames()).toEqual(["Default"]);
});
});
/**
* The visual split between rows kandev ships and rows the user made. Its own
* block: the tree-mode suite is about what the menu contains, this one is about
* how a record is drawn, and together they overran the per-function line limit.
*/
describe("SettingsTree record treatment", () => {
beforeEach(() => {
state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
state.agentDiscovery.items = [];
state.agentDiscovery.loaded = false;
integrationsEnabled.clear();
});
afterEach(() => {
setMenuMode("flat");
cleanup();
});
it("lightens rows from the user's data, leaving navigation alone", () => {
setMenuMode("accordion");
render(
<SettingsTree pathname={`/settings/workspaces/${MAIN_WORKSPACE_ID}/integrations/github`} />,
);
const workspace = screen.getByRole("link", { name: WORKSPACE_ROW });
// `GitHub` sits at the same indent as the workspace name but ships with the
// app, so the split cannot be read off depth.
const integration = screen.getByRole("link", { name: "GitHub" });
expect(recordFlag(workspace)).toBe("true");
expect(recordFlag(integration)).toBeNull();
expect(workspace.className).toContain(FONT_NORMAL);
expect(integration.className).toContain(FONT_MEDIUM);
});
it("treats a profile as the record, not the agent it belongs to", () => {
setMenuMode("persistent", [AGENTS_ROW_KEY, AGENT_KEY]);
render(<SettingsTree pathname="/settings/agents/claude-code/profiles/profile-1" />);
const agent = screen.getByRole("button", { name: AGENT_LABEL });
const profile = screen.getByRole("link", { name: "Default" });
// The agent ships with kandev: full weight.
expect(recordFlag(agent)).toBeNull();
expect(agent.className).toContain(FONT_MEDIUM);
// Its profile is the user's.
expect(recordFlag(profile)).toBe("true");
expect(profile.className).toContain(FONT_NORMAL);
});
it("marks a record with the dot its own page uses, in a full glyph box", () => {
setMenuMode("persistent", [AGENTS_ROW_KEY, AGENT_KEY, WORKSPACES_ROW_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
for (const name of [WORKSPACE_ROW, /^Default$/]) {
const box = screen.getByRole("link", { name }).firstElementChild;
// Same footprint as a tabler glyph, so the label lands in the same column.
expect(box?.getAttribute("class")).toContain(GLYPH_BOX);
// The dot the Agents page puts in front of a profile.
expect(box?.firstElementChild?.getAttribute("class")).toContain(RECORD_DOT);
}
});
it("leaves a row that has a glyph of its own alone", () => {
setMenuMode("persistent", [AGENTS_ROW_KEY, AGENT_KEY, WORKSPACES_ROW_KEY, WORKSPACE_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
// An agent keeps its logo, and a workspace tab its icon — neither is a
// record, and neither gains a dot.
for (const row of [
screen.getByRole("button", { name: AGENT_LABEL }),
screen.getByRole("link", { name: "Repositories" }),
]) {
expect(row.querySelector("svg, img")).not.toBeNull();
expect(row.innerHTML).not.toContain(RECORD_DOT);
}
});
});
describe("SettingsTree badges", () => {
beforeEach(() => {
state.workspaces.activeId = MAIN_WORKSPACE_ID;
state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }];
state.agentDiscovery.items = [];
state.agentDiscovery.loaded = false;
integrationsEnabled.clear();
});
afterEach(() => {
setMenuMode("flat");
cleanup();
});
it("badges a disabled profile, which stays listed so it can be re-enabled", () => {
setMenuMode("persistent", [AGENTS_ROW_KEY, AGENT_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
// Ported from #2339, which added this to the accordion tree the static menu
// replaced. A disabled profile is hidden from every task and session picker
// but stays here, so the menu has to say why it looks inert.
expect(screen.getByRole("link", { name: /Retired/ }).textContent).toContain("Disabled");
expect(screen.getByRole("link", { name: "Default" }).textContent).not.toContain("Disabled");
});
it("badges the active workspace, the way its own list does", () => {
setMenuMode("persistent", [WORKSPACES_ROW_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
// `MAIN_WORKSPACE_ID` is the active one in the mocked store.
expect(
screen.getByRole("link", { name: new RegExp(MAIN_WORKSPACE_NAME) }).textContent,
).toContain("Active");
});
it("badges only the active workspace", () => {
state.workspaces.items = [
{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME },
{ id: "ws-2", name: "Second Workspace" },
];
setMenuMode("persistent", [WORKSPACES_ROW_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
expect(screen.getByRole("link", { name: /Second Workspace/ }).textContent).not.toContain(
"Active",
);
});
it("badges an agent whose CLI the scan cannot find", () => {
state.agentDiscovery.items = [{ name: "claude-code", available: false }];
state.agentDiscovery.loaded = true;
setMenuMode("persistent", [AGENTS_ROW_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
// Its profiles stay listed below, so the agent row says why none can run.
expect(screen.getByRole("button", { name: AGENT_ROW }).textContent).toContain("Not installed");
});
it("says nothing about installation before the scan has reported", () => {
state.agentDiscovery.items = [];
state.agentDiscovery.loaded = false;
setMenuMode("persistent", [AGENTS_ROW_KEY]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
// "Not looked yet" is not "not installed" — badging here would flash a
// wrong claim on every cold load.
expect(screen.getByRole("button", { name: AGENT_ROW }).textContent).not.toContain(
"Not installed",
);
});
it("badges an integration the workspace has connected", () => {
integrationsEnabled.add("github");
setMenuMode("persistent", [WORKSPACES_ROW_KEY, WORKSPACE_KEY, `${WORKSPACE_KEY}:integrations`]);
render(<SettingsTree pathname="/settings/preferences/appearance" />);
expect(screen.getByRole("link", { name: /GitHub/ }).textContent).toContain("Enabled");
expect(screen.getByRole("link", { name: /GitLab/ }).textContent).not.toContain("Enabled");
});
});
describe("SettingsTree search", () => {
afterEach(cleanup);
it("preserves the normal menu until a query filters it to grouped hits", () => {
render(<SettingsTree pathname="/settings" />);
const search = screen.getByRole("searchbox", { name: SEARCH_LABEL });
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
fireEvent.change(search, { target: { value: "font size" } });
const result = screen.getByRole("link", { name: /Terminal Font Size/ });
expect(result.getAttribute("href")).toBe(
"/settings/preferences/terminal-editors#setting-terminal-font-size",
);
expect(result.textContent).toContain("Terminal & Editors");
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
});
it("prefixes per-workspace results with the workspace name", () => {
render(<SettingsTree pathname="/settings" />);
fireEvent.change(screen.getByRole("searchbox", { name: SEARCH_LABEL }), {
target: { value: "Jira" },
});
const result = screen.getByRole("link", { name: /Jira/ });
expect(result.getAttribute("href")).toContain(
`/settings/workspaces/${MAIN_WORKSPACE_ID}/integrations/jira`,
);
expect(result.textContent).toContain(`${MAIN_WORKSPACE_NAME} › Integrations`);
});
it("clears a query with Escape and restores the normal menu", () => {
render(<SettingsTree pathname="/settings" />);
const search = screen.getByRole("searchbox", { name: SEARCH_LABEL });
fireEvent.change(search, { target: { value: "font size" } });
fireEvent.keyDown(search, { key: "Escape" });
expect((search as HTMLInputElement).value).toBe("");
expect(screen.queryByRole("link", { name: VOICE_MODE_LABEL })).toBeNull();
});
it("announces an empty result without rendering the normal menu", () => {
render(<SettingsTree pathname="/settings" />);
fireEvent.change(screen.getByRole("searchbox", { name: SEARCH_LABEL }), {
target: { value: "definitely missing" },
});
expect(screen.getByText("No matching settings")).toBeTruthy();
expect(screen.queryByRole("link", { name: "Voice Mode" })).toBeNull();
});
});