cruise 0.1.34

YAML-driven coding agent workflow orchestrator
Documentation
/**
 * Tests for the GUI settings modal (parallelism configuration).
 *
 * These tests describe the expected behaviour of the settings affordance added to
 * the sidebar header (plan #7). They will fail until the modal is implemented and
 * `getAppConfig` / `updateAppConfig` are wired into App.tsx.
 *
 * Design assumptions (from plan #7):
 *   - A settings button is visible in the sidebar header near "Clean" / "Run All" / "+ New".
 *   - Clicking it opens a modal/dialog (role="dialog").
 *   - The modal contains a numeric input (role="spinbutton") for `run_all_parallelism`.
 *   - Saving calls `updateAppConfig({ runAllParallelism: N })`.
 *   - Parallelism 0 or below is rejected with an inline validation message before saving.
 *   - Saving closes the modal on success.
 *   - A close / cancel button closes the modal without calling `updateAppConfig`.
 */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../App";
import type { Session } from "../types";
import * as commands from "../lib/commands";

// ─── Module mocks ──────────────────────────────────────────────────────────────

vi.mock("@tauri-apps/api/app", () => ({
  getVersion: vi.fn().mockResolvedValue("0.0.0"),
}));

vi.mock("@tauri-apps/api/core", () => ({
  Channel: class {
    onmessage: ((event: unknown) => void) | null = null;
  },
}));

vi.mock("@tauri-apps/plugin-opener", () => ({
  openUrl: vi.fn(),
}));

vi.mock("@tauri-apps/plugin-dialog", () => ({
  open: vi.fn(),
}));

vi.mock("../lib/commands", () => ({
  listSessions: vi.fn(),
  listConfigs: vi.fn(),
  createSession: vi.fn(),
  approveSession: vi.fn(),
  discardSession: vi.fn(),
  getSession: vi.fn(),
  getSessionLog: vi.fn(),
  getSessionPlan: vi.fn(),
  listDirectory: vi.fn(),
  getUpdateReadiness: vi.fn(),
  cleanSessions: vi.fn(),
  deleteSession: vi.fn(),
  runSession: vi.fn(),
  cancelSession: vi.fn(),
  resetSession: vi.fn(),
  respondToOption: vi.fn(),
  runAllSessions: vi.fn(),
  fixSession: vi.fn(),
  askSession: vi.fn(),
  getAppConfig: vi.fn(),
  updateAppConfig: vi.fn(),
}));

vi.mock("../lib/updater", () => ({
  checkForUpdate: vi.fn().mockResolvedValue(null),
  downloadAndInstall: vi.fn(),
}));

vi.mock("../lib/desktopNotifications", () => ({
  notifyDesktop: vi.fn(),
}));

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makeSession(overrides: Partial<Session> = {}): Session {
  return {
    id: "session-1",
    phase: "Planned",
    configSource: "default.yaml",
    baseDir: "/home/user/project",
    input: "test task",
    createdAt: "2026-01-01T00:00:00Z",
    workspaceMode: "Worktree",
    ...overrides,
  };
}

/** Open the settings modal and wait for the dialog to appear. */
async function openSettingsModal(): Promise<void> {
  const btn = screen.getByRole("button", { name: /settings/i });
  await userEvent.click(btn);
  await waitFor(() => {
    expect(screen.getByRole("dialog")).toBeInTheDocument();
  });
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe("App: Settings modal — basic presence", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([makeSession()]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 1 });
    vi.mocked(commands.updateAppConfig).mockResolvedValue();
  });

  afterEach(() => {
    cleanup();
  });

  it("settings button is present in the sidebar", async () => {
    // Given: App is rendered
    render(<App />);

    // Then: a settings button is visible in the sidebar
    await waitFor(() => {
      expect(screen.getByRole("button", { name: /settings/i })).toBeInTheDocument();
    });
  });

  it("opens settings modal dialog when settings button is clicked", async () => {
    // Given: App is rendered
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));

    // When: settings button is clicked
    await userEvent.click(screen.getByRole("button", { name: /settings/i }));

    // Then: a dialog is visible
    await waitFor(() => {
      expect(screen.getByRole("dialog")).toBeInTheDocument();
    });
  });
});

describe("App: Settings modal — config load and display", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
    vi.mocked(commands.updateAppConfig).mockResolvedValue();
  });

  afterEach(() => {
    cleanup();
  });

  it("shows current parallelism value from getAppConfig in the input field", async () => {
    // Given: getAppConfig returns parallelism=4
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 4 });

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));

    // When: settings modal is opened
    await openSettingsModal();

    // Then: the numeric input shows "4" (the persisted value)
    await waitFor(() => {
      const input = screen.getByRole("spinbutton") as HTMLInputElement;
      expect(input.value).toBe("4");
    });
  });

  it("shows default parallelism of 1 when getAppConfig returns default", async () => {
    // Given: getAppConfig returns the default value (1)
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 1 });

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));

    // When: settings modal is opened
    await openSettingsModal();

    // Then: the input shows "1"
    await waitFor(() => {
      const input = screen.getByRole("spinbutton") as HTMLInputElement;
      expect(input.value).toBe("1");
    });
  });
});

