Files
fusion/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx
2026-04-16 08:38:55 -07:00

1044 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { ScheduleForm } from "../ScheduleForm";
import type { ScheduledTask } from "@fusion/core";
// Mock lucide-react
vi.mock("lucide-react", () => ({
Globe: () => <span data-testid="icon-globe">🌍</span>,
Folder: () => <span data-testid="icon-folder">📁</span>,
GripVertical: () => <span data-testid="icon-grip"></span>,
Plus: () => <span data-testid="icon-plus">+</span>,
Pencil: () => <span data-testid="icon-pencil"></span>,
Trash2: () => <span data-testid="icon-trash">🗑</span>,
CheckCircle: () => <span data-testid="icon-check"></span>,
XCircle: () => <span data-testid="icon-x"></span>,
ChevronDown: () => <span data-testid="icon-down"></span>,
ChevronUp: () => <span data-testid="icon-up"></span>,
Sparkles: () => <span data-testid="icon-sparkles"></span>,
Terminal: () => <span data-testid="icon-terminal"></span>,
ArrowUpDown: () => <span data-testid="icon-arrow"></span>,
ListPlus: () => <span data-testid="icon-list-plus"></span>,
}));
// Mock @fusion/core to provide type-only exports (no runtime values needed)
vi.mock("@fusion/core", () => ({}));
// Mock api
const mockFetchModels = vi.fn().mockResolvedValue({
models: [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
});
vi.mock("../api", () => ({
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, disabled, models }: any) => (
<select
data-testid="model-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">Use default</option>
{models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
),
}));
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
return {
id: "test-id",
name: "Test Schedule",
description: "A test schedule",
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,
};
}
describe("ScheduleForm", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const onCancel = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
// Reset the mock to ensure it's fresh for each test
mockFetchModels.mockClear();
});
describe("create mode", () => {
it("renders with empty fields for a new schedule", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("New Schedule")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(screen.getByLabelText("Command")).toHaveProperty("value", "");
});
it("shows 'Create Schedule' submit button text", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Create Schedule")).toBeDefined();
});
it("defaults schedule type to daily", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
const select = screen.getByLabelText("Schedule") as HTMLSelectElement;
expect(select.value).toBe("daily");
});
it("shows type toggle buttons in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "Command" })).toBeDefined();
expect(screen.getByRole("radio", { name: "AI Prompt" })).toBeDefined();
});
it("shows command input when Command type is selected in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Command radio should be selected by default
expect(screen.getByRole("radio", { name: "Command" })).toHaveAttribute("aria-checked", "true");
// Command input should be visible
expect(screen.getByLabelText("Command")).toBeDefined();
});
it("shows prompt textarea when AI Prompt type is selected in simple mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Click on AI Prompt button
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// AI Prompt should now be checked
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Prompt textarea should be visible
expect(screen.getByLabelText("Prompt")).toBeDefined();
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
});
it("shows validation error when prompt is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Submit without entering prompt
fireEvent.click(screen.getByText("Create Schedule"));
// Should show prompt validation error
expect(screen.getByText("Prompt is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits single ai-prompt step when simple mode uses AI Prompt", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "AI Job",
command: "",
steps: expect.arrayContaining([
expect.objectContaining({
type: "ai-prompt",
name: "AI Job",
prompt: "Summarize recent commits",
}),
]),
}),
);
});
});
it("submits with model provider and model ID when provided in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// The model dropdown is present (optional field)
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Submit - model is optional, so this should work
await act(async () => {
fireEvent.click(screen.getByText("Create Schedule"));
});
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
type: "ai-prompt",
prompt: "Summarize recent commits",
}),
]),
}),
);
});
});
it("shows error when only one of model provider/model ID is set in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
// Set only model provider (not model ID) via the dropdown's internal state
// The CustomModelDropdown is mocked, so we need to test the validation path differently
// Since the dropdown sets both when a value is selected, we test the validation by
// directly manipulating the state through the onChange callback
// For this test, we verify the model dropdown is present
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Submit with both fields empty - should pass validation
fireEvent.click(screen.getByText("Create Schedule"));
// Should not show model consistency error
expect(screen.queryByText("Both model provider and model ID must be set")).toBeNull();
});
it("restores AI Prompt simple type when editing schedule with single ai-prompt step", () => {
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Schedule",
prompt: "Summarize this",
modelProvider: "openai",
modelId: "gpt-4o",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// AI Prompt radio should be selected
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Prompt textarea should be populated
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Model dropdown should be present
expect(screen.getByTestId("model-dropdown")).toBeDefined();
});
});
describe("edit mode", () => {
it("populates fields from existing schedule", () => {
const schedule = makeSchedule({ name: "My Job", command: "npm test" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Edit Schedule")).toBeDefined();
expect(screen.getByLabelText("Name")).toHaveProperty("value", "My Job");
expect(screen.getByLabelText("Command")).toHaveProperty("value", "npm test");
});
it("shows 'Save Changes' submit button text", () => {
const schedule = makeSchedule();
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText("Save Changes")).toBeDefined();
});
it("restores command simple type when editing schedule with command (no steps)", () => {
const schedule = makeSchedule({ command: "npm test" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Command radio should be selected
expect(screen.getByRole("radio", { name: "Command" })).toHaveAttribute("aria-checked", "true");
// Command input should be visible and populated
expect(screen.getByLabelText("Command")).toHaveProperty("value", "npm test");
// Prompt textarea should not be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
});
describe("validation", () => {
it("shows error when name is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Name is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error when command is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Command is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error for invalid cron expression with custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "invalid" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText(/Invalid cron format/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("shows error for empty cron expression with custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hi" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
// Clear the cron field
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "" } });
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText("Cron expression is required for custom schedules")).toBeDefined();
});
it("sets aria-invalid on fields with errors", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByLabelText("Name").getAttribute("aria-invalid")).toBe("true");
expect(screen.getByLabelText("Command").getAttribute("aria-invalid")).toBe("true");
});
});
describe("cron expression auto-fill", () => {
it("auto-fills cron expression for preset types", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
// Default is daily
expect(cronField.value).toBe("0 0 * * *");
expect(cronField.disabled).toBe(true);
});
it("enables cron field when custom type is selected", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.disabled).toBe(false);
});
it("updates cron expression when changing preset type", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "hourly" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 * * * *");
});
it("auto-fills cron expression for every15Minutes preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "every15Minutes" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("*/15 * * * *");
});
it("auto-fills cron expression for every6Hours preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "every6Hours" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 */6 * * *");
});
it("auto-fills cron expression for weekdays preset", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "weekdays" } });
const cronField = screen.getByLabelText("Cron Expression") as HTMLInputElement;
expect(cronField.value).toBe("0 9 * * 1-5");
});
});
describe("submission", () => {
it("calls onSubmit with correct data for valid form", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "My Job",
command: "echo hello",
scheduleType: "daily",
enabled: true,
}),
);
});
});
it("includes cronExpression only for custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ cronExpression: undefined }),
);
});
});
it("includes cronExpression for custom type", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "cmd" } });
fireEvent.change(screen.getByLabelText("Schedule"), { target: { value: "custom" } });
fireEvent.change(screen.getByLabelText("Cron Expression"), { target: { value: "0 */6 * * *" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ cronExpression: "0 */6 * * *", scheduleType: "custom" }),
);
});
});
it("submits command as empty string when using AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
command: "",
}),
);
});
});
it("does not include steps when using Command mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Command Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: undefined,
}),
);
});
});
it("generates unique step ID for AI prompt step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Test prompt" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
const call = onSubmit.mock.calls[0][0];
expect(call.steps).toBeDefined();
expect(call.steps.length).toBe(1);
expect(call.steps[0].id).toBeDefined();
// UUID format check (8-4-4-4-12 hex pattern)
expect(call.steps[0].id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^step-\d+-[a-z0-9]+$/);
});
});
});
describe("cancel", () => {
it("calls onCancel when Cancel button is clicked", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByText("Cancel"));
expect(onCancel).toHaveBeenCalled();
});
});
describe("scope toggle", () => {
it("renders scope toggle buttons", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDefined();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDefined();
});
it("clicking Global scope button calls onScopeChange with 'global'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "🌍Global" }));
expect(onScopeChange).toHaveBeenCalledWith("global");
});
it("clicking Project scope button calls onScopeChange with 'project'", () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
expect(onScopeChange).toHaveBeenCalledWith("project");
});
it("scope toggle updates visual active state", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "false");
// Click Project button
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "false");
});
it("Project button is disabled when no projectId", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("scope buttons are disabled when editing existing schedule with scope", () => {
const schedule = makeSchedule({ scope: "global" });
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: "🌍Global" })).toBeDisabled();
expect(screen.getByRole("radio", { name: "📁Project" })).toBeDisabled();
});
it("submit uses localScope when user changes scope", async () => {
const onScopeChange = vi.fn();
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" onScopeChange={onScopeChange} />);
// Fill name and command
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to project scope
fireEvent.click(screen.getByRole("radio", { name: "📁Project" }));
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
scope: "project",
}),
);
});
});
it("localScope syncs when formScope prop changes", () => {
const { rerender } = render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="test-project" />);
// Global should be active initially
expect(screen.getByRole("radio", { name: "🌍Global" })).toHaveAttribute("aria-checked", "true");
// Change prop to project scope
rerender(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
// Project should now be active
expect(screen.getByRole("radio", { name: "📁Project" })).toHaveAttribute("aria-checked", "true");
});
it("shows project scope description when localScope is project", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="test-project" />);
expect(screen.getByText(/scoped to the current project/)).toBeDefined();
});
it("shows global scope description when localScope is global", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByText(/created at global scope/)).toBeDefined();
});
it("shows no project description when no projectId and no schedule", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByText(/No active project/)).toBeDefined();
});
});
describe("multi-step mode", () => {
it("switches to Multi-Step mode and adds a command step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in basic info
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "My Multi-Step" } });
// Switch to Multi-Step mode
fireEvent.click(screen.getByText("Multi-Step"));
// Add a command step
fireEvent.click(screen.getByText("Add Command Step"));
// Step editor should be open
expect(screen.getByText("Save Step")).toBeDefined();
// Fill in step details - use placeholder to find the command field
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
// Save the step
fireEvent.click(screen.getByText("Save Step"));
// Step should be visible in the list
expect(screen.getByText("Run Tests")).toBeDefined();
});
it("adds an AI prompt step", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add an AI prompt step
fireEvent.click(screen.getByText("Add AI Prompt Step"));
// Fill in step details
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Summarize Results" } });
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
target: { value: "Summarize test output" }
});
// Save the step
fireEvent.click(screen.getByText("Save Step"));
// Step should be visible
expect(screen.getByText("Summarize Results")).toBeDefined();
});
it("prevents submission with incomplete steps (missing command)", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Incomplete Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add a step but don't fill in the command
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
// Note: Not filling in the command field
// Try to save step - this should fail validation
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Command is required")).toBeDefined();
// Cancel the step editor - click the Cancel button in the step editor (not the form Cancel)
const cancelButtons = screen.getAllByText("Cancel");
// First Cancel is in the step editor, second is the form Cancel
fireEvent.click(cancelButtons[0]!);
// Try to submit the form - should show error about incomplete steps
fireEvent.click(screen.getByText("Create Schedule"));
expect(screen.getByText(/Step 1: Command is required/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("prevents submission when steps are being edited", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Editing Schedule" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add a step and keep editor open
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Run Tests" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
// Don't save - keep editor open
// Try to submit the form
fireEvent.click(screen.getByText("Create Schedule"));
// Should show editing error
expect(screen.getByText(/Please save or cancel all step edits/)).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("successfully creates a multi-step schedule with valid steps", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Complete Multi-Step" } });
fireEvent.click(screen.getByText("Multi-Step"));
// Add first step
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Build" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm run build" } });
fireEvent.click(screen.getByText("Save Step"));
// Add second step
fireEvent.click(screen.getByText("Add AI Prompt Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Review" } });
fireEvent.change(screen.getByPlaceholderText("e.g. Summarize the test results and highlight any failures"), {
target: { value: "Review the build output" }
});
fireEvent.click(screen.getByText("Save Step"));
// Submit the form
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Complete Multi-Step",
steps: expect.arrayContaining([
expect.objectContaining({ name: "Build", type: "command", command: "npm run build" }),
expect.objectContaining({ name: "Review", type: "ai-prompt", prompt: "Review the build output" }),
]),
}),
);
});
});
it("edits an existing multi-step schedule", () => {
const schedule = makeSchedule({
steps: [
{ id: "step-1", type: "command", name: "Build", command: "npm run build" },
],
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Should be in Multi-Step mode by default when schedule has steps
expect(screen.getByText("Steps (1)")).toBeDefined();
expect(screen.getByText("Build")).toBeDefined();
// Add another step
fireEvent.click(screen.getByText("Add Command Step"));
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "Test" } });
fireEvent.change(screen.getByPlaceholderText("e.g. npm test"), { target: { value: "npm test" } });
fireEvent.click(screen.getByText("Save Step"));
// Should show both steps
expect(screen.getByText("Steps (2)")).toBeDefined();
expect(screen.getByText("Build")).toBeDefined();
expect(screen.getByText("Test")).toBeDefined();
});
});
describe("simple mode AI Prompt edge cases", () => {
it("can switch between Command and AI Prompt without losing form state", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in command mode
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Test Job" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize this" } });
// Switch back to Command mode
fireEvent.click(screen.getByRole("radio", { name: "Command" }));
// Command should be visible again
expect(screen.getByLabelText("Command")).toBeDefined();
// Switch back to AI Prompt - prompt should still be there
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
});
it("submits with trimmed prompt", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: " Summarize commits " } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
prompt: "Summarize commits", // trimmed
}),
]),
}),
);
});
});
it("handles AI prompt schedule with model but no modelProvider/modelId separate fields", async () => {
// When editing a schedule where the step has modelProvider/modelId,
// the form should correctly populate and submit them
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Schedule",
prompt: "Do something",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Form should be in AI Prompt mode
expect(screen.getByRole("radio", { name: "AI Prompt" })).toHaveAttribute("aria-checked", "true");
// Submit without changes
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
]),
}),
);
});
});
});
describe("simple mode create-task", () => {
it("shows Create Task radio button in simple mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByRole("radio", { name: "Create Task" })).toBeDefined();
expect(screen.getByRole("radio", { name: "Command" })).toBeDefined();
expect(screen.getByRole("radio", { name: "AI Prompt" })).toBeDefined();
});
it("shows task creation fields when Create Task type is selected", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Click Create Task radio
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Create Task radio should be checked
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
// Task title input should be visible
expect(screen.getByLabelText("Task Title (optional)")).toBeDefined();
// Task description textarea should be visible
expect(screen.getByLabelText("Task Description (required)")).toBeDefined();
// Target column select should be visible
expect(screen.getByLabelText("Target Column")).toBeDefined();
// Executor model dropdown should be visible
expect(screen.getByTestId("model-dropdown")).toBeDefined();
// Command input should NOT be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Prompt textarea should NOT be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
it("shows validation error when task description is empty on submit", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Submit without task description
fireEvent.click(screen.getByText("Create Schedule"));
// Should show description validation error
expect(screen.getByText("Task description is required")).toBeDefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits single create-task step when simple mode uses Create Task", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Fill task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" }
});
// Select target column (triage is default)
// Submit
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: "Create Task Schedule",
command: "",
steps: expect.arrayContaining([
expect.objectContaining({
type: "create-task",
name: "Create Task Schedule",
taskDescription: "Check npm dependencies for security vulnerabilities",
taskColumn: "triage",
}),
]),
}),
);
});
});
it("submits with task title and model when provided", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill name
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Fill task title
fireEvent.change(screen.getByLabelText("Task Title (optional)"), {
target: { value: "Review Dependencies" }
});
// Fill task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" }
});
// Select To Do column
fireEvent.change(screen.getByLabelText("Target Column"), { target: { value: "todo" } });
// Submit - model is optional
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: expect.arrayContaining([
expect.objectContaining({
type: "create-task",
taskTitle: "Review Dependencies",
taskDescription: "Check npm dependencies for security vulnerabilities",
taskColumn: "todo",
}),
]),
}),
);
});
});
it("restores Create Task simple type when editing schedule with single create-task step", () => {
const schedule = makeSchedule({
steps: [
{
id: "step-1",
type: "create-task",
name: "Create Task Schedule",
taskTitle: "Review Dependencies",
taskDescription: "Check npm dependencies for security vulnerabilities",
taskColumn: "todo",
modelProvider: "openai",
modelId: "gpt-4o",
},
],
command: "",
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
// Create Task radio should be selected
expect(screen.getByRole("radio", { name: "Create Task" })).toHaveAttribute("aria-checked", "true");
// Task title should be populated
expect(screen.getByLabelText("Task Title (optional)")).toHaveProperty("value", "Review Dependencies");
// Task description should be populated
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check npm dependencies for security vulnerabilities");
// Target column should be To Do
expect(screen.getByLabelText("Target Column")).toHaveProperty("value", "todo");
// Command input should not be visible
expect(screen.queryByLabelText("Command")).toBeNull();
// Prompt textarea should not be visible
expect(screen.queryByLabelText("Prompt")).toBeNull();
});
it("can switch between all three simple types without losing form state", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
// Fill in Command mode
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Test Schedule" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo hello" } });
// Switch to AI Prompt mode
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
// Enter prompt
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize this" } });
// Switch to Create Task mode
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
// Enter task description
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check dependencies" }
});
// Switch back to AI Prompt mode - prompt should be preserved
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByLabelText("Prompt")).toHaveProperty("value", "Summarize this");
// Switch back to Create Task mode - description should be preserved
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByLabelText("Task Description (required)")).toHaveProperty("value", "Check dependencies");
});
});
});