31907ba651
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||
import type { ReactNode } from "react";
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import type { WorkspaceContentSearchResult } from "@/lib/types/backend";
|
||
|
||
vi.mock("@kandev/ui/command", () => ({
|
||
Command: ({ children, shouldFilter }: { children: ReactNode; shouldFilter?: boolean }) => (
|
||
<div data-testid="command-root" data-should-filter={String(shouldFilter)}>
|
||
{children}
|
||
</div>
|
||
),
|
||
CommandDialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
|
||
open ? <div>{children}</div> : null,
|
||
CommandEmpty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||
CommandGroup: ({
|
||
children,
|
||
heading,
|
||
"data-testid": testId,
|
||
"data-repository": repository,
|
||
}: {
|
||
children: ReactNode;
|
||
heading?: ReactNode;
|
||
"data-testid"?: string;
|
||
"data-repository"?: string;
|
||
}) => (
|
||
<section {...{ "cmdk-group": "" }} data-testid={testId} data-repository={repository}>
|
||
{heading && (
|
||
<div {...{ "cmdk-group-heading": "" }} aria-hidden>
|
||
{heading}
|
||
</div>
|
||
)}
|
||
{children}
|
||
</section>
|
||
),
|
||
CommandInput: ({
|
||
placeholder,
|
||
value,
|
||
onKeyDown,
|
||
}: {
|
||
placeholder: string;
|
||
value: string;
|
||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||
}) => (
|
||
<div data-slot="command-input-wrapper">
|
||
<input
|
||
role="combobox"
|
||
placeholder={placeholder}
|
||
value={value}
|
||
onKeyDown={onKeyDown}
|
||
readOnly
|
||
/>
|
||
</div>
|
||
),
|
||
CommandItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||
CommandList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||
CommandShortcut: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||
}));
|
||
|
||
vi.mock("@kandev/ui/kbd", () => ({
|
||
Kbd: ({ children }: { children: ReactNode }) => <kbd>{children}</kbd>,
|
||
KbdGroup: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||
}));
|
||
|
||
vi.mock("@kandev/ui/badge", () => ({
|
||
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||
}));
|
||
|
||
vi.mock("@/components/state-provider", () => ({
|
||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||
selector({
|
||
userSettings: { keyboardShortcuts: {} },
|
||
taskSessions: { items: {} },
|
||
kanban: { tasks: [] },
|
||
repositories: { itemsByWorkspaceId: {} },
|
||
}),
|
||
}));
|
||
|
||
type MockContentSearchProps = {
|
||
onSelect: (result: WorkspaceContentSearchResult) => void;
|
||
results: WorkspaceContentSearchResult[];
|
||
};
|
||
|
||
const mockContentSearch = vi.fn(({ onSelect, results }: MockContentSearchProps) => (
|
||
<button data-testid="mock-content-search" onClick={() => onSelect(results[0])}>
|
||
Content results
|
||
</button>
|
||
));
|
||
|
||
vi.mock("./workspace-content-search", () => ({
|
||
WorkspaceContentSearch: (props: MockContentSearchProps) => mockContentSearch(props),
|
||
}));
|
||
|
||
import {
|
||
CommandPanelView,
|
||
MODE_COMMANDS,
|
||
MODE_SEARCH_CONTENT,
|
||
type CommandPanelViewProps,
|
||
} from "./command-panel-footer";
|
||
|
||
const result: WorkspaceContentSearchResult = {
|
||
repository_name: "web",
|
||
path: "src/app.tsx",
|
||
line: 5,
|
||
column: 3,
|
||
preview: "needle",
|
||
match_ranges: [{ start: 0, end: 6 }],
|
||
};
|
||
|
||
const ARIA_SELECTED_ATTRIBUTE = "aria-selected";
|
||
const CMDK_GROUP_HEADING_SELECTOR = "[cmdk-group-heading]";
|
||
|
||
function viewProps(overrides: Partial<CommandPanelViewProps> = {}): CommandPanelViewProps {
|
||
return {
|
||
open: true,
|
||
setOpen: vi.fn(),
|
||
mode: MODE_SEARCH_CONTENT,
|
||
inputCommand: null,
|
||
selectedValue: "",
|
||
setSelectedValue: vi.fn(),
|
||
search: "needle",
|
||
setSearch: vi.fn(),
|
||
handleKeyDown: vi.fn(),
|
||
onScopeChange: vi.fn(),
|
||
goBack: vi.fn(),
|
||
fileResults: [],
|
||
isSearchingFiles: false,
|
||
handleFileSelect: vi.fn(),
|
||
contentResults: [result],
|
||
isSearchingContent: false,
|
||
contentSearchError: null,
|
||
activeSessionId: "session-1",
|
||
workspaceSearchAvailable: true,
|
||
handleContentSelect: vi.fn(),
|
||
commands: [],
|
||
grouped: [],
|
||
handleSelect: vi.fn(),
|
||
isSearching: false,
|
||
taskResults: [],
|
||
stepMap: new Map(),
|
||
repoMap: new Map(),
|
||
handleTaskSelect: vi.fn(),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
mockContentSearch.mockClear();
|
||
});
|
||
|
||
describe("CommandPanelView task content search mode", () => {
|
||
it("renders a dedicated input and forwards repository results", () => {
|
||
const props = viewProps();
|
||
render(<CommandPanelView {...props} />);
|
||
|
||
expect(screen.getByPlaceholderText("Search task contents…")).toBeTruthy();
|
||
expect(screen.getByText("Contents")).toBeTruthy();
|
||
expect(mockContentSearch).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
results: [result],
|
||
isSearching: false,
|
||
error: null,
|
||
search: "needle",
|
||
sessionId: "session-1",
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(screen.getByTestId("mock-content-search"));
|
||
expect(props.handleContentSelect).toHaveBeenCalledWith(result);
|
||
});
|
||
|
||
it("makes all palette scopes visible and switches without clearing the query", () => {
|
||
const onScopeChange = vi.fn();
|
||
const props = viewProps({ onScopeChange });
|
||
render(<CommandPanelView {...props} />);
|
||
|
||
const tabs = screen.getAllByRole("tab");
|
||
expect(tabs).toHaveLength(3);
|
||
expect(
|
||
screen.getByRole("tab", { name: "Commands" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
|
||
).toBe("false");
|
||
expect(screen.getByRole("tab", { name: "Files" }).getAttribute(ARIA_SELECTED_ATTRIBUTE)).toBe(
|
||
"false",
|
||
);
|
||
expect(
|
||
screen.getByRole("tab", { name: "Contents" }).getAttribute(ARIA_SELECTED_ATTRIBUTE),
|
||
).toBe("true");
|
||
|
||
fireEvent.click(screen.getByRole("tab", { name: "Files" }));
|
||
|
||
expect(onScopeChange).toHaveBeenCalledWith("search-files");
|
||
expect(props.setSearch).not.toHaveBeenCalled();
|
||
expect((screen.getByRole("combobox") as HTMLInputElement).value).toBe("needle");
|
||
});
|
||
|
||
it("keeps the mode selector inline with the search input", () => {
|
||
render(<CommandPanelView {...viewProps()} />);
|
||
|
||
const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
|
||
const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
|
||
|
||
expect(switcher.parentElement).toBe(inputWrapper?.parentElement);
|
||
});
|
||
|
||
it("keeps balanced breathing room between the input and header divider", () => {
|
||
render(<CommandPanelView {...viewProps()} />);
|
||
|
||
const inputWrapper = screen.getByRole("combobox").closest("[data-slot=command-input-wrapper]");
|
||
|
||
expect(inputWrapper?.parentElement?.className).toContain(
|
||
"[&>[data-slot=command-input-wrapper]]:pb-1",
|
||
);
|
||
});
|
||
|
||
it("uses a low-chrome text selector with an active underline", () => {
|
||
render(<CommandPanelView {...viewProps()} />);
|
||
|
||
const switcher = screen.getByRole("tablist", { name: "Command palette mode" });
|
||
expect(switcher.querySelector("kbd")).toBeNull();
|
||
expect(switcher.querySelectorAll("svg")).toHaveLength(0);
|
||
expect(screen.queryByTestId("command-panel-scope-indicator")).toBeNull();
|
||
for (const tab of screen.getAllByRole("tab")) {
|
||
expect(tab.getAttribute("tabindex")).toBe("-1");
|
||
expect(tab.className).toContain(
|
||
tab.getAttribute(ARIA_SELECTED_ATTRIBUTE) === "true"
|
||
? "after:opacity-100"
|
||
: "after:opacity-0",
|
||
);
|
||
}
|
||
});
|
||
|
||
it("cycles palette scopes with Tab and Shift+Tab", () => {
|
||
const onScopeChange = vi.fn();
|
||
const props = viewProps({ onScopeChange });
|
||
render(<CommandPanelView {...props} />);
|
||
const input = screen.getByRole("combobox");
|
||
input.focus();
|
||
|
||
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
|
||
expect(onScopeChange).toHaveBeenLastCalledWith("commands");
|
||
expect(document.activeElement).toBe(input);
|
||
|
||
expect(fireEvent.keyDown(input, { key: "Tab", shiftKey: true })).toBe(false);
|
||
expect(onScopeChange).toHaveBeenLastCalledWith("search-files");
|
||
expect(document.activeElement).toBe(input);
|
||
});
|
||
|
||
it("hides workspace modes and leaves Tab alone outside a task workbench", () => {
|
||
const onScopeChange = vi.fn();
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: "commands",
|
||
workspaceSearchAvailable: false,
|
||
onScopeChange,
|
||
})}
|
||
/>,
|
||
);
|
||
const input = screen.getByRole("combobox");
|
||
|
||
expect(screen.queryByRole("tablist", { name: "Command palette mode" })).toBeNull();
|
||
expect(screen.queryByText("Switch mode")).toBeNull();
|
||
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
|
||
expect(onScopeChange).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("CommandPanelView search-only commands", () => {
|
||
const FONT_SIZE_LABEL = "Terminal Font Size";
|
||
const goToSettings = {
|
||
id: "nav-settings",
|
||
label: "Go to Settings",
|
||
group: "Navigation",
|
||
action: vi.fn(),
|
||
};
|
||
const fontSize = {
|
||
id: "setting:terminal-font-size",
|
||
label: FONT_SIZE_LABEL,
|
||
group: "Settings",
|
||
context: "Settings › Terminal & Editors",
|
||
searchOnly: true,
|
||
action: vi.fn(),
|
||
};
|
||
|
||
it("keeps granular settings hidden before typing", () => {
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: MODE_COMMANDS,
|
||
search: "",
|
||
commands: [goToSettings, fontSize],
|
||
grouped: [
|
||
["Navigation", [goToSettings]],
|
||
["Settings", [fontSize]],
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("Go to Settings")).toBeTruthy();
|
||
expect(screen.queryByText(FONT_SIZE_LABEL)).toBeNull();
|
||
});
|
||
|
||
it("shows a matching granular setting with owning context after typing", () => {
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: MODE_COMMANDS,
|
||
search: "font size",
|
||
commands: [goToSettings, fontSize],
|
||
grouped: [],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText(FONT_SIZE_LABEL)).toBeTruthy();
|
||
expect(screen.getByText("Settings › Terminal & Editors")).toBeTruthy();
|
||
expect(screen.getByText("Settings", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeTruthy();
|
||
expect(screen.queryByText("Commands", { selector: CMDK_GROUP_HEADING_SELECTOR })).toBeNull();
|
||
});
|
||
|
||
it("separates regular and granular matches into Commands and Settings", () => {
|
||
const fontSizeGuide = {
|
||
id: "help:terminal-font-size",
|
||
label: "Terminal Font Size Guide",
|
||
group: "Help",
|
||
action: vi.fn(),
|
||
};
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: MODE_COMMANDS,
|
||
search: "terminal font size",
|
||
commands: [fontSizeGuide, fontSize],
|
||
grouped: [],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(
|
||
screen
|
||
.getByText("Terminal Font Size Guide")
|
||
.closest("[cmdk-group]")
|
||
?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent,
|
||
).toBe("Commands");
|
||
expect(
|
||
screen
|
||
.getByText(FONT_SIZE_LABEL, { exact: true })
|
||
.closest("[cmdk-group]")
|
||
?.querySelector(CMDK_GROUP_HEADING_SELECTOR)?.textContent,
|
||
).toBe("Settings");
|
||
});
|
||
});
|
||
|
||
describe("CommandPanelView mode result safety", () => {
|
||
it("does not delegate filtering to cmdk while palette mode groups swap", () => {
|
||
render(<CommandPanelView {...viewProps({ mode: "commands" })} />);
|
||
|
||
expect(screen.getByTestId("command-root").getAttribute("data-should-filter")).toBe("false");
|
||
});
|
||
|
||
it("filters command results before rendering them", () => {
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: "commands",
|
||
search: "needle",
|
||
commands: [
|
||
{ id: "matching-command", label: "Needle command", group: "Actions" },
|
||
{ id: "unrelated-command", label: "Unrelated action", group: "Actions" },
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(screen.getByText("Needle command")).toBeTruthy();
|
||
expect(screen.queryByText("Unrelated action")).toBeNull();
|
||
});
|
||
|
||
it("returns a stale workspace-search mode to commands when context disappears", () => {
|
||
const onScopeChange = vi.fn();
|
||
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: "search-content",
|
||
workspaceSearchAvailable: false,
|
||
onScopeChange,
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
expect(onScopeChange).toHaveBeenCalledWith("commands");
|
||
});
|
||
|
||
it("groups file matches by repository", () => {
|
||
render(
|
||
<CommandPanelView
|
||
{...viewProps({
|
||
mode: "search-files",
|
||
search: "shared",
|
||
fileResults: [
|
||
{
|
||
repository_name: "backend",
|
||
path: "backend/src/shared-search.go",
|
||
},
|
||
{
|
||
repository_name: "frontend",
|
||
path: "frontend/src/shared-search.ts",
|
||
},
|
||
],
|
||
})}
|
||
/>,
|
||
);
|
||
|
||
const groups = screen.getAllByTestId("file-search-repo-group");
|
||
expect(groups).toHaveLength(2);
|
||
expect(groups[0].getAttribute("data-repository")).toBe("backend");
|
||
expect(groups[1].getAttribute("data-repository")).toBe("frontend");
|
||
});
|
||
});
|