cruise 0.1.34

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

// ─── 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(),
  getConfigSteps: vi.fn().mockResolvedValue([]),
  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,
  };
}

/**
 * Set up the runAllSessions mock to emit events via the captured channel.
 * Returns handles to fire each event at an explicit moment in tests.
 */
function setupRunAllSessions() {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let capturedChannel: { onmessage: ((event: any) => void) | null } | null = null;
  let resolveRunAll!: () => void;

  vi.mocked(commands.runAllSessions).mockImplementationOnce(
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (channel: any) => {
      capturedChannel = channel;
      return new Promise<void>((resolve) => {
        resolveRunAll = resolve;
      });
    }
  );

  return {
    emitRunAllStarted(total: number): void {
      capturedChannel!.onmessage?.({ event: "runAllStarted", data: { total } });
    },
    emitSessionStarted(sessionId: string, input: string): void {
      capturedChannel!.onmessage?.({
        event: "runAllSessionStarted",
        data: { sessionId, input },
      });
    },
    emitSessionFinished(sessionId: string, input: string, phase: SessionPhase, error?: string): void {
      capturedChannel!.onmessage?.({
        event: "runAllSessionFinished",
        data: { sessionId, input, phase, error },
      });
    },
    emitCompleted(cancelled = 0): void {
      capturedChannel!.onmessage?.({ event: "runAllCompleted", data: { cancelled } });
      resolveRunAll();
    },
  };
}

// ─── RunAll: sidebar refresh ──────────────────────────────────────────────────

describe("App: RunAll — sidebar refreshes immediately on session finish", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    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 listSessions immediately when a RunAll session finishes, without waiting for idle poll", async () => {
    // Given: two sessions in the sidebar; runAllSessions is controlled
    const sessA = makeSession({ id: "sess-a", input: "task A", phase: "Awaiting Approval", planAvailable: true });
    const sessB = makeSession({ id: "sess-b", input: "task B", phase: "Awaiting Approval", planAvailable: true });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);

    const control = setupRunAllSessions();

    render(<App />);
    await waitFor(() => screen.getByText("task A"));

    // Navigate to Run All view
    await userEvent.click(screen.getByRole("button", { name: /run all/i }));

    // RunAll starts; session A begins
    await act(async () => { control.emitRunAllStarted(2); });
    await act(async () => { control.emitSessionStarted("sess-a", "task A"); });

    const callsBeforeFinish = vi.mocked(commands.listSessions).mock.calls.length;

    // When: session A finishes
    await act(async () => {
      control.emitSessionFinished("sess-a", "task A", "Completed");
    });

    // Then: sidebar is refreshed immediately (not waiting for 3-second idle poll)
    await waitFor(() => {
      expect(vi.mocked(commands.listSessions).mock.calls.length).toBeGreaterThan(callsBeforeFinish);
    });

    // Cleanup
    await act(async () => { control.emitCompleted(); });
  });
});

// ─── RunAll: completed notification ──────────────────────────────────────────

describe("App: RunAll — completed notification on session finish", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    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("emits completed notification when a RunAll session transitions to Completed", async () => {
    // Given: session starts as Awaiting Approval (plan ready)
    const session = makeSession({ id: "sess-run", input: "task run", phase: "Awaiting Approval", planAvailable: true });
    vi.mocked(commands.listSessions).mockResolvedValue([session]);

    const control = setupRunAllSessions();

    render(<App />);
    await waitFor(() => screen.getByText("task run"));

    // Navigate to Run All
    await userEvent.click(screen.getByRole("button", { name: /run all/i }));

    await act(async () => { control.emitRunAllStarted(1); });
    await act(async () => { control.emitSessionStarted("sess-run", "task run"); });

    // When: session finishes; listSessions now returns Completed phase
    vi.mocked(commands.listSessions).mockResolvedValue([
      { ...session, phase: "Completed" },
    ]);
    await act(async () => {
      control.emitSessionFinished("sess-run", "task run", "Completed");
    });

    // Then: completed desktop notification is fired
    await waitFor(() => {
      expect(vi.mocked(desktopNotifications.notifyDesktop)).toHaveBeenCalledWith(
        "Cruise",
        expect.stringContaining("Completed"),
      );
    });

    // Cleanup
    await act(async () => { control.emitCompleted(); });
  });

  it("emits a notification for each completed session individually", async () => {
    // Given: two sessions ready to run
    const sessA = makeSession({ id: "sess-a", input: "task A", phase: "Awaiting Approval", planAvailable: true });
    const sessB = makeSession({ id: "sess-b", input: "task B", phase: "Awaiting Approval", planAvailable: true });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);

    const control = setupRunAllSessions();

    render(<App />);
    await waitFor(() => screen.getByText("task A"));

    await userEvent.click(screen.getByRole("button", { name: /run all/i }));
    await act(async () => { control.emitRunAllStarted(2); });

    // Session A finishes
    await act(async () => { control.emitSessionStarted("sess-a", "task A"); });
    vi.mocked(commands.listSessions).mockResolvedValue([
      { ...sessA, phase: "Completed" },
      sessB,
    ]);
    await act(async () => {
      control.emitSessionFinished("sess-a", "task A", "Completed");
    });
    await waitFor(() => {
      expect(vi.mocked(desktopNotifications.notifyDesktop)).toHaveBeenCalledTimes(1);
    });

    // Session B finishes
    await act(async () => { control.emitSessionStarted("sess-b", "task B"); });
    vi.mocked(commands.listSessions).mockResolvedValue([
      { ...sessA, phase: "Completed" },
      { ...sessB, phase: "Completed" },
    ]);
    await act(async () => {
      control.emitSessionFinished("sess-b", "task B", "Completed");
    });

    // Then: two separate completed notifications fired (one per session)
    await waitFor(() => {
      expect(vi.mocked(desktopNotifications.notifyDesktop)).toHaveBeenCalledTimes(2);
    });

    // Cleanup
    await act(async () => { control.emitCompleted(); });
  });

  it("does not emit completed notification for sessions already Completed at app startup", async () => {
    // Given: app starts with sessions already in Completed state
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "pre-done", input: "pre-done task", phase: "Completed" }),
    ]);

    render(<App />);
    await waitFor(() => screen.getByText("pre-done task"));
    await act(async () => { await new Promise<void>((r) => setTimeout(r, 20)); });

    // Then: no completed notification for pre-existing completed sessions (startup suppression)
    expect(vi.mocked(desktopNotifications.notifyDesktop)).not.toHaveBeenCalled();
  });
});