fix(FN-1952): recover routines schedules merge

This commit is contained in:
gsxdsm
2026-04-16 23:07:40 -07:00
parent 64ae788e92
commit 8bf3e4a3ec
25 changed files with 1002 additions and 1016 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers } from "lucide-react";
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
/**
@@ -224,6 +224,17 @@ export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, runnin
</div>
<div className="routine-card-meta">
{routine.steps && routine.steps.length > 0 ? (
<div className="routine-meta-item">
<Layers size={12} />
<span className="routine-policy-badge">{routine.steps.length} step{routine.steps.length === 1 ? "" : "s"}</span>
</div>
) : routine.command ? (
<div className="routine-meta-item routine-meta-command-preview" title={routine.command}>
<code className="routine-cron">{routine.command}</code>
</div>
) : null}
{/* Cron expression for cron triggers */}
{routine.trigger.type === "cron" && cronExpression && (
<div className="routine-meta-item">

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { Calendar, Webhook, Code, Zap, Globe, Folder } from "lucide-react";
import type {
Routine,
@@ -12,7 +12,11 @@ import type {
RoutineManualTrigger,
RoutineCatchUpPolicy,
RoutineExecutionPolicy,
AutomationStep,
} from "@fusion/core";
import { ScheduleStepsEditor } from "./ScheduleStepsEditor";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { fetchModels, type ModelInfo } from "../api";
type CronPresetType = "hourly" | "daily" | "weekly" | "monthly" | "custom";
@@ -141,6 +145,16 @@ const CATCH_UP_POLICY_OPTIONS: { value: RoutineCatchUpPolicy; label: string }[]
{ value: "run", label: "Run all missed runs" },
];
function generateStepId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
type ActionMode = "simple" | "advanced";
type SimpleActionType = "command" | "ai-prompt" | "create-task";
interface RoutineEditorProps {
/** Existing routine for editing. Omit for create mode. */
routine?: Routine;
@@ -184,10 +198,72 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
routine?.catchUpPolicy ?? "run_one"
);
const [enabled, setEnabled] = useState(routine?.enabled ?? true);
const isSimpleAiPrompt = routine?.steps && routine.steps.length === 1 &&
routine.steps[0].type === "ai-prompt" && !routine.command;
const isSimpleCreateTask = routine?.steps && routine.steps.length === 1 &&
routine.steps[0].type === "create-task" && !routine.command;
const [actionMode, setActionMode] = useState<ActionMode>(
routine?.steps && routine.steps.length > 0 && !isSimpleAiPrompt && !isSimpleCreateTask ? "advanced" : "simple"
);
const [simpleActionType, setSimpleActionType] = useState<SimpleActionType>(() => {
if (isSimpleAiPrompt) return "ai-prompt";
if (isSimpleCreateTask) return "create-task";
return "command";
});
const [command, setCommand] = useState(routine?.command ?? "");
const [steps, setSteps] = useState<AutomationStep[]>(routine?.steps ?? []);
const [hasEditingSteps, setHasEditingSteps] = useState(false);
const [timeoutMs, setTimeoutMs] = useState<number>(routine?.timeoutMs ?? 300000);
const [prompt, setPrompt] = useState(isSimpleAiPrompt ? routine.steps?.[0]?.prompt ?? "" : "");
const [taskTitle, setTaskTitle] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskTitle ?? "" : "");
const [taskDescription, setTaskDescription] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskDescription ?? "" : "");
const [taskColumn, setTaskColumn] = useState(isSimpleCreateTask ? routine.steps?.[0]?.taskColumn ?? "triage" : "triage");
const [modelProvider, setModelProvider] = useState(
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelProvider ?? "" : ""
);
const [modelId, setModelId] = useState(
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelId ?? "" : ""
);
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((response) => {
if (!cancelled) setModels(response.models);
})
.catch((err: unknown) => {
if (!cancelled) setModelsError(err instanceof Error ? err.message : "Failed to load models");
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const modelValue = modelProvider && modelId ? `${modelProvider}/${modelId}` : "";
const handleModelChange = useCallback((value: string) => {
if (!value) {
setModelProvider("");
setModelId("");
return;
}
const slashIdx = value.indexOf("/");
if (slashIdx !== -1) {
setModelProvider(value.slice(0, slashIdx));
setModelId(value.slice(slashIdx + 1));
}
}, []);
const validate = useCallback((): boolean => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = "Name is required";
@@ -210,9 +286,29 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
if (triggerType === "api" && !endpoint.trim()) {
e.endpoint = "API endpoint is required";
}
if (actionMode === "simple") {
if (simpleActionType === "command" && !command.trim()) e.command = "Command is required";
if (simpleActionType === "ai-prompt" && !prompt.trim()) e.prompt = "Prompt is required";
if (simpleActionType === "create-task" && !taskDescription.trim()) e.taskDescription = "Task description is required";
if ((modelProvider.trim() && !modelId.trim()) || (!modelProvider.trim() && modelId.trim())) {
e.model = "Both model provider and model ID must be set, or both must be empty";
}
} else {
if (steps.length === 0) e.steps = "At least one step is required";
if (hasEditingSteps) e.stepsEditing = "Please save or cancel all step edits before saving the routine";
const incompleteSteps: string[] = [];
steps.forEach((step, index) => {
if (!step.name?.trim()) incompleteSteps.push(`Step ${index + 1}: Name is required`);
if (step.type === "command" && !step.command?.trim()) incompleteSteps.push(`Step ${index + 1}: Command is required`);
if (step.type === "ai-prompt" && !step.prompt?.trim()) incompleteSteps.push(`Step ${index + 1}: Prompt is required`);
if (step.type === "create-task" && !step.taskDescription?.trim()) incompleteSteps.push(`Step ${index + 1}: Task description is required`);
});
if (incompleteSteps.length > 0) e.steps = incompleteSteps.join("; ");
}
if (timeoutMs < 1000) e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
setErrors(e);
return Object.keys(e).length === 0;
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId]);
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -228,11 +324,43 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
}
const trigger = buildTrigger(triggerType, cronExpression, webhookPath, webhookSecret, endpoint);
let actionCommand: string | undefined;
let actionSteps: AutomationStep[] | undefined;
if (actionMode === "simple") {
if (simpleActionType === "command") {
actionCommand = command.trim() || undefined;
} else if (simpleActionType === "ai-prompt") {
actionSteps = [{
id: generateStepId(),
type: "ai-prompt",
name: name.trim(),
prompt: prompt.trim(),
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
}];
} else {
actionSteps = [{
id: generateStepId(),
type: "create-task",
name: name.trim(),
taskTitle: taskTitle.trim() || undefined,
taskDescription: taskDescription.trim(),
taskColumn,
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
}];
}
} else {
actionSteps = steps;
}
const input: RoutineCreateInput = {
name: name.trim(),
agentId: routine?.agentId ?? "",
description: description.trim() || undefined,
trigger,
command: actionCommand,
steps: actionSteps,
timeoutMs,
executionPolicy,
catchUpPolicy,
enabled,
@@ -243,13 +371,18 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
setSubmitting(false);
}
},
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope],
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope, routine?.agentId],
);
const nameErrorId = "routine-name-error";
const cronErrorId = "routine-cron-error";
const webhookErrorId = "routine-webhook-error";
const endpointErrorId = "routine-endpoint-error";
const commandErrorId = "routine-command-error";
const promptErrorId = "routine-prompt-error";
const taskDescriptionErrorId = "routine-task-description-error";
const modelErrorId = "routine-model-error";
const timeoutErrorId = "routine-timeout-error";
const handleCronPresetChange = useCallback((preset: CronPresetType) => {
setCronPreset(preset);
@@ -481,6 +614,103 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
</div>
)}
<div className="form-group">
<label>Action Mode</label>
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action mode">
<button
type="button"
className={`schedule-mode-btn${actionMode === "simple" ? " active" : ""}`}
onClick={() => setActionMode("simple")}
role="radio"
aria-checked={actionMode === "simple"}
>
Simple
</button>
<button
type="button"
className={`schedule-mode-btn${actionMode === "advanced" ? " active" : ""}`}
onClick={() => setActionMode("advanced")}
role="radio"
aria-checked={actionMode === "advanced"}
>
Multi-Step
</button>
</div>
<small>{actionMode === "simple" ? "Run one command, prompt, or task creation action" : "Run multiple actions sequentially"}</small>
</div>
{actionMode === "simple" ? (
<>
<div className="form-group">
<label>Action Type</label>
<div className="schedule-mode-toggle" role="radiogroup" aria-label="Action type">
<button type="button" className={`schedule-mode-btn${simpleActionType === "command" ? " active" : ""}`} onClick={() => setSimpleActionType("command")} role="radio" aria-checked={simpleActionType === "command"}>Command</button>
<button type="button" className={`schedule-mode-btn${simpleActionType === "ai-prompt" ? " active" : ""}`} onClick={() => setSimpleActionType("ai-prompt")} role="radio" aria-checked={simpleActionType === "ai-prompt"}>AI Prompt</button>
<button type="button" className={`schedule-mode-btn${simpleActionType === "create-task" ? " active" : ""}`} onClick={() => setSimpleActionType("create-task")} role="radio" aria-checked={simpleActionType === "create-task"}>Create Task</button>
</div>
</div>
{simpleActionType === "command" ? (
<div className="form-group">
<label htmlFor="routine-command">Command</label>
<input id="routine-command" type="text" placeholder="e.g. fn backup --create" value={command} onChange={(e) => setCommand(e.target.value)} aria-invalid={!!errors.command} aria-describedby={errors.command ? commandErrorId : undefined} />
{errors.command ? <small id={commandErrorId} className="field-error">{errors.command}</small> : <small>Shell command to execute.</small>}
</div>
) : simpleActionType === "ai-prompt" ? (
<>
<div className="form-group">
<label htmlFor="routine-prompt">Prompt</label>
<textarea id="routine-prompt" placeholder="e.g. Summarize recent activity and create action items" value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={3} aria-invalid={!!errors.prompt} aria-describedby={errors.prompt ? promptErrorId : undefined} />
{errors.prompt ? <small id={promptErrorId} className="field-error">{errors.prompt}</small> : <small>AI prompt to execute.</small>}
</div>
<div className="form-group">
<label htmlFor="routine-model">Model (optional)</label>
<CustomModelDropdown id="routine-model" label="Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>
</>
) : (
<>
<div className="form-group">
<label htmlFor="routine-task-title">Task Title (optional)</label>
<input id="routine-task-title" type="text" placeholder="e.g. Review weekly dependencies" value={taskTitle} onChange={(e) => setTaskTitle(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="routine-task-description">Task Description</label>
<textarea id="routine-task-description" placeholder="e.g. Check npm dependencies for security vulnerabilities" value={taskDescription} onChange={(e) => setTaskDescription(e.target.value)} rows={4} aria-invalid={!!errors.taskDescription} aria-describedby={errors.taskDescription ? taskDescriptionErrorId : undefined} />
{errors.taskDescription ? <small id={taskDescriptionErrorId} className="field-error">{errors.taskDescription}</small> : <small>Describes the task that will be created.</small>}
</div>
<div className="form-group">
<label htmlFor="routine-task-column">Target Column</label>
<select id="routine-task-column" value={taskColumn} onChange={(e) => setTaskColumn(e.target.value)}>
<option value="triage">Triage</option>
<option value="todo">To Do</option>
</select>
</div>
<div className="form-group">
<label htmlFor="routine-task-model">Executor Model (optional)</label>
<CustomModelDropdown id="routine-task-model" label="Executor Model" models={models} value={modelValue} onChange={handleModelChange} placeholder="Use default" disabled={modelsLoading} />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>
</>
)}
</>
) : (
<>
<ScheduleStepsEditor steps={steps} onChange={setSteps} onEditingChange={setHasEditingSteps} />
{errors.steps && <small className="field-error">{errors.steps}</small>}
{errors.stepsEditing && <small className="field-error">{errors.stepsEditing}</small>}
</>
)}
<div className="form-group">
<label htmlFor="routine-timeout">Timeout (ms)</label>
<input id="routine-timeout" type="number" min={1000} step={1000} value={timeoutMs} onChange={(e) => setTimeoutMs(Number(e.target.value))} aria-invalid={!!errors.timeoutMs} aria-describedby={errors.timeoutMs ? timeoutErrorId : undefined} />
{errors.timeoutMs ? <small id={timeoutErrorId} className="field-error">{errors.timeoutMs}</small> : <small>Maximum execution time in milliseconds.</small>}
</div>
{/* Execution Policy */}
<div className="form-group">
<label htmlFor="routine-execution-policy">Execution Policy</label>

View File

@@ -1,26 +1,13 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Plus, Clock, Zap, Globe, Folder } from "lucide-react";
import type {
ScheduledTask,
ScheduledTaskCreateInput,
Routine,
RoutineCreateInput,
} from "@fusion/core";
import { Plus, Zap, Globe, Folder } from "lucide-react";
import type { Routine, RoutineCreateInput } from "@fusion/core";
import {
fetchAutomations,
createAutomation,
updateAutomation,
deleteAutomation,
runAutomation,
toggleAutomation,
fetchRoutines,
createRoutine,
updateRoutine,
deleteRoutine,
runRoutine,
} from "../api";
import { ScheduleForm } from "./ScheduleForm";
import { ScheduleCard } from "./ScheduleCard";
import { RoutineCard } from "./RoutineCard";
import { RoutineEditor } from "./RoutineEditor";
import type { ToastType } from "../hooks/useToast";
@@ -38,24 +25,10 @@ interface ScheduledTasksModalProps {
projectId?: string;
}
type ModalView = "list" | "create" | "edit";
type ActiveTab = "schedules" | "routines";
export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledTasksModalProps) {
// Tab state
const [activeTab, setActiveTab] = useState<ActiveTab>("schedules");
// Scope state: defaults to "project" when projectId exists, else "global"
const [activeScope, setActiveScope] = useState<SchedulingScope>(() => projectId ? "project" : "global");
// Schedule state
const [schedules, setSchedules] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<ModalView>("list");
const [editingSchedule, setEditingSchedule] = useState<ScheduledTask | undefined>();
/** Track which schedule is currently running a manual execution. */
const [runningId, setRunningId] = useState<string | null>(null);
// Routine state
const [routines, setRoutines] = useState<Routine[]>([]);
const [routineView, setRoutineView] = useState<"list" | "create" | "edit">("list");
@@ -68,18 +41,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
projectId: activeScope === "project" ? projectId : undefined,
}), [activeScope, projectId]);
// Load schedules
const loadSchedules = useCallback(async () => {
try {
const data = await fetchAutomations(scopeOptions);
setSchedules(data);
} catch (err: any) {
addToast(err.message || "Failed to load schedules", "error");
} finally {
setLoading(false);
}
}, [addToast, scopeOptions]);
// Load routines
const loadRoutines = useCallback(async () => {
try {
@@ -91,44 +52,32 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
}, [addToast, scopeOptions]);
useEffect(() => {
void loadSchedules();
void loadRoutines();
}, [loadSchedules, loadRoutines]);
}, [loadRoutines]);
// Poll for updates while modal is open
useEffect(() => {
const interval = setInterval(() => {
void loadSchedules();
void loadRoutines();
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [loadSchedules, loadRoutines]);
}, [loadRoutines]);
// Close on Escape (only when not in a sub-form)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (activeTab === "schedules") {
if (view !== "list") {
setView("list");
setEditingSchedule(undefined);
} else {
onClose();
}
if (routineView !== "list") {
setRoutineView("list");
setEditingRoutine(undefined);
} else {
// Routines tab
if (routineView !== "list") {
setRoutineView("list");
setEditingRoutine(undefined);
} else {
onClose();
}
onClose();
}
}
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose, activeTab, view, routineView]);
}, [onClose, routineView]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
@@ -137,97 +86,6 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
[onClose],
);
// ── Schedule CRUD handlers ──────────────────────────────────────────────
const handleCreate = useCallback(
async (input: ScheduledTaskCreateInput) => {
try {
await createAutomation(input, scopeOptions);
addToast("Schedule created", "success");
setView("list");
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to create schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleEdit = useCallback((schedule: ScheduledTask) => {
setEditingSchedule(schedule);
setView("edit");
}, []);
const handleUpdate = useCallback(
async (input: ScheduledTaskCreateInput) => {
if (!editingSchedule) return;
try {
await updateAutomation(editingSchedule.id, input, scopeOptions);
addToast("Schedule updated", "success");
setView("list");
setEditingSchedule(undefined);
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to update schedule", "error");
}
},
[editingSchedule, addToast, loadSchedules, scopeOptions],
);
const handleDelete = useCallback(
async (schedule: ScheduledTask) => {
try {
await deleteAutomation(schedule.id, scopeOptions);
addToast(`Deleted "${schedule.name}"`, "success");
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to delete schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleRun = useCallback(
async (schedule: ScheduledTask) => {
setRunningId(schedule.id);
try {
const { result } = await runAutomation(schedule.id, scopeOptions);
if (result.success) {
addToast(`"${schedule.name}" completed successfully`, "success");
} else {
addToast(`"${schedule.name}" failed: ${result.error || "Unknown error"}`, "error");
}
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to run schedule", "error");
} finally {
setRunningId(null);
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleToggle = useCallback(
async (schedule: ScheduledTask) => {
try {
await toggleAutomation(schedule.id, scopeOptions);
addToast(
`"${schedule.name}" ${schedule.enabled ? "disabled" : "enabled"}`,
"success",
);
await loadSchedules();
} catch (err: any) {
addToast(err.message || "Failed to toggle schedule", "error");
}
},
[addToast, loadSchedules, scopeOptions],
);
const handleFormCancel = useCallback(() => {
setView("list");
setEditingSchedule(undefined);
}, []);
// ── Routine CRUD handlers ───────────────────────────────────────────────
const handleCreateRoutine = useCallback(
@@ -319,86 +177,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
setEditingRoutine(undefined);
}, []);
// ── Tab switch handlers ─────────────────────────────────────────────────
const handleTabSwitch = useCallback((tab: ActiveTab) => {
setActiveTab(tab);
setView("list");
setEditingSchedule(undefined);
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Scope switch handler ───────────────────────────────────────────────
const handleScopeSwitch = useCallback((scope: SchedulingScope) => {
setActiveScope(scope);
// Reset to list view when switching scope
setView("list");
setEditingSchedule(undefined);
setRoutineView("list");
setEditingRoutine(undefined);
}, []);
// ── Render content ─────────────────────────────────────────────────────
const renderSchedulesContent = () => {
if (view === "create") {
return <ScheduleForm onSubmit={handleCreate} onCancel={handleFormCancel} scope={activeScope} projectId={projectId} onScopeChange={handleScopeSwitch} />;
}
if (view === "edit" && editingSchedule) {
return (
<ScheduleForm
schedule={editingSchedule}
onSubmit={handleUpdate}
onCancel={handleFormCancel}
scope={activeScope}
projectId={projectId}
onScopeChange={handleScopeSwitch}
/>
);
}
// List view
if (loading) {
return <div className="settings-empty-state settings-loading">Loading schedules</div>;
}
if (schedules.length === 0) {
return (
<div className="schedule-empty-state">
<Clock size={48} strokeWidth={1} />
<h4>No scheduled tasks yet</h4>
<p>Create a schedule to automate recurring tasks.</p>
<button
className="btn btn-primary btn-sm"
onClick={() => setView("create")}
>
<Plus size={14} />
Create your first schedule
</button>
</div>
);
}
return (
<div className="schedule-list">
{schedules.map((s) => (
<ScheduleCard
key={s.id}
schedule={s}
onEdit={handleEdit}
onDelete={handleDelete}
onRun={handleRun}
onToggle={handleToggle}
running={runningId === s.id}
/>
))}
</div>
);
};
const renderRoutinesContent = () => {
if (routineView === "create") {
return <RoutineEditor onSubmit={handleCreateRoutine} onCancel={handleRoutineCancel} scope={activeScope} projectId={projectId} />;
@@ -421,14 +210,14 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
return (
<div className="routine-empty-state">
<Zap size={48} strokeWidth={1} />
<h4>No routines yet</h4>
<p>Create a routine to assign recurring tasks to agents.</p>
<h4>No automations yet</h4>
<p>Create an automation with a schedule, webhook, API, or manual trigger.</p>
<button
className="btn btn-primary btn-sm"
onClick={() => setRoutineView("create")}
>
<Plus size={14} />
Create your first routine
Create your first automation
</button>
</div>
);
@@ -452,23 +241,17 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
};
const renderContent = () => {
if (activeTab === "schedules") {
return renderSchedulesContent();
}
return renderRoutinesContent();
};
// Determine if we're in "list" view for showing the "New" button
const isShowingList =
activeTab === "schedules" ? view === "list" && schedules.length > 0 : routineView === "list" && routines.length > 0;
const isShowingEmptyState =
activeTab === "schedules" ? view === "list" && schedules.length === 0 && !loading : routineView === "list" && routines.length === 0;
routineView === "list" && routines.length > 0;
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="schedules-modal-title">
<div className="modal-header">
<h3 id="schedules-modal-title">Scheduled Tasks</h3>
<h3 id="schedules-modal-title">Automations</h3>
<div className="modal-header-actions">
{/* Scope selector */}
<div className="scheduling-scope-selector" role="group" aria-label="Scheduling scope">
@@ -477,7 +260,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
className={`scope-btn${activeScope === "global" ? " active" : ""}`}
onClick={() => handleScopeSwitch("global")}
aria-pressed={activeScope === "global"}
title="Global (user-level) schedules"
title="Global (user-level) automations"
>
<Globe size={14} />
Global
@@ -487,7 +270,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
className={`scope-btn${activeScope === "project" ? " active" : ""}`}
onClick={() => handleScopeSwitch("project")}
aria-pressed={activeScope === "project"}
title="Project-scoped schedules"
title="Project-scoped automations"
>
<Folder size={14} />
Project
@@ -496,17 +279,11 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
{isShowingList && (
<button
className="btn btn-primary btn-sm"
onClick={() => {
if (activeTab === "schedules") {
setView("create");
} else {
setRoutineView("create");
}
}}
aria-label={activeTab === "schedules" ? "Create new schedule" : "Create new routine"}
onClick={() => setRoutineView("create")}
aria-label="Create new automation"
>
<Plus size={14} />
{activeTab === "schedules" ? "New Schedule" : "New Routine"}
New Automation
</button>
)}
<button className="modal-close" onClick={onClose} aria-label="Close">
@@ -515,25 +292,21 @@ export function ScheduledTasksModal({ onClose, addToast, projectId }: ScheduledT
</div>
</div>
{/* Tab navigation */}
<div className="scheduling-summary" aria-live="polite">
<Zap size={14} />
<span>{routines.length} automation{routines.length === 1 ? "" : "s"}</span>
</div>
<div className="detail-tabs" role="tablist">
<button
className={`detail-tab${activeTab === "schedules" ? " detail-tab-active" : ""}`}
role="tab"
id="tab-schedules"
aria-selected={activeTab === "schedules"}
aria-controls="scheduled-tasks-content"
onClick={() => handleTabSwitch("schedules")}
>
<Clock size={14} /> Schedules
</button>
<button
className={`detail-tab${activeTab === "routines" ? " detail-tab-active" : ""}`}
className="detail-tab detail-tab-active"
role="tab"
id="tab-routines"
aria-selected={activeTab === "routines"}
aria-selected="true"
aria-controls="scheduled-tasks-content"
onClick={() => handleTabSwitch("routines")}
onClick={() => {
setRoutineView("list");
setEditingRoutine(undefined);
}}
>
<Zap size={14} /> Routines
</button>

View File

@@ -130,6 +130,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
// Clean up localStorage
localStorage.removeItem("kb-onboarding-state");
});
@@ -1486,20 +1487,22 @@ describe("ModelOnboardingModal", () => {
});
// Click Login
vi.useFakeTimers();
fireEvent.click(screen.getByText("Login"));
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2000);
});
// Wait for the login to complete (poll detects authenticated on 2nd call)
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
}, { timeout: 3000 });
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
// Check that login outcome was persisted
await waitFor(() => {
const saveCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(saveCall).toBeDefined();
}, { timeout: 3000 });
const saveCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(saveCall).toBeDefined();
});
it("login outcome persisted to stepData after successful login", async () => {
@@ -1532,15 +1535,19 @@ describe("ModelOnboardingModal", () => {
});
// Click Login
vi.useFakeTimers();
fireEvent.click(screen.getByText("Login"));
await act(async () => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(2000);
});
// Wait for saveOnboardingState to be called with success outcome
await waitFor(() => {
const successCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(successCall).toBeDefined();
}, { timeout: 3000 });
const successCall = mockSaveOnboardingState.mock.calls.find(
(call) => call[1]?.stepData?.["ai-setup"]?.loginOutcomes?.anthropic === "success"
);
expect(successCall).toBeDefined();
});
it("stale pending outcomes are filtered on mount", async () => {

View File

@@ -22,6 +22,7 @@ function makeRoutine(overrides: Partial<Routine> = {}): Routine {
name: "Test Routine",
description: "A test routine",
trigger: { type: "cron", cronExpression: "0 * * * *" },
command: "echo test",
executionPolicy: "parallel",
catchUpPolicy: "skip",
enabled: true,
@@ -37,6 +38,10 @@ 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();
});
@@ -286,6 +291,7 @@ describe("RoutineEditor", () => {
it("calls onSubmit with correct RoutineCreateInput shape on valid create", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
@@ -304,6 +310,7 @@ describe("RoutineEditor", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
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(() => {
@@ -335,6 +342,7 @@ describe("RoutineEditor", () => {
const slowSubmit = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
render(<RoutineEditor onSubmit={slowSubmit} onCancel={onCancel} />);
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" });
@@ -349,6 +357,7 @@ describe("RoutineEditor", () => {
it("re-enables submit button after submission completes", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Routine" } });
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
@@ -364,6 +373,7 @@ describe("RoutineEditor", () => {
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(() => {
@@ -384,6 +394,7 @@ describe("RoutineEditor", () => {
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(() => {
@@ -402,6 +413,7 @@ describe("RoutineEditor", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Manual Routine" } });
fireEvent.click(screen.getByText("Manual"));
fillCommand();
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {

View File

@@ -1,32 +1,30 @@
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";
import type { Routine } from "@fusion/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>,
Calendar: () => <span data-testid="icon-calendar">📅</span>,
Webhook: () => <span data-testid="icon-webhook">🔗</span>,
Code: () => <span data-testid="icon-code">💻</span>,
Zap: () => <span data-testid="icon-zap"></span>,
Globe: () => <span data-testid="icon-globe">🌍</span>,
Folder: () => <span data-testid="icon-folder">📁</span>,
Clock: () => <span data-testid="icon-clock">Clock</span>,
Play: () => <span data-testid="icon-play">Play</span>,
Pause: () => <span data-testid="icon-pause">Pause</span>,
Pencil: () => <span data-testid="icon-pencil">Edit</span>,
Trash2: () => <span data-testid="icon-trash">Delete</span>,
CheckCircle: () => <span data-testid="icon-check">Success</span>,
XCircle: () => <span data-testid="icon-x">Failure</span>,
ChevronDown: () => <span data-testid="icon-down">Down</span>,
ChevronUp: () => <span data-testid="icon-up">Up</span>,
Calendar: () => <span data-testid="icon-calendar">Calendar</span>,
Webhook: () => <span data-testid="icon-webhook">Webhook</span>,
Code: () => <span data-testid="icon-code">Code</span>,
Zap: () => <span data-testid="icon-zap">Zap</span>,
Globe: () => <span data-testid="icon-globe">Global</span>,
Folder: () => <span data-testid="icon-folder">Project</span>,
Layers: () => <span data-testid="icon-layers">Layers</span>,
}));
// 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();
@@ -60,7 +58,6 @@ vi.mock("../../api", () => ({
}),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, disabled, models }: any) => (
<select
@@ -79,23 +76,6 @@ vi.mock("../CustomModelDropdown", () => ({
),
}));
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,
};
}
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
id: "routine-001",
@@ -123,691 +103,214 @@ describe("ScheduledTasksModal", () => {
mockFetchRoutines.mockResolvedValue([]);
});
it("renders modal with title", async () => {
it("renders the unified automations modal", 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} />);
expect(screen.getByText("Automations")).toBeDefined();
expect(screen.getByRole("dialog").getAttribute("aria-labelledby")).toBe("schedules-modal-title");
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
expect(screen.getByText("No automations yet")).toBeDefined();
});
expect(screen.getByText("Create your first schedule")).toBeDefined();
expect(screen.getByText("Create your first automation")).toBeDefined();
expect(screen.getByText("Routines")).toBeDefined();
expect(mockFetchAutomations).not.toHaveBeenCalled();
});
it("shows schedule cards when schedules exist", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "My Job" })]);
it("shows routine cards and the new automation button when routines exist", async () => {
mockFetchRoutines.mockResolvedValue([
makeRoutine({ name: "Database Backup", command: "fn backup --create" }),
]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
expect(screen.getByText("Database Backup")).toBeDefined();
});
expect(screen.getByText("fn backup --create")).toBeDefined();
expect(screen.getByText("New Automation")).toBeDefined();
});
it("shows New Schedule button when schedules exist", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule()]);
it("uses routine APIs with global scope by default", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
});
});
it("calls onClose when close button is clicked", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByLabelText("Close"));
expect(onClose).toHaveBeenCalled();
});
it("uses routine APIs with project scope when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
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("Scope behavior", () => {
it("defaults to global scope when no projectId provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchAutomations was called with global scope
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
it("defaults to project scope when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchAutomations was called with project scope and projectId
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
});
it("forwards projectId to fetchRoutines when projectId is provided", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-456" />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
// Verify fetchRoutines was called with project scope and projectId
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-456" });
});
});
it("can switch from global to project scope and reloads data", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
// Initial load with project scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
});
// Clear mocks to track the reload after scope switch
mockFetchAutomations.mockClear();
// Click the global scope button
const globalBtn = screen.getByRole("button", { name: /global/i });
fireEvent.click(globalBtn);
// Should reload with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
it("reloads routines when switching scope", async () => {
mockFetchRoutines.mockResolvedValue([makeRoutine({ name: "Scoped Routine" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-789" />);
await waitFor(() => {
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "project", projectId: "proj-789" });
});
mockFetchRoutines.mockClear();
it("switches from project to global scope and reloads data", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
// Initial load with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
// Clear mocks to track the reload after scope switch
mockFetchAutomations.mockClear();
// Click the project scope button
const projectBtn = screen.getByRole("button", { name: /project/i });
fireEvent.click(projectBtn);
// Should reload with project scope (but no projectId available, so falls back to global)
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project" });
});
});
fireEvent.click(screen.getByRole("button", { name: /global/i }));
it("resets view to list when switching scope", async () => {
mockFetchAutomations.mockResolvedValue([makeSchedule({ name: "Test Job" })]);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("New Schedule")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
await waitFor(() => {
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
});
// Clear mocks
mockFetchAutomations.mockClear();
// Switch scope - should reset to list view
const globalBtn = screen.getByRole("button", { name: /global/i });
fireEvent.click(globalBtn);
await waitFor(() => {
// Should be back to list view, not create form
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
expect(screen.getByText("Test Job")).toBeDefined();
});
await waitFor(() => {
expect(mockFetchRoutines).toHaveBeenCalledWith({ scope: "global" });
});
});
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("opens the routine editor from the empty state", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
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();
expect(screen.getByText("New Routine", { selector: "h4" })).toBeDefined();
expect(screen.getByLabelText("Name")).toBeDefined();
});
it("creates a command automation and returns to the list", async () => {
const created = makeRoutine({ name: "New Automation", command: "echo test" });
mockFetchRoutines
.mockResolvedValueOnce([])
.mockResolvedValueOnce([created]);
mockCreateRoutine.mockResolvedValue(created);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New Automation" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo test" } });
fireEvent.click(screen.getByText("Create Routine"));
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");
});
});
it("changing scope in create form updates modal's activeScope and reloads data", async () => {
const schedule = makeSchedule({ name: "Test Job" });
mockFetchAutomations
.mockResolvedValueOnce([schedule]) // initial load with project scope
.mockResolvedValueOnce([]); // reload with global scope after scope switch
mockCreateAutomation.mockResolvedValue(schedule);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
// Initial load with project scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "project", projectId: "proj-123" });
expect(screen.getByText("Test Job")).toBeDefined();
});
// Open create form
fireEvent.click(screen.getByText("New Schedule"));
await waitFor(() => {
expect(screen.getByText("New Schedule", { selector: "h4" })).toBeDefined();
});
// Clear mocks to track reload
mockFetchAutomations.mockClear();
// Click Global scope button in the form (note: icon chars in accessible name)
const globalBtn = screen.getByRole("radio", { name: "🌍Global" });
fireEvent.click(globalBtn);
// Modal should reload with global scope
await waitFor(() => {
expect(mockFetchAutomations).toHaveBeenCalledWith({ scope: "global" });
});
// Should be back in list view
await waitFor(() => {
expect(screen.queryByText("New Schedule", { selector: "h4" })).toBeNull();
});
await waitFor(() => {
expect(mockCreateRoutine).toHaveBeenCalledWith(
expect.objectContaining({ name: "New Automation", command: "echo test" }),
{ scope: "global" },
);
expect(addToast).toHaveBeenCalledWith("Routine 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 });
it("edits routines through the unified interface", async () => {
const routine = makeRoutine({ name: "My Routine", command: "echo before" });
const updated = { ...routine, name: "Updated Routine", command: "echo after" };
mockFetchRoutines
.mockResolvedValueOnce([routine])
.mockResolvedValueOnce([updated]);
mockUpdateRoutine.mockResolvedValue(updated);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByLabelText("Disable My Job"));
await waitFor(() => {
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Job" disabled', "success");
});
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();
});
it("forwards projectId when projectId is provided", async () => {
const schedule = makeSchedule({ name: "My Job", enabled: true });
mockFetchAutomations.mockResolvedValue([schedule]);
mockToggleAutomation.mockResolvedValue({ ...schedule, enabled: false });
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated Routine" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "echo after" } });
fireEvent.click(screen.getByText("Save Changes"));
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} projectId="proj-123" />);
await waitFor(() => {
expect(screen.getByText("My Job")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Disable My Job"));
await waitFor(() => {
expect(mockToggleAutomation).toHaveBeenCalledWith("sched-1", { scope: "project", projectId: "proj-123" });
});
await waitFor(() => {
expect(mockUpdateRoutine).toHaveBeenCalledWith(
"routine-001",
expect.objectContaining({ name: "Updated Routine", command: "echo after" }),
{ scope: "global" },
);
expect(addToast).toHaveBeenCalledWith("Routine updated", "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", { scope: "global" });
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", { scope: "global" });
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");
});
});
});
// ── Routine Tab Tests ─────────────────────────────────────────────────────
describe("Tab navigation", () => {
it("shows both Schedules and Routines tabs", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Schedules")).toBeDefined();
expect(screen.getByText("Routines")).toBeDefined();
});
});
it("defaults to Schedules tab", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("No scheduled tasks yet")).toBeDefined();
});
});
it("clicking Routines tab switches to routines view", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
// 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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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 = {
it("runs routines and shows success or failure toasts", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
mockRunRoutine.mockResolvedValue({
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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", { scope: "global" });
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(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Run My Routine now"));
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",
);
});
await waitFor(() => {
expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success");
});
});
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);
it("deletes routines after confirmation", async () => {
const routine = makeRoutine({ name: "My Routine" });
mockFetchRoutines.mockResolvedValue([routine]);
mockDeleteRoutine.mockResolvedValue(undefined);
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
fireEvent.click(screen.getByText("Routines"));
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Delete My Routine"));
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
});
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Delete My Routine"));
confirmSpy.mockRestore();
await waitFor(() => {
expect(mockDeleteRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" });
expect(addToast).toHaveBeenCalledWith('Deleted "My Routine"', "success");
});
confirmSpy.mockRestore();
});
it("toggles routines through updateRoutine", async () => {
const routine = makeRoutine({ name: "My Routine", enabled: true });
mockFetchRoutines.mockResolvedValue([routine]);
mockUpdateRoutine.mockResolvedValue({ ...routine, enabled: false });
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("My Routine")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Disable My Routine"));
await waitFor(() => {
expect(mockUpdateRoutine).toHaveBeenCalledWith("routine-001", { enabled: false }, { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
});
});
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);
it("backs out of editor on Escape and closes from list on Escape", async () => {
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
render(<ScheduledTasksModal onClose={onClose} addToast={addToast} />);
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 }, { scope: "global" });
expect(addToast).toHaveBeenCalledWith('"My Routine" disabled', "success");
});
await waitFor(() => {
expect(screen.getByText("Create your first automation")).toBeDefined();
});
fireEvent.click(screen.getByText("Create your first automation"));
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText("No automations yet")).toBeDefined();
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
});