import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { CommandMenu, CommandMenuTrigger, } from "#/components/features/command-menu"; import { COMMAND_MENU_ROUTE } from "#/components/features/command-menu/command-menu-items"; import { getInterfaceCopy } from "#/manifests/automation-interface"; import { NavigationProvider, type NavigationContextValue, } from "#/context/navigation-context"; import { useCommandMenuStore } from "#/stores/command-menu-store"; import { useSidebarStore } from "#/stores/sidebar-store"; import { renderWithProviders } from "../../../../test-utils"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: (namespace?: string) => ({ t: (key: string) => namespace === "openhands" ? key : `missing-namespace:${key}`, i18n: { language: "en", exists: () => false, }, }), })); const OPEN_LABEL_KEY = "COMMAND_MENU$OPEN_LABEL"; const SEARCH_LABEL_KEY = "COMMAND_MENU$SEARCH_LABEL"; const CLEAR_SEARCH_LABEL_KEY = "COMMAND_MENU$CLEAR_SEARCH_LABEL"; const NO_RESULTS_TITLE_KEY = "COMMAND_MENU$NO_RESULTS_TITLE"; const NAVIGATION_GROUP_KEY = "COMMAND_MENU$GROUP_NAVIGATION"; const SETTINGS_GROUP_KEY = "COMMAND_MENU$GROUP_SETTINGS"; const ACTIONS_GROUP_KEY = "COMMAND_MENU$GROUP_ACTIONS"; // The automation interface manifest owns the automations command's copy, so // its title renders as the manifest literal rather than a translation key. const AUTOMATIONS_TITLE_KEY = getInterfaceCopy().commandMenuTitle; const NEW_CHAT_TITLE_KEY = "COMMAND_MENU$NEW_CHAT_TITLE"; const SECRETS_TITLE_KEY = "COMMAND_MENU$SECRETS_SETTINGS_TITLE"; const TOGGLE_SIDEBAR_TITLE_KEY = "COMMAND_MENU$TOGGLE_SIDEBAR_TITLE"; const SEARCH_INPUT_ID = "command-menu-search"; const RESULTS_LISTBOX_ID = "command-menu-results"; const NEW_CHAT_OPTION_ID = "command-menu-option-new-chat"; const navigateMock = vi.fn(); function renderCommandMenu(navigate = navigateMock) { const view = renderWithProviders(, { navigation: { navigate }, }); return { ...view, navigate }; } function withNavigation(navigate: NavigationContextValue["navigate"]) { return ( ); } beforeEach(() => { navigateMock.mockReset(); window.localStorage.clear(); useCommandMenuStore.setState({ isOpen: false }); useSidebarStore.setState({ collapsed: false }); }); describe("CommandMenu", () => { it("opens from the global command-k shortcut and closes with escape", async () => { renderCommandMenu(); fireEvent.keyDown(window, { key: "k", metaKey: true }); const searchInput = await screen.findByRole("combobox", { name: SEARCH_LABEL_KEY, }); await waitFor(() => expect(searchInput).toHaveFocus()); expect(screen.getByTestId("command-menu")).toBeInTheDocument(); await userEvent.keyboard("{Escape}"); await waitFor(() => { expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); }); }); it("opens from the global ctrl-k shortcut", async () => { renderCommandMenu(); fireEvent.keyDown(window, { key: "k", ctrlKey: true }); const searchInput = await screen.findByRole("combobox", { name: SEARCH_LABEL_KEY, }); await waitFor(() => expect(searchInput).toHaveFocus()); }); it("ignores unmodified and unrelated global shortcuts", () => { renderCommandMenu(); fireEvent.keyDown(window, { key: "k" }); fireEvent.keyDown(window, { key: "x", metaKey: true }); expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); expect(useCommandMenuStore.getState().isOpen).toBe(false); }); it("removes the global shortcut listener when unmounted", () => { const { unmount } = renderCommandMenu(); unmount(); fireEvent.keyDown(window, { key: "k", ctrlKey: true }); expect(useCommandMenuStore.getState().isOpen).toBe(false); }); it("cancels deferred focus and safely ignores it after unmount", () => { let focusCallback: FrameRequestCallback | undefined; const requestFrame = vi .spyOn(globalThis, "requestAnimationFrame") .mockImplementation((callback) => { focusCallback = callback; return 42; }); const cancelFrame = vi .spyOn(globalThis, "cancelAnimationFrame") .mockImplementation(() => undefined); useCommandMenuStore.getState().open(); const { unmount } = renderCommandMenu(); expect(requestFrame).toHaveBeenCalledOnce(); unmount(); expect(cancelFrame).toHaveBeenCalledWith(42); expect(focusCallback).toBeDefined(); expect(() => focusCallback?.(0)).not.toThrow(); requestFrame.mockRestore(); cancelFrame.mockRestore(); }); it("filters commands by page and setting keywords", async () => { useCommandMenuStore.getState().open(); renderCommandMenu(); await userEvent.type( screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }), "secrets", ); expect(screen.getByText(SECRETS_TITLE_KEY)).toBeInTheDocument(); expect(screen.queryByText(NEW_CHAT_TITLE_KEY)).not.toBeInTheDocument(); expect(screen.getByText(SETTINGS_GROUP_KEY)).toBeInTheDocument(); expect(screen.queryByText(NAVIGATION_GROUP_KEY)).not.toBeInTheDocument(); expect(screen.queryByText(ACTIONS_GROUP_KEY)).not.toBeInTheDocument(); }); it("keeps translated search fields separated", async () => { useCommandMenuStore.getState().open(); renderCommandMenu(); await userEvent.type( screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }), "titlecommand", ); expect(screen.getByText(NO_RESULTS_TITLE_KEY)).toBeInTheDocument(); }); it("navigates to the selected command and closes the menu", async () => { useCommandMenuStore.getState().open(); const { navigate } = renderCommandMenu(); await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY)); expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations); await waitFor(() => { expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); }); }); it("supports arrow-key navigation and enter selection", async () => { useCommandMenuStore.getState().open(); const { navigate } = renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); await userEvent.type(searchInput, "settings"); await userEvent.keyboard("{ArrowDown}{ArrowUp}{Enter}"); expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.settings); await waitFor(() => { expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); }); }); it("wraps arrow-up navigation from the first to the last command", async () => { useCommandMenuStore.getState().open(); renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); const options = screen.getAllByRole("option"); await userEvent.type(searchInput, "{arrowup}"); expect(options.at(-1)).toHaveAttribute("aria-selected", "true"); expect(searchInput).toHaveAttribute( "aria-activedescendant", options.at(-1)?.id, ); }); it("runs local actions from the menu", async () => { useCommandMenuStore.getState().open(); renderCommandMenu(); await userEvent.type( screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }), "toggle", ); await userEvent.click(screen.getByText(TOGGLE_SIDEBAR_TITLE_KEY)); expect(useSidebarStore.getState().collapsed).toBe(true); }); it("shows an empty state and ignores selection keys when nothing matches", async () => { const user = userEvent.setup(); useCommandMenuStore.getState().open(); const { navigate } = renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); await user.type(searchInput, "no matching command"); expect(screen.getByText(NO_RESULTS_TITLE_KEY)).toBeInTheDocument(); expect(searchInput).not.toHaveAttribute("aria-activedescendant"); await user.keyboard("{ArrowDown}{ArrowUp}{Enter}"); expect(navigate).not.toHaveBeenCalled(); expect(screen.getByTestId("command-menu")).toBeInTheDocument(); await user.click( screen.getByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }), ); const firstOption = screen.getAllByRole("option")[0]; expect(firstOption).toHaveAttribute("aria-selected", "true"); expect(searchInput).toHaveAttribute( "aria-activedescendant", NEW_CHAT_OPTION_ID, ); }); it("scrolls the newly active option into view", () => { useCommandMenuStore.getState().open(); renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); const options = screen.getAllByRole("option"); const nextOption = options[1]; const scrollIntoView = vi.fn(); Object.defineProperty(nextOption, "scrollIntoView", { configurable: true, value: scrollIntoView, }); fireEvent.keyDown(searchInput, { key: "ArrowDown" }); expect(searchInput).toHaveAttribute("id", SEARCH_INPUT_ID); expect(searchInput).toHaveAttribute("aria-controls", RESULTS_LISTBOX_ID); expect(screen.getByRole("listbox")).toHaveAttribute( "id", RESULTS_LISTBOX_ID, ); expect(options[0]).toHaveAttribute("id", NEW_CHAT_OPTION_ID); expect(options[0]).toHaveAttribute("aria-selected", "false"); expect(nextOption).toHaveAttribute("aria-selected", "true"); expect(searchInput).toHaveAttribute("aria-activedescendant", nextOption.id); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); }); it("preserves and clamps pointer selection when filtering", async () => { const user = userEvent.setup(); useCommandMenuStore.getState().open(); renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); const allOptions = screen.getAllByRole("option"); await user.hover(allOptions[1]); expect(allOptions[1]).toHaveAttribute("aria-selected", "true"); await user.hover(allOptions.at(-1) as HTMLElement); expect(allOptions.at(-1)).toHaveAttribute("aria-selected", "true"); await user.type(searchInput, "settings"); const filteredOptions = screen.getAllByRole("option"); expect(filteredOptions.length).toBeLessThan(allOptions.length); expect(filteredOptions.at(-1)).toHaveAttribute("aria-selected", "true"); expect(searchInput).toHaveAttribute( "aria-activedescendant", filteredOptions.at(-1)?.id, ); }); it("clears the search and restores the full command list", async () => { const user = userEvent.setup(); useCommandMenuStore.getState().open(); renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); await user.type(searchInput, "secrets"); expect(screen.queryByText(NEW_CHAT_TITLE_KEY)).not.toBeInTheDocument(); await user.click( screen.getByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }), ); expect(searchInput).toHaveValue(""); expect(searchInput).toHaveFocus(); expect(screen.getByText(NEW_CHAT_TITLE_KEY)).toBeInTheDocument(); expect( screen.queryByRole("button", { name: CLEAR_SEARCH_LABEL_KEY }), ).not.toBeInTheDocument(); }); it("resets search and selection after closing and reopening", async () => { const user = userEvent.setup(); useCommandMenuStore.getState().open(); renderCommandMenu(); const searchInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); await user.type(searchInput, "secrets"); await user.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(), ); act(() => useCommandMenuStore.getState().open()); const reopenedInput = screen.getByRole("combobox", { name: SEARCH_LABEL_KEY, }); expect(reopenedInput).toHaveValue(""); expect(screen.getAllByRole("option")).toHaveLength(12); expect(screen.getAllByRole("option")[0]).toHaveAttribute( "aria-selected", "true", ); }); it("uses the latest navigation context when a command runs", async () => { const firstNavigate = vi.fn(); const latestNavigate = vi.fn(); useCommandMenuStore.getState().open(); const { rerender } = renderWithProviders(withNavigation(firstNavigate)); rerender(withNavigation(latestNavigate)); await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY)); expect(firstNavigate).not.toHaveBeenCalled(); expect(latestNavigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations); }); it("renders navigation commands as links and local actions as buttons", () => { useCommandMenuStore.getState().open(); renderCommandMenu(); expect(screen.getByText(NEW_CHAT_TITLE_KEY).closest("a")).toHaveAttribute( "href", COMMAND_MENU_ROUTE.conversations, ); expect( screen.getByText(TOGGLE_SIDEBAR_TITLE_KEY).closest("button"), ).toHaveAttribute("type", "button"); }); it("applies the command palette and active-option presentation", () => { useCommandMenuStore.getState().open(); renderCommandMenu(); const dialog = screen.getByRole("dialog"); const surface = dialog.children[1] as HTMLElement; const [activeOption, inactiveOption] = screen.getAllByRole("option"); const activeIcon = activeOption.querySelector('[aria-hidden="true"]'); const inactiveIcon = inactiveOption.querySelector('[aria-hidden="true"]'); expect(surface).toHaveClass( "max-w-2xl", "border-[var(--oh-border)]", "shadow-[0_24px_90px_rgba(0,0,0,0.52),0_0_0_1px_rgba(255,255,255,0.03)_inset]", ); expect(activeOption).toHaveClass("rounded-xl", "bg-white/[0.09]"); expect(inactiveOption).toHaveClass("rounded-xl", "text-[var(--oh-muted)]"); expect(activeIcon).toHaveClass("size-9", "border-[var(--oh-accent)]"); expect(inactiveIcon).toHaveClass("size-9", "border-[var(--oh-border)]"); }); it("leaves modified command links to native browser behavior", () => { useCommandMenuStore.getState().open(); const { navigate } = renderCommandMenu(); const commandLink = screen.getByText(NEW_CHAT_TITLE_KEY).closest("a"); expect(commandLink).not.toBeNull(); commandLink?.addEventListener("click", (event) => event.preventDefault(), { once: true, }); fireEvent.click(commandLink as HTMLAnchorElement, { metaKey: true }); expect(navigate).not.toHaveBeenCalled(); expect(screen.getByTestId("command-menu")).toBeInTheDocument(); }); it("does not render the browser portal when open during server rendering", async () => { const serverStoreState = { isOpen: true, open: () => undefined, close: () => undefined, toggle: () => undefined, }; vi.resetModules(); vi.doMock("#/stores/command-menu-store", () => ({ useCommandMenuStore: ( selector: (state: typeof serverStoreState) => unknown, ) => selector(serverStoreState), })); const { CommandMenu: ServerCommandMenu } = await import("#/components/features/command-menu/command-menu"); const originalDocument = globalThis.document; Object.defineProperty(globalThis, "document", { configurable: true, value: undefined, }); try { expect(renderToStaticMarkup()).toBe(""); } finally { Object.defineProperty(globalThis, "document", { configurable: true, value: originalDocument, }); vi.doUnmock("#/stores/command-menu-store"); vi.resetModules(); } }); }); describe("CommandMenuTrigger", () => { it("opens the command menu from the sidebar trigger", async () => { renderWithProviders( <> , ); await userEvent.click(screen.getByRole("button", { name: OPEN_LABEL_KEY })); expect( await screen.findByRole("combobox", { name: SEARCH_LABEL_KEY }), ).toBeInTheDocument(); }); });