cruise 0.1.49

YAML-driven coding agent workflow orchestrator
Documentation
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session, SkippableStepDto } from "../types";

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

vi.mock("../lib/commands", () => ({
  listConfigs: vi.fn(),
  getNewSessionConfigDefaults: vi.fn(),
  updateSessionSettings: vi.fn(),
  regenerateSessionPlan: vi.fn(),
}));

import { listConfigs, getNewSessionConfigDefaults, updateSessionSettings, regenerateSessionPlan } from "../lib/commands";
import { SessionConfigEditor } from "./SessionConfigEditor";

const mockListConfigs = vi.mocked(listConfigs);
const mockGetDefaults = vi.mocked(getNewSessionConfigDefaults);
const mockUpdateSettings = vi.mocked(updateSessionSettings);
const mockRegenerate = vi.mocked(regenerateSessionPlan);

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",
    skippedSteps: [],
    ...overrides,
  };
}

function makeStep(id: string, children: SkippableStepDto[] = []): SkippableStepDto {
  const expandedStepIds = children.length === 0
    ? [id]
    : children.flatMap((c) => c.expandedStepIds);
  return { id, expandedStepIds, children };
}

const defaultProps = {
  sessionId: "session-1",
  baseDir: "/home/user/project",
  configPath: undefined,
  skippedSteps: [] as string[],
  onSessionUpdated: vi.fn(),
  onPlanRegenerated: vi.fn(),
  onRegeneratingChange: vi.fn(),
  onError: vi.fn(),
  disabled: false,
};

beforeEach(() => {
  vi.clearAllMocks();
  mockListConfigs.mockResolvedValue([]);
  mockGetDefaults.mockResolvedValue({ steps: [], defaultSkippedSteps: [] });
  mockUpdateSettings.mockResolvedValue(makeSession());
  mockRegenerate.mockResolvedValue("");
});

afterEach(() => cleanup());