describe("App: Settings modal — save behaviour", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 1 });
    vi.mocked(commands.updateAppConfig).mockResolvedValue();
  });

  afterEach(() => {
    cleanup();
  });

  it("calls updateAppConfig with the new parallelism value when saved", async () => {
    // Given: modal is open showing parallelism=1
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change value to 3 and click save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "3");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: updateAppConfig is called with the new value
    await waitFor(() => {
      expect(vi.mocked(commands.updateAppConfig)).toHaveBeenCalledWith({ runAllParallelism: 3 });
    });
  });

  it("closes the modal after a successful save", async () => {
    // Given: modal is open
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change value and save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "2");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: modal is dismissed on success
    await waitFor(() => {
      expect(screen.queryByRole("dialog")).toBeNull();
    });
  });

  it("does not call updateAppConfig when cancel/close is clicked", async () => {
    // Given: modal is open
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: the close/cancel button is clicked (without saving)
    const closeButton = screen.getByRole("button", { name: /close|cancel/i });
    await userEvent.click(closeButton);

    // Then: modal closes and updateAppConfig was NOT called
    await waitFor(() => {
      expect(screen.queryByRole("dialog")).toBeNull();
    });
    expect(vi.mocked(commands.updateAppConfig)).not.toHaveBeenCalled();
  });

  it("shows an error message when updateAppConfig rejects", async () => {
    // Given: updateAppConfig will fail with a server error
    vi.mocked(commands.updateAppConfig).mockRejectedValueOnce(new Error("disk write failed"));

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change value and attempt to save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "2");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: an error message is displayed in the modal
    await waitFor(() => {
      expect(screen.getByText(/disk write failed/i)).toBeInTheDocument();
    });
    // And: the dialog is still visible (not dismissed on error)
    expect(screen.getByRole("dialog")).toBeInTheDocument();
  });
});

describe("App: Settings modal — validation", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 1 });
    vi.mocked(commands.updateAppConfig).mockResolvedValue();
  });

  afterEach(() => {
    cleanup();
  });

  it("shows a validation error and does not call updateAppConfig when parallelism is 0", async () => {
    // Given: modal is open
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change parallelism to 0 (invalid — must be ≥ 1) and click save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "0");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: a validation error is shown (the value 0 is explicitly rejected)
    await waitFor(() => {
      // Error message should mention the constraint (≥1) or the invalid value
      const errorText = screen.getByText(/must be|at least|≥\s*1|minimum/i);
      expect(errorText).toBeInTheDocument();
    });
    // And: updateAppConfig must NOT be called — 0 is never silently coerced
    expect(vi.mocked(commands.updateAppConfig)).not.toHaveBeenCalled();
  });

  it("shows a validation error for negative parallelism", async () => {
    // Given: modal is open
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: user types a negative number (the HTML input min=1 may prevent this,
    // but we verify the save path also guards against it)
    const input = screen.getByRole("spinbutton") as HTMLInputElement;
    // Override value directly to bypass HTML min attr
    Object.defineProperty(input, "valueAsNumber", { get: () => -1, configurable: true });
    await userEvent.clear(input);
    await userEvent.type(input, "-1");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: updateAppConfig must NOT be called for invalid values (≤ 0)
    expect(vi.mocked(commands.updateAppConfig)).not.toHaveBeenCalled();
  });

  it("accepts parallelism of 1 (minimum valid value)", async () => {
    // Given: modal is open showing a value other than 1
    vi.mocked(commands.getAppConfig).mockResolvedValue({ runAllParallelism: 4 });

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change value to 1 and save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "1");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: updateAppConfig is called with 1 — minimum is valid
    await waitFor(() => {
      expect(vi.mocked(commands.updateAppConfig)).toHaveBeenCalledWith({ runAllParallelism: 1 });
    });
  });

  it("accepts a large parallelism value", async () => {
    // Given: modal is open
    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: /settings/i }));
    await openSettingsModal();

    // When: change value to 8 and save
    const input = screen.getByRole("spinbutton");
    await userEvent.clear(input);
    await userEvent.type(input, "8");
    await userEvent.click(screen.getByRole("button", { name: /save/i }));

    // Then: updateAppConfig is called with the large value
    await waitFor(() => {
      expect(vi.mocked(commands.updateAppConfig)).toHaveBeenCalledWith({ runAllParallelism: 8 });
    });
  });
});