From c0e60da238b68a9a903fbf7a3e6f32f0a66aae3c Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Sat, 16 May 2026 16:21:42 +0000 Subject: [PATCH] test(canvas/ChatTab): add abilities banner coverage Tests the talk_to_user_enabled=false banner and Enable button in ChatTab: - Banner renders when talkToUserEnabled=false - Banner absent when true or undefined - Enable button calls PATCH /workspaces/:id/abilities { talk_to_user_enabled: true } - Store updated on success - Errors silently swallowed (no crash on network failure) Refs: #1312 --- .../ChatTab.abilitiesBanner.test.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 canvas/src/components/tabs/__tests__/ChatTab.abilitiesBanner.test.tsx diff --git a/canvas/src/components/tabs/__tests__/ChatTab.abilitiesBanner.test.tsx b/canvas/src/components/tabs/__tests__/ChatTab.abilitiesBanner.test.tsx new file mode 100644 index 000000000..290122104 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ChatTab.abilitiesBanner.test.tsx @@ -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>(); +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, + 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("../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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + }); + }); +}); -- 2.52.0