describe("SessionConfigEditor", () => {
  describe("Save button visibility control", () => {
    it("Save button is not shown when there are no changes", async () => {
      // Given
      render(<SessionConfigEditor {...defaultProps} />);
      // When (no changes)
      // Then
      await waitFor(() => {
        expect(screen.queryByRole("button", { name: /save/i })).not.toBeInTheDocument();
        expect(screen.queryByRole("button", { name: /regenerate/i })).not.toBeInTheDocument();
      });
    });

    it("'Save' button is shown when skip steps are changed", async () => {
      // Given
      const steps = [makeStep("step-a"), makeStep("step-b")];
      mockGetDefaults.mockResolvedValue({ steps, defaultSkippedSteps: [] });
      render(<SessionConfigEditor {...defaultProps} />);
      await waitFor(() => screen.getByLabelText("step-a"));

      // When: check step-a checkbox
      await userEvent.click(screen.getByLabelText("step-a"));

      // Then
      expect(screen.getByRole("button", { name: /^save$/i })).toBeInTheDocument();
    });

    it("'Save & Regenerate Plan' button is shown when config is changed", async () => {
      // Given
      mockListConfigs.mockResolvedValue([{ name: "custom.yaml", path: "/path/custom.yaml" }]);
      render(<SessionConfigEditor {...defaultProps} />);
      await waitFor(() => screen.getByLabelText("Config"));

      // When: change config
      await userEvent.selectOptions(screen.getByLabelText("Config"), "/path/custom.yaml");

      // Then
      await waitFor(() => {
        expect(screen.getByRole("button", { name: /save & regenerate plan/i })).toBeInTheDocument();
      });
    });
  });

  describe("Save button disabled state", () => {
    it("Save button is disabled when disabled=true", async () => {
      // Given: re-render with disabled=true after changing skip steps,
      // to simulate state where there are changes but disabled=true
      const steps = [makeStep("step-a")];
      mockGetDefaults.mockResolvedValue({ steps, defaultSkippedSteps: [] });
      const { rerender } = render(
        <SessionConfigEditor {...defaultProps} skippedSteps={["step-a"]} />
      );
      await waitFor(() => screen.getByLabelText("step-a"));

      // When: uncheck to create a change, then re-render with disabled=true
      // skippedSteps stays ["step-a"] so hasSkipChanged remains true after the uncheck
      await userEvent.click(screen.getByLabelText("step-a"));
      rerender(<SessionConfigEditor {...defaultProps} skippedSteps={["step-a"]} disabled={true} />);

      // Then: Save button is rendered (change exists) but must be disabled
      const saveBtn = screen.getByRole("button", { name: /^save$/i });
      expect(saveBtn).toBeDisabled();
    });
  });

  describe("updateSessionSettings invocation", () => {
    it("updateSessionSettings is called with correct args when Save button is clicked", async () => {
      // Given: skip step-a, then uncheck and save
      const steps = [makeStep("step-a"), makeStep("step-b")];
      mockGetDefaults.mockResolvedValue({ steps, defaultSkippedSteps: [] });
      render(
        <SessionConfigEditor
          {...defaultProps}
          skippedSteps={[]}
        />
      );
      await waitFor(() => screen.getByLabelText("step-a"));

      // When: check step-a
      await userEvent.click(screen.getByLabelText("step-a"));
      const saveBtn = await screen.findByRole("button", { name: /^save$/i });
      await userEvent.click(saveBtn);

      // Then
      await waitFor(() => {
        expect(mockUpdateSettings).toHaveBeenCalledWith("session-1", {
          configPath: undefined,
          skippedSteps: ["step-a"],
        });
      });
    });

    it("onSessionUpdated is called after save", async () => {
      // Given
      const steps = [makeStep("step-a")];
      mockGetDefaults.mockResolvedValue({ steps, defaultSkippedSteps: [] });
      const updatedSession = makeSession({ id: "session-1", phase: "Planned" });
      mockUpdateSettings.mockResolvedValue(updatedSession);
      const onSessionUpdated = vi.fn();
      render(
        <SessionConfigEditor
          {...defaultProps}
          onSessionUpdated={onSessionUpdated}
        />
      );
      await waitFor(() => screen.getByLabelText("step-a"));

      // When
      await userEvent.click(screen.getByLabelText("step-a"));
      await userEvent.click(await screen.findByRole("button", { name: /^save$/i }));

      // Then
      await waitFor(() => {
        expect(onSessionUpdated).toHaveBeenCalledWith(updatedSession);
      });
    });
  });

  describe("planFailed channel handling", () => {
    it("shows error and calls onError when planFailed event is received", async () => {
      // Given
      mockListConfigs.mockResolvedValue([{ name: "custom.yaml", path: "/path/custom.yaml" }]);
      const onError = vi.fn();
      mockRegenerate.mockImplementation(async (_sessionId, channel) => {
        channel.onmessage({ event: "planFailed", data: { sessionId: "session-1", error: "plan generation failed" } });
        return "";
      });
      render(<SessionConfigEditor {...defaultProps} onError={onError} />);
      await waitFor(() => screen.getByLabelText("Config"));

      // When: select custom config to show "Save & Regenerate Plan" button
      await userEvent.selectOptions(screen.getByLabelText("Config"), "/path/custom.yaml");
      const regenBtn = await screen.findByRole("button", { name: /save & regenerate plan/i });
      await userEvent.click(regenBtn);

      // Then: error message is displayed and onError is called
      await waitFor(() => {
        expect(screen.getByText("plan generation failed")).toBeInTheDocument();
        expect(onError).toHaveBeenCalledWith("plan generation failed");
      });
    });
  });

  describe("Checkbox tri-state (parent node)", () => {
    it("Parent checkbox is checked=true when all child checkboxes are on", async () => {
      // Given: parent node has two children
      const child1 = makeStep("parent/child1");
      const child2 = makeStep("parent/child2");
      const parent = makeStep("parent", [child1, child2]);
      parent.expandedStepIds = ["parent/child1", "parent/child2"];
      mockGetDefaults.mockResolvedValue({ steps: [parent], defaultSkippedSteps: [] });
      render(
        <SessionConfigEditor
          {...defaultProps}
          skippedSteps={["parent/child1", "parent/child2"]}
        />
      );

      // When
      await waitFor(() => screen.getByLabelText("parent"));

      // Then: parent checkbox is checked
      const parentCheckbox = screen.getByLabelText("parent") as HTMLInputElement;
      expect(parentCheckbox.checked).toBe(true);
      expect(parentCheckbox.indeterminate).toBe(false);
    });

    it("Parent checkbox is indeterminate when some child checkboxes are on", async () => {
      // Given
      const child1 = makeStep("parent/child1");
      const child2 = makeStep("parent/child2");
      const parent = makeStep("parent", [child1, child2]);
      parent.expandedStepIds = ["parent/child1", "parent/child2"];
      mockGetDefaults.mockResolvedValue({ steps: [parent], defaultSkippedSteps: [] });
      render(
        <SessionConfigEditor
          {...defaultProps}
          skippedSteps={["parent/child1"]}
        />
      );

      // When
      await waitFor(() => screen.getByLabelText("parent"));

      // Then: parent checkbox is indeterminate
      const parentCheckbox = screen.getByLabelText("parent") as HTMLInputElement;
      expect(parentCheckbox.indeterminate).toBe(true);
    });
  });
});