nanobot/webui/src/tests/nanobot-client.test.ts
Xubin Ren 3a420136bb
feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
2026-05-29 03:42:53 +08:00

707 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NanobotClient } from "@/lib/nanobot-client";
/**
* Minimal fake WebSocket implementing the subset NanobotClient touches.
* Every instance is retrievable via ``FakeSocket.instances`` so tests can
* drive open/close/message lifecycles deterministically.
*/
class FakeSocket {
static instances: FakeSocket[] = [];
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
url: string;
readyState = FakeSocket.CONNECTING;
sent: string[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev?: { code?: number }) => void) | null = null;
constructor(url: string) {
this.url = url;
FakeSocket.instances.push(this);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = FakeSocket.CLOSED;
this.onclose?.();
}
/** Simulate a server-initiated drop with a specific wire-level close code
* (e.g. ``1009`` for Message Too Big). */
fakeCloseWithCode(code: number) {
this.readyState = FakeSocket.CLOSED;
this.onclose?.({ code });
}
fakeOpen() {
this.readyState = FakeSocket.OPEN;
this.onopen?.();
}
fakeMessage(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
}
function lastSocket(): FakeSocket {
const s = FakeSocket.instances.at(-1);
if (!s) throw new Error("no socket created yet");
return s;
}
beforeEach(() => {
FakeSocket.instances = [];
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("NanobotClient", () => {
it("routes events to the matching chat handler", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("chat-a", handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "message", chat_id: "chat-a", text: "hi" });
lastSocket().fakeMessage({ event: "message", chat_id: "chat-b", text: "no" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0]).toMatchObject({
event: "message",
chat_id: "chat-a",
text: "hi",
});
});
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
// Nobody listening yet — deltas must not be dropped (user switched away).
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "a" });
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "b" });
const handler = vi.fn();
client.onChat("chat-queue", handler);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[0][0]).toMatchObject({ event: "delta", text: "a" });
expect(handler.mock.calls[1][0]).toMatchObject({ event: "delta", text: "b" });
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "c" });
expect(handler).toHaveBeenCalledTimes(3);
});
it("records goal_status run strip without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
expect(client.getRunStartedAt("chat-strip")).toBe(12_345);
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "idle",
});
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears run strip when a turn_end arrives without idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-strip",
});
expect(client.getRunStartedAt("chat-strip")).toBeNull();
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
});
it("notifies run status subscribers and replays running chats", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-status",
status: "running",
started_at: 12_345,
});
expect(handler).toHaveBeenCalledWith("chat-status", 12_345);
const lateHandler = vi.fn();
client.onRunStatus(lateHandler);
expect(lateHandler).toHaveBeenCalledWith("chat-status", 12_345);
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-status",
status: "idle",
});
expect(handler).toHaveBeenCalledWith("chat-status", null);
expect(lateHandler).toHaveBeenCalledWith("chat-status", null);
});
it("records goal_state per chat_id without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-a",
goal_state: { active: true, ui_summary: "Docs" },
});
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-b",
goal_state: { active: true, objective: "Ship API" },
});
expect(client.getGoalState("chat-goal-a")).toEqual({ active: true, ui_summary: "Docs" });
expect(client.getGoalState("chat-goal-b")).toEqual({
active: true,
objective: "Ship API",
});
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-a",
goal_state: { active: false },
});
expect(client.getGoalState("chat-goal-a")).toEqual({ active: false });
});
it("records goal_state from turn_end payload when present", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-te",
goal_state: { active: true, objective: "Long task" },
});
expect(client.getGoalState("chat-te")).toEqual({ active: true, objective: "Long task" });
});
it("buffers after unsubscribe until the chat is subscribed again", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const h1 = vi.fn();
const unsub = client.onChat("chat-rejoin", h1);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "live" });
expect(h1).toHaveBeenCalledTimes(1);
unsub();
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "queued" });
expect(h1).toHaveBeenCalledTimes(1);
const h2 = vi.fn();
client.onChat("chat-rejoin", h2);
expect(h2).toHaveBeenCalledTimes(1);
expect(h2.mock.calls[0][0]).toMatchObject({ event: "delta", text: "queued" });
});
it("dispatches runtime model updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRuntimeModelUpdate(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "runtime_model_updated",
model_name: "openai/gpt-4.1",
model_preset: "fast",
});
expect(handler).toHaveBeenCalledWith("openai/gpt-4.1", "fast");
});
it("dispatches session updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const globalHandler = vi.fn();
const chatHandler = vi.fn();
client.onSessionUpdate(globalHandler);
client.onChat("chat-title", chatHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "session_updated",
chat_id: "chat-title",
scope: "metadata",
workspace_scope: {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
restrict_to_workspace: true,
},
});
expect(globalHandler).toHaveBeenCalledWith(
"chat-title",
"metadata",
expect.objectContaining({ project_path: "/tmp/project" }),
);
expect(chatHandler).not.toHaveBeenCalled();
});
it("resolves newChat() via the server-assigned chat_id", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const promise = client.newChat(1_000);
expect(lastSocket().sent).toContain(JSON.stringify({ type: "new_chat" }));
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
await expect(promise).resolves.toBe("fresh-id");
});
it("serializes workspace scope for new chats and messages", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const workspaceScope = {
project_path: "/tmp/project",
project_name: "project",
access_mode: "full" as const,
restrict_to_workspace: false,
};
client.connect();
lastSocket().fakeOpen();
const promise = client.newChat(1_000, workspaceScope);
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "new_chat", workspace_scope: workspaceScope }),
);
lastSocket().fakeMessage({ event: "attached", chat_id: "fresh-id" });
await expect(promise).resolves.toBe("fresh-id");
client.sendMessage("fresh-id", "hello", undefined, { workspaceScope });
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "fresh-id",
content: "hello",
workspace_scope: workspaceScope,
webui: true,
}),
);
});
it("queues sends while connecting and flushes on open", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
client.sendMessage("chat-x", "hello");
expect(lastSocket().sent).toEqual([]);
lastSocket().fakeOpen();
// Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
);
});
it("includes image generation options in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-img",
"draw a banner",
undefined,
{ imageGeneration: { enabled: true, aspect_ratio: "16:9" } },
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-img",
content: "draw a banner",
image_generation: { enabled: true, aspect_ratio: "16:9" },
webui: true,
}),
);
});
it("includes CLI app attachments in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-cli",
"@drawio please make this diagram",
undefined,
{
cliApps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
},
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-cli",
content: "@drawio please make this diagram",
cli_apps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
webui: true,
}),
);
});
it("includes MCP preset attachments in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-mcp",
"@browserbase check this page",
undefined,
{
mcpPresets: [{
name: "browserbase",
display_name: "Browserbase",
category: "browser",
transport: "streamableHttp",
status: "configured",
configured: true,
logo_url: "https://example.invalid/browserbase.svg",
brand_color: "#111827",
}],
},
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-mcp",
content: "@browserbase check this page",
mcp_presets: [{
name: "browserbase",
display_name: "Browserbase",
category: "browser",
transport: "streamableHttp",
status: "configured",
configured: true,
logo_url: "https://example.invalid/browserbase.svg",
brand_color: "#111827",
}],
webui: true,
}),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.onChat("chat-z", () => {});
client.connect();
lastSocket().fakeOpen();
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
);
// Drop the socket.
lastSocket().close();
// Advance the backoff timer.
await vi.advanceTimersByTimeAsync(20);
const reconnected = lastSocket();
expect(reconnected).not.toBe(FakeSocket.instances[0]);
reconnected.fakeOpen();
expect(reconnected.sent).toContain(
JSON.stringify({ type: "attach", chat_id: "chat-z" }),
);
});
it("reports status transitions through onStatus", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
lastSocket().close();
expect(seen).toEqual(["idle", "connecting", "open", "closed"]);
});
it("does not schedule a reconnect when close() is called explicitly", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 10,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
client.close();
// Advance past any possible backoff window to prove no reconnect was scheduled.
await vi.advanceTimersByTimeAsync(200);
expect(FakeSocket.instances).toHaveLength(1);
// "reconnecting" must never appear after an intentional close.
expect(seen).not.toContain("reconnecting");
expect(seen.at(-1)).toBe("closed");
});
it("passes media through into the message envelope", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "look", [
{ data_url: "data:image/png;base64,AAAA", name: "shot.png" },
]);
const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
expect(lastFrame).toEqual({
type: "message",
chat_id: "chat-x",
content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
});
});
it("omits media from the envelope when no images are attached", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-x", "hello");
const lastFrame = JSON.parse(lastSocket().sent.at(-1) as string);
expect(lastFrame).not.toHaveProperty("media");
expect(lastFrame).toEqual({
type: "message",
chat_id: "chat-x",
content: "hello",
webui: true,
});
});
it("emits a message_too_big error when the socket closes with code 1009", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string }> = [];
client.onError((e) => errors.push(e));
client.connect();
lastSocket().fakeOpen();
// Server rejected an outbound frame as too large.
lastSocket().fakeCloseWithCode(1009);
expect(errors).toEqual([{ kind: "message_too_big" }]);
});
it("emits workspace scope rejection errors from server frames", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; reason?: string; chatId?: string }> = [];
client.onError((e) => errors.push(e));
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-a",
detail: "workspace_scope_rejected",
reason: "chat_running",
});
expect(errors).toEqual([
{
kind: "workspace_scope_rejected",
reason: "chat_running",
chatId: "chat-a",
},
]);
});
it("rejects pending new chats when workspace scope is rejected", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const pending = client.newChat(5_000, {
project_path: "/missing",
project_name: "missing",
access_mode: "restricted",
});
lastSocket().fakeMessage({
event: "error",
detail: "workspace_scope_rejected",
reason: "project_path must be an existing directory",
});
await expect(pending).rejects.toThrow("workspace_scope_rejected");
});
it("isolates throwing error handlers so reconnect bookkeeping still runs", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 5,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
// First handler explodes; subsequent reconnect state must be untouched.
client.onError(() => {
throw new Error("subscriber blew up");
});
const seenStatuses: string[] = [];
client.onStatus((s) => seenStatuses.push(s));
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeCloseWithCode(1009);
// Despite the throwing handler, the client must still schedule a reconnect.
expect(seenStatuses).toContain("reconnecting");
await vi.advanceTimersByTimeAsync(20);
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
it("does not emit a stream error on a vanilla socket close", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string }> = [];
client.onError((e) => errors.push(e));
client.connect();
lastSocket().fakeOpen();
lastSocket().close();
expect(errors).toEqual([]);
});
it("surfaces 'reconnecting' only on an unexpected drop", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 5,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const seen: string[] = [];
client.onStatus((s) => seen.push(s));
client.connect();
lastSocket().fakeOpen();
// Simulate the remote side hanging up (no client.close() call).
lastSocket().close();
await vi.advanceTimersByTimeAsync(50);
expect(seen).toContain("reconnecting");
expect(FakeSocket.instances.length).toBeGreaterThan(1);
});
});