cruise 0.1.69

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 type { DagDto } from "../types";

// WorkflowDagPanel keeps a module-level `mermaidInitialized` flag, so each test
// needs a fresh module instance (vi.resetModules) to observe initialize() calls
// in isolation -- otherwise only the first test in this file would see the call.

let mockGetSessionDag: ReturnType<typeof vi.fn>;
let mockMermaidInitialize: ReturnType<typeof vi.fn>;
let mockMermaidRender: ReturnType<typeof vi.fn>;
let WorkflowDagPanel: typeof import("./WorkflowDagPanel").WorkflowDagPanel;

beforeEach(async () => {
  vi.resetModules();
  mockGetSessionDag = vi.fn();
  mockMermaidInitialize = vi.fn();
  mockMermaidRender = vi.fn();
  vi.doMock("../lib/commands", () => ({
    getSessionDag: mockGetSessionDag,
  }));
  vi.doMock("mermaid", () => ({
    default: {
      initialize: mockMermaidInitialize,
      render: mockMermaidRender,
    },
  }));
  const mod = await import("./WorkflowDagPanel");
  WorkflowDagPanel = mod.WorkflowDagPanel;
});

afterEach(() => cleanup());

const baseProps = {
  panelId: "panel-dag-1",
  tabId: "tab-dag-1",
};

function makeDag(overrides: Partial<DagDto> = {}): DagDto {
  return {
    startStep: "build",
    currentStep: "test",
    steps: [
      { name: "build", kind: "command", isTerminal: false },
      { name: "test", kind: "command", isTerminal: true },
    ],
    edges: [
      { from: "build", to: "test", reason: "ifFileChanged", selector: "src/**" },
      { from: "test", to: null, reason: "sequential", selector: null },
    ],
    ...overrides,
  };
}

