feat(KB-045): add scheduled tasks automation system
- Add AutomationStore and core automation types for cron-based scheduling - Implement CronRunner engine for executing scheduled automations - Add REST API routes for CRUD operations on automations - Create UI components: ScheduleCard, ScheduleForm, and ScheduledTasksModal - Integrate scheduled tasks into dashboard App.tsx and CLI dashboard command - Add comprehensive tests for store, runner, API, and UI components - Include changeset for the new scheduled tasks feature
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ScheduleCard } from "../ScheduleCard";
|
||||
import type { ScheduledTask, AutomationRunResult } from "@kb/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Play: () => <span data-testid="icon-play">▶</span>,
|
||||
Pause: () => <span data-testid="icon-pause">⏸</span>,
|
||||
Pencil: () => <span data-testid="icon-pencil">✎</span>,
|
||||
Trash2: () => <span data-testid="icon-trash">🗑</span>,
|
||||
Clock: () => <span data-testid="icon-clock">🕐</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>,
|
||||
}));
|
||||
|
||||
function makeResult(overrides: Partial<AutomationRunResult> = {}): AutomationRunResult {
|
||||
return {
|
||||
success: true,
|
||||
output: "hello world",
|
||||
startedAt: "2026-01-01T00:00:00.000Z",
|
||||
completedAt: "2026-01-01T00:00:05.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
||||
return {
|
||||
id: "sched-1",
|
||||
name: "Update Dependencies",
|
||||
description: "Run npm update weekly",
|
||||
scheduleType: "weekly",
|
||||
cronExpression: "0 0 * * 1",
|
||||
command: "npm update",
|
||||
enabled: true,
|
||||
runCount: 5,
|
||||
runHistory: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduleCard", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onRun = vi.fn();
|
||||
const onToggle = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("displays schedule name", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Update Dependencies")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays schedule description", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Run npm update weekly")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays schedule type badge", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("weekly")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays cron expression", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule()} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("0 0 * * 1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays run count", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runCount: 42 })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("42")).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies disabled class when schedule is disabled", () => {
|
||||
const { container } = render(
|
||||
<ScheduleCard schedule={makeSchedule({ enabled: false })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(container.querySelector(".schedule-card.disabled")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply disabled class when schedule is enabled", () => {
|
||||
const { container } = render(
|
||||
<ScheduleCard schedule={makeSchedule({ enabled: true })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(container.querySelector(".schedule-card.disabled")).toBeNull();
|
||||
});
|
||||
|
||||
describe("last run result", () => {
|
||||
it("shows success badge for successful last run", () => {
|
||||
const schedule = makeSchedule({ lastRunResult: makeResult({ success: true }) });
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Success")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows failure badge for failed last run", () => {
|
||||
const schedule = makeSchedule({ lastRunResult: makeResult({ success: false }) });
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Failed")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action buttons", () => {
|
||||
it("calls onRun when run button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Run ${schedule.name} now`));
|
||||
expect(onRun).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("disables run button when running", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} running={true} />
|
||||
);
|
||||
const btn = screen.getByLabelText("Running…");
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onToggle when toggle button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Disable ${schedule.name}`));
|
||||
expect(onToggle).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("calls onEdit when edit button is clicked", () => {
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Edit ${schedule.name}`));
|
||||
expect(onEdit).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it("calls onDelete after confirm when delete button is clicked", () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Delete ${schedule.name}`));
|
||||
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("Update Dependencies"));
|
||||
expect(onDelete).toHaveBeenCalledWith(schedule);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not call onDelete when confirm is cancelled", () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
const schedule = makeSchedule();
|
||||
render(
|
||||
<ScheduleCard schedule={schedule} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(`Delete ${schedule.name}`));
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("run history", () => {
|
||||
it("does not show history toggle when no history", () => {
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: [] })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.queryByText(/Run History/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows history toggle when history exists", () => {
|
||||
const history = [makeResult()];
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: history })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
expect(screen.getByText("Run History (1)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("expands history on toggle click", () => {
|
||||
const history = [makeResult({ output: "test output" })];
|
||||
render(
|
||||
<ScheduleCard schedule={makeSchedule({ runHistory: history })} onEdit={onEdit} onDelete={onDelete} onRun={onRun} onToggle={onToggle} />
|
||||
);
|
||||
fireEvent.click(screen.getByText("Run History (1)"));
|
||||
// History items should now be visible
|
||||
expect(screen.getByText(/just now|ago/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ScheduleForm } from "../ScheduleForm";
|
||||
import type { ScheduledTask } from "@kb/core";
|
||||
|
||||
// Mock @kb/core to provide type-only exports (no runtime values needed)
|
||||
vi.mock("@kb/core", () => ({}));
|
||||
|
||||
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(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 * * * *");
|
||||
});
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel", () => {
|
||||
it("calls onCancel when Cancel button is clicked", () => {
|
||||
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
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 } from "@kb/core";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", () => ({
|
||||
Plus: () => <span data-testid="icon-plus">+</span>,
|
||||
Clock: (props: any) => <span data-testid="icon-clock" style={props.strokeWidth ? {} : {}}>🕐</span>,
|
||||
Play: () => <span data-testid="icon-play">▶</span>,
|
||||
Pause: () => <span data-testid="icon-pause">⏸</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>,
|
||||
}));
|
||||
|
||||
// Mock @kb/core (no runtime values needed — ScheduleForm inlines presets)
|
||||
vi.mock("@kb/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();
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduledTask> = {}): 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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ScheduledTasksModal", () => {
|
||||
const onClose = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("renders modal with title", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
expect(screen.getByText("Scheduled Tasks")).toBeDefined();
|
||||
});
|
||||
|
||||
it("has role=dialog and aria-labelledby", () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
expect(screen.getByText("Loading schedules…")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows empty state when no schedules", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Job")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows New Schedule button when schedules exist", async () => {
|
||||
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("New Schedule")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when overlay is clicked", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
fireEvent.click(overlay);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose on Escape when in list view", async () => {
|
||||
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user