refactor(webui): unify floating controls
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
@@ -1715,6 +1716,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("opens the settings view from the sidebar footer", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
@@ -1729,6 +1731,18 @@ describe("App layout", () => {
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href === "/api/settings/api-service") {
|
||||
return jsonResponse({
|
||||
installed: false,
|
||||
running: false,
|
||||
managed: false,
|
||||
host: "127.0.0.1",
|
||||
port: 8900,
|
||||
timeout: 120,
|
||||
endpoint: "http://127.0.0.1:8900/v1",
|
||||
command: "nanobot serve",
|
||||
});
|
||||
}
|
||||
if (href === "/api/settings/provider-models?provider=openai") {
|
||||
return jsonResponse({
|
||||
provider: "openai",
|
||||
@@ -1996,8 +2010,8 @@ describe("App layout", () => {
|
||||
.getAllByRole("button", { name: /OpenAI/ })
|
||||
.some((button) => button.getAttribute("aria-haspopup") === "menu"),
|
||||
).toBe(true);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
|
||||
fireEvent.click(await screen.findByText("openai/gpt-4o-mini"));
|
||||
await user.click(screen.getByRole("button", { name: "Select model" }));
|
||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
||||
@@ -2006,14 +2020,13 @@ describe("App layout", () => {
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||
const openModelPicker = () => {
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: /Auto/ }));
|
||||
const openModelPicker = async () => {
|
||||
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await user.click(modelButtons[modelButtons.length - 1]);
|
||||
};
|
||||
openModelPicker();
|
||||
await screen.findByText("openai/gpt-4o-mini");
|
||||
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
|
||||
await openModelPicker();
|
||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Model providers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
|
||||
@@ -2078,9 +2091,9 @@ describe("App layout", () => {
|
||||
target: { value: "unsaved-brave-key" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Brave Search/ }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Tavily" }));
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Tavily" }));
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Tavily/ }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
|
||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Brave Search" }));
|
||||
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
|
||||
|
||||
@@ -2095,12 +2108,13 @@ describe("App layout", () => {
|
||||
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Default workspace")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
|
||||
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
|
||||
fireEvent.click(screen.getByRole("button", { name: "UTC" }));
|
||||
const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
|
||||
expect(timezoneSearch).toBeInTheDocument();
|
||||
fireEvent.change(timezoneSearch, {
|
||||
target: { value: "Shanghai" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
|
||||
await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ }));
|
||||
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ComboboxOption,
|
||||
useComboboxNavigation,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
const OPTIONS = ["Alpha", "Beta", "Gamma"];
|
||||
|
||||
function ComboboxHarness() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [selected, setSelected] = useState("Beta");
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: OPTIONS,
|
||||
selectedValue: selected,
|
||||
onSelect: setSelected,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<input aria-label="Options" {...navigation.inputProps} />
|
||||
{open ? (
|
||||
<div {...navigation.listProps} aria-label="Available options">
|
||||
{OPTIONS.map((option) => (
|
||||
<ComboboxOption key={option} {...navigation.getOptionProps(option)}>
|
||||
{option}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<output aria-label="Selection">{selected}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("combobox navigation", () => {
|
||||
it("exposes listbox semantics and selects the active option from the keyboard", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-activedescendant",
|
||||
screen.getByRole("option", { name: "Gamma" }).id,
|
||||
);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma");
|
||||
});
|
||||
|
||||
it("closes the listbox on Escape", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("combobox", { name: "Options" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ describe("SessionInfoPopover", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/automations",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
@@ -372,6 +373,10 @@ async function togglePresetEditor(name = "primary") {
|
||||
fireEvent.click(within(row).getAllByRole("button")[0]);
|
||||
}
|
||||
|
||||
async function openPopover(trigger: HTMLElement) {
|
||||
await userEvent.setup().click(trigger);
|
||||
}
|
||||
|
||||
async function chooseProviderToConfigure(label: string) {
|
||||
fireEvent.pointerDown(
|
||||
await screen.findByRole("button", { name: "Add your own model provider" }),
|
||||
@@ -2460,8 +2465,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
|
||||
target: { value: "Writer" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
|
||||
const modelSearch = await screen.findByRole("textbox", {
|
||||
await openPopover(screen.getByRole("button", { name: "Select model" }));
|
||||
const modelSearch = await screen.findByRole("combobox", {
|
||||
name: "Search or type model ID",
|
||||
});
|
||||
fireEvent.change(modelSearch, {
|
||||
@@ -3206,7 +3211,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
target: { value: "http://127.0.0.1:7890" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Thinking style" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "enable_thinking" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "enable_thinking" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save provider" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -3272,27 +3277,27 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
expect(screen.queryByDisplayValue("openai/gpt-5.4-image-2")).not.toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "OpenRouter" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Gemini" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" }));
|
||||
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
|
||||
const modelInput = await screen.findByRole("textbox", { name: "Search or type model ID" });
|
||||
await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
|
||||
const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
|
||||
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Use “imagen-5-preview”" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
|
||||
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Custom" }));
|
||||
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-5-preview" }));
|
||||
const customProviderInput = await screen.findByRole("textbox", {
|
||||
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
|
||||
const customProviderInput = await screen.findByRole("combobox", {
|
||||
name: "Search or type model ID",
|
||||
});
|
||||
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
|
||||
@@ -3572,9 +3577,9 @@ describe("SettingsView Apps catalog", () => {
|
||||
if (!providerPicker) throw new Error("provider picker was not found");
|
||||
fireEvent.pointerDown(providerPicker);
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("menuitemradio", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitemradio", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitemradio", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch model lists for unsigned OAuth providers", async () => {
|
||||
@@ -3638,7 +3643,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
await togglePresetEditor();
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
|
||||
await openPopover(await screen.findByRole("button", { name: /Select model/i }));
|
||||
expect(
|
||||
await screen.findByText("Configure this provider before loading models."),
|
||||
).toBeInTheDocument();
|
||||
@@ -3698,7 +3703,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
await togglePresetEditor();
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
|
||||
expect(input.value).toBe("open-codex/gpt-5.5");
|
||||
|
||||
@@ -3776,7 +3781,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
const modelButtons = await screen.findAllByRole("button", {
|
||||
name: /openai-codex\/gpt-5\.5/i,
|
||||
});
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
|
||||
expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
|
||||
@@ -3900,7 +3905,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
await togglePresetEditor();
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
await screen.findByText("deepseek-reasoner");
|
||||
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
|
||||
@@ -4023,7 +4028,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "browser" });
|
||||
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /DuckDuckGo/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Keenable" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Keenable" }));
|
||||
const saveButton = screen
|
||||
.getAllByRole("button", { name: "Save" })
|
||||
.find((button) => !(button as HTMLButtonElement).disabled);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
@@ -967,7 +968,7 @@ describe("ThreadComposer", () => {
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Full Access/ }));
|
||||
|
||||
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -1007,6 +1008,7 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
|
||||
it("keeps project selection as a compact composer dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
@@ -1030,10 +1032,10 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByLabelText("Paste path");
|
||||
fireEvent.change(input, { target: { value: "relative/project" } });
|
||||
@@ -1054,7 +1056,7 @@ describe("ThreadComposer", () => {
|
||||
restrict_to_workspace: false,
|
||||
}));
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
const reopenedInput = await screen.findByLabelText("Paste path");
|
||||
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
@@ -1102,7 +1104,7 @@ describe("ThreadComposer", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Default workspace/ })).not.toBeInTheDocument();
|
||||
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
project_path: "/Users/test/native-project",
|
||||
project_name: "native-project",
|
||||
@@ -1112,6 +1114,7 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
|
||||
it("uses the web path menu when no native host picker is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
@@ -1131,9 +1134,9 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user