fix(FN-1952): recover routines schedules merge
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useWorkspaces } from "../useWorkspaces";
|
||||
import * as api from "../../api";
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("useWorkspaces", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads project and task workspaces", async () => {
|
||||
@@ -41,6 +42,7 @@ describe("useWorkspaces", () => {
|
||||
});
|
||||
|
||||
it("polls for workspace updates", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchWorkspaces
|
||||
.mockResolvedValueOnce({ project: "/repo", tasks: [] })
|
||||
.mockResolvedValueOnce({
|
||||
@@ -48,17 +50,23 @@ describe("useWorkspaces", () => {
|
||||
tasks: [{ id: "FN-200", title: "Later", worktree: "/repo/.worktrees/kb-200" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaces());
|
||||
const { result, unmount } = renderHook(() => useWorkspaces());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.workspaces).toEqual([]);
|
||||
|
||||
// Wait for the polling interval (10 seconds) - use real timers
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.workspaces).toHaveLength(1));
|
||||
expect(result.current.workspaces).toHaveLength(1);
|
||||
expect(mockFetchWorkspaces).toHaveBeenCalledTimes(2);
|
||||
}, 15000);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("surfaces fetch errors", async () => {
|
||||
mockFetchWorkspaces.mockRejectedValueOnce(new Error("Failed to load workspaces"));
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
|
||||
|
||||
import { promisify } from "node:util";
|
||||
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, readMemory, writeMemory, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -2409,14 +2409,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
const settings = await scopedStore.updateSettings(clientSettings);
|
||||
|
||||
// Sync backup automation schedule when backup settings change
|
||||
const automationStoreForProject = engine?.getAutomationStore() ?? options?.automationStore;
|
||||
if (automationStoreForProject) {
|
||||
// Sync backup routine when backup settings change.
|
||||
const routineStoreForProject = engine?.getRoutineStore() ?? options?.routineStore;
|
||||
if (routineStoreForProject) {
|
||||
try {
|
||||
await syncBackupAutomation(automationStoreForProject, settings);
|
||||
await syncBackupRoutine(routineStoreForProject, settings);
|
||||
} catch (err) {
|
||||
// Log but don't fail the settings update if automation sync fails
|
||||
console.error("Failed to sync backup automation:", err);
|
||||
// Log but don't fail the settings update if routine sync fails
|
||||
console.error("Failed to sync backup routine:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9553,7 +9553,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const routineStore = resolveRoutineStore(req, scope);
|
||||
|
||||
try {
|
||||
const { name, agentId, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
const { name, agentId, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!name?.trim()) {
|
||||
@@ -9577,6 +9577,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
|
||||
}
|
||||
}
|
||||
const hasSteps = Array.isArray(steps) && steps.length > 0;
|
||||
const hasCommand = typeof command === "string" && command.trim().length > 0;
|
||||
if (hasSteps) {
|
||||
const stepErr = validateAutomationSteps(steps);
|
||||
if (stepErr) {
|
||||
throw badRequest(stepErr);
|
||||
}
|
||||
}
|
||||
if (catchUpPolicy !== undefined) {
|
||||
const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"];
|
||||
if (!validCatchUpPolicies.includes(catchUpPolicy)) {
|
||||
@@ -9599,6 +9607,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
agentId: typeof agentId === "string" ? agentId.trim() : "",
|
||||
description,
|
||||
trigger,
|
||||
command: hasCommand ? command : undefined,
|
||||
steps: hasSteps ? steps : undefined,
|
||||
timeoutMs,
|
||||
catchUpPolicy,
|
||||
executionPolicy,
|
||||
enabled,
|
||||
@@ -9655,7 +9666,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
}
|
||||
|
||||
const { name, description, trigger, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
const { name, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
|
||||
|
||||
// Validate name if provided
|
||||
if (name !== undefined && !name.trim()) {
|
||||
@@ -9676,11 +9687,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length > 0) {
|
||||
const stepErr = validateAutomationSteps(steps);
|
||||
if (stepErr) {
|
||||
throw badRequest(stepErr);
|
||||
}
|
||||
}
|
||||
|
||||
const routine = await routineStore.updateRoutine(id, {
|
||||
name: name !== undefined ? name.trim() : undefined,
|
||||
description,
|
||||
trigger,
|
||||
command: command !== undefined ? command : undefined,
|
||||
steps: steps !== undefined ? steps : undefined,
|
||||
timeoutMs,
|
||||
catchUpPolicy,
|
||||
executionPolicy,
|
||||
enabled,
|
||||
@@ -17021,8 +17041,8 @@ function validateAutomationSteps(steps: unknown[]): string | null {
|
||||
if (!step.id || typeof step.id !== "string") {
|
||||
return `Step ${i + 1}: id is required`;
|
||||
}
|
||||
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt")) {
|
||||
return `Step ${i + 1}: type must be "command" or "ai-prompt"`;
|
||||
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt" && step.type !== "create-task")) {
|
||||
return `Step ${i + 1}: type must be "command", "ai-prompt", or "create-task"`;
|
||||
}
|
||||
if (!step.name || typeof step.name !== "string" || !step.name.trim()) {
|
||||
return `Step ${i + 1}: name is required`;
|
||||
@@ -17037,6 +17057,11 @@ function validateAutomationSteps(steps: unknown[]): string | null {
|
||||
return `Step ${i + 1}: prompt is required for ai-prompt steps`;
|
||||
}
|
||||
}
|
||||
if (step.type === "create-task") {
|
||||
if (!step.taskDescription || typeof step.taskDescription !== "string" || !step.taskDescription.trim()) {
|
||||
return `Step ${i + 1}: taskDescription is required for create-task steps`;
|
||||
}
|
||||
}
|
||||
// Validate model fields are both present or both absent
|
||||
const hasProvider = step.modelProvider && typeof step.modelProvider === "string";
|
||||
const hasModelId = step.modelId && typeof step.modelId === "string";
|
||||
|
||||
@@ -3184,6 +3184,10 @@ describe("usage", () => {
|
||||
});
|
||||
|
||||
describe("withTimeout", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves with provider result when fetch completes within timeout", async () => {
|
||||
const provider: ProviderUsage = {
|
||||
name: "TestProvider",
|
||||
@@ -3197,31 +3201,39 @@ describe("usage", () => {
|
||||
});
|
||||
|
||||
it("returns error provider when fetch exceeds timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const slowPromise = new Promise<ProviderUsage>((resolve) => {
|
||||
setTimeout(() => resolve({ name: "Slow", icon: "🐌", status: "ok", windows: [] }), 10000);
|
||||
});
|
||||
const result = await withTimeout(slowPromise, "Slow", 50); // 50ms timeout
|
||||
const resultPromise = withTimeout(slowPromise, "Slow", 50);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const result = await resultPromise;
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toBe("Timed out after 0s");
|
||||
expect(result.name).toBe("Slow");
|
||||
});
|
||||
|
||||
it("includes timeout duration in error message for different durations", async () => {
|
||||
vi.useFakeTimers();
|
||||
// 100ms => "0s"
|
||||
const result100 = await withTimeout(
|
||||
const result100Promise = withTimeout(
|
||||
new Promise<ProviderUsage>(() => {}),
|
||||
"Test",
|
||||
100,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const result100 = await result100Promise;
|
||||
expect(result100.error).toBe("Timed out after 0s");
|
||||
|
||||
// 10_000ms is too long to actually wait, but we can verify the format
|
||||
// by using a 1050ms timeout (rounds to 1s)
|
||||
const result1s = await withTimeout(
|
||||
const result1sPromise = withTimeout(
|
||||
new Promise<ProviderUsage>(() => {}),
|
||||
"Test",
|
||||
1050,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1050);
|
||||
const result1s = await result1sPromise;
|
||||
expect(result1s.error).toBe("Timed out after 1s");
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
import { availableParallelism } from "node:os";
|
||||
|
||||
const defaultMaxWorkers = Math.max(1, Math.min(2, Math.ceil(availableParallelism() / 8)));
|
||||
const defaultMaxWorkers = Math.max(1, Math.min(4, Math.ceil(availableParallelism() / 4)));
|
||||
const maxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user