diff --git a/canvas/src/components/tabs/__tests__/EventsTab.test.tsx b/canvas/src/components/tabs/__tests__/EventsTab.test.tsx new file mode 100644 index 00000000..aea9a71d --- /dev/null +++ b/canvas/src/components/tabs/__tests__/EventsTab.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +/** + * Tests for EventsTab component. + * + * Covers: formatTime pure function, EVENT_COLORS constant, + * loading/error/empty states, event list rendering, expand/collapse, + * refresh button, auto-refresh setup. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventsTab } from "../EventsTab"; + +// 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(); + vi.restoreAllMocks(); +}); + +// ─── formatTime tests (via rendered output) ──────────────────────────────────── + +describe("EventsTab — formatTime", () => { + it("shows 'ago' for events less than a minute old", async () => { + const now = new Date(); + const recent = new Date(now.getTime() - 30_000).toISOString(); + _mockGet.mockResolvedValueOnce([ + { id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: recent }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText(/ago/)).toBeTruthy(); + }); + }); + + it("shows 'm ago' for events less than an hour old", async () => { + const now = new Date(); + const minsAgo = new Date(now.getTime() - 5 * 60_000).toISOString(); + _mockGet.mockResolvedValueOnce([ + { id: "e1", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: minsAgo }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText(/m ago/)).toBeTruthy(); + }); + }); + + it("shows 'h ago' for events less than a day old", async () => { + const now = new Date(); + const hoursAgo = new Date(now.getTime() - 3 * 3_600_000).toISOString(); + _mockGet.mockResolvedValueOnce([ + { id: "e1", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: hoursAgo }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText(/h ago/)).toBeTruthy(); + }); + }); +}); + +// ─── EVENT_COLORS rendering ─────────────────────────────────────────────────── + +describe("EventsTab — EVENT_COLORS", () => { + it("renders all known event types without crashing", async () => { + const eventTypes = [ + "WORKSPACE_ONLINE", + "WORKSPACE_OFFLINE", + "WORKSPACE_DEGRADED", + "WORKSPACE_PROVISIONING", + "WORKSPACE_REMOVED", + "WORKSPACE_PROVISION_FAILED", + "AGENT_CARD_UPDATED", + ]; + _mockGet.mockResolvedValueOnce( + eventTypes.map((event_type, i) => ({ + id: `e-${i}`, event_type, workspace_id: null, payload: {}, created_at: new Date().toISOString(), + })), + ); + render(); + await waitFor(() => { + for (const et of eventTypes) { + expect(screen.getByText(et)).toBeTruthy(); + } + }); + }); + + it("renders unknown event types without crashing", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "e-unk", event_type: "UNKNOWN_EVENT_XYZ", workspace_id: null, payload: {}, created_at: new Date().toISOString() }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText("UNKNOWN_EVENT_XYZ")).toBeTruthy(); + }); + }); +}); + +// ─── States ─────────────────────────────────────────────────────────────────── + +describe("EventsTab — states", () => { + it("shows loading text initially", () => { + _mockGet.mockImplementation(() => new Promise(() => {})); // never resolves + render(); + expect(screen.getByText("Loading events...")).toBeTruthy(); + }); + + it("shows empty message when no events returned", async () => { + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => { + expect(screen.getByText("No events yet")).toBeTruthy(); + }); + }); + + it("shows error alert when fetch fails", async () => { + _mockGet.mockRejectedValueOnce(new Error("server error")); + render(); + await waitFor(() => { + expect(screen.getByText(/server error/i)).toBeTruthy(); + }); + }); +}); + +// ─── Event list ─────────────────────────────────────────────────────────────── + +describe("EventsTab — event list", () => { + it("renders all returned events", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { foo: 1 }, created_at: new Date().toISOString() }, + { id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: { bar: 2 }, created_at: new Date().toISOString() }, + ]); + render(); + await waitFor(() => { + expect(screen.getAllByText(/WORKSPACE_/).length).toBeGreaterThanOrEqual(2); + }); + }); + + it("shows event count in header", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() }, + { id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() }, + { id: "e3", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: new Date().toISOString() }, + ]); + render(); + await waitFor(() => { + expect(screen.getByText("3 events")).toBeTruthy(); + }); + }); + + it("expands payload panel on click", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "e-expand", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { key: "value" }, created_at: new Date().toISOString() }, + ]); + render(); + await waitFor(() => screen.getByText("WORKSPACE_ONLINE")); + + fireEvent.click(screen.getByText("WORKSPACE_ONLINE")); + + await waitFor(() => { + expect(screen.getByText(/"key":\s*"value"/)).toBeTruthy(); + }); + }); + + it("collapses expanded panel on second click", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "e-collapse", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: { x: 1 }, created_at: new Date().toISOString() }, + ]); + render(); + await waitFor(() => screen.getByText("WORKSPACE_DEGRADED")); + + fireEvent.click(screen.getByText("WORKSPACE_DEGRADED")); + await waitFor(() => expect(screen.getByText(/"x":\s*1/)).toBeTruthy()); + + fireEvent.click(screen.getByText("WORKSPACE_DEGRADED")); + await waitFor(() => { + expect(screen.queryByText(/"x":\s*1/)).toBeNull(); + }); + }); +}); + +// ─── Refresh button ─────────────────────────────────────────────────────────── + +describe("EventsTab — refresh", () => { + it("has a Refresh button", async () => { + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => {}); + expect(screen.getByRole("button", { name: /refresh/i })).toBeTruthy(); + }); + + it("Refresh button triggers a reload", async () => { + _mockGet.mockResolvedValueOnce([]); + render(); + await waitFor(() => screen.getByRole("button", { name: /refresh/i })); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + // Called at least twice: initial load + refresh click + expect(_mockGet).toHaveBeenCalled(); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/ScheduleTab.test.tsx b/canvas/src/components/tabs/__tests__/ScheduleTab.test.tsx new file mode 100644 index 00000000..34ee2213 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ScheduleTab.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +/** + * Tests for ScheduleTab component. + * + * Covers: cronToHuman pure function, relativeTime pure function, + * loading/error/empty states, schedule list rendering. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ScheduleTab } from "../ScheduleTab"; + +const _mockGet = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock("@/lib/api", () => ({ + api: { get: _mockGet }, +})); + +afterEach(() => { + cleanup(); + _mockGet.mockReset(); +}); + +// ─── cronToHuman tests ───────────────────────────────────────────────────── + +describe("ScheduleTab — cronToHuman", () => { + it('returns "Every minute" for "* * * * *"', async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "* * * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Every minute")).toBeTruthy(); + }); + + it("returns 'Every X minutes' for '*/X * * * *'", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "*/15 * * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Every 15 minutes")).toBeTruthy(); + }); + + it("returns 'Every X hours' for '0 */X * * *'", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 */3 * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Every 3 hours")).toBeTruthy(); + }); + + it("returns 'Daily at HH:MM UTC' for daily schedules", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "30 14 * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Daily at 14:30 UTC")).toBeTruthy(); + }); + + it("returns 'Weekdays at HH:MM UTC' for weekday schedules", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * 1-5", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Weekdays at 09:00 UTC")).toBeTruthy(); + }); + + it("falls back to raw expression for unrecognised patterns", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 0 1 * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("0 0 1 * *")).toBeTruthy(); + }); + + it("falls back to raw expression for malformed input", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "not a cron", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("not a cron")).toBeTruthy(); + }); +}); + +// ─── relativeTime tests ───────────────────────────────────────────────────── + +describe("ScheduleTab — relativeTime", () => { + it('shows "Last: never" when last_run_at is null', async () => { + // Use mockResolvedValue (persistent) instead of mockResolvedValueOnce because + // ScheduleTab's 10 s auto-refresh interval fires and calls fetchSchedules + // a second time, consuming a one-time mock and clearing the DOM. + _mockGet.mockResolvedValue([ + { id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + // Use "Last: never" to match the exact label text in ScheduleTab.tsx:349. + // findByText("never") would throw on the multiple-match ambiguity since + // "never" also appears in the "Next: never" span. + expect(await screen.findByText("Last: never")).toBeTruthy(); + }); +}); + +// ─── States ─────────────────────────────────────────────────────────────── + +describe("ScheduleTab — states", () => { + it("shows empty message when no schedules", async () => { + _mockGet.mockResolvedValueOnce([]); + render(); + expect(await screen.findByText("No schedules yet")).toBeTruthy(); + }); + // Note: ScheduleTab silently swallows fetch errors (no error state for + // the initial load). Error state only exists for form-level actions + // (save/delete/toggle) which require api.post/del/patch mocking. +}); + +// ─── Schedule list ───────────────────────────────────────────────────────── + +describe("ScheduleTab — list", () => { + it("renders schedule name", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Nightly Run", cron_expr: "0 2 * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Nightly Run")).toBeTruthy(); + }); + + it("renders multiple schedules", async () => { + _mockGet.mockResolvedValueOnce([ + { id: "s1", workspace_id: "ws-1", name: "Schedule A", cron_expr: "0 9 * * *", + timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + { id: "s2", workspace_id: "ws-1", name: "Schedule B", cron_expr: "*/15 * * * *", + timezone: "UTC", prompt: "", enabled: false, last_run_at: null, next_run_at: null, + run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() }, + ]); + render(); + expect(await screen.findByText("Schedule A")).toBeTruthy(); + expect(await screen.findByText("Schedule B")).toBeTruthy(); + }); +});