203 lines
7.5 KiB
TypeScript
203 lines
7.5 KiB
TypeScript
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(
|
||
|
|
<TaskEditorModal project={project} task={task} onClose={onClose} onSaved={onSaved} />,
|
||
|
|
);
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
});
|