feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+122
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureChannel,
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchAutomations,
fetchApiService,
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
@@ -28,6 +30,11 @@ import {
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
startApiService,
stopApiService,
cancelChannelConnect,
pollChannelConnect,
startChannelConnect,
updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
@@ -37,6 +44,7 @@ import {
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
validateChannel,
} from "@/lib/api";
describe("webui API helpers", () => {
@@ -116,6 +124,82 @@ describe("webui API helpers", () => {
);
});
it("validates channel settings with form values", async () => {
await validateChannel(
"tok",
"slack",
{ "channels.slack.botToken": "xoxb-test" },
{ instanceId: "default" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/validate?name=slack&instance_id=default",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.slack.botToken": "xoxb-test",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
);
});
it("configures channels through the WebSocket HTTP shim", async () => {
await configureChannel(
"tok",
"discord",
{ "channels.discord.token": "saved-secret" },
{ enable: true },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/configure?name=discord&enable=true",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.discord.token": "saved-secret",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
);
});
it("serializes channel QR connect helpers", async () => {
await startChannelConnect("tok", "weixin", { force: true });
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/start?force=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await pollChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await cancelChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
@@ -478,6 +562,44 @@ describe("webui API helpers", () => {
);
});
it("manages the API service capability", async () => {
await fetchApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService(
"tok",
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
},
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.stringContaining("secret-token"),
expect.anything(),
);
await stopApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/stop",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("reads MCP presets and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+42
View File
@@ -249,6 +249,8 @@ describe("App layout", () => {
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
localStorage.removeItem("nanobot-webui.restartStartedAt");
localStorage.removeItem("nanobot-webui.restartRoute");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
api_token: "api-tok",
@@ -343,6 +345,35 @@ describe("App layout", () => {
).toBeTruthy();
});
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");
window.history.replaceState(null, "", "/#/new");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/nanobot-features": {
features: [{
name: "websocket",
display_name: "Websocket",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
expect(window.location.hash).toBe("#/settings?section=channels");
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
@@ -1586,6 +1617,7 @@ describe("App layout", () => {
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Files" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
@@ -1708,6 +1740,16 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings?section=voice");
});
it("falls back to Overview for the retired Files settings URL", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
window.history.replaceState(null, "", "/#/settings?section=files");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
});
it("updates the URL hash when switching settings sections", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
describe("Slack setup manifest", () => {
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
"subscribes to %s",
(event) => expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${event}`),
);
it.each([
"app_mentions:read",
"channels:history",
"channels:read",
"chat:write",
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:write",
"mpim:history",
"reactions:write",
"users:read",
])("requests the %s scope", (scope) => {
expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${scope}`);
});
});
-1
View File
@@ -65,7 +65,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.capabilities",
"settings.sections.apps",
"settings.apps.description",
"settings.apps.filterPlugins",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.nanobotFeatures.disable",
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { isLoopbackHost } from "@/lib/network";
describe("isLoopbackHost", () => {
it.each(["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"])(
"accepts explicit loopback host %s",
(host) => expect(isLoopbackHost(host)).toBe(true),
);
it.each(["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"])(
"rejects network host %s",
(host) => expect(isLoopbackHost(host)).toBe(false),
);
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
__clearLogoFallbackCacheForTests,
useLogoFallback,
} from "@/hooks/useLogoFallback";
function TestLogo({ urls }: { urls: string[] }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(urls);
if (!logoUrl) return <span>No logo</span>;
return (
<img
src={logoUrl}
alt="Logo"
onLoad={onLogoLoad}
onError={onLogoError}
/>
);
}
describe("useLogoFallback", () => {
afterEach(() => {
__clearLogoFallbackCacheForTests();
});
it("remembers failed and loaded logo candidates across remounts", () => {
const urls = [
"https://bad.example/favicon.ico",
"https://good.example/favicon.ico",
];
const first = render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[0]);
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
fireEvent.load(screen.getByRole("img", { name: "Logo" }));
first.unmount();
render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
});
it("returns no logo once every candidate failed", () => {
const urls = ["https://bad.example/favicon.ico"];
render(<TestLogo urls={urls} />);
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByText("No logo")).toBeInTheDocument();
});
});