test(canvas/ChatTab): add abilities banner coverage #1364

Merged
agent-reviewer merged 1 commits from feat/canvas-abilities-banner-test into staging 2026-06-09 07:15:04 +00:00
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
//
// Tests for the talk_to_user_enabled banner in ChatTab.
//
// The banner appears when the agent's workspace has talkToUserEnabled=false.
// It shows an informational message and an "Enable" button that calls:
// PATCH /workspaces/:id/abilities { talk_to_user_enabled: true }
// and updates the canvas store on success.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
// ── API mock ──────────────────────────────────────────────────────────
const apiPatch = vi.fn<[_path: string, _body: unknown], Promise<void>>();
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(() => Promise.resolve([])),
post: vi.fn(() => Promise.resolve({})),
patch: (path: string, body: unknown) => apiPatch(path, body),
del: vi.fn(),
put: vi.fn(),
},
}));
// ── Store mock ────────────────────────────────────────────────────────
// Must include agentMessages / consumeAgentMessages — used by useChatSocket
// inside ChatTab even when the banner is shown. Also provides getState()
// so ChatTab can call useCanvasStore.getState().updateNodeData() directly.
const mockState = {
updateNodeData: vi.fn(),
agentMessages: {} as Record<string, unknown[]>,
consumeAgentMessages: vi.fn(() => []),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(selector?: (s: typeof mockState) => unknown) =>
selector ? selector(mockState) : mockState,
{ getState: () => mockState },
) as unknown as typeof import("@/store/canvas").useCanvasStore,
}));
// ── Upload mock ───────────────────────────────────────────────────────
vi.mock("../chat/uploads", async () => {
const actual = await vi.importActual<typeof import("../chat/uploads")>("../chat/uploads");
return { ...actual, downloadChatFile: vi.fn() };
});
// ── Helpers ────────────────────────────────────────────────────────────
const minimalData = {
status: "online" as const,
runtime: "claude-code",
currentTask: null,
} as unknown as import("@/store/canvas").WorkspaceNodeData;
beforeEach(() => {
apiPatch.mockClear();
mockState.updateNodeData.mockClear();
Element.prototype.scrollIntoView = vi.fn();
class FakeIO {
observe() {}
unobserve() {}
disconnect() {}
}
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
(globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = FakeIO;
});
import { ChatTab } from "../ChatTab";
// ─── Banner renders ───────────────────────────────────────────────────────────
describe("ChatTab — talk_to_user_enabled banner", () => {
it("shows the banner when talkToUserEnabled is false", () => {
const data = { ...minimalData, talkToUserEnabled: false };
render(<ChatTab workspaceId="ws-disabled" data={data} />);
// screen.getByText throws if not found, so absence of error = found
expect(screen.getByText((content: string) =>
content.includes("Agent is not enabled to chat with you"),
)).toBeTruthy();
expect(screen.getByRole("button", { name: /enable/i })).toBeTruthy();
});
it("does NOT show the banner when talkToUserEnabled is true", () => {
const data = { ...minimalData, talkToUserEnabled: true };
render(<ChatTab workspaceId="ws-enabled" data={data} />);
expect(screen.queryByText((content: string) =>
content.includes("Agent is not enabled to chat with you"),
)).toBeFalsy();
});
it("does NOT show the banner when talkToUserEnabled is absent (undefined)", () => {
const data = { ...minimalData };
render(<ChatTab workspaceId="ws-unset" data={data} />);
expect(screen.queryByText((content: string) =>
content.includes("Agent is not enabled to chat with you"),
)).toBeFalsy();
});
// ─── Enable button ────────────────────────────────────────────────────────────
it('Enable button calls PATCH /workspaces/:id/abilities with talk_to_user_enabled: true', async () => {
const data = { ...minimalData, talkToUserEnabled: false };
apiPatch.mockResolvedValue(undefined);
render(<ChatTab workspaceId="ws-banner-1" data={data} />);
fireEvent.click(screen.getByRole("button", { name: /enable/i }));
await waitFor(() => {
expect(apiPatch).toHaveBeenCalledOnce();
expect(apiPatch).toHaveBeenCalledWith(
"/workspaces/ws-banner-1/abilities",
{ talk_to_user_enabled: true },
);
});
});
it("Enable button updates the store on success", async () => {
const data = { ...minimalData, talkToUserEnabled: false };
apiPatch.mockResolvedValue(undefined);
render(<ChatTab workspaceId="ws-banner-2" data={data} />);
fireEvent.click(screen.getByRole("button", { name: /enable/i }));
await waitFor(() => {
expect(mockState.updateNodeData).toHaveBeenCalledOnce();
expect(mockState.updateNodeData).toHaveBeenCalledWith("ws-banner-2", { talkToUserEnabled: true });
});
});
it("Enable button does NOT throw when PATCH fails — error is silently swallowed", async () => {
const data = { ...minimalData, talkToUserEnabled: false };
apiPatch.mockRejectedValue(new Error("network error"));
render(<ChatTab workspaceId="ws-banner-3" data={data} />);
// Should not throw — the catch block swallows the error
fireEvent.click(screen.getByRole("button", { name: /enable/i }));
// Store update should NOT have been called since the API call failed
await waitFor(() => {
expect(mockState.updateNodeData).not.toHaveBeenCalled();
});
});
});