import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ScheduledTasksModal } from "../ScheduledTasksModal"; import type { ScheduledTask, AutomationRunResult, Routine } from "@fusion/core"; // Mock lucide-react vi.mock("lucide-react", () => ({ Plus: () => +, Clock: (props: any) => πŸ•, Play: () => β–Ά, Pause: () => ⏸, Pencil: () => ✎, Trash2: () => πŸ—‘, CheckCircle: () => βœ“, XCircle: () => βœ—, ChevronDown: () => β–Ό, ChevronUp: () => β–², Calendar: () => πŸ“…, Webhook: () => πŸ”—, Code: () => πŸ’», Zap: () => ⚑, })); // Mock @fusion/core (no runtime values needed β€” ScheduleForm inlines presets) vi.mock("@fusion/core", () => ({})); // Mock the API module const mockFetchAutomations = vi.fn(); const mockCreateAutomation = vi.fn(); const mockUpdateAutomation = vi.fn(); const mockDeleteAutomation = vi.fn(); const mockRunAutomation = vi.fn(); const mockToggleAutomation = vi.fn(); const mockFetchRoutines = vi.fn(); const mockCreateRoutine = vi.fn(); const mockUpdateRoutine = vi.fn(); const mockDeleteRoutine = vi.fn(); const mockRunRoutine = vi.fn(); vi.mock("../../api", () => ({ fetchAutomations: (...args: any[]) => mockFetchAutomations(...args), createAutomation: (...args: any[]) => mockCreateAutomation(...args), updateAutomation: (...args: any[]) => mockUpdateAutomation(...args), deleteAutomation: (...args: any[]) => mockDeleteAutomation(...args), runAutomation: (...args: any[]) => mockRunAutomation(...args), toggleAutomation: (...args: any[]) => mockToggleAutomation(...args), fetchRoutines: (...args: any[]) => mockFetchRoutines(...args), createRoutine: (...args: any[]) => mockCreateRoutine(...args), updateRoutine: (...args: any[]) => mockUpdateRoutine(...args), deleteRoutine: (...args: any[]) => mockDeleteRoutine(...args), runRoutine: (...args: any[]) => mockRunRoutine(...args), })); function makeSchedule(overrides: Partial = {}): ScheduledTask { return { id: "sched-1", name: "Test Schedule", description: "A test", scheduleType: "daily", cronExpression: "0 0 * * *", command: "echo hello", enabled: true, runCount: 0, runHistory: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, }; } function makeRoutine(overrides: Partial = {}): Routine { return { id: "routine-001", name: "Test Routine", description: "A test routine", trigger: { type: "cron", cronExpression: "0 * * * *" }, executionPolicy: "queue", catchUpPolicy: "run_one", enabled: true, runCount: 0, runHistory: [], createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z", ...overrides, }; } describe("ScheduledTasksModal", () => { const onClose = vi.fn(); const addToast = vi.fn(); beforeEach(() => { vi.clearAllMocks(); mockFetchAutomations.mockResolvedValue([]); mockFetchRoutines.mockResolvedValue([]); }); it("renders modal with title", async () => { render(); expect(screen.getByText("Scheduled Tasks")).toBeDefined(); }); it("has role=dialog and aria-labelledby", () => { render(); const dialog = screen.getByRole("dialog"); expect(dialog).toBeDefined(); expect(dialog.getAttribute("aria-labelledby")).toBe("schedules-modal-title"); }); it("shows loading state initially", () => { mockFetchAutomations.mockReturnValue(new Promise(() => {})); // never resolves render(); expect(screen.getByText("Loading schedules…")).toBeDefined(); }); it("shows empty state when no schedules", async () => { mockFetchAutomations.mockResolvedValue([]); render(); await waitFor(() => { expect(screen.getByText("No scheduled tasks yet")).toBeDefined(); }); expect(screen.getByText("Create your first schedule")).toBeDefined(); }); it("shows schedule cards when schedules exist", async () => { mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "My Job" })]); render(); await waitFor(() => { expect(screen.getByText("My Job")).toBeDefined(); }); }); it("shows New Schedule button when schedules exist", async () => { mockFetchAutomations.mockResolvedValue([makeSchedule()]); render(); await waitFor(() => { expect(screen.getByText("New Schedule")).toBeDefined(); }); }); it("calls onClose when close button is clicked", async () => { render(); fireEvent.click(screen.getByLabelText("Close")); expect(onClose).toHaveBeenCalled(); }); it("calls onClose when overlay is clicked", async () => { render(); const overlay = screen.getByRole("dialog").parentElement!; fireEvent.click(overlay); expect(onClose).toHaveBeenCalled(); }); it("calls onClose on Escape when in list view", async () => { render(); await waitFor(() => { expect(screen.getByText("No scheduled tasks yet")).toBeDefined(); }); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); describe("create flow", () => { it("shows create form when clicking New Schedule", async () => { mockFetchAutomations.mockResolvedValue([makeSchedule()]); render(); await waitFor(() => { expect(screen.getByText("New Schedule")).toBeDefined(); }); fireEvent.click(screen.getByText("New Schedule")); expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined(); expect(screen.getByLabelText("Name")).toBeDefined(); }); it("shows create form from empty state CTA button", async () => { mockFetchAutomations.mockResolvedValue([]); render(); await waitFor(() => { expect(screen.getByText("Create your first schedule")).toBeDefined(); }); fireEvent.click(screen.getByText("Create your first schedule")); expect(screen.getByLabelText("Name")).toBeDefined(); }); it("goes back to list on Escape from create form", async () => { mockFetchAutomations.mockResolvedValue([makeSchedule()]); render(); await waitFor(() => { expect(screen.getByText("New Schedule")).toBeDefined(); }); fireEvent.click(screen.getByText("New Schedule")); expect(screen.getByLabelText("Name")).toBeDefined(); fireEvent.keyDown(document, { key: "Escape" }); // Should not close the modal, just go back to list expect(onClose).not.toHaveBeenCalled(); }); it("creates schedule and returns to list on success", async () => { const created = makeSchedule({ name: "New Job" }); mockFetchAutomations .mockResolvedValueOnce([]) // initial load .mockResolvedValueOnce([created]); // after create mockCreateAutomation.mockResolvedValue(created); render(); await waitFor(() => { expect(screen.getByText("Create your first schedule")).toBeDefined(); }); fireEvent.click(screen.getByText("Create your first schedule")); // Fill form fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Job" } }); fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } }); fireEvent.click(screen.getByText("Create Schedule")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Schedule created", "success"); }); }); }); describe("toggle", () => { it("calls toggleAutomation and shows toast", async () => { const schedule = makeSchedule({ name: "My Job", enabled: true }); mockFetchAutomations.mockResolvedValue([schedule]); mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false }); render(); await waitFor(() => { expect(screen.getByText("My Job")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Disable My Job")); await waitFor(() => { expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1"); expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "success"); }); }); }); describe("delete", () => { it("calls deleteAutomation after confirm", async () => { const schedule = makeSchedule({ name: "My Job" }); mockFetchAutomations.mockResolvedValue([schedule]); mockDeleteAutomation.mockResolvedValue(schedule); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); render(); await waitFor(() => { expect(screen.getByText("My Job")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Delete My Job")); await waitFor(() => { expect(mockDeleteAutomation).toHaveBeenCalledWith("sched-1"); expect(addToast).toHaveBeenCalledWith('Deleted "My Job"', "success"); }); confirmSpy.mockRestore(); }); }); describe("manual run", () => { it("calls runAutomation and shows success toast", async () => { const schedule = makeSchedule({ name: "My Job" }); mockFetchAutomations.mockResolvedValue([schedule]); const result: AutomationRunResult = { success: true, output: "ok", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:01.000Z", }; mockRunAutomation.mockResolvedValue({ schedule, result }); render(); await waitFor(() => { expect(screen.getByText("My Job")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Run My Job now")); await waitFor(() => { expect(mockRunAutomation).toHaveBeenCalledWith("sched-1"); expect(addToast).toHaveBeenCalledWith('"My Job" completed successfully', "success"); }); }); it("shows error toast when run fails", async () => { const schedule = makeSchedule({ name: "My Job" }); mockFetchAutomations.mockResolvedValue([schedule]); const result: AutomationRunResult = { success: false, output: "", error: "Command not found", startedAt: "2026-01-01T00:00:00.000Z", completedAt: "2026-01-01T00:00:01.000Z", }; mockRunAutomation.mockResolvedValue({ schedule, result }); render(); await waitFor(() => { expect(screen.getByText("My Job")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Run My Job now")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Command not found"), "error", ); }); }); }); describe("error handling", () => { it("shows error toast when loading fails", async () => { mockFetchAutomations.mockRejectedValue(new Error("Network error")); render(); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Network error", "error"); }); }); }); // ── Routine Tab Tests ───────────────────────────────────────────────────── describe("Tab navigation", () => { it("shows both Schedules and Routines tabs", async () => { render(); await waitFor(() => { expect(screen.getByText("Schedules")).toBeDefined(); expect(screen.getByText("Routines")).toBeDefined(); }); }); it("defaults to Schedules tab", async () => { render(); await waitFor(() => { expect(screen.getByText("No scheduled tasks yet")).toBeDefined(); }); }); it("clicking Routines tab switches to routines view", async () => { render(); await waitFor(() => { expect(screen.getByText("Schedules")).toBeDefined(); }); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("No routines yet")).toBeDefined(); }); }); it("clicking Schedules tab switches back to schedules view", async () => { render(); // Switch to Routines fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("No routines yet")).toBeDefined(); }); // Switch back to Schedules fireEvent.click(screen.getByText("Schedules")); await waitFor(() => { expect(screen.getByText("No scheduled tasks yet")).toBeDefined(); }); }); it("switching tabs resets sub-views", async () => { mockFetchAutomations.mockResolvedValue([makeSchedule()]); render(); await waitFor(() => { expect(screen.getByText("New Schedule")).toBeDefined(); }); // Open create form fireEvent.click(screen.getByText("New Schedule")); expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined(); // Switch to Routines tab fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("No routines yet")).toBeDefined(); }); // Switch back to Schedules - should be in list view, not create fireEvent.click(screen.getByText("Schedules")); await waitFor(() => { expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull(); expect(screen.getByText("New Schedule")).toBeDefined(); // The button }); }); }); describe("Routines list", () => { it("shows empty state when no routines exist", async () => { render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("No routines yet")).toBeDefined(); expect(screen.getByText("Create your first routine")).toBeDefined(); }); }); it("shows routine cards when routines exist", async () => { mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "My Routine" })]); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); }); it('shows "New Routine" button when routines exist', async () => { mockFetchRoutines.mockResolvedValue([makeRoutine()]); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("New Routine")).toBeDefined(); }); }); }); describe("Routines create flow", () => { it('shows RoutineEditor when clicking "New Routine"', async () => { mockFetchRoutines.mockResolvedValue([makeRoutine()]); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("New Routine")).toBeDefined(); }); fireEvent.click(screen.getByText("New Routine")); await waitFor(() => { expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined(); }); }); it("shows RoutineEditor from empty state CTA", async () => { render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("Create your first routine")).toBeDefined(); }); fireEvent.click(screen.getByText("Create your first routine")); await waitFor(() => { expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined(); }); }); it("returns to list on Escape from create form", async () => { render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("Create your first routine")).toBeDefined(); }); fireEvent.click(screen.getByText("Create your first routine")); expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined(); fireEvent.keyDown(document, { key: "Escape" }); // Should not close the modal, just go back to list expect(onClose).not.toHaveBeenCalled(); }); it("creates routine and returns to list on success", async () => { const created = makeRoutine({ name: "New Routine" }); mockFetchRoutines .mockResolvedValueOnce([]) // initial load .mockResolvedValueOnce([created]); // after create mockCreateRoutine.mockResolvedValue(created); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("Create your first routine")).toBeDefined(); }); fireEvent.click(screen.getByText("Create your first routine")); // Fill form fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Routine created", "success"); }); }); }); describe("Routines edit flow", () => { it("shows RoutineEditor with pre-filled data when editing", async () => { const routine = makeRoutine({ name: "My Routine" }); mockFetchRoutines.mockResolvedValue([routine]); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Edit My Routine")); await waitFor(() => { expect(screen.getByText("Edit Routine", { selector: "h4" })).toBeDefined(); expect(screen.getByLabelText("Name")).toHaveValue("My Routine"); }); }); it("updates routine and returns to list on success", async () => { const routine = makeRoutine({ name: "My Routine" }); const updated = { ...routine, name: "Updated Routine" }; mockFetchRoutines .mockResolvedValueOnce([routine]) // initial load .mockResolvedValueOnce([updated]); // after update mockUpdateRoutine.mockResolvedValue(updated); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Edit My Routine")); await waitFor(() => { expect(screen.getByLabelText("Name")).toHaveValue("My Routine"); }); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } }); fireEvent.click(screen.getByText("Save Changes")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Routine updated", "success"); }); }); }); describe("Routines run", () => { it("calls runRoutine and shows success toast", async () => { const routine = makeRoutine({ name: "My Routine" }); mockFetchRoutines.mockResolvedValue([routine]); const result = { routineId: routine.id, success: true, output: "Done", startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z", }; mockRunRoutine.mockResolvedValue({ result }); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Run My Routine now")); await waitFor(() => { expect(mockRunRoutine).toHaveBeenCalledWith("routine-001"); expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success"); }); }); it("shows error toast when run fails", async () => { const routine = makeRoutine({ name: "My Routine" }); mockFetchRoutines.mockResolvedValue([routine]); const result = { routineId: routine.id, success: false, error: "Failed", startedAt: "2026-04-08T00:00:00.000Z", completedAt: "2026-04-08T00:01:00.000Z", }; mockRunRoutine.mockResolvedValue({ result }); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Run My Routine now")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Failed"), "error", ); }); }); }); describe("Routines delete", () => { it("calls deleteRoutine after confirm dialog", async () => { const routine = makeRoutine({ name: "My Routine" }); mockFetchRoutines.mockResolvedValue([routine]); mockDeleteRoutine.mockResolvedValue(undefined); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Delete My Routine")); await waitFor(() => { expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001"); expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success"); }); confirmSpy.mockRestore(); }); }); describe("Routines toggle", () => { it("calls updateRoutine with flipped enabled state", async () => { const routine = makeRoutine({ name: "My Routine", enabled: true }); const updated = { ...routine, enabled: false }; mockFetchRoutines.mockResolvedValue([routine]); mockUpdateRoutine.mockResolvedValue(updated); render(); fireEvent.click(screen.getByText("Routines")); await waitFor(() => { expect(screen.getByText("My Routine")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Disable My Routine")); await waitFor(() => { expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }); expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success"); }); }); }); });