describe("WorkflowDagPanel", () => {
  describe("role / accessibility", () => {
    it("element with role='tabpanel' exists", () => {
      // Given / When
      mockGetSessionDag.mockReturnValue(new Promise(() => {}));
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      expect(screen.getByRole("tabpanel")).toBeInTheDocument();
    });

    it("panelId is set as id", () => {
      // Given / When
      mockGetSessionDag.mockReturnValue(new Promise(() => {}));
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      expect(screen.getByRole("tabpanel")).toHaveAttribute("id", "panel-dag-1");
    });
  });

  describe("loading state", () => {
    it("'Loading DAG…' is shown before getSessionDag resolves", () => {
      // Given: getSessionDag never resolves within this test
      mockGetSessionDag.mockReturnValue(new Promise(() => {}));
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      expect(screen.getByText("Loading DAG…")).toBeInTheDocument();
    });
  });

  describe("success state", () => {
    it("calls getSessionDag with the given sessionId", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: "<svg></svg>" });
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(mockMermaidRender).toHaveBeenCalled());
      // Then
      expect(mockGetSessionDag).toHaveBeenCalledTimes(1);
      expect(mockGetSessionDag).toHaveBeenCalledWith("session-1");
    });

    it("calls mermaid.initialize once with startOnLoad=false", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: "<svg></svg>" });
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(mockMermaidRender).toHaveBeenCalled());
      // Then
      expect(mockMermaidInitialize).toHaveBeenCalledTimes(1);
      expect(mockMermaidInitialize).toHaveBeenCalledWith({ startOnLoad: false, theme: "default" });
    });

    it("passes a Mermaid source with graph header, step labels, and terminal END node", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: "<svg></svg>" });
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(mockMermaidRender).toHaveBeenCalled());
      // Then
      const [, source] = mockMermaidRender.mock.calls[0];
      expect(source).toContain("graph TD");
      expect(source).toContain('s0_build["build"]');
      expect(source).toContain('s1_test["test"]');
      expect(source).toContain("end_terminal[/END/]");
    });

    it("passes a Mermaid source with an edge label derived from the edge reason/selector", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: "<svg></svg>" });
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(mockMermaidRender).toHaveBeenCalled());
      // Then
      const [, source] = mockMermaidRender.mock.calls[0];
      expect(source).toContain('s0_build -->|"if-file-changed: src/**"| s1_test');
      expect(source).toContain("s1_test --> end_terminal");
    });

    it("passes a Mermaid source with style lines for the start and current steps", async () => {
      // Given: startStep="build", currentStep="test"
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: "<svg></svg>" });
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(mockMermaidRender).toHaveBeenCalled());
      // Then
      const [, source] = mockMermaidRender.mock.calls[0];
      expect(source).toContain("style s0_build fill:#10b981,color:#fff,stroke:#059669,stroke-width:2px");
      expect(source).toContain("style s1_test fill:#3b82f6,color:#fff,stroke:#2563eb,stroke-width:2px");
    });

    it("injects the rendered SVG returned by mermaid.render into the panel", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockResolvedValue({ svg: '<svg data-testid="mock-svg"></svg>' });
      // When
      const { container } = render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      await waitFor(() => {
        expect(container.querySelector('[data-testid="mock-svg"]')).not.toBeNull();
      });
    });
  });

  describe("empty state", () => {
    it("'No DAG available.' is shown when steps is empty", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag({ steps: [], edges: [], currentStep: null }));
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      await waitFor(() => expect(screen.getByText("No DAG available.")).toBeInTheDocument());
    });

    it("does not call mermaid.render when steps is empty", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag({ steps: [], edges: [], currentStep: null }));
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      await waitFor(() => expect(screen.getByText("No DAG available.")).toBeInTheDocument());
      // Then
      expect(mockMermaidRender).not.toHaveBeenCalled();
      expect(mockMermaidInitialize).not.toHaveBeenCalled();
    });
  });

  describe("error state", () => {
    it("shows 'Failed to render DAG:' with the error text when getSessionDag rejects", async () => {
      // Given
      mockGetSessionDag.mockRejectedValue(new Error("network failure"));
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      await waitFor(() => {
        expect(screen.getByText(/Failed to render DAG:/)).toBeInTheDocument();
      });
      expect(screen.getByText(/network failure/)).toBeInTheDocument();
    });

    it("shows 'Failed to render DAG:' with the error text when mermaid.render rejects", async () => {
      // Given
      mockGetSessionDag.mockResolvedValue(makeDag());
      mockMermaidRender.mockRejectedValue(new Error("render failure"));
      // When
      render(<WorkflowDagPanel {...baseProps} sessionId="session-1" />);
      // Then
      await waitFor(() => {
        expect(screen.getByText(/Failed to render DAG:/)).toBeInTheDocument();
      });
      expect(screen.getByText(/render failure/)).toBeInTheDocument();
    });
  });

  describe("session switch behavior", () => {
    it("keeps the newer session's SVG when an older session's response resolves later", async () => {
      // Given: session-a's request stays pending; session-b's resolves immediately
      let resolveSessionA: ((dag: DagDto) => void) | undefined;
      const pendingSessionA = new Promise<DagDto>((resolve) => {
        resolveSessionA = resolve;
      });
      mockGetSessionDag.mockImplementation((sessionId: string) =>
        sessionId === "session-a"
          ? pendingSessionA
          : Promise.resolve(
              makeDag({
                steps: [{ name: "from-b", kind: "command", isTerminal: true }],
                startStep: "from-b",
                currentStep: null,
                edges: [],
              }),
            ),
      );
      mockMermaidRender.mockImplementation((_id: string, source: string) =>
        Promise.resolve({ svg: `<div class="rendered-source">${source}</div>` }),
      );

      // When: mount with session-a, then switch to session-b before session-a resolves
      const { rerender, container } = render(
        <WorkflowDagPanel {...baseProps} sessionId="session-a" />,
      );
      rerender(<WorkflowDagPanel {...baseProps} sessionId="session-b" />);

      await waitFor(() => {
        expect(container.querySelector(".rendered-source")).not.toBeNull();
      });
      expect(container.querySelector(".rendered-source")!.textContent).toContain("from-b");

      // When: session-a's stale response finally arrives
      await act(async () => {
        resolveSessionA!(
          makeDag({
            steps: [{ name: "from-a", kind: "command", isTerminal: true }],
            startStep: "from-a",
            currentStep: null,
            edges: [],
          }),
        );
        await Promise.resolve();
        await Promise.resolve();
      });

      // Then: the panel still shows session-b's SVG, not session-a's stale one
      expect(container.querySelector(".rendered-source")!.textContent).toContain("from-b");
      expect(container.querySelector(".rendered-source")!.textContent).not.toContain("from-a");
    });
  });
});