import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { RoutineEditor } from "../RoutineEditor"; import type { Routine, RoutineTriggerType } from "@fusion/core"; // Mock lucide-react vi.mock("lucide-react", () => ({ Calendar: () => πŸ“…, Webhook: () => πŸ”—, Code: () => πŸ’», Zap: () => ⚑, Globe: () => 🌍, Folder: () => πŸ“, })); // Mock API vi.mock("../api", () => ({ fetchModels: vi.fn(() => new Promise(() => {})), })); // Mock @fusion/core vi.mock("@fusion/core", () => ({})); function makeRoutine(overrides: Partial = {}): Routine { return { id: "routine-001", name: "Test Routine", description: "A test routine", trigger: { type: "cron", cronExpression: "0 * * * *" }, command: "echo test", executionPolicy: "parallel", catchUpPolicy: "skip", enabled: true, runCount: 0, runHistory: [], createdAt: "2026-04-07T00:00:00.000Z", updatedAt: "2026-04-07T00:00:00.000Z", ...overrides, }; } describe("RoutineEditor", () => { const onSubmit = vi.fn().mockResolvedValue(undefined); const onCancel = vi.fn(); const fillCommand = (value = "echo test") => { fireEvent.change(screen.getByLabelText("Command"), { target: { value } }); }; beforeEach(() => { vi.clearAllMocks(); }); // ── Create mode (no routine prop) ───────────────────────────────────── describe("Create mode (no routine prop)", () => { it("renders form with empty fields", () => { render(); expect(screen.getByLabelText("Name")).toHaveValue(""); expect(screen.getByLabelText("Description (optional)")).toHaveValue(""); }); it('shows "New Routine" heading', () => { render(); expect(screen.getByText("New Routine")).toBeDefined(); }); it('shows "Create Routine" submit button', () => { render(); expect(screen.getByText("Create Routine")).toBeDefined(); }); it("defaults triggerType to 'cron'", () => { render(); expect(screen.getByText("Cron")).toBeDefined(); // Cron expression input should be visible expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 * * * *"); }); it("shows frequency dropdown with preset options when triggerType is 'cron'", () => { render(); const frequency = screen.getByLabelText("Frequency"); expect(frequency).toBeDefined(); expect(screen.getByRole("option", { name: "Every hour" })).toBeDefined(); expect(screen.getByRole("option", { name: "Every day (midnight)" })).toBeDefined(); expect(screen.getByRole("option", { name: "Every week (Monday)" })).toBeDefined(); expect(screen.getByRole("option", { name: "Every month (1st)" })).toBeDefined(); expect(screen.getByRole("option", { name: "Custom cron expression" })).toBeDefined(); }); it("selecting a preset auto-fills the cron expression and disables the input", () => { render(); fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "daily" } }); const cronInput = screen.getByLabelText("Cron Expression"); expect(cronInput).toHaveValue("0 0 * * *"); expect(cronInput).toBeDisabled(); expect(screen.getByText("Auto-filled from preset: 0 0 * * *")).toBeDefined(); }); it("selecting 'Custom' enables the cron expression input", () => { render(); fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } }); expect(screen.getByLabelText("Cron Expression")).not.toBeDisabled(); expect(screen.getByText(/crontab\.guru/)).toBeDefined(); }); it("defaults executionPolicy to 'queue'", () => { render(); expect(screen.getByLabelText("Execution Policy")).toHaveValue("queue"); }); it("defaults catchUpPolicy to 'run_one'", () => { render(); expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run_one"); }); it("defaults enabled to true", () => { render(); expect(screen.getByLabelText("Enabled")).toBeChecked(); }); it("shows cron expression input when triggerType is 'cron'", () => { render(); expect(screen.getByLabelText("Cron Expression")).toBeDefined(); }); it("shows webhook inputs when triggerType is changed to 'webhook'", async () => { render(); fireEvent.click(screen.getByText("Webhook")); await waitFor(() => { expect(screen.getByLabelText("Webhook Path")).toBeDefined(); expect(screen.getByLabelText("Webhook Secret (optional)")).toBeDefined(); }); }); it("shows API info when triggerType is changed to 'api'", async () => { render(); fireEvent.click(screen.getByText("API")); await waitFor(() => { expect(screen.getByLabelText("API Endpoint")).toBeDefined(); }); }); it("shows Manual trigger info when triggerType is changed to 'manual'", async () => { render(); fireEvent.click(screen.getByText("Manual")); await waitFor(() => { expect(screen.getByText(/triggered manually/)).toBeDefined(); }); }); it("hides cron expression input when triggerType is not 'cron'", async () => { render(); fireEvent.click(screen.getByText("Webhook")); await waitFor(() => { expect(screen.queryByLabelText("Cron Expression")).toBeNull(); }); }); }); // ── Edit mode (with routine prop) ───────────────────────────────────── describe("Edit mode (with routine prop)", () => { it("pre-fills all fields from the routine prop", () => { const routine = makeRoutine({ name: "My Routine", description: "My description", trigger: { type: "cron", cronExpression: "0 9 * * *" }, executionPolicy: "reject", catchUpPolicy: "run", enabled: false, }); render(); expect(screen.getByLabelText("Name")).toHaveValue("My Routine"); expect(screen.getByLabelText("Description (optional)")).toHaveValue("My description"); expect(screen.getByLabelText("Cron Expression")).toHaveValue("0 9 * * *"); expect(screen.getByLabelText("Execution Policy")).toHaveValue("reject"); expect(screen.getByLabelText("Catch-up Policy")).toHaveValue("run"); expect(screen.getByLabelText("Enabled")).not.toBeChecked(); }); it('shows "Edit Routine" heading', () => { render(); expect(screen.getByText("Edit Routine")).toBeDefined(); }); it('shows "Save Changes" submit button', () => { render(); expect(screen.getByText("Save Changes")).toBeDefined(); }); it("editing an existing routine with a preset cron expression selects the matching preset", () => { const routine = makeRoutine({ trigger: { type: "cron", cronExpression: "0 0 * * *" }, }); render(); expect(screen.getByLabelText("Frequency")).toHaveValue("daily"); expect(screen.getByLabelText("Cron Expression")).toBeDisabled(); }); it("editing an existing routine with a non-preset cron expression selects 'Custom'", () => { const routine = makeRoutine({ trigger: { type: "cron", cronExpression: "0 9 * * 1-5" }, }); render(); expect(screen.getByLabelText("Frequency")).toHaveValue("custom"); expect(screen.getByLabelText("Cron Expression")).not.toBeDisabled(); }); it("pre-fills webhook trigger fields", () => { const routine = makeRoutine({ trigger: { type: "webhook", webhookPath: "/trigger/test", secret: "secret123" }, }); render(); expect(screen.getByText("Webhook")).toBeDefined(); expect(screen.getByLabelText("Webhook Path")).toHaveValue("/trigger/test"); expect(screen.getByLabelText("Webhook Secret (optional)")).toHaveValue("secret123"); }); it("pre-fills api trigger fields", () => { const routine = makeRoutine({ trigger: { type: "api", endpoint: "/api/test" }, }); render(); expect(screen.getByText("API")).toBeDefined(); expect(screen.getByLabelText("API Endpoint")).toHaveValue("/api/test"); }); }); // ── Scope selector ────────────────────────────────────────────────── describe("Scope selector", () => { it("clicking Global sets active state", () => { render(); const globalButton = screen.getByRole("radio", { name: /Global/i }); const projectButton = screen.getByRole("radio", { name: /Project/i }); expect(globalButton).toHaveAttribute("aria-checked", "false"); expect(projectButton).toHaveAttribute("aria-checked", "true"); fireEvent.click(globalButton); expect(globalButton).toHaveAttribute("aria-checked", "true"); expect(projectButton).toHaveAttribute("aria-checked", "false"); }); it("clicking Project sets active state", () => { render(); const globalButton = screen.getByRole("radio", { name: /Global/i }); const projectButton = screen.getByRole("radio", { name: /Project/i }); expect(globalButton).toHaveAttribute("aria-checked", "true"); expect(projectButton).toHaveAttribute("aria-checked", "false"); fireEvent.click(projectButton); expect(globalButton).toHaveAttribute("aria-checked", "false"); expect(projectButton).toHaveAttribute("aria-checked", "true"); }); it("calls onScopeChange when clicking scope buttons", () => { const onScopeChange = vi.fn(); render( , ); fireEvent.click(screen.getByRole("radio", { name: /Project/i })); fireEvent.click(screen.getByRole("radio", { name: /Global/i })); expect(onScopeChange).toHaveBeenNthCalledWith(1, "project"); expect(onScopeChange).toHaveBeenNthCalledWith(2, "global"); }); it("disables Project scope button when no projectId is provided", () => { render(); expect(screen.getByRole("radio", { name: /Project/i })).toBeDisabled(); }); it("disables both scope buttons when editing an existing routine with locked scope", () => { render( , ); expect(screen.getByRole("radio", { name: /Global/i })).toBeDisabled(); expect(screen.getByRole("radio", { name: /Project/i })).toBeDisabled(); }); it("submits selected scope from the scope toggle", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Scoped Routine" } }); fillCommand(); fireEvent.click(screen.getByRole("radio", { name: /Project/i })); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ scope: "project", }), ); }); }); it("syncs local scope when parent scope prop changes", () => { const { rerender } = render( , ); expect(screen.getByRole("radio", { name: /Global/i })).toHaveAttribute("aria-checked", "true"); expect(screen.getByRole("radio", { name: /Project/i })).toHaveAttribute("aria-checked", "false"); rerender(); expect(screen.getByRole("radio", { name: /Global/i })).toHaveAttribute("aria-checked", "false"); expect(screen.getByRole("radio", { name: /Project/i })).toHaveAttribute("aria-checked", "true"); }); }); // ── Validation ─────────────────────────────────────────────────────── describe("Validation", () => { it("shows error when name is empty on submit", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(screen.getByText("Name is required")).toBeDefined(); }); expect(onSubmit).not.toHaveBeenCalled(); }); it("shows error when triggerType is 'cron' and custom cronExpression is empty", async () => { render(); fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } }); fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(screen.getByText("Cron expression is required")).toBeDefined(); }); expect(onSubmit).not.toHaveBeenCalled(); }); it("shows error when triggerType is 'cron' and custom cronExpression is invalid", async () => { render(); fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "custom" } }); fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(screen.getByText(/Invalid cron format/)).toBeDefined(); }); expect(onSubmit).not.toHaveBeenCalled(); }); it("shows error when triggerType is 'webhook' and webhookPath is empty", async () => { render(); fireEvent.click(screen.getByText("Webhook")); fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(screen.getByText("Webhook path is required")).toBeDefined(); }); expect(onSubmit).not.toHaveBeenCalled(); }); it("shows error when triggerType is 'api' and endpoint is empty", async () => { render(); fireEvent.click(screen.getByText("API")); fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "" } }); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(screen.getByText("API endpoint is required")).toBeDefined(); }); expect(onSubmit).not.toHaveBeenCalled(); }); }); // ── Submission ─────────────────────────────────────────────────────── describe("Submission", () => { it("calls onSubmit with correct RoutineCreateInput shape on valid create", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } }); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ name: "New Routine", trigger: { type: "cron", cronExpression: "0 * * * *" }, executionPolicy: "queue", catchUpPolicy: "run_one", enabled: true, }) ); }); }); it("submitting with a preset sends the correct cron expression", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } }); fireEvent.change(screen.getByLabelText("Frequency"), { target: { value: "weekly" } }); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ name: "Weekly Routine", trigger: { type: "cron", cronExpression: "0 0 * * 1" }, }) ); }); }); it("calls onSubmit with correct shape on valid edit", async () => { const routine = makeRoutine({ name: "Old Name" }); render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Name" } }); fireEvent.click(screen.getByText("Save Changes")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ name: "Updated Name", trigger: { type: "cron", cronExpression: "0 * * * *" }, }) ); }); }); it("disables submit button and shows 'Saving…' during submission", async () => { const slowSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } }); fillCommand(); // Use role and name to find the submit button specifically const submitButton = screen.getByRole("button", { name: "Create Routine" }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByText("Saving…")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled(); }); it("re-enables submit button after submission completes", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } }); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalled(); }); // Button should be re-enabled expect(screen.getByText("Create Routine")).not.toBeDisabled(); }); it("builds correct webhook trigger on submit", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Webhook Routine" } }); fireEvent.click(screen.getByText("Webhook")); fireEvent.change(screen.getByLabelText("Webhook Path"), { target: { value: "/trigger/my-hook" } }); fireEvent.change(screen.getByLabelText("Webhook Secret (optional)"), { target: { value: "my-secret" } }); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ trigger: { type: "webhook", webhookPath: "/trigger/my-hook", secret: "my-secret", }, }) ); }); }); it("builds correct api trigger on submit", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "API Routine" } }); fireEvent.click(screen.getByText("API")); fireEvent.change(screen.getByLabelText("API Endpoint"), { target: { value: "/api/my-routine" } }); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ trigger: { type: "api", endpoint: "/api/my-routine", }, }) ); }); }); it("builds correct manual trigger on submit", async () => { render(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } }); fireEvent.click(screen.getByText("Manual")); fillCommand(); fireEvent.click(screen.getByText("Create Routine")); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ trigger: { type: "manual" }, }) ); }); }); }); // ── Cancel ────────────────────────────────────────────────────────── describe("Cancel", () => { it("calls onCancel when cancel button is clicked", () => { render(); fireEvent.click(screen.getByText("Cancel")); expect(onCancel).toHaveBeenCalled(); }); it("does not call onSubmit when cancel is clicked", () => { render(); fireEvent.click(screen.getByText("Cancel")); expect(onSubmit).not.toHaveBeenCalled(); }); }); });