import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import TaskEditorModal from "./TaskEditorModal"; import type { Project, ScheduledTask } from "../../../lib/types"; const addScheduledTask = vi.fn(async () => "a1b2c3d4"); const updateScheduledTask = vi.fn(async () => "e5f6a7b8"); vi.mock("../../../lib/tauri-commands", () => ({ addScheduledTask: (...args: unknown[]) => addScheduledTask(...(args as [])), updateScheduledTask: (...args: unknown[]) => updateScheduledTask(...(args as [])), })); /** Modal focuses via rAF; jsdom needs a flush. */ async function flushFocus() { await act(async () => { vi.advanceTimersByTime(20); }); } const baseProject: Project = { id: "p1", name: "api-server", paths: [{ host_path: "/home/user/api", mount_name: "api" }], container_id: "c1", status: "running", backend: "anthropic", bedrock_config: null, ollama_config: null, openai_compatible_config: null, allow_docker_access: false, sandbox_mode_enabled: true, mission_control_enabled: false, auth_bridge_enabled: false, use_shared_auth_token: true, full_permissions: false, permission_mode: "bypass", ssh_key_path: null, git_token: null, git_user_name: null, git_user_email: null, custom_env_vars: [], port_mappings: [], claude_instructions: null, claude_code_settings: null, renamed_session_names: {}, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; const existingTask: ScheduledTask = { id: "a1b2c3d4", name: "nightly", prompt: "Run the suite", schedule: "0 3 * * *", task_type: "recurring", at: null, enabled: false, working_dir: "/workspace/api", created_at: null, last_run: null, next_run: null, }; async function renderEditor(task: ScheduledTask | null = null, project = baseProject) { const onClose = vi.fn(); const onSaved = vi.fn(); render( , ); await flushFocus(); return { onClose, onSaved }; } const field = (name: RegExp) => screen.getByLabelText(name) as HTMLInputElement; const submit = async () => await act(async () => { fireEvent.click(screen.getByRole("button", { name: /create task|save changes/i })); }); describe("TaskEditorModal", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] }); }); afterEach(() => vi.useRealTimers()); it("sends the typed values through as data, untouched", async () => { await renderEditor(); fireEvent.change(field(/^name$/i), { target: { value: " nightly " } }); // A prompt full of shell syntax must reach the backend verbatim. fireEvent.change(field(/^prompt$/i), { target: { value: 'echo "hi"; rm -rf / $(id)\nsecond line' }, }); fireEvent.change(field(/cron expression/i), { target: { value: "0 3 * * *" } }); await submit(); expect(addScheduledTask).toHaveBeenCalledWith("p1", { name: "nightly", prompt: 'echo "hi"; rm -rf / $(id)\nsecond line', scheduleKind: "recurring", schedule: "0 3 * * *", workingDir: "/workspace", }); }); it("refuses to submit an invalid cron expression and says why", async () => { const { onSaved } = await renderEditor(); fireEvent.change(field(/^name$/i), { target: { value: "nightly" } }); fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } }); fireEvent.change(field(/cron expression/i), { target: { value: "99 * * * *" } }); await submit(); expect(addScheduledTask).not.toHaveBeenCalled(); expect(onSaved).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent(/out of range for the minute field/i); }); it("refuses a relative working directory", async () => { await renderEditor(); fireEvent.change(field(/^name$/i), { target: { value: "nightly" } }); fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } }); fireEvent.change(field(/working directory/i), { target: { value: "relative/path" } }); await submit(); expect(addScheduledTask).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent(/absolute path/i); }); it("reads the cron expression back in English", async () => { await renderEditor(); fireEvent.change(field(/cron expression/i), { target: { value: "0 9 * * 1-5" } }); expect(screen.getByText("At 09:00, on Monday to Friday.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Hourly" })); expect(field(/cron expression/i).value).toBe("0 * * * *"); expect(screen.getByText("At :00 past every hour, every day.")).toBeInTheDocument(); }); it("switches to a one-shot time and validates its format", async () => { await renderEditor(); fireEvent.change(field(/^name$/i), { target: { value: "one-off" } }); fireEvent.change(field(/^prompt$/i), { target: { value: "commit" } }); fireEvent.click(screen.getByRole("radio", { name: "Once" })); fireEvent.change(field(/run at/i), { target: { value: "tomorrow" } }); await submit(); expect(addScheduledTask).not.toHaveBeenCalled(); expect(screen.getByRole("alert")).toHaveTextContent(/YYYY-MM-DD HH:MM/); fireEvent.change(field(/run at/i), { target: { value: "2099-12-25 09:05" } }); await submit(); expect(addScheduledTask).toHaveBeenCalledWith( "p1", expect.objectContaining({ scheduleKind: "once", schedule: "2099-12-25 09:05" }), ); }); it("warns that a headless run cannot answer a permission prompt", async () => { // Bypass is the only mode where an unattended run is safe from stalling. await renderEditor(null, { ...baseProject, permission_mode: "bypass" }); expect(screen.getByText(/headless/i)).toBeInTheDocument(); expect(screen.queryByText(/cannot answer a permission prompt/i)).toBeNull(); }); it("spells out the stall risk in any non-Bypass mode", async () => { await renderEditor(null, { ...baseProject, permission_mode: "default" }); expect(screen.getByText(/cannot answer a permission prompt/i)).toBeInTheDocument(); }); it("edits an existing task, carrying its enabled state and warning about the new id", async () => { const { onSaved, onClose } = await renderEditor(existingTask); expect(field(/^name$/i).value).toBe("nightly"); expect(field(/cron expression/i).value).toBe("0 3 * * *"); expect(field(/working directory/i).value).toBe("/workspace/api"); // The id changes on edit; the user is told before they save. expect(screen.getByText(/re-creates this task under a new id/i)).toBeInTheDocument(); fireEvent.change(field(/^name$/i), { target: { value: "nightly-v2" } }); await submit(); expect(updateScheduledTask).toHaveBeenCalledWith( "p1", "a1b2c3d4", expect.objectContaining({ name: "nightly-v2", workingDir: "/workspace/api" }), false, // the task was disabled and must not come back enabled ); expect(onSaved).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled(); }); it("surfaces a backend rejection instead of closing", async () => { addScheduledTask.mockRejectedValueOnce(new Error("Container is not running")); const { onClose } = await renderEditor(); fireEvent.change(field(/^name$/i), { target: { value: "nightly" } }); fireEvent.change(field(/^prompt$/i), { target: { value: "do the thing" } }); await submit(); expect(screen.getByRole("alert")).toHaveTextContent(/Container is not running/); expect(onClose).not.toHaveBeenCalled(); }); });