fix(webui): improve UX recovery and empty states (#5315)
This commit is contained in:
@@ -315,11 +315,68 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
const password = screen.getByLabelText("Password");
|
||||
expect(password).toHaveAttribute(
|
||||
"autocomplete",
|
||||
"current-password",
|
||||
);
|
||||
expect(password).not.toHaveAttribute("placeholder");
|
||||
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles password visibility without changing the password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
await user.type(password, "correct horse battery staple");
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show password" }));
|
||||
|
||||
expect(password).toHaveAttribute("type", "text");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
const hidePassword = screen.getByRole("button", { name: "Hide password" });
|
||||
expect(hidePassword).toHaveFocus();
|
||||
|
||||
await user.click(hidePassword);
|
||||
|
||||
expect(password).toHaveAttribute("type", "password");
|
||||
expect(password).toHaveValue("correct horse battery staple");
|
||||
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("explains and focuses an empty auth password", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValue(
|
||||
new Error("bootstrap failed: HTTP 401"),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByLabelText("Password");
|
||||
const connect = screen.getByRole("button", { name: "Connect" });
|
||||
expect(connect).toBeEnabled();
|
||||
fireEvent.click(connect);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Enter your password.",
|
||||
);
|
||||
expect(password).toHaveAttribute("aria-invalid", "true");
|
||||
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
|
||||
expect(password).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the auth form when bootstrap does not issue an API token", async () => {
|
||||
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
|
||||
new BootstrapAuthRequiredError(
|
||||
@@ -329,8 +386,11 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Incorrect password. Try again."),
|
||||
).not.toBeInTheDocument();
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -341,11 +401,16 @@ describe("App layout", () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
const password = await screen.findByPlaceholderText("Password");
|
||||
const password = await screen.findByLabelText("Password");
|
||||
fireEvent.change(password, { target: { value: "wrong-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
|
||||
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
|
||||
const retryPassword = await screen.findByLabelText("Password");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Incorrect password. Try again.",
|
||||
);
|
||||
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
|
||||
expect(retryPassword).toHaveFocus();
|
||||
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
|
||||
expect(connectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -365,6 +430,21 @@ describe("App layout", () => {
|
||||
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses one main landmark and a page heading in desktop settings", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
const { container } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("main")).toHaveLength(1);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
render(<App />);
|
||||
|
||||
@@ -652,6 +732,57 @@ describe("App layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the first message when the gateway rejects a project", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
createChatSpy.mockRejectedValueOnce(
|
||||
new Error("workspace_scope_rejected:project_path must be an existing directory"),
|
||||
);
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "restricted",
|
||||
default_scope: {
|
||||
project_path: "C:\\workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
|
||||
fireEvent.change(await screen.findByLabelText("Paste path"), {
|
||||
target: { value: "C:\\missing-project" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
|
||||
const message = screen.getByLabelText("Message input");
|
||||
fireEvent.change(message, { target: { value: "keep this first message" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
||||
expect(message).toHaveValue("keep this first message");
|
||||
const projectButton = screen.getByRole("button", { name: "Choose project" });
|
||||
await waitFor(() => expect(projectButton).toHaveFocus());
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
fireEvent.click(projectButton);
|
||||
const projectPath = await screen.findByLabelText("Paste path");
|
||||
expect(projectPath).toHaveValue("C:\\missing-project");
|
||||
expect(projectPath).toHaveAttribute("aria-invalid", "true");
|
||||
expect(projectPath).toHaveFocus();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
|
||||
);
|
||||
expect(window.location.hash).toBe("");
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
|
||||
Reference in New Issue
Block a user