diff --git a/canvas/src/components/tabs/__tests__/ChannelsTab.test.tsx b/canvas/src/components/tabs/__tests__/ChannelsTab.test.tsx new file mode 100644 index 00000000..9fe5d5dd --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ChannelsTab.test.tsx @@ -0,0 +1,271 @@ +// @vitest-environment jsdom +/** + * Tests for ChannelsTab component. + * + * Covers: relativeTime pure function, SUPPORTS_DETECT_CHATS constant, + * loading/empty/error states, channel list rendering (enabled/disabled), + * auto-refresh interval, header + Connect button. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChannelsTab } from "../ChannelsTab"; + +// Mock @/lib/api — hoisted so it's applied before the module loads. +const _mockGet = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock("@/lib/api", () => ({ + api: { get: _mockGet }, +})); + +afterEach(() => { + cleanup(); + _mockGet.mockReset(); +}); + +// ─── SUPPORTS_DETECT_CHATS ───────────────────────────────────────────────── + +describe("ChannelsTab — SUPPORTS_DETECT_CHATS", () => { + it("supports Detect Chats for telegram", async () => { + // Telegram is the only platform that supports Detect Chats. + // This is a smoke test: the tab must render without crashing. + // NOTE: ChannelsTab calls Promise.allSettled([channels, adapters]) so + // both must be mocked for the load() to complete and exit loading state. + _mockGet.mockResolvedValueOnce([]); + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => { + expect(screen.getByText("Channels")).toBeTruthy(); + }); + }); +}); + +// ─── States ─────────────────────────────────────────────────────────────── + +describe("ChannelsTab — states", () => { + it("shows loading text initially", () => { + _mockGet.mockImplementation( + () => new Promise(() => {}) as unknown as Promise + ); + render(); + expect(screen.getByText("Loading channels...")).toBeTruthy(); + }); + + it("shows empty message when no channels", async () => { + _mockGet.mockResolvedValueOnce([]); + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => { + expect(screen.getByText("No channels connected")).toBeTruthy(); + }); + }); + + it("shows error alert when channels fetch fails", async () => { + // Channels fails; adapters succeeds. Both must be resolved/rejected + // for Promise.allSettled to settle and setLoading(false). + _mockGet.mockRejectedValueOnce(new Error("server error")); + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => { + // The component renders a generic error message, not the raw error text. + expect(screen.getByText(/Failed to load connected channels/i)).toBeTruthy(); + }); + }); + + it("shows error alert when adapters fetch fails", async () => { + _mockGet.mockResolvedValueOnce([]); + _mockGet.mockRejectedValueOnce(new Error("adapters error")); + render(); + await waitFor(() => { + expect(screen.getByText(/Failed to load platforms/i)).toBeTruthy(); + }); + }); +}); + +// ─── Channel list ────────────────────────────────────────────────────────── + +describe("ChannelsTab — channel list", () => { + // ChannelsTab.load() calls Promise.allSettled([channels, adapters]). + // Both must be mocked for load() to complete and exit loading state. + const channelsPayload = (channels: object[]) => channels; + const adaptersPayload: unknown[] = []; + + it("renders one channel", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: { chat_id: "12345" }, enabled: true, allowed_users: [], + message_count: 10, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("Telegram")).toBeTruthy(); + }); + }); + + it("renders multiple channels", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([ + { id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: { chat_id: "1" }, enabled: true, allowed_users: [], + message_count: 5, last_message_at: null, created_at: new Date().toISOString() }, + { id: "ch-2", workspace_id: "ws-1", channel_type: "slack", + config: { channel_id: "C0123" }, enabled: false, allowed_users: [], + message_count: 0, last_message_at: null, created_at: new Date().toISOString() }, + ])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("Telegram")).toBeTruthy(); + expect(screen.getByText("Slack")).toBeTruthy(); + }); + }); + + it("capitalises channel type", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "discord", + config: {}, enabled: true, allowed_users: [], + message_count: 0, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("Discord")).toBeTruthy(); + }); + }); + + it("shows 'On' toggle for enabled channel", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 1, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + // Use exact string "On" (not regex) — "Connect" contains "on" and would + // match /on/i, causing multiple-element errors. + expect(screen.getByRole("button", { name: "On" })).toBeTruthy(); + }); + }); + + it("shows 'Off' toggle for disabled channel", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: false, allowed_users: [], + message_count: 1, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Off" })).toBeTruthy(); + }); + }); + + it("shows message count", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 42, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("42 messages")).toBeTruthy(); + }); + }); + + it("shows 'Last: never' when last_message_at is null", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 0, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("Last: never")).toBeTruthy(); + }); + }); + + it("shows allowed user count when users are present", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: ["alice", "bob"], + message_count: 0, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByText("2 allowed user(s)")).toBeTruthy(); + }); + }); + + it("has a Test button for each channel", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 0, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /test/i })).toBeTruthy(); + }); + }); + + it("has a Remove button for each channel", async () => { + _mockGet.mockResolvedValueOnce(channelsPayload([{ + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 0, last_message_at: null, created_at: new Date().toISOString(), + }])); + _mockGet.mockResolvedValueOnce(adaptersPayload); + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /remove/i })).toBeTruthy(); + }); + }); +}); + +// ─── Header + Connect button ─────────────────────────────────────────────── + +describe("ChannelsTab — header", () => { + it("renders the Channels heading", async () => { + _mockGet.mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.getByText("Channels")).toBeTruthy(); + }); + }); + + it("has a Connect button when channels exist", async () => { + _mockGet.mockResolvedValue([ + { + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 1, last_message_at: null, created_at: new Date().toISOString(), + }, + ]); + render(); + await waitFor(() => { + expect(screen.getByRole("button", { name: /connect/i })).toBeTruthy(); + }); + }); + + it("has a Cancel button when form is open", async () => { + _mockGet.mockResolvedValue([ + { + id: "ch-1", workspace_id: "ws-1", channel_type: "telegram", + config: {}, enabled: true, allowed_users: [], + message_count: 1, last_message_at: null, created_at: new Date().toISOString(), + }, + ]); + render(); + await waitFor(() => { + const btn = screen.getByRole("button", { name: /connect/i }); + fireEvent.click(btn); + }); + await waitFor(() => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeTruthy(); + }); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/TracesTab.test.tsx b/canvas/src/components/tabs/__tests__/TracesTab.test.tsx new file mode 100644 index 00000000..bda63ab4 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/TracesTab.test.tsx @@ -0,0 +1,292 @@ +// @vitest-environment jsdom +/** + * Tests for TracesTab component. + * + * Covers: loading/empty/error states, trace list rendering, expand/collapse, + * status dot coloring, latency formatting, token usage display, Refresh button. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TracesTab } from "../TracesTab"; + +const _mockGet = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock("@/lib/api", () => ({ + api: { get: _mockGet }, +})); + +afterEach(() => { + cleanup(); + _mockGet.mockReset(); +}); + +// ─── States ─────────────────────────────────────────────────────────────── + +describe("TracesTab — states", () => { + it("shows loading text initially", () => { + _mockGet.mockImplementation( + () => new Promise(() => {}) as unknown as Promise + ); + render(); + expect(screen.getByText("Loading traces...")).toBeTruthy(); + }); + + it("shows empty message when no traces", async () => { + _mockGet.mockResolvedValueOnce({ data: [] }); + render(); + await waitFor(() => { + expect(screen.getByText("No traces yet")).toBeTruthy(); + }); + }); + + it("shows empty message when API returns null data", async () => { + _mockGet.mockResolvedValueOnce({ data: null }); + render(); + await waitFor(() => { + expect(screen.getByText("No traces yet")).toBeTruthy(); + }); + }); + + it("shows error alert when fetch fails", async () => { + _mockGet.mockRejectedValueOnce(new Error("trace service unavailable")); + render(); + await waitFor(() => { + expect(screen.getByText(/trace service unavailable/i)).toBeTruthy(); + }); + }); +}); + +// ─── Trace list ────────────────────────────────────────────────────────── + +describe("TracesTab — trace list", () => { + it("renders one trace", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "deploy-agent", + timestamp: new Date().toISOString(), + status: "ok", + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("deploy-agent")).toBeTruthy(); + }); + }); + + it("renders trace name as fallback to 'trace'", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "", + timestamp: new Date().toISOString(), + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("trace")).toBeTruthy(); + }); + }); + + it("shows trace count in header", async () => { + _mockGet.mockResolvedValueOnce({ + data: [ + { id: "t-1", name: "A", timestamp: new Date().toISOString() }, + { id: "t-2", name: "B", timestamp: new Date().toISOString() }, + { id: "t-3", name: "C", timestamp: new Date().toISOString() }, + ], + }); + render(); + await waitFor(() => { + expect(screen.getByText("3 traces")).toBeTruthy(); + }); + }); + + it("shows latency in milliseconds for values < 1000", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "Fast trace", + timestamp: new Date().toISOString(), + latency: 250, + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("250ms")).toBeTruthy(); + }); + }); + + it("shows latency in seconds for values >= 1000", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "Slow trace", + timestamp: new Date().toISOString(), + latency: 3500, + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("3.5s")).toBeTruthy(); + }); + }); + + it("shows token usage when present", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "AI trace", + timestamp: new Date().toISOString(), + usage: { input: 100, output: 200, total: 300 }, + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("300 tok")).toBeTruthy(); + }); + }); + + it("does not show latency when latency is absent", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-1", + name: "Trace", + timestamp: new Date().toISOString(), + }], + }); + render(); + await waitFor(() => { + expect(screen.getByText("Trace")).toBeTruthy(); + // Should have no "ms" or "s" latency text + expect(screen.queryByText(/\d+ms/)).toBeNull(); + expect(screen.queryByText(/\d+\.\d+s/)).toBeNull(); + }); + }); +}); + +// ─── Expand / Collapse ─────────────────────────────────────────────────── + +describe("TracesTab — expand/collapse", () => { + it("expands a trace row on click", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-expand", + name: "Expandable", + timestamp: new Date().toISOString(), + input: { prompt: "hello" }, + output: { result: "world" }, + totalCost: 0.000123, + }], + }); + render(); + await waitFor(() => screen.getByText("Expandable")); + + fireEvent.click(screen.getByText("Expandable")); + + await waitFor(() => { + expect(screen.getByText("Input")).toBeTruthy(); + expect(screen.getByText("Output")).toBeTruthy(); + }); + }); + + it("shows trace ID in expanded panel", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "trace-id-abc123", + name: "Trace", + timestamp: new Date().toISOString(), + }], + }); + render(); + await waitFor(() => screen.getByText("Trace")); + + fireEvent.click(screen.getByText("Trace")); + + await waitFor(() => { + expect(screen.getByText("trace-id-abc123")).toBeTruthy(); + }); + }); + + it("shows cost when totalCost is present", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-cost", + name: "Costly trace", + timestamp: new Date().toISOString(), + totalCost: 0.000456, + }], + }); + render(); + await waitFor(() => screen.getByText("Costly trace")); + + fireEvent.click(screen.getByText("Costly trace")); + + await waitFor(() => { + // Use regex — toFixed(6) is locale-sensitive (may render as "0,000456"). + expect(screen.getByText(/\$0\.000456/)).toBeTruthy(); + }); + }); + + it("collapses expanded row on second click", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-collapse", + name: "Collapsible", + timestamp: new Date().toISOString(), + input: { key: "value" }, + }], + }); + render(); + await waitFor(() => screen.getByText("Collapsible")); + + fireEvent.click(screen.getByText("Collapsible")); + await waitFor(() => expect(screen.getByText("Input")).toBeTruthy()); + + fireEvent.click(screen.getByText("Collapsible")); + await waitFor(() => { + expect(screen.queryByText("Input")).toBeNull(); + }); + }); + + it("has aria-expanded attribute on trace row", async () => { + _mockGet.mockResolvedValueOnce({ + data: [{ + id: "t-a11y", + name: "A11y trace", + timestamp: new Date().toISOString(), + }], + }); + render(); + await waitFor(() => screen.getByText("A11y trace")); + + const row = screen.getByText("A11y trace").closest("button"); + expect(row?.getAttribute("aria-expanded")).toBe("false"); + + fireEvent.click(row!); + await waitFor(() => { + expect(row?.getAttribute("aria-expanded")).toBe("true"); + }); + }); +}); + +// ─── Refresh button ─────────────────────────────────────────────────────── + +describe("TracesTab — refresh", () => { + it("has a Refresh button", async () => { + _mockGet.mockResolvedValueOnce({ data: [] }); + render(); + await waitFor(() => {}); + expect(screen.getByRole("button", { name: /refresh/i })).toBeTruthy(); + }); + + it("Refresh button triggers a reload", async () => { + _mockGet.mockResolvedValueOnce({ data: [] }); + render(); + await waitFor(() => screen.getByRole("button", { name: /refresh/i })); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + expect(_mockGet).toHaveBeenCalled(); + }); +});